0d3cb498a3
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
878 lines
31 KiB
TypeScript
878 lines
31 KiB
TypeScript
import OpenAI from 'openai';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
calculateOpenAICost,
|
|
failApiCall,
|
|
formatOpenAiError,
|
|
getTokenUsage,
|
|
OPENAI_CHAT_MODELS,
|
|
OPENAI_REALTIME_MODELS,
|
|
OPENAI_RESPONSES_ONLY_MODELS,
|
|
validateFunctionCall,
|
|
} from '../../../src/providers/openai/util';
|
|
|
|
vi.mock('../../../src/cache');
|
|
|
|
describe('failApiCall', () => {
|
|
it('should format OpenAI API errors', () => {
|
|
const error = new OpenAI.APIError(400, {}, 'Bad request', new Headers());
|
|
Object.defineProperty(error, 'type', {
|
|
value: 'invalid_request_error',
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
Object.defineProperty(error, 'message', {
|
|
value: 'Bad request',
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
Object.defineProperty(error, 'status', {
|
|
value: 400,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
|
|
const result = failApiCall(error);
|
|
expect(result).toEqual({
|
|
error: `API error: invalid_request_error 400 Bad request`,
|
|
});
|
|
});
|
|
|
|
it('should format generic errors', () => {
|
|
const error = new Error('Network error');
|
|
const result = failApiCall(error);
|
|
expect(result).toEqual({
|
|
error: `API error: Error: Network error`,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('getTokenUsage', () => {
|
|
it('should return token usage for non-cached response', () => {
|
|
const data = {
|
|
usage: {
|
|
total_tokens: 100,
|
|
prompt_tokens: 40,
|
|
completion_tokens: 60,
|
|
},
|
|
};
|
|
|
|
const result = getTokenUsage(data, false);
|
|
expect(result).toEqual({
|
|
total: 100,
|
|
prompt: 40,
|
|
completion: 60,
|
|
numRequests: 1,
|
|
});
|
|
});
|
|
|
|
it('should return cached token usage', () => {
|
|
const data = {
|
|
usage: {
|
|
total_tokens: 100,
|
|
},
|
|
};
|
|
|
|
const result = getTokenUsage(data, true);
|
|
// Cached responses don't count as a new request
|
|
expect(result).toEqual({
|
|
cached: 100,
|
|
total: 100,
|
|
});
|
|
});
|
|
|
|
it('should handle missing usage data', () => {
|
|
const data = {};
|
|
const result = getTokenUsage(data, false);
|
|
expect(result).toEqual({});
|
|
});
|
|
|
|
it('should handle completion tokens details', () => {
|
|
const data = {
|
|
usage: {
|
|
total_tokens: 100,
|
|
prompt_tokens: 40,
|
|
completion_tokens: 60,
|
|
completion_tokens_details: {
|
|
reasoning_tokens: 20,
|
|
accepted_prediction_tokens: 30,
|
|
rejected_prediction_tokens: 10,
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = getTokenUsage(data, false);
|
|
expect(result).toEqual({
|
|
total: 100,
|
|
prompt: 40,
|
|
completion: 60,
|
|
numRequests: 1,
|
|
completionDetails: {
|
|
reasoning: 20,
|
|
acceptedPrediction: 30,
|
|
rejectedPrediction: 10,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should preserve provider-side cache read and write tokens', () => {
|
|
const data = {
|
|
usage: {
|
|
total_tokens: 100,
|
|
prompt_tokens: 40,
|
|
completion_tokens: 60,
|
|
prompt_tokens_details: {
|
|
cached_tokens: 32,
|
|
cache_write_tokens: 4,
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = getTokenUsage(data, false);
|
|
expect(result).toEqual({
|
|
total: 100,
|
|
prompt: 40,
|
|
completion: 60,
|
|
numRequests: 1,
|
|
completionDetails: {
|
|
cacheReadInputTokens: 32,
|
|
cacheCreationInputTokens: 4,
|
|
},
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
['top-level positive', { cache_write_input_tokens: 4 }, 4],
|
|
['nested explicit zero', { prompt_tokens_details: { cache_write_tokens: 0 } }, 0],
|
|
])('should preserve %s cache-write usage', (_name, usageDetails, expected) => {
|
|
const result = getTokenUsage(
|
|
{
|
|
usage: {
|
|
total_tokens: 100,
|
|
prompt_tokens: 40,
|
|
completion_tokens: 60,
|
|
...usageDetails,
|
|
},
|
|
},
|
|
false,
|
|
);
|
|
|
|
expect(result.completionDetails).toEqual({
|
|
cacheCreationInputTokens: expected,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('calculateOpenAICost', () => {
|
|
it('should calculate cost correctly for TTS model gpt-4o-mini-tts', () => {
|
|
const cost = calculateOpenAICost('gpt-4o-mini-tts', {}, 1000, 0, 0, 500);
|
|
expect(cost).toBeCloseTo((1000 * 0.6 + 500 * 12) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for TTS model gpt-4o-mini-tts-2025-12-15', () => {
|
|
const cost = calculateOpenAICost('gpt-4o-mini-tts-2025-12-15', {}, 1000, 0, 0, 500);
|
|
expect(cost).toBeCloseTo((1000 * 0.6 + 500 * 12) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for search preview model gpt-4o-search-preview', () => {
|
|
const cost = calculateOpenAICost('gpt-4o-search-preview', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 2.5 + 500 * 10) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for search preview model gpt-4o-search-preview-2025-03-11', () => {
|
|
const cost = calculateOpenAICost('gpt-4o-search-preview-2025-03-11', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 2.5 + 500 * 10) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for mini search preview model gpt-4o-mini-search-preview', () => {
|
|
const cost = calculateOpenAICost('gpt-4o-mini-search-preview', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 0.15 + 500 * 0.6) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for computer use model computer-use-preview', () => {
|
|
const cost = calculateOpenAICost('computer-use-preview', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 3 + 500 * 12) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-4-1106-vision-preview', () => {
|
|
const cost = calculateOpenAICost('gpt-4-1106-vision-preview', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 10 + 500 * 30) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-4o-realtime-preview-2024-10-01', () => {
|
|
const cost = calculateOpenAICost('gpt-4o-realtime-preview-2024-10-01', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 5 + 500 * 20) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-4o-realtime-preview-2024-12-17', () => {
|
|
const cost = calculateOpenAICost('gpt-4o-realtime-preview-2024-12-17', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 5 + 500 * 20) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-4o-mini-realtime-preview-2024-12-17', () => {
|
|
const cost = calculateOpenAICost('gpt-4o-mini-realtime-preview-2024-12-17', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 0.6 + 500 * 2.4) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-realtime', () => {
|
|
const cost = calculateOpenAICost('gpt-realtime', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 4 + 500 * 16) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-realtime-1.5', () => {
|
|
const cost = calculateOpenAICost('gpt-realtime-1.5', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 4 + 500 * 16) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-realtime-2025-08-28', () => {
|
|
const cost = calculateOpenAICost('gpt-realtime-2025-08-28', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 4 + 500 * 16) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-realtime-2', () => {
|
|
const cost = calculateOpenAICost('gpt-realtime-2', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 4 + 500 * 24) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly with audio tokens', () => {
|
|
const cost = calculateOpenAICost('gpt-4o-audio-preview', {}, 1000, 500, 200, 100);
|
|
expect(cost).toBeCloseTo((1000 * 2.5 + 500 * 10 + 200 * 40 + 100 * 80) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly with audio tokens for gpt-audio-1.5', () => {
|
|
const cost = calculateOpenAICost('gpt-audio-1.5', {}, 1000, 500, 200, 100);
|
|
expect(cost).toBeCloseTo((1000 * 2.5 + 500 * 10 + 200 * 32 + 100 * 64) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly with audio tokens for gpt-audio (repriced to $32/$64)', () => {
|
|
const cost = calculateOpenAICost('gpt-audio', {}, 1000, 500, 200, 100);
|
|
expect(cost).toBeCloseTo((1000 * 2.5 + 500 * 10 + 200 * 32 + 100 * 64) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for the chat-latest alias', () => {
|
|
const cost = calculateOpenAICost('chat-latest', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 5 + 500 * 30) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-audio-mini-2025-12-15', () => {
|
|
const cost = calculateOpenAICost('gpt-audio-mini-2025-12-15', {}, 1000, 500, 200, 100);
|
|
expect(cost).toBeCloseTo((1000 * 0.6 + 500 * 2.4 + 200 * 10 + 100 * 20) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-4', () => {
|
|
const cost = calculateOpenAICost('gpt-4', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 30 + 500 * 60) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-4.1', () => {
|
|
const cost = calculateOpenAICost('gpt-4.1', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 2 + 500 * 8) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-3.5-turbo', () => {
|
|
const cost = calculateOpenAICost('gpt-3.5-turbo', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 0.5 + 500 * 1.5) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for o4-mini', () => {
|
|
const cost = calculateOpenAICost('o4-mini', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 1.1 + 500 * 4.4) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for codex-mini-latest', () => {
|
|
const cost = calculateOpenAICost('codex-mini-latest', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 1.5 + 500 * 6.0) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-5', () => {
|
|
const cost = calculateOpenAICost('gpt-5', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 1.25 + 500 * 10) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-5-chat-latest', () => {
|
|
const cost = calculateOpenAICost('gpt-5-chat-latest', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 1.25 + 500 * 10) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-5-chat', () => {
|
|
const cost = calculateOpenAICost('gpt-5-chat', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 1.25 + 500 * 10) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-5.1-chat-latest', () => {
|
|
const cost = calculateOpenAICost('gpt-5.1-chat-latest', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 1.25 + 500 * 10) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-5.2-chat-latest', () => {
|
|
const cost = calculateOpenAICost('gpt-5.2-chat-latest', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 1.75 + 500 * 14) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-5.3-chat-latest', () => {
|
|
const cost = calculateOpenAICost('gpt-5.3-chat-latest', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 1.75 + 500 * 14) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-5.5', () => {
|
|
const cost = calculateOpenAICost('gpt-5.5', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 5 + 500 * 30) / 1e6, 6);
|
|
});
|
|
|
|
it.each([
|
|
['gpt-5.6', 5, 30],
|
|
['gpt-5.6-sol', 5, 30],
|
|
['gpt-5.6-terra', 2.5, 15],
|
|
['gpt-5.6-luna', 1, 6],
|
|
])('should calculate cost correctly for %s', (model, inputRate, outputRate) => {
|
|
const cost = calculateOpenAICost(model, {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * inputRate + 500 * outputRate) / 1e6, 6);
|
|
});
|
|
|
|
it.each([
|
|
['gpt-5.6', 5, 30, 10, 45],
|
|
['gpt-5.6-sol', 5, 30, 10, 45],
|
|
['gpt-5.6-terra', 2.5, 15, 5, 22.5],
|
|
['gpt-5.6-luna', 1, 6, 2, 9],
|
|
])('should apply long-context pricing above 272K for %s', (model, baseInputRate, baseOutputRate, longInputRate, longOutputRate) => {
|
|
expect(calculateOpenAICost(model, {}, 272_000, 1_000)).toBeCloseTo(
|
|
(272_000 * baseInputRate + 1_000 * baseOutputRate) / 1e6,
|
|
6,
|
|
);
|
|
expect(calculateOpenAICost(model, {}, 272_001, 1_000)).toBeCloseTo(
|
|
(272_001 * longInputRate + 1_000 * longOutputRate) / 1e6,
|
|
6,
|
|
);
|
|
});
|
|
|
|
it('should calculate long-context cost correctly for gpt-5.5', () => {
|
|
const cost = calculateOpenAICost('gpt-5.5', {}, 300_000, 1_000);
|
|
expect(cost).toBeCloseTo((300_000 * 10 + 1_000 * 45) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-5.5-2026-04-23', () => {
|
|
const cost = calculateOpenAICost('gpt-5.5-2026-04-23', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 5 + 500 * 30) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-5.4', () => {
|
|
const cost = calculateOpenAICost('gpt-5.4', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 2.5 + 500 * 15) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate long-context cost correctly for gpt-5.4', () => {
|
|
const cost = calculateOpenAICost('gpt-5.4', {}, 300_000, 1_000);
|
|
expect(cost).toBeCloseTo((300_000 * 5 + 1_000 * 22.5) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-5.4-2026-03-05', () => {
|
|
const cost = calculateOpenAICost('gpt-5.4-2026-03-05', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 2.5 + 500 * 15) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-5.4-mini', () => {
|
|
const cost = calculateOpenAICost('gpt-5.4-mini', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 0.75 + 500 * 4.5) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-5.4-mini-2026-03-17', () => {
|
|
const cost = calculateOpenAICost('gpt-5.4-mini-2026-03-17', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 0.75 + 500 * 4.5) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-5.4-nano', () => {
|
|
const cost = calculateOpenAICost('gpt-5.4-nano', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 0.2 + 500 * 1.25) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-5.4-nano-2026-03-17', () => {
|
|
const cost = calculateOpenAICost('gpt-5.4-nano-2026-03-17', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 0.2 + 500 * 1.25) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-5.2-codex', () => {
|
|
const cost = calculateOpenAICost('gpt-5.2-codex', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 1.75 + 500 * 14) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-5.2-pro', () => {
|
|
const cost = calculateOpenAICost('gpt-5.2-pro', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 21 + 500 * 168) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-5.5-pro', () => {
|
|
const cost = calculateOpenAICost('gpt-5.5-pro', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 30 + 500 * 180) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-5.5-pro-2026-04-23', () => {
|
|
const cost = calculateOpenAICost('gpt-5.5-pro-2026-04-23', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 30 + 500 * 180) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate long-context cost correctly for gpt-5.5-pro', () => {
|
|
const cost = calculateOpenAICost('gpt-5.5-pro', {}, 300_000, 1_000);
|
|
expect(cost).toBeCloseTo((300_000 * 60 + 1_000 * 270) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-5.4-pro', () => {
|
|
const cost = calculateOpenAICost('gpt-5.4-pro', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 30 + 500 * 180) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate long-context cost correctly for gpt-5.4-pro', () => {
|
|
const cost = calculateOpenAICost('gpt-5.4-pro', {}, 300_000, 1_000);
|
|
expect(cost).toBeCloseTo((300_000 * 60 + 1_000 * 270) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-5.4-pro-2026-03-05', () => {
|
|
const cost = calculateOpenAICost('gpt-5.4-pro-2026-03-05', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 30 + 500 * 180) / 1e6, 6);
|
|
});
|
|
|
|
it('should recognize GPT-5.5 models with built-in pricing', () => {
|
|
expect(OPENAI_CHAT_MODELS.some((model) => model.id === 'gpt-5.5')).toBe(true);
|
|
expect(OPENAI_CHAT_MODELS.some((model) => model.id === 'gpt-5.5-2026-04-23')).toBe(true);
|
|
expect(OPENAI_RESPONSES_ONLY_MODELS.some((model) => model.id === 'gpt-5.5-pro')).toBe(true);
|
|
expect(
|
|
OPENAI_RESPONSES_ONLY_MODELS.some((model) => model.id === 'gpt-5.5-pro-2026-04-23'),
|
|
).toBe(true);
|
|
expect(calculateOpenAICost('gpt-5.5', {}, 1000, 500)).toBeCloseTo(
|
|
(1000 * 5 + 500 * 30) / 1e6,
|
|
6,
|
|
);
|
|
expect(calculateOpenAICost('gpt-5.5-pro', {}, 1000, 500)).toBeCloseTo(
|
|
(1000 * 30 + 500 * 180) / 1e6,
|
|
6,
|
|
);
|
|
});
|
|
|
|
it('should recognize GPT-5.6 models for Chat Completions and Responses', () => {
|
|
for (const model of ['gpt-5.6', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna']) {
|
|
expect(OPENAI_CHAT_MODELS.some((candidate) => candidate.id === model)).toBe(true);
|
|
expect(OPENAI_RESPONSES_ONLY_MODELS.some((candidate) => candidate.id === model)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('should keep GPT-5.4 and GPT-5.5 Pro out of Chat Completions routing', () => {
|
|
expect(OPENAI_CHAT_MODELS.some((model) => model.id === 'gpt-5.4-pro')).toBe(false);
|
|
expect(OPENAI_CHAT_MODELS.some((model) => model.id === 'gpt-5.4-pro-2026-03-05')).toBe(false);
|
|
expect(OPENAI_RESPONSES_ONLY_MODELS.some((model) => model.id === 'gpt-5.4-pro')).toBe(true);
|
|
expect(
|
|
OPENAI_RESPONSES_ONLY_MODELS.some((model) => model.id === 'gpt-5.4-pro-2026-03-05'),
|
|
).toBe(true);
|
|
expect(OPENAI_CHAT_MODELS.some((model) => model.id === 'gpt-5.5-pro')).toBe(false);
|
|
expect(OPENAI_CHAT_MODELS.some((model) => model.id === 'gpt-5.5-pro-2026-04-23')).toBe(false);
|
|
});
|
|
|
|
it('should exclude retired preview audio and realtime models from current routing registries', () => {
|
|
expect(OPENAI_CHAT_MODELS.some((model) => model.id === 'gpt-audio-1.5')).toBe(true);
|
|
expect(OPENAI_CHAT_MODELS.some((model) => model.id === 'gpt-4o-audio-preview')).toBe(false);
|
|
expect(OPENAI_REALTIME_MODELS.some((model) => model.id === 'gpt-realtime-1.5')).toBe(true);
|
|
expect(OPENAI_REALTIME_MODELS.some((model) => model.id === 'gpt-realtime-2')).toBe(true);
|
|
expect(
|
|
OPENAI_REALTIME_MODELS.some(
|
|
(model) => model.id === 'gpt-4o-mini-realtime-preview-2024-12-17',
|
|
),
|
|
).toBe(true);
|
|
expect(OPENAI_REALTIME_MODELS.some((model) => model.id === 'gpt-4o-realtime-preview')).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-5-nano', () => {
|
|
const cost = calculateOpenAICost('gpt-5-nano', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 0.05 + 500 * 0.4) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-5-mini', () => {
|
|
const cost = calculateOpenAICost('gpt-5-mini', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 0.25 + 500 * 2) / 1e6, 6);
|
|
});
|
|
|
|
it('should handle undefined token counts', () => {
|
|
const cost = calculateOpenAICost('gpt-4', {}, undefined, undefined);
|
|
expect(cost).toBeUndefined();
|
|
});
|
|
|
|
it('should handle unknown models', () => {
|
|
const cost = calculateOpenAICost('unknown-model', {}, 100, 50);
|
|
expect(cost).toBeUndefined();
|
|
});
|
|
|
|
it('should use custom cost from config when provided', () => {
|
|
const cost = calculateOpenAICost('gpt-4', { cost: 0.123 }, 1000, 500);
|
|
expect(cost).toBe(184.5);
|
|
});
|
|
|
|
it('should use separate custom input and output costs from config when provided', () => {
|
|
const cost = calculateOpenAICost('gpt-4', { inputCost: 0.001, outputCost: 0.003 }, 1000, 500);
|
|
expect(cost).toBe(2.5);
|
|
});
|
|
|
|
it('should prefer separate custom input and output costs over custom cost', () => {
|
|
const cost = calculateOpenAICost(
|
|
'gpt-4',
|
|
{ cost: 0.123, inputCost: 0.001, outputCost: 0.003 },
|
|
1000,
|
|
500,
|
|
);
|
|
expect(cost).toBe(2.5);
|
|
});
|
|
|
|
it('should use custom cost as fallback for partial separate text cost overrides', () => {
|
|
const cost = calculateOpenAICost('gpt-4', { cost: 0.004, outputCost: 0.001 }, 1000, 500);
|
|
expect(cost).toBe(4.5);
|
|
});
|
|
|
|
it('should calculate cost correctly with custom audioCost', () => {
|
|
const cost = calculateOpenAICost(
|
|
'gpt-4o-audio-preview',
|
|
{ audioCost: 0.05 },
|
|
1000,
|
|
500,
|
|
200,
|
|
100,
|
|
);
|
|
expect(cost).toBe(15.0075);
|
|
});
|
|
|
|
it('should use separate custom audio input and output costs from config when provided', () => {
|
|
const cost = calculateOpenAICost(
|
|
'gpt-4o-audio-preview',
|
|
{ audioInputCost: 0.01, audioOutputCost: 0.03 },
|
|
1000,
|
|
500,
|
|
200,
|
|
100,
|
|
);
|
|
expect(cost).toBe(5.0075);
|
|
});
|
|
|
|
it('should prefer separate custom audio costs over custom audioCost', () => {
|
|
const cost = calculateOpenAICost(
|
|
'gpt-4o-audio-preview',
|
|
{ audioCost: 0.05, audioInputCost: 0.01, audioOutputCost: 0.03 },
|
|
1000,
|
|
500,
|
|
200,
|
|
100,
|
|
);
|
|
expect(cost).toBe(5.0075);
|
|
});
|
|
|
|
it('should fall back to model default for partial audio override (audioInputCost only)', () => {
|
|
const cost = calculateOpenAICost(
|
|
'gpt-4o-audio-preview',
|
|
{ audioInputCost: 0.01 },
|
|
1000,
|
|
500,
|
|
200,
|
|
100,
|
|
);
|
|
const expected = (2.5 * 1000) / 1e6 + (10 * 500) / 1e6 + 0.01 * 200 + (80 * 100) / 1e6;
|
|
expect(cost).toBeCloseTo(expected, 6);
|
|
});
|
|
|
|
it('should fall back to audioCost for partial audio override (audioInputCost + audioCost)', () => {
|
|
const cost = calculateOpenAICost(
|
|
'gpt-4o-audio-preview',
|
|
{ audioCost: 0.02, audioInputCost: 0.01 },
|
|
1000,
|
|
500,
|
|
200,
|
|
100,
|
|
);
|
|
const expected = (2.5 * 1000) / 1e6 + (10 * 500) / 1e6 + 0.01 * 200 + 0.02 * 100;
|
|
expect(cost).toBeCloseTo(expected, 6);
|
|
});
|
|
|
|
it('should handle a model with no cost property', () => {
|
|
const cost = calculateOpenAICost('text-davinci-002', {}, 1000, 500);
|
|
expect(cost).toBeUndefined();
|
|
});
|
|
|
|
it('should calculate cost correctly for o1-pro', () => {
|
|
const cost = calculateOpenAICost('o1-pro', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 150 + 500 * 600) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for o3-pro', () => {
|
|
const cost = calculateOpenAICost('o3-pro', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 20 + 500 * 80) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for o1-pro-2025-03-19', () => {
|
|
const cost = calculateOpenAICost('o1-pro-2025-03-19', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 150 + 500 * 600) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for o3', () => {
|
|
const cost = calculateOpenAICost('o3', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 2 + 500 * 8) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for o3-2025-04-16', () => {
|
|
const cost = calculateOpenAICost('o3-2025-04-16', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 2 + 500 * 8) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for o3-pro-2025-06-10', () => {
|
|
const cost = calculateOpenAICost('o3-pro-2025-06-10', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo(0.06); // 20/1M * 1000 + 80/1M * 500
|
|
});
|
|
|
|
it('should calculate audio token costs for gpt-4o-realtime-preview-2024-12-17', () => {
|
|
const cost = calculateOpenAICost('gpt-4o-realtime-preview-2024-12-17', {}, 1000, 500, 200, 100);
|
|
const expectedCost = (1000 * 5 + 500 * 20 + 200 * 40 + 100 * 80) / 1e6;
|
|
expect(cost).toBeCloseTo(expectedCost, 6);
|
|
});
|
|
|
|
it('should calculate audio token costs for gpt-4o-mini-realtime-preview-2024-12-17', () => {
|
|
const cost = calculateOpenAICost(
|
|
'gpt-4o-mini-realtime-preview-2024-12-17',
|
|
{},
|
|
1000,
|
|
500,
|
|
200,
|
|
100,
|
|
);
|
|
const expectedCost = (1000 * 0.6 + 500 * 2.4 + 200 * 10 + 100 * 20) / 1e6;
|
|
expect(cost).toBeCloseTo(expectedCost, 6);
|
|
});
|
|
|
|
it('should calculate audio token costs for gpt-realtime', () => {
|
|
const cost = calculateOpenAICost('gpt-realtime', {}, 1000, 500, 200, 100);
|
|
const expectedCost = (1000 * 4 + 500 * 16 + 200 * 32 + 100 * 64) / 1e6;
|
|
expect(cost).toBeCloseTo(expectedCost, 6);
|
|
});
|
|
|
|
it('should calculate audio token costs for gpt-realtime-2', () => {
|
|
const cost = calculateOpenAICost('gpt-realtime-2', {}, 1000, 500, 200, 100);
|
|
const expectedCost = (1000 * 4 + 500 * 24 + 200 * 32 + 100 * 64) / 1e6;
|
|
expect(cost).toBeCloseTo(expectedCost, 6);
|
|
});
|
|
|
|
it('should calculate audio token costs for gpt-realtime-mini-2025-12-15', () => {
|
|
const cost = calculateOpenAICost('gpt-realtime-mini-2025-12-15', {}, 1000, 500, 200, 100);
|
|
const expectedCost = (1000 * 0.6 + 500 * 2.4 + 200 * 10 + 100 * 20) / 1e6;
|
|
expect(cost).toBeCloseTo(expectedCost, 6);
|
|
});
|
|
|
|
it('should return zero cost for zero tokens', () => {
|
|
const cost = calculateOpenAICost('gpt-4.1', {}, 0, 0);
|
|
expect(cost).toBe(0);
|
|
});
|
|
|
|
it('should handle only prompt tokens', () => {
|
|
const cost = calculateOpenAICost('gpt-4.1', {}, 1000, 0);
|
|
expect(cost).toBeCloseTo((1000 * 2) / 1e6, 6);
|
|
});
|
|
|
|
it('should handle only completion tokens', () => {
|
|
const cost = calculateOpenAICost('gpt-4.1', {}, 0, 1000);
|
|
expect(cost).toBeCloseTo((1000 * 8) / 1e6, 6);
|
|
});
|
|
|
|
it('should handle large token counts', () => {
|
|
const cost = calculateOpenAICost('gpt-4.1', {}, 1000000, 1000000);
|
|
expect(cost).toBeCloseTo((1000000 * 2 + 1000000 * 8) / 1e6, 6);
|
|
});
|
|
|
|
it('should handle mixed undefined audio tokens', () => {
|
|
const cost = calculateOpenAICost('gpt-4o-audio-preview', {}, 1000, 500, undefined, 100);
|
|
expect(cost).toBeUndefined();
|
|
});
|
|
|
|
it('should use custom audioCost from config when provided', () => {
|
|
const audioCost = 0.05; // per 1M tokens
|
|
|
|
const promptTokens = 1000;
|
|
const completionTokens = 500;
|
|
const audioPromptTokens = 200;
|
|
const audioCompletionTokens = 100;
|
|
|
|
const model = OPENAI_CHAT_MODELS.find(
|
|
(m) =>
|
|
m.id === 'gpt-4o-audio-preview' &&
|
|
m.cost &&
|
|
'audioInput' in m.cost &&
|
|
'audioOutput' in m.cost,
|
|
);
|
|
|
|
if (!model || !model.cost) {
|
|
return;
|
|
}
|
|
|
|
const baseInputCost = model.cost.input * promptTokens;
|
|
const baseOutputCost = model.cost.output * completionTokens;
|
|
|
|
const audioInputCostCustom = audioCost * audioPromptTokens;
|
|
const audioOutputCostCustom = audioCost * audioCompletionTokens;
|
|
|
|
const expectedTotalCost =
|
|
(baseInputCost + baseOutputCost + audioInputCostCustom + audioOutputCostCustom) / 1;
|
|
|
|
const cost = calculateOpenAICost(
|
|
'gpt-4o-audio-preview',
|
|
{ audioCost },
|
|
promptTokens,
|
|
completionTokens,
|
|
audioPromptTokens,
|
|
audioCompletionTokens,
|
|
);
|
|
|
|
expect(cost).toBeCloseTo(expectedTotalCost, 2);
|
|
});
|
|
|
|
it('should handle a non-existent model with no cost property', () => {
|
|
const fakeModelName = 'non-existent-model-with-no-cost';
|
|
|
|
const cost = calculateOpenAICost(fakeModelName, {}, 1000, 500);
|
|
expect(cost).toBeUndefined();
|
|
});
|
|
|
|
// Legacy GPT-4 model tests
|
|
it('should calculate cost correctly for gpt-4-0314', () => {
|
|
const cost = calculateOpenAICost('gpt-4-0314', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo(0.06); // 30/1M * 1000 + 60/1M * 500
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-4-32k-0314', () => {
|
|
const cost = calculateOpenAICost('gpt-4-32k-0314', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo(0.12); // 60/1M * 1000 + 120/1M * 500
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-4-32k-0613', () => {
|
|
const cost = calculateOpenAICost('gpt-4-32k-0613', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo(0.12); // 60/1M * 1000 + 120/1M * 500
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-4-vision-preview', () => {
|
|
const cost = calculateOpenAICost('gpt-4-vision-preview', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo(0.025); // 10/1M * 1000 + 30/1M * 500
|
|
});
|
|
|
|
// Legacy GPT-3.5 model tests
|
|
it('should calculate cost correctly for gpt-3.5-turbo-0301', () => {
|
|
const cost = calculateOpenAICost('gpt-3.5-turbo-0301', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo(0.0025); // 1.5/1M * 1000 + 2/1M * 500
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-3.5-turbo-16k', () => {
|
|
const cost = calculateOpenAICost('gpt-3.5-turbo-16k', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo(0.005); // 3/1M * 1000 + 4/1M * 500
|
|
});
|
|
|
|
it('should calculate cost correctly for gpt-3.5-turbo-16k-0613', () => {
|
|
const cost = calculateOpenAICost('gpt-3.5-turbo-16k-0613', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo(0.005); // 3/1M * 1000 + 4/1M * 500
|
|
});
|
|
|
|
// Latest audio model test
|
|
it('should calculate cost correctly for gpt-4o-audio-preview-2025-06-03', () => {
|
|
const cost = calculateOpenAICost('gpt-4o-audio-preview-2025-06-03', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo(0.0075); // 2.5/1M * 1000 + 10/1M * 500
|
|
});
|
|
|
|
it('should calculate cost correctly for o4-mini (responses model)', () => {
|
|
const cost = calculateOpenAICost('o4-mini', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 1.1 + 500 * 4.4) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for o3-deep-research', () => {
|
|
const cost = calculateOpenAICost('o3-deep-research', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 10 + 500 * 40) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for o3-deep-research-2025-06-26', () => {
|
|
const cost = calculateOpenAICost('o3-deep-research-2025-06-26', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 10 + 500 * 40) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for o4-mini-deep-research', () => {
|
|
const cost = calculateOpenAICost('o4-mini-deep-research', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 2 + 500 * 8) / 1e6, 6);
|
|
});
|
|
|
|
it('should calculate cost correctly for o4-mini-deep-research-2025-06-26', () => {
|
|
const cost = calculateOpenAICost('o4-mini-deep-research-2025-06-26', {}, 1000, 500);
|
|
expect(cost).toBeCloseTo((1000 * 2 + 500 * 8) / 1e6, 6);
|
|
});
|
|
});
|
|
|
|
describe('validateFunctionCall', () => {
|
|
const sampleFunction = {
|
|
name: 'testFunction',
|
|
parameters: {
|
|
type: 'object' as const,
|
|
properties: {
|
|
foo: { type: 'string' },
|
|
bar: { type: 'number' },
|
|
},
|
|
required: ['foo'],
|
|
},
|
|
};
|
|
|
|
it('should validate valid function call', () => {
|
|
const functionCall = {
|
|
name: 'testFunction',
|
|
arguments: JSON.stringify({ foo: 'test', bar: 123 }),
|
|
};
|
|
|
|
expect(() => validateFunctionCall(functionCall, [sampleFunction], {})).not.toThrow();
|
|
});
|
|
|
|
it('should throw error for invalid function name', () => {
|
|
const functionCall = {
|
|
name: 'nonexistentFunction',
|
|
arguments: JSON.stringify({ foo: 'test' }),
|
|
};
|
|
|
|
expect(() => validateFunctionCall(functionCall, [sampleFunction], {})).toThrow(
|
|
'Called "nonexistentFunction", but there is no function with that name',
|
|
);
|
|
});
|
|
|
|
it('should throw error for invalid arguments', () => {
|
|
const functionCall = {
|
|
name: 'testFunction',
|
|
arguments: JSON.stringify({ bar: 'not a number' }),
|
|
};
|
|
|
|
expect(() => validateFunctionCall(functionCall, [sampleFunction], {})).toThrow(
|
|
/Call to "testFunction" does not match schema/,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('formatOpenAiError', () => {
|
|
it('should format error with type and code', () => {
|
|
const error = {
|
|
error: {
|
|
message: 'Error message',
|
|
type: 'error_type',
|
|
code: 'error_code',
|
|
},
|
|
};
|
|
|
|
const result = formatOpenAiError(error);
|
|
expect(result).toContain('API error: Error message');
|
|
expect(result).toContain('Type: error_type');
|
|
expect(result).toContain('Code: error_code');
|
|
});
|
|
|
|
it('should format error without type and code', () => {
|
|
const error = {
|
|
error: {
|
|
message: 'Error message',
|
|
},
|
|
};
|
|
|
|
const result = formatOpenAiError(error);
|
|
expect(result).toContain('API error: Error message');
|
|
expect(result).not.toContain('Type:');
|
|
expect(result).not.toContain('Code:');
|
|
});
|
|
});
|