Files
promptfoo--promptfoo/test/providers/xai/chat.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

1079 lines
40 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
calculateXAICost,
createXAIProvider,
GROK_3_MINI_MODELS,
GROK_4_MODELS,
GROK_REASONING_EFFORT_MODELS,
GROK_REASONING_MODELS,
getXAICostInUsd,
XAI_CHAT_MODELS,
} from '../../../src/providers/xai/chat';
import type { ProviderOptions } from '../../../src/types/providers';
// Mock only external dependencies - NOT the OpenAiChatCompletionProvider class
vi.mock('../../../src/logger');
const mockFetchWithCache = vi.hoisted(() => vi.fn());
vi.mock('../../../src/cache', async (importOriginal) => {
return {
...(await importOriginal()),
fetchWithCache: (...args: any[]) => mockFetchWithCache(...args),
getCache: vi.fn(),
isCacheEnabled: vi.fn().mockReturnValue(false),
};
});
describe('xAI Chat Provider', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mock for fetchWithCache - successful response
mockFetchWithCache.mockResolvedValue({
data: {
choices: [{ message: { content: 'Mock response' } }],
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
},
cached: false,
status: 200,
statusText: 'OK',
});
});
afterEach(() => {
vi.resetAllMocks();
});
describe('Provider creation and configuration', () => {
it('throws an error if no model name is provided', () => {
expect(() => createXAIProvider('xai:')).toThrow('Model name is required');
});
it('creates an xAI provider with specified model', () => {
const provider = createXAIProvider('xai:grok-2') as any;
expect(provider.id()).toBe('xai:grok-2');
expect(provider.modelName).toBe('grok-2');
expect(typeof provider.toString).toBe('function');
});
it('sets the correct API base URL and API key environment variable', () => {
const provider = createXAIProvider('xai:grok-2') as any;
expect(provider.config.apiBaseUrl).toBe('https://api.x.ai/v1');
expect(provider.config.apiKeyEnvar).toBe('XAI_API_KEY');
});
it('uses region-specific API base URL when region is provided', () => {
const provider = createXAIProvider('xai:grok-2', {
config: {
config: {
region: 'eu-west-1',
},
},
}) as any;
expect(provider.config.apiBaseUrl).toBe('https://eu-west-1.api.x.ai/v1');
expect(provider.config.apiKeyEnvar).toBe('XAI_API_KEY');
});
it('merges provided options with xAI-specific config', () => {
const options: ProviderOptions = {
config: {
temperature: 0.7,
max_tokens: 100,
},
id: 'custom-id',
};
const provider = createXAIProvider('xai:grok-2', options) as any;
expect(provider.config.apiBaseUrl).toBe('https://api.x.ai/v1');
expect(provider.config.apiKeyEnvar).toBe('XAI_API_KEY');
expect(provider.config.temperature).toBe(0.7);
expect(provider.config.max_tokens).toBe(100);
});
it('stores originalConfig during initialization', () => {
const config = {
config: {
search_parameters: { mode: 'test' },
region: 'eu-west-1',
},
};
const provider = createXAIProvider('xai:grok-2', { config }) as any;
expect(provider.originalConfig).toEqual(config.config);
});
});
describe('supported models', () => {
it('includes Grok 4.3 and 4.20 in the reasoning and Grok-4 parameter-restriction lists', () => {
expect(XAI_CHAT_MODELS).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: 'grok-4.3' }),
expect.objectContaining({ id: 'grok-4.20-0309-reasoning' }),
expect.objectContaining({ id: 'grok-4.20-0309-non-reasoning' }),
expect.objectContaining({ id: 'grok-4.20-multi-agent-0309' }),
]),
);
expect(GROK_REASONING_MODELS).toEqual(
expect.arrayContaining(['grok-4.3', 'grok-4.3-latest', 'grok-4.20-reasoning', 'grok-4.20']),
);
expect(GROK_4_MODELS).toEqual(
expect.arrayContaining([
'grok-4.3',
'grok-4.20-reasoning',
'grok-4.20-non-reasoning',
'grok-4.20-multi-agent',
]),
);
});
it('tracks a reviewed snapshot of public text model ids reported by xAI /v1/language-models', () => {
// Maintenance note:
// This is a point-in-time snapshot of xAI public text model ids.
// Review and update periodically (or whenever models are added/renamed/deprecated)
// against xAI /v1/language-models.
const currentPublicTextModelIds = [
'grok-3',
'grok-3-mini',
'grok-4-0709',
'grok-4-1-fast-non-reasoning',
'grok-4-1-fast-reasoning',
'grok-4-fast-non-reasoning',
'grok-4-fast-reasoning',
'grok-4.20-0309-non-reasoning',
'grok-4.20-0309-reasoning',
'grok-4.20-multi-agent-0309',
'grok-4.3',
'grok-code-fast-1',
];
const trackedModelIds = XAI_CHAT_MODELS.flatMap((model) => [
model.id,
...(model.aliases ?? []),
]);
for (const modelId of currentPublicTextModelIds) {
expect(trackedModelIds).toContain(modelId);
}
});
});
describe('Provider methods', () => {
it('generates correct id() for the provider', () => {
const provider = createXAIProvider('xai:grok-3-beta');
expect(provider.id()).toBe('xai:grok-3-beta');
});
it('returns readable toString() description', () => {
const provider = createXAIProvider('xai:grok-3-mini-beta');
expect(provider.toString()).toBe('[xAI Provider grok-3-mini-beta]');
});
it('serializes properly with toJSON() and masks API key', () => {
const provider = createXAIProvider('xai:grok-3-beta', {
config: {
temperature: 0.7,
} as any,
});
expect(provider.toJSON).toBeDefined();
const json = provider.toJSON!();
expect(json).toMatchObject({
provider: 'xai',
model: 'grok-3-beta',
});
// Verify API key is not exposed in serialized output
expect(json.config?.apiKey).toBeUndefined();
});
});
describe('Reported cost handling', () => {
it('converts xAI cost ticks to USD', () => {
expect(getXAICostInUsd({ cost_in_usd_ticks: 37_756_000 })).toBe(0.0037756);
expect(getXAICostInUsd()).toBeUndefined();
});
});
describe('Search parameters handling', () => {
it('renders search_parameters with context variables', async () => {
const provider = createXAIProvider('xai:grok-3-beta', {
config: {
config: {
search_parameters: { mode: '{{mode}}', filter: '{{filter}}' },
},
},
});
const result = await (provider as any).getOpenAiBody('test prompt', {
vars: {
mode: 'advanced',
filter: 'latest',
},
});
expect(result.body.search_parameters).toEqual({
mode: 'advanced',
filter: 'latest',
});
});
it('includes search_parameters in API body when defined', async () => {
const provider = createXAIProvider('xai:grok-3-beta', {
config: {
config: {
search_parameters: { mode: 'test' },
},
},
});
const result = await (provider as any).getOpenAiBody('test prompt');
expect(result.body.search_parameters).toEqual({ mode: 'test' });
});
it('does not include search_parameters when undefined and preserves original configuration', async () => {
const provider = createXAIProvider('xai:grok-3-beta');
const result = await (provider as any).getOpenAiBody('test prompt');
expect(result.body.search_parameters).toBeUndefined();
// Test preserving original config
const searchParams = { mode: 'test', filter: 'all' };
const providerWithParams = createXAIProvider('xai:grok-3-beta', {
config: {
config: {
search_parameters: searchParams,
},
},
}) as any;
expect(providerWithParams.originalConfig.search_parameters).toEqual(searchParams);
});
});
describe('Temperature zero handling', () => {
it('should correctly send temperature: 0 in the request body', async () => {
// Test that temperature: 0 is correctly sent (not filtered out by falsy check)
const provider = createXAIProvider('xai:grok-3-beta', {
config: {
temperature: 0,
} as any,
});
const result = await (provider as any).getOpenAiBody('test prompt');
// temperature: 0 should be present in the request body
expect(result.body.temperature).toBe(0);
expect('temperature' in result.body).toBe(true);
});
});
describe('Model type detection and capabilities', () => {
it('identifies reasoning models correctly', () => {
expect(GROK_3_MINI_MODELS).toContain('grok-3-mini-beta');
expect(GROK_3_MINI_MODELS).toContain('grok-3-mini-fast-beta');
expect(GROK_3_MINI_MODELS).not.toContain('grok-2-1212');
});
it('identifies Grok Build and its Grok Code Fast aliases as reasoning models', () => {
expect(GROK_REASONING_MODELS).toContain('grok-build-0.1');
expect(GROK_REASONING_MODELS).toContain('grok-code-fast-1');
expect(GROK_REASONING_MODELS).toContain('grok-code-fast');
expect(GROK_REASONING_MODELS).toContain('grok-code-fast-1-0825');
});
});
describe('Reasoning models configuration', () => {
it('supports reasoning effort parameters for mini models', () => {
// Test high reasoning effort
const provider = createXAIProvider('xai:grok-3-mini-beta', {
config: {
config: {
reasoning_effort: 'high',
},
},
}) as any;
expect(provider.config.apiBaseUrl).toBe('https://api.x.ai/v1');
expect(provider.config.apiKeyEnvar).toBe('XAI_API_KEY');
// Test low reasoning effort
const provider2 = createXAIProvider('xai:grok-3-mini-beta', {
config: {
config: {
reasoning_effort: 'low',
},
},
}) as any;
expect(provider2.config.apiKeyEnvar).toBe('XAI_API_KEY');
});
it('handles multiple configuration options for mini models', () => {
const options: ProviderOptions = {
config: {
config: {
reasoning_effort: 'high',
region: 'us-east-1',
},
temperature: 0.5,
max_tokens: 1000,
} as any,
};
const provider = createXAIProvider('xai:grok-3-mini-beta', options) as any;
expect(provider.config.apiBaseUrl).toBe('https://us-east-1.api.x.ai/v1');
expect(provider.config.temperature).toBe(0.5);
expect(provider.config.max_tokens).toBe(1000);
});
});
describe('Grok-4 specific functionality', () => {
it('recognizes Grok 4.3 models as reasoning models', () => {
const provider = createXAIProvider('xai:grok-4.3') as any;
expect(provider.isReasoningModel()).toBe(true);
const providerAlias = createXAIProvider('xai:grok-4.3-latest') as any;
expect(providerAlias.isReasoningModel()).toBe(true);
});
it('recognizes Grok-4 models as reasoning models', () => {
const provider = createXAIProvider('xai:grok-4-0709') as any;
expect(provider.isReasoningModel()).toBe(true);
const providerAlias = createXAIProvider('xai:grok-4') as any;
expect(providerAlias.isReasoningModel()).toBe(true);
});
it('does not support reasoning_effort for Grok-4', () => {
// xAI's chat-completions endpoint rejects `reasoning_effort` on legacy
// Grok-4 family slugs even after the retirement-redirect to grok-4.3 is
// documented; the parameter is silently stripped before sending.
const provider = createXAIProvider('xai:grok-4-0709') as any;
expect(provider.supportsReasoningEffort()).toBe(false);
});
it('preserves reasoning_effort for Grok 4.3 chat requests', async () => {
const provider = createXAIProvider('xai:grok-4.3') as any;
const result = await provider.getOpenAiBody('test prompt', {
prompt: {
config: {
reasoning_effort: 'high',
presence_penalty: 0.5,
frequency_penalty: 0.7,
stop: ['\\n'],
},
},
});
expect(provider.supportsReasoningEffort()).toBe(true);
expect(result.body.reasoning_effort).toBe('high');
expect(result.body.presence_penalty).toBeUndefined();
expect(result.body.frequency_penalty).toBeUndefined();
expect(result.body.stop).toBeUndefined();
});
it('filters unsupported parameters for Grok-4 aliases', async () => {
const provider = createXAIProvider('xai:grok-4') as any;
const mockContext = {
prompt: {
config: {
presence_penalty: 0.5,
frequency_penalty: 0.7,
stop: ['\\n'],
reasoning_effort: 'high',
temperature: 0.8,
},
},
};
const result = await provider.getOpenAiBody('test prompt', mockContext);
// xAI's chat-completions endpoint rejects every one of these on the
// Grok-4 family, so they are stripped before send.
expect(result.body.presence_penalty).toBeUndefined();
expect(result.body.frequency_penalty).toBeUndefined();
expect(result.body.stop).toBeUndefined();
expect(result.body.reasoning_effort).toBeUndefined();
expect(result.body.temperature).toBe(0.8);
});
it('filters unsupported parameters for Grok 4.1 Fast models', async () => {
const provider = createXAIProvider('xai:grok-4-1-fast-reasoning') as any;
const mockContext = {
prompt: {
config: {
presence_penalty: 0.5,
frequency_penalty: 0.7,
stop: ['\\n'],
reasoning_effort: 'high',
temperature: 0.7,
max_completion_tokens: 2048,
},
},
};
const result = await provider.getOpenAiBody('test prompt', mockContext);
expect(result.body.presence_penalty).toBeUndefined();
expect(result.body.frequency_penalty).toBeUndefined();
expect(result.body.stop).toBeUndefined();
expect(result.body.reasoning_effort).toBeUndefined();
expect(result.body.temperature).toBe(0.7);
expect(result.body.max_completion_tokens).toBe(2048);
});
it('filters unsupported parameters for Grok 4 Fast models', async () => {
const provider = createXAIProvider('xai:grok-4-fast-reasoning') as any;
const mockContext = {
prompt: {
config: {
presence_penalty: 0.5,
frequency_penalty: 0.7,
stop: ['\\n'],
reasoning_effort: 'high',
temperature: 0.7,
},
},
};
const result = await provider.getOpenAiBody('test prompt', mockContext);
expect(result.body.presence_penalty).toBeUndefined();
expect(result.body.frequency_penalty).toBeUndefined();
expect(result.body.stop).toBeUndefined();
expect(result.body.reasoning_effort).toBeUndefined();
expect(result.body.temperature).toBe(0.7);
});
it('filters unsupported parameters for non-reasoning variants', async () => {
const provider = createXAIProvider('xai:grok-4-1-fast-non-reasoning') as any;
const mockContext = {
prompt: {
config: {
presence_penalty: 0.5,
frequency_penalty: 0.7,
stop: ['\\n'],
temperature: 0.5,
},
},
};
const result = await provider.getOpenAiBody('test prompt', mockContext);
// These should be filtered out for Grok 4.1 Fast non-reasoning
expect(result.body.presence_penalty).toBeUndefined();
expect(result.body.frequency_penalty).toBeUndefined();
expect(result.body.stop).toBeUndefined();
// Temperature should still be present
expect(result.body.temperature).toBe(0.5);
});
});
describe('Model constants', () => {
it('includes Grok-4 in reasoning models list', () => {
expect(GROK_REASONING_MODELS).toContain('grok-4-0709');
expect(GROK_REASONING_MODELS).toContain('grok-4');
expect(GROK_REASONING_MODELS).toContain('grok-4-latest');
});
it('includes Grok 4.3 in reasoning models list', () => {
expect(GROK_REASONING_MODELS).toContain('grok-4.3');
expect(GROK_REASONING_MODELS).toContain('grok-4.3-latest');
});
it('includes Grok 4.1 Fast reasoning models in reasoning models list', () => {
expect(GROK_REASONING_MODELS).toContain('grok-4-1-fast-reasoning');
expect(GROK_REASONING_MODELS).toContain('grok-4-1-fast');
expect(GROK_REASONING_MODELS).toContain('grok-4-1-fast-latest');
expect(GROK_REASONING_MODELS).toContain('grok-4-1-fast-reasoning-latest');
});
it('includes Grok 4 Fast reasoning models in reasoning models list', () => {
expect(GROK_REASONING_MODELS).toContain('grok-4-fast-reasoning');
expect(GROK_REASONING_MODELS).toContain('grok-4-fast');
expect(GROK_REASONING_MODELS).toContain('grok-4-fast-latest');
expect(GROK_REASONING_MODELS).toContain('grok-4-fast-reasoning-latest');
});
it('does NOT include non-reasoning variants in reasoning models list', () => {
expect(GROK_REASONING_MODELS).not.toContain('grok-4-1-fast-non-reasoning');
expect(GROK_REASONING_MODELS).not.toContain('grok-4-1-fast-non-reasoning-latest');
expect(GROK_REASONING_MODELS).not.toContain('grok-4-fast-non-reasoning');
expect(GROK_REASONING_MODELS).not.toContain('grok-4-fast-non-reasoning-latest');
});
it('includes Grok 4.3 and Grok 4-family models in GROK_4_MODELS', () => {
expect(GROK_4_MODELS).toContain('grok-4.3');
expect(GROK_4_MODELS).toContain('grok-4.3-latest');
expect(GROK_4_MODELS).toContain('grok-4-1-fast-reasoning');
expect(GROK_4_MODELS).toContain('grok-4-1-fast');
expect(GROK_4_MODELS).toContain('grok-4-1-fast-latest');
expect(GROK_4_MODELS).toContain('grok-4-1-fast-reasoning-latest');
expect(GROK_4_MODELS).toContain('grok-4-1-fast-non-reasoning');
expect(GROK_4_MODELS).toContain('grok-4-1-fast-non-reasoning-latest');
expect(GROK_4_MODELS).toContain('grok-4-fast-reasoning');
expect(GROK_4_MODELS).toContain('grok-4-fast');
expect(GROK_4_MODELS).toContain('grok-4-fast-latest');
expect(GROK_4_MODELS).toContain('grok-4-fast-reasoning-latest');
expect(GROK_4_MODELS).toContain('grok-4-fast-non-reasoning');
expect(GROK_4_MODELS).toContain('grok-4-fast-non-reasoning-latest');
});
it('does not include Grok-4 in reasoning effort models list', () => {
expect(GROK_REASONING_EFFORT_MODELS).not.toContain('grok-4-0709');
expect(GROK_REASONING_EFFORT_MODELS).not.toContain('grok-4');
expect(GROK_REASONING_EFFORT_MODELS).not.toContain('grok-4-latest');
});
it('includes Grok 4.3 in reasoning effort models list', () => {
expect(GROK_REASONING_EFFORT_MODELS).toContain('grok-4.3');
expect(GROK_REASONING_EFFORT_MODELS).toContain('grok-4.3-latest');
});
it('includes Grok-3 mini models in reasoning effort models list', () => {
expect(GROK_REASONING_EFFORT_MODELS).toContain('grok-3-mini-beta');
expect(GROK_REASONING_EFFORT_MODELS).toContain('grok-3-mini-fast-beta');
});
it('includes Grok-4 in XAI_CHAT_MODELS with correct pricing', () => {
const grok4 = XAI_CHAT_MODELS.find((m) => m.id === 'grok-4-0709');
expect(grok4).toBeDefined();
expect(grok4?.cost?.input).toBeDefined();
expect(grok4?.cost?.output).toBeDefined();
});
it('includes Grok 4.3 in XAI_CHAT_MODELS with API-sourced pricing', () => {
const grok43 = XAI_CHAT_MODELS.find((m) => m.id === 'grok-4.3');
expect(grok43).toBeDefined();
expect(grok43?.aliases).toContain('grok-4.3-latest');
// Verified against xAI /v1/language-models/grok-4.3 (12500/25000/2000 ticks).
expect(grok43?.cost?.input).toBe(1.25 / 1e6);
expect(grok43?.cost?.output).toBe(2.5 / 1e6);
expect(grok43?.cost?.cache_read).toBe(0.2 / 1e6);
});
it('includes Grok 4.20 family with API-sourced pricing', () => {
for (const modelId of [
'grok-4.20-0309-reasoning',
'grok-4.20-0309-non-reasoning',
'grok-4.20-multi-agent-0309',
]) {
const model = XAI_CHAT_MODELS.find((candidate) => candidate.id === modelId);
expect(model?.cost?.input).toBe(1.25 / 1e6);
expect(model?.cost?.output).toBe(2.5 / 1e6);
expect(model?.cost?.cache_read).toBe(0.2 / 1e6);
}
});
it('includes Grok 4.1 Fast in XAI_CHAT_MODELS with correct pricing', () => {
const grok41Fast = XAI_CHAT_MODELS.find((m) => m.id === 'grok-4-1-fast-reasoning');
expect(grok41Fast).toBeDefined();
expect(grok41Fast?.cost?.input).toBeDefined();
expect(grok41Fast?.cost?.output).toBeDefined();
});
it('includes Grok 4 Fast in XAI_CHAT_MODELS with correct pricing', () => {
const grok4Fast = XAI_CHAT_MODELS.find((m) => m.id === 'grok-4-fast-reasoning');
expect(grok4Fast).toBeDefined();
expect(grok4Fast?.cost?.input).toBeDefined();
expect(grok4Fast?.cost?.output).toBeDefined();
});
it('includes all Grok-3 mini aliases in reasoning constants', () => {
// Verify base models
expect(GROK_3_MINI_MODELS).toContain('grok-3-mini-beta');
expect(GROK_3_MINI_MODELS).toContain('grok-3-mini-fast-beta');
// Verify aliases exist in XAI_CHAT_MODELS
const miniModel = XAI_CHAT_MODELS.find((m) => m.id === 'grok-3-mini-beta');
expect(miniModel?.aliases).toContain('grok-3-mini');
expect(miniModel?.aliases).toContain('grok-3-mini-latest');
});
it('recognizes Grok-3 mini aliases as reasoning models', () => {
// The base models are in GROK_3_MINI_MODELS
expect(GROK_3_MINI_MODELS).toContain('grok-3-mini-beta');
// Verify aliases are properly linked
const miniModel = XAI_CHAT_MODELS.find((m) => m.id === 'grok-3-mini-beta');
expect(miniModel?.aliases).toBeDefined();
expect(miniModel?.aliases?.length).toBeGreaterThan(0);
});
});
describe('Cost calculation', () => {
it('calculates costs correctly for all model types', () => {
// Test Grok-2 - calculateXAICost(modelName, config, promptTokens, completionTokens)
const grok2Cost = calculateXAICost('grok-2-1212', {}, 600, 400);
expect(grok2Cost).toBeDefined();
expect(typeof grok2Cost).toBe('number');
// Test Grok-4
const grok4Cost = calculateXAICost('grok-4-0709', {}, 600, 400);
expect(grok4Cost).toBeDefined();
// Test Grok-3 mini (reasoning model)
const grok3MiniCost = calculateXAICost('grok-3-mini-beta', {}, 600, 400);
expect(grok3MiniCost).toBeDefined();
});
it('handles model aliases correctly', () => {
// grok-4 is an alias for grok-4-0709
const aliasCost = calculateXAICost('grok-4', {}, 600, 400);
expect(aliasCost).toBeDefined();
// grok-3-mini is an alias for grok-3-mini-beta
const miniAliasCost = calculateXAICost('grok-3-mini', {}, 600, 400);
expect(miniAliasCost).toBeDefined();
});
it('applies the cache-read rate to cached prompt tokens', () => {
// The model table defines cache_read for grok-4, but it was never applied.
const full = calculateXAICost('grok-4-0709', {}, 1000, 500);
const discounted = calculateXAICost('grok-4-0709', {}, 1000, 500, 0, 800);
expect(full).toBeDefined();
expect(discounted as number).toBeLessThan(full as number);
});
it('bills the cached prompt subset at the cache-read rate (deterministic)', () => {
// input $3/M, output $15/M, cache-read $0.75/M; 1000 prompt (800 cached), 500 output.
const rates = { inputCost: 3e-6, outputCost: 15e-6, cacheReadCost: 0.75e-6 };
// (200 uncached * 3e-6) + (800 cached * 0.75e-6) + (500 * 15e-6) = 0.0087
expect(calculateXAICost('grok-4-0709', rates, 1000, 500, 0, 800)).toBeCloseTo(0.0087, 10);
// Without any cached tokens: (1000 * 3e-6) + (500 * 15e-6) = 0.0105
expect(calculateXAICost('grok-4-0709', rates, 1000, 500)).toBeCloseTo(0.0105, 10);
// A proxy may make cache hits free; an explicit zero must not fall back to model pricing.
expect(
calculateXAICost('grok-4-0709', { ...rates, cacheReadCost: 0 }, 1000, 500, 0, 800),
).toBeCloseTo(0.0081, 10);
});
it('clamps cached prompt tokens to valid usage boundaries', () => {
const rates = { inputCost: 3e-6, outputCost: 15e-6, cacheReadCost: 0.75e-6 };
// A full cache hit bills every prompt token at the reduced rate.
expect(calculateXAICost('grok-4-0709', rates, 1000, 500, 0, 1000)).toBeCloseTo(0.00825, 10);
// Inconsistent provider usage cannot discount more tokens than the prompt contains.
expect(calculateXAICost('grok-4-0709', rates, 1000, 500, 0, 2000)).toBeCloseTo(0.00825, 10);
// Invalid negative usage falls back to the undiscounted input cost.
expect(calculateXAICost('grok-4-0709', rates, 1000, 500, 0, -1)).toBeCloseTo(0.0105, 10);
expect(calculateXAICost('grok-4-0709', rates, 1000, 500, 0, Number.NaN)).toBeCloseTo(
0.0105,
10,
);
expect(calculateXAICost('grok-4-0709', rates, 1000, 500, 0, Infinity)).toBeCloseTo(
0.0105,
10,
);
});
it('falls back to full input pricing when the model has no cache-read rate', () => {
// grok-2-1212 has input: 2.0/1e6, output: 10.0/1e6, and no cache_read rate.
// Cached tokens reported by the provider must not produce a NaN cost or a
// phantom discount; they bill at the full input rate exactly as if no cache
// tokens were reported.
const withCache = calculateXAICost('grok-2-1212', {}, 1000, 500, 0, 800);
const withoutCache = calculateXAICost('grok-2-1212', {}, 1000, 500);
// (2.0/1e6 * 1000) + (10.0/1e6 * 500) = 0.002 + 0.005 = 0.007
expect(withCache).toBeCloseTo(0.007, 10);
expect(withCache).toBe(withoutCache);
});
it('uses Grok 4.3 fallback pricing for redirected legacy chat slugs', () => {
for (const modelName of [
'grok-4-1-fast-reasoning',
'grok-4-fast-non-reasoning',
'grok-4',
'grok-3',
// The grok-3 family collapses every -beta and -fast variant into the
// same id on xAI's catalog, so all of them must redirect together.
'grok-3-beta',
'grok-3-fast',
'grok-3-fast-beta',
'grok-3-fast-latest',
]) {
expect(calculateXAICost(modelName, {}, 1_000_000, 1_000_000)).toBeCloseTo(3.75, 10);
}
});
it('bills grok-build-0.1 and its grok-code-fast aliases at its own pricing', () => {
// xAI's docs list grok-code-fast-1 / grok-code-fast / grok-code-fast-1-0825 as
// aliases of grok-build-0.1 ($1.00 input / $2.00 output per 1M tokens), so they
// must NOT redirect to grok-4.3 pricing.
for (const modelName of [
'grok-build-0.1',
'grok-code-fast-1',
'grok-code-fast',
'grok-code-fast-1-0825',
]) {
expect(calculateXAICost(modelName, {}, 100_000, 1_000_000)).toBeCloseTo(2.1, 10);
}
});
it('switches grok-build-0.1 and its aliases to higher-context pricing above 200k tokens', () => {
for (const modelName of [
'grok-build-0.1',
'grok-code-fast-1',
'grok-code-fast',
'grok-code-fast-1-0825',
]) {
// Exactly at the threshold stays on the standard tier (exclusive >).
expect(calculateXAICost(modelName, {}, 200_000, 1_000)).toBeCloseTo(
(200_000 * 1 + 1_000 * 2) / 1e6,
10,
);
// One token over switches the whole request to the higher tier.
expect(calculateXAICost(modelName, {}, 200_001, 1_000)).toBeCloseTo(
(200_001 * 2 + 1_000 * 4) / 1e6,
10,
);
}
});
it('uses higher-context cache-read pricing for grok-build-0.1 cached prompt tokens', () => {
expect(calculateXAICost('grok-build-0.1', {}, 200_001, 1_000, 0, 100_000)).toBeCloseTo(
(100_001 * 2 + 100_000 * 0.4 + 1_000 * 4) / 1e6,
10,
);
});
it('returns undefined for invalid inputs', () => {
// Unknown model
expect(calculateXAICost('invalid-model', {}, 600, 400)).toBe(undefined);
// Missing token counts
expect(calculateXAICost('grok-2-1212', {})).toBe(undefined);
expect(calculateXAICost('grok-2-1212', {}, 0, 0)).toBe(undefined);
});
it('calculates cost based on model pricing', () => {
// grok-2-1212 has input: 2.0/1e6 and output: 10.0/1e6
const cost = calculateXAICost('grok-2-1212', {}, 1000000, 1000000);
expect(cost).toBeDefined();
// Expected: (2.0/1e6 * 1000000) + (10.0/1e6 * 1000000) = 2.0 + 10.0 = 12.0
expect(cost).toBeCloseTo(12.0, 2);
});
it('uses separate custom input and output costs from config', () => {
const cost = calculateXAICost(
'grok-2-1212',
{ inputCost: 0.001, outputCost: 0.003 },
1000,
500,
);
expect(cost).toBe(2.5);
});
it('prefers separate custom costs over custom cost', () => {
const cost = calculateXAICost(
'grok-2-1212',
{ cost: 0.02, inputCost: 0.001, outputCost: 0.003 },
1000,
500,
);
expect(cost).toBe(2.5);
});
it('does not mix model cache pricing with custom input cost overrides', () => {
expect(calculateXAICost('grok-4-0709', { cost: 0.001 }, 1000, 500, 0, 800)).toBe(1.5);
expect(
calculateXAICost('grok-4-0709', { inputCost: 0.001, outputCost: 0.003 }, 1000, 500, 0, 800),
).toBe(2.5);
});
it('does not double-count reasoning tokens already included in completion tokens', () => {
// grok-3-mini-beta: input $0.30/M, output $0.50/M.
const baseline = calculateXAICost('grok-3-mini-beta', {}, 500, 500);
// 500 input @ 0.30/M + 500 output @ 0.50/M = 0.00015 + 0.00025 = 0.0004
expect(baseline).toBeCloseTo(0.0004, 10);
// completion_tokens already INCLUDES reasoning_tokens (the OpenAI
// convention xAI follows), so passing reasoning separately must not add
// to the bill — the cost is identical to the baseline.
const withReasoning = calculateXAICost('grok-3-mini-beta', {}, 500, 500, 200);
expect(withReasoning).toBeCloseTo(0.0004, 10);
});
it('bills a reasoning-only response (no other completion tokens)', () => {
// grok-3-mini-beta: input $0.30/M, output $0.50/M. A turn that reports
// zero completion tokens but non-zero reasoning still has a real cost: we
// fall back to billing the reasoning tokens at the output rate.
const cost = calculateXAICost('grok-3-mini-beta', {}, 500, 0, 200);
// 500 input @ 0.30/M + 200 reasoning @ 0.50/M = 0.00015 + 0.0001
expect(cost).toBeCloseTo(0.00025, 10);
});
it('bills only completion tokens (reasoning included) when input tokens are zero', () => {
const cost = calculateXAICost('grok-3-mini-beta', {}, 0, 200, 50);
// 0 input @ 0.30/M + 200 completion @ 0.50/M (the 50 reasoning tokens are
// already part of the 200 completion tokens, not added on top).
expect(cost).toBeCloseTo(0.0001, 10);
});
});
describe('Model constants and configuration', () => {
it('defines correct Grok-3 mini models', () => {
expect(GROK_3_MINI_MODELS).toContain('grok-3-mini-beta');
expect(GROK_3_MINI_MODELS).toContain('grok-3-mini-fast-beta');
});
it('defines model costs correctly', () => {
// Check that XAI_CHAT_MODELS has cost information
const modelsWithCosts = XAI_CHAT_MODELS.filter((m) => m.cost);
expect(modelsWithCosts.length).toBeGreaterThan(0);
// Verify cost structure
modelsWithCosts.forEach((model) => {
expect(model.cost).toBeDefined();
expect(model.cost?.input).toBeDefined();
expect(model.cost?.output).toBeDefined();
});
});
it('includes all required model properties', () => {
XAI_CHAT_MODELS.forEach((model) => {
expect(model.id).toBeDefined();
expect(typeof model.id).toBe('string');
});
});
it('has correct aliases for models', () => {
// grok-4 should have grok-4-latest alias
const grok4 = XAI_CHAT_MODELS.find((m) => m.id === 'grok-4-0709');
expect(grok4?.aliases).toContain('grok-4');
expect(grok4?.aliases).toContain('grok-4-latest');
// grok-3-beta should have aliases
const _grok3 = XAI_CHAT_MODELS.find((m) => m.id === 'grok-3-beta');
const modelWithAliases = XAI_CHAT_MODELS.find((m) => m.aliases?.includes('grok-3'));
expect(modelWithAliases).toBeDefined();
expect(modelWithAliases?.aliases).toEqual(['grok-3', 'grok-3-latest']);
});
});
describe('callApi error handling', () => {
// These tests verify XAIProvider's error handling behavior
// Note: When fetchWithCache throws, the parent class (OpenAiChatCompletionProvider)
// catches it first and returns { error: 'API call error: ...' }
// XAIProvider then enhances specific error patterns with xAI-specific messages
it('should pass through errors from parent class', async () => {
// When fetchWithCache throws, parent class catches and returns error object
mockFetchWithCache.mockRejectedValueOnce(new Error('Network timeout'));
const provider = createXAIProvider('xai:grok-4', {
config: {
apiKey: 'test-key',
} as any,
});
const result = await provider.callApi('test prompt');
expect(result.error).toBeDefined();
// Error comes through parent class first
expect(result.error).toContain('API call error:');
});
it('should enhance 502 Bad Gateway errors in response.error', async () => {
// When the parent returns an error containing '502 Bad Gateway',
// XAIProvider enhances it with xAI-specific messaging
mockFetchWithCache.mockResolvedValueOnce({
data: { error: { message: 'Bad Gateway' } },
cached: false,
status: 502,
statusText: 'Bad Gateway',
});
const provider = createXAIProvider('xai:grok-4', {
config: {
apiKey: 'test-key',
} as any,
});
const result = await provider.callApi('test prompt');
expect(result.error).toBeDefined();
// XAIProvider enhances errors containing '502 Bad Gateway'
expect(result.error).toContain('x.ai API error:');
expect(result.error).toContain('XAI_API_KEY');
});
it('should handle authentication errors', async () => {
// Mock fetchWithCache to return an authentication error
mockFetchWithCache.mockResolvedValueOnce({
data: { error: { message: 'Invalid API key' } },
cached: false,
status: 401,
statusText: 'Unauthorized',
});
const provider = createXAIProvider('xai:grok-4', {
config: {
apiKey: 'test-key',
} as any,
});
const result = await provider.callApi('test prompt');
expect(result.error).toBeDefined();
expect(result.error).toContain('API error: 401');
});
it('should pass through successful responses', async () => {
const successResponse = {
data: {
choices: [{ message: { content: 'Test response' } }],
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
},
cached: false,
status: 200,
statusText: 'OK',
};
mockFetchWithCache.mockResolvedValueOnce(successResponse);
const provider = createXAIProvider('xai:grok-4', {
config: {
apiKey: 'test-key',
} as any,
});
const result = await provider.callApi('test prompt');
expect(result.output).toBe('Test response');
expect(result.error).toBeUndefined();
});
it('should include token usage in response', async () => {
mockFetchWithCache.mockResolvedValueOnce({
data: {
choices: [{ message: { content: 'Response with tokens' } }],
usage: { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 },
},
cached: false,
status: 200,
statusText: 'OK',
});
const provider = createXAIProvider('xai:grok-4', {
config: {
apiKey: 'test-key',
} as any,
});
const result = await provider.callApi('test prompt');
expect(result.output).toBe('Response with tokens');
expect(result.tokenUsage).toBeDefined();
expect(result.tokenUsage?.prompt).toBe(100);
expect(result.tokenUsage?.completion).toBe(50);
expect(result.tokenUsage?.total).toBe(150);
});
it('should calculate cost for successful responses', async () => {
mockFetchWithCache.mockResolvedValueOnce({
data: {
choices: [{ message: { content: 'Test response' } }],
usage: { prompt_tokens: 1000, completion_tokens: 500, total_tokens: 1500 },
},
cached: false,
status: 200,
statusText: 'OK',
});
const provider = createXAIProvider('xai:grok-4', {
config: {
apiKey: 'test-key',
} as any,
});
const result = await provider.callApi('test prompt');
expect(result.output).toBe('Test response');
expect(result.cost).toBeDefined();
expect(typeof result.cost).toBe('number');
});
it('should apply cache-read pricing from normalized token usage', async () => {
mockFetchWithCache.mockResolvedValueOnce({
data: {
choices: [{ message: { content: 'Cached response' } }],
usage: {
prompt_tokens: 1000,
completion_tokens: 500,
total_tokens: 1500,
prompt_tokens_details: { cached_tokens: 800 },
},
},
cached: false,
status: 200,
statusText: 'OK',
});
const provider = createXAIProvider('xai:grok-4-0709', {
config: {
config: {
apiKey: 'test-key',
inputCost: 3e-6,
outputCost: 15e-6,
cacheReadCost: 0.75e-6,
},
},
});
const result = await provider.callApi('test prompt');
expect(result.tokenUsage?.completionDetails?.cacheReadInputTokens).toBe(800);
expect(result.cost).toBeCloseTo(0.0087, 10);
});
it('should leave cost undefined when usage is missing', async () => {
mockFetchWithCache.mockResolvedValueOnce({
data: {
choices: [{ message: { content: 'Response without usage' } }],
},
cached: false,
status: 200,
statusText: 'OK',
});
const provider = createXAIProvider('xai:grok-4-0709', {
config: { apiKey: 'test-key' } as any,
});
const result = await provider.callApi('test prompt');
expect(result.output).toBe('Response without usage');
expect(result.cost).toBeUndefined();
});
it('does not double-count reasoning tokens already included in completion tokens', async () => {
mockFetchWithCache.mockResolvedValueOnce({
data: {
choices: [{ message: { content: 'reasoned answer' } }],
usage: {
prompt_tokens: 500,
// completion_tokens already includes the 200 reasoning tokens
// (prompt 500 + completion 500 = total 1000 confirms reasoning is a
// subset of completion, not a separate addend).
completion_tokens: 500,
total_tokens: 1000,
completion_tokens_details: { reasoning_tokens: 200 },
},
},
cached: false,
status: 200,
statusText: 'OK',
});
const provider = createXAIProvider('xai:grok-3-mini-beta', {
config: { apiKey: 'test-key' } as any,
});
const result = await provider.callApi('test prompt');
// grok-3-mini-beta: input $0.30/M, output $0.50/M. Reasoning tokens are
// already part of the 500 completion tokens, so bill 500 output tokens:
// 500 input @ 0.30/M + 500 output @ 0.50/M = 0.00015 + 0.00025 = 0.0004.
expect(result.tokenUsage?.completionDetails?.reasoning).toBe(200);
expect(result.cost).toBeCloseTo(0.0004, 10);
});
});
});