Files
promptfoo--promptfoo/test/providers/minimax.test.ts
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

566 lines
19 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fetchWithCache } from '../../src/cache';
import {
calculateMiniMaxCost,
createMiniMaxProvider,
MINIMAX_CHAT_MODELS,
} from '../../src/providers/minimax';
import { OpenAiChatCompletionProvider } from '../../src/providers/openai/chat';
import { HttpRateLimitError } from '../../src/util/fetch/errors';
import { mockProcessEnv } from '../util/utils';
vi.mock('../../src/cache', async (importOriginal) => ({
...(await importOriginal<typeof import('../../src/cache')>()),
fetchWithCache: vi.fn(),
}));
vi.mock('../../src/logger', () => ({
default: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
describe('calculateMiniMaxCost', () => {
it('should calculate cost without cache for MiniMax-M3', () => {
const cost = calculateMiniMaxCost('MiniMax-M3', {}, 500000, 1000000);
expect(cost).toBeCloseTo(1.35); // (0.3 * 0.5 + 1.2) — standard <=512k tier
});
it('should calculate cost with cache hits for MiniMax-M3', () => {
const cost = calculateMiniMaxCost('MiniMax-M3', {}, 500000, 1000000, 250000);
expect(cost).toBeCloseTo(1.29); // (0.3 * 0.25 + 0.06 * 0.25 + 1.2)
});
it('should calculate long-context cost for MiniMax-M3 above 512k prompt tokens', () => {
const cost = calculateMiniMaxCost('MiniMax-M3', {}, 1000000, 1000000, 500000);
expect(cost).toBeCloseTo(2.76); // (0.6 * 0.5 + 0.12 * 0.5 + 2.4)
});
it('should switch MiniMax-M3 to long-context pricing only above the 512k threshold', () => {
// Exactly at the threshold stays on the standard tier (exclusive >).
expect(calculateMiniMaxCost('MiniMax-M3', {}, 512_000, 1_000_000)).toBeCloseTo(
(0.3 * 512_000 + 1.2 * 1_000_000) / 1e6,
10,
);
// One token over switches the whole request to the long-context tier.
expect(calculateMiniMaxCost('MiniMax-M3', {}, 512_001, 1_000_000)).toBeCloseTo(
(0.6 * 512_001 + 2.4 * 1_000_000) / 1e6,
10,
);
});
it('should calculate cost without cache for MiniMax-M2.7', () => {
const cost = calculateMiniMaxCost('MiniMax-M2.7', {}, 1000000, 1000000);
expect(cost).toBeCloseTo(1.5); // (0.3 + 1.2)
});
it('should calculate cost with cache hits for MiniMax-M2.7', () => {
const cost = calculateMiniMaxCost('MiniMax-M2.7', {}, 1000000, 1000000, 500000);
expect(cost).toBeCloseTo(1.38); // (0.3 * 0.5 + 0.06 * 0.5 + 1.2)
});
it('should calculate cost for MiniMax-M2.7-highspeed', () => {
const cost = calculateMiniMaxCost('MiniMax-M2.7-highspeed', {}, 1000000, 1000000);
expect(cost).toBeCloseTo(3.0); // (0.6 + 2.4)
});
it('should calculate cost with cache hits for MiniMax-M2.7-highspeed', () => {
const cost = calculateMiniMaxCost('MiniMax-M2.7-highspeed', {}, 1000000, 1000000, 500000);
expect(cost).toBeCloseTo(2.73); // (0.6 * 0.5 + 0.06 * 0.5 + 2.4)
});
it('should return undefined if promptTokens is missing', () => {
const cost = calculateMiniMaxCost('MiniMax-M2.7', {}, undefined, 1000000);
expect(cost).toBeUndefined();
});
it('should return undefined if completionTokens is missing', () => {
const cost = calculateMiniMaxCost('MiniMax-M2.7', {}, 1000000, undefined);
expect(cost).toBeUndefined();
});
it('should charge input cost when a response has zero completion tokens', () => {
const cost = calculateMiniMaxCost('MiniMax-M2.7', {}, 1000000, 0);
expect(cost).toBeCloseTo(0.3);
});
it('should clamp inconsistent cached-token usage to prompt token usage', () => {
const cost = calculateMiniMaxCost('MiniMax-M2.7', {}, 1000000, 0, 2000000);
expect(cost).toBeCloseTo(0.06);
});
it('should use custom cost from config', () => {
const config = { cost: 1.0 / 1e6 };
const cost = calculateMiniMaxCost('MiniMax-M2.7', config, 1000000, 1000000);
expect(cost).toBeCloseTo(2.0); // (1.0 + 1.0) from config override
});
it('should use separate input and output cost overrides', () => {
const cost = calculateMiniMaxCost(
'MiniMax-M2.7',
{ inputCost: 1.0 / 1e6, outputCost: 2.0 / 1e6 },
1000000,
1000000,
);
expect(cost).toBeCloseTo(3.0);
});
it('should handle unknown model names', () => {
const cost = calculateMiniMaxCost('unknown-model', {}, 1000000, 1000000);
expect(cost).toBeUndefined();
});
it('should calculate cost with 100% cache hits for M2.7', () => {
const cost = calculateMiniMaxCost('MiniMax-M2.7', {}, 1000000, 1000000, 1000000);
expect(cost).toBeCloseTo(1.26); // (0.06 + 1.2) - all input tokens are cached
});
});
describe('MINIMAX_CHAT_MODELS', () => {
it('should have correct pricing for MiniMax-M3', () => {
const model = MINIMAX_CHAT_MODELS.find((m) => m.id === 'MiniMax-M3');
expect(model).toBeDefined();
expect(model?.cost.input).toBeCloseTo(0.3 / 1e6);
expect(model?.cost.output).toBeCloseTo(1.2 / 1e6);
expect(model?.cost.cache_read).toBeCloseTo(0.06 / 1e6);
expect(model?.cost.longContext).toEqual({
threshold: 512_000,
input: 0.6 / 1e6,
output: 2.4 / 1e6,
cache_read: 0.12 / 1e6,
});
});
it('should have correct pricing for MiniMax-M2.7', () => {
const model = MINIMAX_CHAT_MODELS.find((m) => m.id === 'MiniMax-M2.7');
expect(model).toBeDefined();
expect(model?.cost.input).toBeCloseTo(0.3 / 1e6);
expect(model?.cost.output).toBeCloseTo(1.2 / 1e6);
expect(model?.cost.cache_read).toBeCloseTo(0.06 / 1e6);
});
it('should have correct pricing for MiniMax-M2.7-highspeed', () => {
const model = MINIMAX_CHAT_MODELS.find((m) => m.id === 'MiniMax-M2.7-highspeed');
expect(model).toBeDefined();
expect(model?.cost.input).toBeCloseTo(0.6 / 1e6);
expect(model?.cost.output).toBeCloseTo(2.4 / 1e6);
expect(model?.cost.cache_read).toBeCloseTo(0.06 / 1e6);
});
it('should contain exactly three models', () => {
expect(MINIMAX_CHAT_MODELS).toHaveLength(3);
});
it('should have M3 as first model in the list', () => {
expect(MINIMAX_CHAT_MODELS[0].id).toBe('MiniMax-M3');
});
it('should have M2.7 as second model in the list', () => {
expect(MINIMAX_CHAT_MODELS[1].id).toBe('MiniMax-M2.7');
});
it('should have M2.7-highspeed as third model in the list', () => {
expect(MINIMAX_CHAT_MODELS[2].id).toBe('MiniMax-M2.7-highspeed');
});
it('should no longer include the older M2.5 models', () => {
const ids = MINIMAX_CHAT_MODELS.map((m) => m.id);
expect(ids).not.toContain('MiniMax-M2.5');
expect(ids).not.toContain('MiniMax-M2.5-highspeed');
});
});
describe('createMiniMaxProvider', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should use default model MiniMax-M3 when no model specified', () => {
const provider = createMiniMaxProvider('minimax');
expect(provider.id()).toBe('minimax:MiniMax-M3');
});
it('should parse model name from provider path', () => {
const provider = createMiniMaxProvider('minimax:MiniMax-M3');
expect(provider.id()).toBe('minimax:MiniMax-M3');
});
it('should handle model names with colons', () => {
const provider = createMiniMaxProvider('minimax:MiniMax-M2.7-highspeed');
expect(provider.id()).toBe('minimax:MiniMax-M2.7-highspeed');
});
it('should pass options to the provider', () => {
const provider = createMiniMaxProvider('minimax:MiniMax-M3', {
config: {
config: {
temperature: 0.8,
max_tokens: 2000,
},
},
});
expect(provider.id()).toBe('minimax:MiniMax-M3');
});
});
describe('MiniMaxProvider', () => {
let restoreEnv: () => void;
beforeEach(() => {
vi.mocked(fetchWithCache).mockReset();
restoreEnv = mockProcessEnv({
MINIMAX_API_KEY: undefined,
OPENAI_API_KEY: undefined,
OPENAI_ORGANIZATION: undefined,
OPENAI_API_HOST: undefined,
OPENAI_API_BASE_URL: undefined,
OPENAI_BASE_URL: undefined,
OPENAI_TEMPERATURE: undefined,
OPENAI_MAX_TOKENS: undefined,
OPENAI_TOP_P: undefined,
OPENAI_PRESENCE_PENALTY: undefined,
OPENAI_FREQUENCY_PENALTY: undefined,
});
});
afterEach(() => {
restoreEnv();
vi.restoreAllMocks();
});
it('should return correct id', () => {
const provider = createMiniMaxProvider('minimax:MiniMax-M2.7');
expect(provider.id()).toBe('minimax:MiniMax-M2.7');
});
it('should return correct string representation', () => {
const provider = createMiniMaxProvider('minimax:MiniMax-M2.7');
expect(provider.toString()).toBe('[MiniMax Provider MiniMax-M2.7]');
});
it('should serialize to JSON correctly', () => {
const provider = createMiniMaxProvider('minimax:MiniMax-M2.7', {
config: {
config: {
temperature: 0.7,
max_tokens: 100,
},
},
});
const json = (provider as any).toJSON();
expect(json.provider).toBe('minimax');
expect(json.model).toBe('MiniMax-M2.7');
});
it('should not expose apiKey in JSON serialization', () => {
const provider = createMiniMaxProvider('minimax:MiniMax-M2.7', {
config: {
config: {
apiKey: 'secret-key',
},
},
});
const json = (provider as any).toJSON();
expect(json.config.apiKey).toBeUndefined();
});
it('should set apiBaseUrl to MiniMax API endpoint', () => {
const provider = createMiniMaxProvider('minimax:MiniMax-M2.7');
const json = (provider as any).toJSON();
expect(json.config.apiBaseUrl).toBe('https://api.minimax.io/v1');
});
it('should set apiKeyEnvar to MINIMAX_API_KEY', () => {
const provider = createMiniMaxProvider('minimax:MiniMax-M2.7');
const json = (provider as any).toJSON();
expect(json.config.apiKeyEnvar).toBe('MINIMAX_API_KEY');
});
it('should reject deprecated function_call configuration', async () => {
const provider = createMiniMaxProvider('minimax:MiniMax-M2.7', {
config: {
config: {
function_call: 'auto',
},
},
});
await expect((provider as any).getOpenAiBody('Call a tool')).rejects.toThrow(
'MiniMax does not support function_call. Use tools and tool_choice for tool calling instead.',
);
});
it('should preserve explicit API endpoint and API key environment overrides', () => {
const restoreCustomEnv = mockProcessEnv({ CUSTOM_MINIMAX_KEY: 'custom-minimax-key' });
try {
const provider = createMiniMaxProvider('minimax:MiniMax-M2.7', {
config: {
config: {
apiBaseUrl: 'https://proxy.example.com/minimax/v1',
apiKeyEnvar: 'CUSTOM_MINIMAX_KEY',
},
},
}) as any;
expect(provider.getApiUrl()).toBe('https://proxy.example.com/minimax/v1');
expect(provider.getApiKey()).toBe('custom-minimax-key');
} finally {
restoreCustomEnv();
}
});
it('should not forward OpenAI credentials, organization, or endpoint environment values', () => {
const restoreOpenAiEnv = mockProcessEnv({
OPENAI_API_KEY: 'sk-openai-secret',
OPENAI_ORGANIZATION: 'org-openai-secret',
OPENAI_API_HOST: 'wrong-service.example.com',
OPENAI_API_BASE_URL: 'https://wrong-service.example.com/v1',
});
try {
const provider = createMiniMaxProvider('minimax:MiniMax-M2.7') as any;
expect(provider.getApiKey()).toBeUndefined();
expect(provider.getOrganization()).toBeUndefined();
expect(provider.getApiUrl()).toBe('https://api.minimax.io/v1');
} finally {
restoreOpenAiEnv();
}
});
it('should send MiniMax completion limits and omit its invalid inherited temperature default', async () => {
const provider = createMiniMaxProvider('minimax:MiniMax-M2.7', {
config: { config: { max_tokens: 256 } },
}) as any;
const { body } = await provider.getOpenAiBody('Test prompt');
expect(body.max_completion_tokens).toBe(256);
expect(body.max_tokens).toBeUndefined();
expect(body.temperature).toBeUndefined();
});
it('should preserve explicit supported MiniMax request parameters', async () => {
const provider = createMiniMaxProvider('minimax:MiniMax-M2.7', {
config: { config: { max_completion_tokens: 512, temperature: 0.7 } },
}) as any;
const { body } = await provider.getOpenAiBody('Test prompt');
expect(body.max_completion_tokens).toBe(512);
expect(body.max_tokens).toBeUndefined();
expect(body.temperature).toBe(0.7);
});
it('should not leak OpenAI sampling env defaults (OPENAI_TOP_P / OPENAI_PRESENCE_PENALTY / OPENAI_FREQUENCY_PENALTY) into MiniMax requests', async () => {
const restoreOpenAiSamplingEnv = mockProcessEnv({
OPENAI_TOP_P: '0.5',
OPENAI_PRESENCE_PENALTY: '0.7',
OPENAI_FREQUENCY_PENALTY: '0.9',
});
try {
const provider = createMiniMaxProvider('minimax:MiniMax-M2.7') as any;
const { body } = await provider.getOpenAiBody('Test prompt');
expect(body.top_p).toBeUndefined();
expect(body.presence_penalty).toBeUndefined();
expect(body.frequency_penalty).toBeUndefined();
} finally {
restoreOpenAiSamplingEnv();
}
});
it('should preserve explicit MiniMax sampling parameters even when OPENAI_* sampling envs are set', async () => {
const restoreOpenAiSamplingEnv = mockProcessEnv({
OPENAI_TOP_P: '0.5',
OPENAI_PRESENCE_PENALTY: '0.7',
OPENAI_FREQUENCY_PENALTY: '0.9',
});
try {
const provider = createMiniMaxProvider('minimax:MiniMax-M2.7', {
config: { config: { top_p: 0.95, presence_penalty: 0.1, frequency_penalty: 0.2 } },
}) as any;
const { body } = await provider.getOpenAiBody('Test prompt');
expect(body.top_p).toBe(0.95);
expect(body.presence_penalty).toBe(0.1);
expect(body.frequency_penalty).toBe(0.2);
} finally {
restoreOpenAiSamplingEnv();
}
});
it('should call a configured MiniMax-compatible endpoint and charge prompt-cache reads', async () => {
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: {
choices: [{ message: { content: 'MiniMax output' }, finish_reason: 'stop' }],
usage: {
total_tokens: 15,
prompt_tokens: 10,
completion_tokens: 5,
prompt_tokens_details: { cached_tokens: 4 },
},
},
cached: false,
status: 200,
statusText: 'OK',
});
const provider = createMiniMaxProvider('minimax:MiniMax-M2.7', {
config: {
config: {
apiKey: 'minimax-key',
apiBaseUrl: 'https://proxy.example.com/minimax/v1',
max_tokens: 256,
temperature: 0.7,
},
},
});
const result = await provider.callApi('Test prompt');
const [url, init] = vi.mocked(fetchWithCache).mock.calls[0] ?? [];
expect(url).toBe('https://proxy.example.com/minimax/v1/chat/completions');
expect((init as RequestInit).headers).toMatchObject({
Authorization: 'Bearer minimax-key',
});
const body = JSON.parse((init as RequestInit).body as string);
expect(body).toMatchObject({ max_completion_tokens: 256, temperature: 0.7 });
expect(body.max_tokens).toBeUndefined();
expect(result.output).toBe('MiniMax output');
expect(result.cost).toBe(calculateMiniMaxCost('MiniMax-M2.7', {}, 10, 5, 4));
});
it('should return API errors from the MiniMax endpoint', async () => {
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: { error: { message: 'invalid model' } },
cached: false,
status: 400,
statusText: 'Bad Request',
} as never);
const provider = createMiniMaxProvider('minimax:MiniMax-M2.7', {
config: { config: { apiKey: 'minimax-key' } },
});
const result = await provider.callApi('Test prompt');
expect(result.error).toContain('API error: 400 Bad Request');
expect(result.error).toContain('invalid model');
});
it('should preserve structured rate-limit errors', async () => {
vi.mocked(fetchWithCache).mockRejectedValueOnce(
new HttpRateLimitError({
status: 429,
code: 'rate_limit_exceeded',
retryAfterMs: 1000,
}),
);
const provider = createMiniMaxProvider('minimax:MiniMax-M2.7', {
config: { config: { apiKey: 'minimax-key' } },
});
const result = await provider.callApi('Test prompt');
expect(result.error).toContain('Rate limit exceeded');
expect(result.metadata?.rateLimitKind).toBe('rate_limit');
});
it('should calculate cost from cached token metadata in string raw responses', async () => {
vi.spyOn(OpenAiChatCompletionProvider.prototype, 'callApi').mockResolvedValueOnce({
output: 'MiniMax response',
tokenUsage: {
total: 150,
prompt: 100,
completion: 50,
},
raw: JSON.stringify({
usage: {
prompt_tokens_details: {
cached_tokens: 40,
},
},
}),
});
const provider = createMiniMaxProvider('minimax:MiniMax-M2.7');
const result = await provider.callApi('Test prompt');
expect(result.cost).toBe(calculateMiniMaxCost('MiniMax-M2.7', {}, 100, 50, 40));
});
it('should calculate cost from cached token metadata in object raw responses', async () => {
vi.spyOn(OpenAiChatCompletionProvider.prototype, 'callApi').mockResolvedValueOnce({
output: 'MiniMax response',
tokenUsage: {
total: 150,
prompt: 100,
completion: 50,
},
raw: {
usage: {
prompt_tokens_details: {
cached_tokens: 25,
},
},
},
});
const provider = createMiniMaxProvider('minimax:MiniMax-M2.7-highspeed');
const result = await provider.callApi('Test prompt');
expect(result.cost).toBe(calculateMiniMaxCost('MiniMax-M2.7-highspeed', {}, 100, 50, 25));
});
it('should still calculate cost when cache metadata is malformed', async () => {
vi.spyOn(OpenAiChatCompletionProvider.prototype, 'callApi').mockResolvedValueOnce({
output: 'MiniMax response',
tokenUsage: {
total: 150,
prompt: 100,
completion: 50,
},
raw: '{"usage":',
});
const provider = createMiniMaxProvider('minimax:MiniMax-M3');
const result = await provider.callApi('Test prompt');
expect(result.cost).toBe(calculateMiniMaxCost('MiniMax-M3', {}, 100, 50, 0));
});
it('should preserve cached responses without recalculating cost', async () => {
vi.spyOn(OpenAiChatCompletionProvider.prototype, 'callApi').mockResolvedValueOnce({
output: 'Cached MiniMax response',
tokenUsage: {
total: 150,
prompt: 100,
completion: 50,
},
raw: {
usage: {
prompt_tokens_details: {
cached_tokens: 100,
},
},
},
cached: true,
});
const provider = createMiniMaxProvider('minimax:MiniMax-M2.7-highspeed');
const result = await provider.callApi('Test prompt');
expect(result.cached).toBe(true);
expect(result.cost).toBeUndefined();
});
it('should pass through empty and error parent responses', async () => {
const callApi = vi.spyOn(OpenAiChatCompletionProvider.prototype, 'callApi');
callApi.mockResolvedValueOnce(undefined as any);
callApi.mockResolvedValueOnce({ error: 'API error' });
const provider = createMiniMaxProvider('minimax:MiniMax-M2.7');
await expect(provider.callApi('Empty response')).resolves.toBeUndefined();
await expect(provider.callApi('Error response')).resolves.toEqual({ error: 'API error' });
});
});