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
1079 lines
40 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|