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
4872 lines
162 KiB
TypeScript
4872 lines
162 KiB
TypeScript
import { BedrockRuntime } from '@aws-sdk/client-bedrock-runtime';
|
|
import { NodeHttpHandler } from '@smithy/node-http-handler';
|
|
import dedent from 'dedent';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { getCache, isCacheEnabled } from '../../../src/cache';
|
|
import logger from '../../../src/logger';
|
|
import {
|
|
AwsBedrockGenericProvider,
|
|
createBedrockCacheKeyHash,
|
|
} from '../../../src/providers/bedrock/base';
|
|
import {
|
|
AWS_BEDROCK_MODELS,
|
|
AwsBedrockCompletionProvider,
|
|
addConfigParam,
|
|
BEDROCK_MODEL,
|
|
coerceStrToNum,
|
|
extractTextAndImages,
|
|
extractTextContent,
|
|
formatPromptLlama2Chat,
|
|
formatPromptLlama3Instruct,
|
|
formatPromptLlama4,
|
|
formatPromptLlama32Vision,
|
|
getHandlerForModel,
|
|
getLlamaModelHandler,
|
|
LlamaVersion,
|
|
parseValue,
|
|
} from '../../../src/providers/bedrock/index';
|
|
import { mockProcessEnv } from '../../util/utils';
|
|
|
|
import type {
|
|
BedrockAI21GenerationOptions,
|
|
BedrockClaudeMessagesCompletionOptions,
|
|
BedrockOpenAIGenerationOptions,
|
|
IBedrockModel,
|
|
LlamaMessage,
|
|
TextGenerationOptions,
|
|
} from '../../../src/providers/bedrock/index';
|
|
|
|
const RETIRED_BEDROCK_MODEL_IDS = [
|
|
'amazon.titan-text-express-v1',
|
|
'amazon.titan-text-lite-v1',
|
|
'amazon.titan-text-premier-v1:0',
|
|
'anthropic.claude-3-opus-20240229-v1:0',
|
|
'us.anthropic.claude-3-opus-20240229-v1:0',
|
|
'anthropic.claude-opus-4-20250514-v1:0',
|
|
'us.anthropic.claude-opus-4-20250514-v1:0',
|
|
'anthropic.claude-instant-v1',
|
|
'anthropic.claude-v1',
|
|
'anthropic.claude-v2',
|
|
'anthropic.claude-v2:1',
|
|
'cohere.command-text-v14',
|
|
'cohere.command-light-text-v14',
|
|
'meta.llama2-13b-chat-v1',
|
|
'meta.llama2-70b-chat-v1',
|
|
] as const;
|
|
|
|
const bedrockRuntimeFactory = vi.hoisted(() => {
|
|
const mockInvokeModel = vi.fn();
|
|
const BedrockRuntimeMock = vi.fn(function BedrockRuntimeMock(this: any) {
|
|
return { invokeModel: mockInvokeModel };
|
|
});
|
|
|
|
return { BedrockRuntimeMock, mockInvokeModel };
|
|
});
|
|
|
|
const nodeHttpHandlerFactory = vi.hoisted(() => {
|
|
let handlerFactory = () => ({ handle: vi.fn() });
|
|
|
|
const NodeHttpHandlerMock = vi.fn(function NodeHttpHandlerMock(this: any) {
|
|
return handlerFactory();
|
|
});
|
|
|
|
return {
|
|
NodeHttpHandlerMock,
|
|
setHandlerFactory: (factory: () => any) => {
|
|
handlerFactory = factory;
|
|
},
|
|
};
|
|
});
|
|
|
|
const credentialProviderSsoFactory = vi.hoisted(() => ({
|
|
fromSSO: vi.fn(() => 'sso-provider'),
|
|
}));
|
|
|
|
vi.mock('@aws-sdk/client-bedrock-runtime', async (importOriginal) => {
|
|
return {
|
|
...(await importOriginal()),
|
|
BedrockRuntime: bedrockRuntimeFactory.BedrockRuntimeMock,
|
|
};
|
|
});
|
|
|
|
const BedrockRuntimeMock = vi.mocked(BedrockRuntime);
|
|
|
|
vi.mock('@smithy/node-http-handler', () => ({
|
|
__esModule: true,
|
|
NodeHttpHandler: nodeHttpHandlerFactory.NodeHttpHandlerMock,
|
|
default: nodeHttpHandlerFactory.NodeHttpHandlerMock,
|
|
}));
|
|
|
|
const NodeHttpHandlerMock = vi.mocked(NodeHttpHandler);
|
|
|
|
vi.mock('@aws-sdk/credential-provider-sso', () => ({
|
|
fromSSO: credentialProviderSsoFactory.fromSSO,
|
|
}));
|
|
|
|
// Preserve proxy variables so they can be restored after each test. These are
|
|
// set in the container environment and can influence proxy-related logic in the
|
|
// provider implementation.
|
|
const ORIGINAL_HTTP_PROXY = process.env.HTTP_PROXY;
|
|
const ORIGINAL_HTTPS_PROXY = process.env.HTTPS_PROXY;
|
|
|
|
vi.mock('proxy-agent', () => ({
|
|
__esModule: true,
|
|
ProxyAgent: vi.fn(function ProxyAgentMock() {}),
|
|
default: vi.fn(function ProxyAgentMock() {}),
|
|
}));
|
|
|
|
vi.mock('../../../src/cache', async (importOriginal) => {
|
|
return {
|
|
...(await importOriginal()),
|
|
getCache: vi.fn(),
|
|
isCacheEnabled: vi.fn(),
|
|
};
|
|
});
|
|
|
|
class TestBedrockProvider extends AwsBedrockGenericProvider {
|
|
modelName = 'test-model';
|
|
|
|
constructor(config: any = {}) {
|
|
super('test-model', { config });
|
|
}
|
|
|
|
async getClient() {
|
|
return this.getBedrockInstance();
|
|
}
|
|
|
|
async generateText(_prompt: string, _options?: TextGenerationOptions): Promise<string> {
|
|
return '';
|
|
}
|
|
|
|
async generateChat(_messages: any[], _options?: any): Promise<any> {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
describe('AwsBedrockGenericProvider', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
credentialProviderSsoFactory.fromSSO.mockReset();
|
|
credentialProviderSsoFactory.fromSSO.mockReturnValue('sso-provider');
|
|
mockProcessEnv({ AWS_BEDROCK_MAX_RETRIES: undefined });
|
|
mockProcessEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined });
|
|
// Ensure proxy environment variables do not force proxy-specific code paths
|
|
// when running tests. The container sets HTTP_PROXY by default which causes
|
|
// getBedrockInstance to require optional dependencies that are not
|
|
// installed in the test environment.
|
|
mockProcessEnv({ HTTP_PROXY: '' });
|
|
mockProcessEnv({ HTTPS_PROXY: '' });
|
|
|
|
nodeHttpHandlerFactory.setHandlerFactory(function () {
|
|
return { handle: vi.fn() };
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
mockProcessEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined });
|
|
if (ORIGINAL_HTTP_PROXY === undefined) {
|
|
mockProcessEnv({ HTTP_PROXY: undefined });
|
|
} else {
|
|
mockProcessEnv({ HTTP_PROXY: ORIGINAL_HTTP_PROXY });
|
|
}
|
|
if (ORIGINAL_HTTPS_PROXY === undefined) {
|
|
mockProcessEnv({ HTTPS_PROXY: undefined });
|
|
} else {
|
|
mockProcessEnv({ HTTPS_PROXY: ORIGINAL_HTTPS_PROXY });
|
|
}
|
|
});
|
|
|
|
it('should create Bedrock instance without proxy settings', async () => {
|
|
const provider = new (class extends AwsBedrockGenericProvider {
|
|
constructor() {
|
|
super('test-model', { config: { region: 'us-east-1' } });
|
|
}
|
|
})();
|
|
await provider.getBedrockInstance();
|
|
|
|
expect(NodeHttpHandlerMock).toHaveBeenCalled();
|
|
const requestHandler = NodeHttpHandlerMock.mock.results.at(-1)?.value;
|
|
expect(BedrockRuntimeMock).toHaveBeenCalledWith({
|
|
region: 'us-east-1',
|
|
retryMode: 'adaptive',
|
|
maxAttempts: 10,
|
|
requestHandler,
|
|
});
|
|
});
|
|
|
|
it('should create Bedrock instance with credentials', async () => {
|
|
const provider = new (class extends AwsBedrockGenericProvider {
|
|
constructor() {
|
|
super('test-model', {
|
|
config: {
|
|
region: 'us-east-1',
|
|
accessKeyId: 'test-access-key',
|
|
secretAccessKey: 'test-secret-key',
|
|
},
|
|
});
|
|
}
|
|
})();
|
|
await provider.getBedrockInstance();
|
|
|
|
expect(NodeHttpHandlerMock).toHaveBeenCalled();
|
|
const requestHandler = NodeHttpHandlerMock.mock.results.at(-1)?.value;
|
|
expect(BedrockRuntimeMock).toHaveBeenCalledWith({
|
|
region: 'us-east-1',
|
|
retryMode: 'adaptive',
|
|
maxAttempts: 10,
|
|
requestHandler,
|
|
credentials: {
|
|
accessKeyId: 'test-access-key',
|
|
secretAccessKey: 'test-secret-key',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should not include credentials if not provided', async () => {
|
|
const provider = new (class extends AwsBedrockGenericProvider {
|
|
constructor() {
|
|
super('test-model', { config: { region: 'us-east-1' } });
|
|
}
|
|
})();
|
|
await provider.getBedrockInstance();
|
|
|
|
expect(NodeHttpHandlerMock).toHaveBeenCalled();
|
|
const requestHandler = NodeHttpHandlerMock.mock.results.at(-1)?.value;
|
|
expect(BedrockRuntimeMock).toHaveBeenCalledWith({
|
|
region: 'us-east-1',
|
|
retryMode: 'adaptive',
|
|
maxAttempts: 10,
|
|
requestHandler,
|
|
});
|
|
expect(BedrockRuntimeMock).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({ credentials: expect.anything() }),
|
|
);
|
|
});
|
|
|
|
it('should respect AWS_BEDROCK_MAX_RETRIES environment variable', async () => {
|
|
mockProcessEnv({ AWS_BEDROCK_MAX_RETRIES: '10' });
|
|
const provider = new (class extends AwsBedrockGenericProvider {
|
|
constructor() {
|
|
super('test-model', { config: { region: 'us-east-1' } });
|
|
}
|
|
})();
|
|
await provider.getBedrockInstance();
|
|
|
|
expect(NodeHttpHandlerMock).toHaveBeenCalled();
|
|
const requestHandler = NodeHttpHandlerMock.mock.results.at(-1)?.value;
|
|
expect(BedrockRuntimeMock).toHaveBeenCalledWith({
|
|
region: 'us-east-1',
|
|
retryMode: 'adaptive',
|
|
maxAttempts: 10,
|
|
requestHandler,
|
|
});
|
|
});
|
|
|
|
it('should create Bedrock instance with custom request handler for API key authentication', async () => {
|
|
const mockHandler = {
|
|
handle: vi.fn(),
|
|
};
|
|
nodeHttpHandlerFactory.setHandlerFactory(function () {
|
|
return mockHandler;
|
|
});
|
|
|
|
const provider = new (class extends AwsBedrockGenericProvider {
|
|
constructor() {
|
|
super('test-model', {
|
|
config: {
|
|
region: 'us-east-1',
|
|
apiKey: 'test-api-key',
|
|
},
|
|
});
|
|
}
|
|
})();
|
|
|
|
await provider.getBedrockInstance();
|
|
|
|
expect(NodeHttpHandlerMock).toHaveBeenCalled();
|
|
const requestHandler = NodeHttpHandlerMock.mock.results.at(-1)?.value;
|
|
expect(BedrockRuntimeMock).toHaveBeenCalledWith({
|
|
region: 'us-east-1',
|
|
retryMode: 'adaptive',
|
|
maxAttempts: 10,
|
|
requestHandler,
|
|
});
|
|
});
|
|
|
|
it('should create custom request handler when AWS_BEARER_TOKEN_BEDROCK env var is set', async () => {
|
|
mockProcessEnv({ AWS_BEARER_TOKEN_BEDROCK: 'test-env-api-key' });
|
|
const mockHandler = {
|
|
handle: vi.fn(),
|
|
};
|
|
nodeHttpHandlerFactory.setHandlerFactory(function () {
|
|
return mockHandler;
|
|
});
|
|
|
|
const provider = new (class extends AwsBedrockGenericProvider {
|
|
constructor() {
|
|
super('test-model', { config: { region: 'us-east-1' } });
|
|
}
|
|
})();
|
|
|
|
await provider.getBedrockInstance();
|
|
|
|
expect(NodeHttpHandlerMock).toHaveBeenCalled();
|
|
const requestHandler = NodeHttpHandlerMock.mock.results.at(-1)?.value;
|
|
expect(BedrockRuntimeMock).toHaveBeenCalledWith({
|
|
region: 'us-east-1',
|
|
retryMode: 'adaptive',
|
|
maxAttempts: 10,
|
|
requestHandler,
|
|
});
|
|
|
|
mockProcessEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined });
|
|
});
|
|
|
|
it('should add Authorization header with Bearer token to requests when using API key', async () => {
|
|
const mockOriginalHandle = vi.fn().mockResolvedValue('response');
|
|
const mockHandler = {
|
|
handle: mockOriginalHandle,
|
|
};
|
|
nodeHttpHandlerFactory.setHandlerFactory(function () {
|
|
return mockHandler;
|
|
});
|
|
|
|
const provider = new (class extends AwsBedrockGenericProvider {
|
|
constructor() {
|
|
super('test-model', {
|
|
config: {
|
|
region: 'us-east-1',
|
|
apiKey: 'test-api-key',
|
|
},
|
|
});
|
|
}
|
|
})();
|
|
|
|
await provider.getBedrockInstance();
|
|
|
|
// Verify the handler was modified to add Bearer token
|
|
expect(mockHandler.handle).toBeDefined();
|
|
|
|
// Test that the modified handler adds the Authorization header
|
|
const mockRequest = {
|
|
headers: {},
|
|
};
|
|
|
|
await mockHandler.handle(mockRequest, {});
|
|
|
|
expect(mockRequest.headers).toEqual({
|
|
Authorization: 'Bearer test-api-key',
|
|
});
|
|
expect(mockOriginalHandle).toHaveBeenCalledWith(mockRequest, {});
|
|
});
|
|
|
|
describe('Custom endpoint support', () => {
|
|
it('should use custom endpoint when endpoint is specified', async () => {
|
|
const provider = new (class extends AwsBedrockGenericProvider {
|
|
constructor() {
|
|
super('test-model', {
|
|
config: {
|
|
region: 'us-east-1',
|
|
endpoint: 'https://custom-bedrock-endpoint.example.com',
|
|
},
|
|
});
|
|
}
|
|
})();
|
|
|
|
await provider.getBedrockInstance();
|
|
|
|
expect(BedrockRuntimeMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
region: 'us-east-1',
|
|
retryMode: 'adaptive',
|
|
maxAttempts: 10,
|
|
endpoint: 'https://custom-bedrock-endpoint.example.com',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should not set endpoint when endpoint is not specified', async () => {
|
|
const provider = new (class extends AwsBedrockGenericProvider {
|
|
constructor() {
|
|
super('test-model', {
|
|
config: {
|
|
region: 'us-east-1',
|
|
},
|
|
});
|
|
}
|
|
})();
|
|
|
|
await provider.getBedrockInstance();
|
|
|
|
const callArgs = BedrockRuntimeMock.mock.calls[0][0];
|
|
expect(callArgs).not.toHaveProperty('endpoint');
|
|
});
|
|
});
|
|
|
|
describe('Inference Profile ARN support', () => {
|
|
it('should handle inference profile ARN with claude model type', async () => {
|
|
const arnModelName =
|
|
'arn:aws:bedrock:us-east-1:123456789012:inference-profile/claude-inference';
|
|
const config: any = { inferenceModelType: 'claude' };
|
|
|
|
// This test checks that getHandlerForModel correctly identifies the handler
|
|
// We need to import and test the actual function
|
|
const provider = new AwsBedrockCompletionProvider(arnModelName, { config });
|
|
|
|
// The handler should be CLAUDE_MESSAGES based on the inferenceModelType
|
|
expect(provider.modelName).toBe(arnModelName);
|
|
expect((provider.config as any).inferenceModelType).toBe('claude');
|
|
});
|
|
|
|
it('should handle inference profile ARN with nova model type', async () => {
|
|
const arnModelName =
|
|
'arn:aws:bedrock:us-east-1:123456789012:inference-profile/nova-inference';
|
|
const config: any = { inferenceModelType: 'nova' };
|
|
|
|
const provider = new AwsBedrockCompletionProvider(arnModelName, { config });
|
|
|
|
expect(provider.modelName).toBe(arnModelName);
|
|
expect((provider.config as any).inferenceModelType).toBe('nova');
|
|
});
|
|
|
|
it('should handle inference profile ARN with nova2 model type', async () => {
|
|
const arnModelName =
|
|
'arn:aws:bedrock:us-east-1:123456789012:inference-profile/nova2-inference';
|
|
const config: any = { inferenceModelType: 'nova2' };
|
|
|
|
const provider = new AwsBedrockCompletionProvider(arnModelName, { config });
|
|
|
|
expect(provider.modelName).toBe(arnModelName);
|
|
expect((provider.config as any).inferenceModelType).toBe('nova2');
|
|
});
|
|
|
|
it('should handle inference profile ARN with llama model type', async () => {
|
|
const arnModelName =
|
|
'arn:aws:bedrock:us-east-1:123456789012:inference-profile/llama-inference';
|
|
const config: any = { inferenceModelType: 'llama' };
|
|
|
|
const provider = new AwsBedrockCompletionProvider(arnModelName, { config });
|
|
|
|
expect(provider.modelName).toBe(arnModelName);
|
|
expect((provider.config as any).inferenceModelType).toBe('llama');
|
|
});
|
|
|
|
it('should handle inference profile ARN with deepseek model type', async () => {
|
|
const arnModelName =
|
|
'arn:aws:bedrock:us-east-1:123456789012:inference-profile/deepseek-inference';
|
|
const config: any = { inferenceModelType: 'deepseek' };
|
|
|
|
const provider = new AwsBedrockCompletionProvider(arnModelName, { config });
|
|
|
|
expect(provider.modelName).toBe(arnModelName);
|
|
expect((provider.config as any).inferenceModelType).toBe('deepseek');
|
|
});
|
|
|
|
it('should handle inference profile ARN with openai model type', async () => {
|
|
const arnModelName =
|
|
'arn:aws:bedrock:us-east-1:123456789012:inference-profile/openai-inference';
|
|
const config: any = { inferenceModelType: 'openai' };
|
|
|
|
const provider = new AwsBedrockCompletionProvider(arnModelName, { config });
|
|
|
|
expect(provider.modelName).toBe(arnModelName);
|
|
expect((provider.config as any).inferenceModelType).toBe('openai');
|
|
});
|
|
|
|
it('should throw error for inference profile ARN without inferenceModelType', async () => {
|
|
const arnModelName =
|
|
'arn:aws:bedrock:us-east-1:123456789012:inference-profile/some-inference';
|
|
|
|
const provider = new AwsBedrockCompletionProvider(arnModelName, { config: {} });
|
|
|
|
// This should throw when callApi is invoked and it tries to get the handler
|
|
expect(provider.modelName).toBe(arnModelName);
|
|
// The error will be thrown when actually trying to use the model
|
|
});
|
|
});
|
|
|
|
describe('BEDROCK_MODEL CLAUDE_MESSAGES', () => {
|
|
const modelHandler = BEDROCK_MODEL.CLAUDE_MESSAGES;
|
|
|
|
it('should include tools and tool_choice in params when provided', async () => {
|
|
const config: BedrockClaudeMessagesCompletionOptions = {
|
|
region: 'us-east-1',
|
|
tools: [
|
|
{
|
|
name: 'get_current_weather',
|
|
description: 'Get the current weather in a given location',
|
|
input_schema: {
|
|
type: 'object',
|
|
properties: {
|
|
location: { type: 'string' },
|
|
unit: { type: 'string', enum: ['celsius', 'fahrenheit'] },
|
|
},
|
|
required: ['location'],
|
|
},
|
|
},
|
|
],
|
|
tool_choice: {
|
|
type: 'auto',
|
|
},
|
|
};
|
|
|
|
const params = await modelHandler.params(config, 'Test prompt');
|
|
|
|
expect(params).toHaveProperty('tools');
|
|
expect(params.tools).toHaveLength(1);
|
|
expect(params.tools[0]).toHaveProperty('name', 'get_current_weather');
|
|
expect(params).toHaveProperty('tool_choice');
|
|
expect(params.tool_choice).toEqual({ type: 'auto' });
|
|
});
|
|
|
|
it('should not include tools and tool_choice in params when not provided', async () => {
|
|
const config: BedrockClaudeMessagesCompletionOptions = {
|
|
region: 'us-east-1',
|
|
};
|
|
|
|
const params = await modelHandler.params(config, 'Test prompt');
|
|
|
|
expect(params).not.toHaveProperty('tools');
|
|
expect(params).not.toHaveProperty('tool_choice');
|
|
});
|
|
|
|
it('should include specific tool_choice when provided', async () => {
|
|
const config: BedrockClaudeMessagesCompletionOptions = {
|
|
region: 'us-east-1',
|
|
tools: [
|
|
{
|
|
name: 'get_current_weather',
|
|
description: 'Get the current weather in a given location',
|
|
input_schema: {
|
|
type: 'object',
|
|
properties: {
|
|
location: { type: 'string' },
|
|
unit: { type: 'string', enum: ['celsius', 'fahrenheit'] },
|
|
},
|
|
required: ['location'],
|
|
},
|
|
},
|
|
],
|
|
tool_choice: {
|
|
type: 'tool',
|
|
name: 'get_current_weather',
|
|
},
|
|
};
|
|
|
|
const params = await modelHandler.params(config, 'Test prompt');
|
|
|
|
expect(params).toHaveProperty('tool_choice');
|
|
expect(params.tool_choice).toEqual({ type: 'tool', name: 'get_current_weather' });
|
|
});
|
|
|
|
it('should handle JSON message array with image content', async () => {
|
|
const config: BedrockClaudeMessagesCompletionOptions = {
|
|
region: 'us-east-1',
|
|
};
|
|
|
|
const prompt = JSON.stringify([
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'text', text: "What's in this image?" },
|
|
{
|
|
type: 'image',
|
|
source: {
|
|
type: 'base64',
|
|
media_type: 'image/jpeg',
|
|
data: 'base64EncodedImageData',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
const params = await BEDROCK_MODEL.CLAUDE_MESSAGES.params(config, prompt);
|
|
|
|
expect(params.messages).toEqual([
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'text', text: "What's in this image?" },
|
|
{
|
|
type: 'image',
|
|
source: {
|
|
type: 'base64',
|
|
media_type: 'image/jpeg',
|
|
data: 'base64EncodedImageData',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should handle JSON message array with system message and image content', async () => {
|
|
const config: BedrockClaudeMessagesCompletionOptions = {
|
|
region: 'us-east-1',
|
|
};
|
|
|
|
const prompt = JSON.stringify([
|
|
{ role: 'system', content: 'You are a helpful assistant.' },
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'text', text: 'Describe this image:' },
|
|
{
|
|
type: 'image',
|
|
source: {
|
|
type: 'base64',
|
|
media_type: 'image/png',
|
|
data: 'base64EncodedImageData',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
const params = await BEDROCK_MODEL.CLAUDE_MESSAGES.params(config, prompt);
|
|
|
|
expect(params.messages).toEqual([
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'text', text: 'Describe this image:' },
|
|
{
|
|
type: 'image',
|
|
source: {
|
|
type: 'base64',
|
|
media_type: 'image/png',
|
|
data: 'base64EncodedImageData',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
expect(params.system).toBe('You are a helpful assistant.');
|
|
});
|
|
|
|
it('omits temperature for Claude Opus 4.7 on Bedrock invokeModel path', async () => {
|
|
const config: BedrockClaudeMessagesCompletionOptions = {
|
|
region: 'us-east-1',
|
|
temperature: 0.5,
|
|
};
|
|
// Regional inference profile ID — matches `us.`, `eu.`, `jp.`, `global.` via .includes()
|
|
const params = await BEDROCK_MODEL.CLAUDE_MESSAGES.params(
|
|
config,
|
|
'hi',
|
|
undefined,
|
|
'us.anthropic.claude-opus-4-7',
|
|
);
|
|
expect(params.temperature).toBeUndefined();
|
|
});
|
|
|
|
it('omits temperature for Claude Opus 4.8 on Bedrock invokeModel path', async () => {
|
|
const config: BedrockClaudeMessagesCompletionOptions = {
|
|
region: 'us-east-1',
|
|
temperature: 0.5,
|
|
};
|
|
// Regional inference profile ID — matches `us.`, `eu.`, `jp.`, `global.` via .includes()
|
|
const params = await BEDROCK_MODEL.CLAUDE_MESSAGES.params(
|
|
config,
|
|
'hi',
|
|
undefined,
|
|
'us.anthropic.claude-opus-4-8',
|
|
);
|
|
expect(params.temperature).toBeUndefined();
|
|
});
|
|
|
|
it('converts manual thinking to adaptive for Claude Opus 4.8 on Bedrock invokeModel', async () => {
|
|
const config: BedrockClaudeMessagesCompletionOptions = {
|
|
region: 'us-east-1',
|
|
thinking: { type: 'enabled', budget_tokens: 5000 },
|
|
};
|
|
const params = await BEDROCK_MODEL.CLAUDE_MESSAGES.params(
|
|
config,
|
|
'hi',
|
|
undefined,
|
|
'us.anthropic.claude-opus-4-8',
|
|
);
|
|
expect(params.thinking).toEqual({ type: 'adaptive' });
|
|
});
|
|
|
|
it('normalizes unsupported controls for Claude Fable 5 on Bedrock invokeModel', async () => {
|
|
const enabledParams = await BEDROCK_MODEL.CLAUDE_MESSAGES.params(
|
|
{
|
|
region: 'us-east-1',
|
|
temperature: 0.5,
|
|
thinking: { type: 'enabled', budget_tokens: 5000, display: 'summarized' },
|
|
},
|
|
'hi',
|
|
undefined,
|
|
'anthropic.claude-fable-5',
|
|
);
|
|
expect(enabledParams.temperature).toBeUndefined();
|
|
expect(enabledParams.thinking).toEqual({ type: 'adaptive', display: 'summarized' });
|
|
|
|
const disabledParams = await BEDROCK_MODEL.CLAUDE_MESSAGES.params(
|
|
{ region: 'us-east-1', thinking: { type: 'disabled' } },
|
|
'hi',
|
|
undefined,
|
|
'anthropic.claude-fable-5',
|
|
);
|
|
expect(disabledParams.thinking).toBeUndefined();
|
|
});
|
|
|
|
it.each([
|
|
{ type: 'any' as const },
|
|
{ type: 'tool' as const, name: 'get_weather' },
|
|
])('omits forced tool choice for Claude Fable 5: %j', async (tool_choice) => {
|
|
const params = await BEDROCK_MODEL.CLAUDE_MESSAGES.params(
|
|
{
|
|
region: 'us-east-1',
|
|
tools: [
|
|
{
|
|
name: 'get_weather',
|
|
description: 'Get the weather',
|
|
input_schema: { type: 'object', properties: {} },
|
|
},
|
|
],
|
|
tool_choice,
|
|
},
|
|
'hi',
|
|
undefined,
|
|
'anthropic.claude-fable-5',
|
|
);
|
|
|
|
expect(params.tools).toHaveLength(1);
|
|
expect(params.tool_choice).toBeUndefined();
|
|
});
|
|
|
|
it('keeps manual thinking enabled for non-deprecated Claude Opus 4.6 on Bedrock invokeModel', async () => {
|
|
const config: BedrockClaudeMessagesCompletionOptions = {
|
|
region: 'us-east-1',
|
|
thinking: { type: 'enabled', budget_tokens: 5000 },
|
|
};
|
|
const params = await BEDROCK_MODEL.CLAUDE_MESSAGES.params(
|
|
config,
|
|
'hi',
|
|
undefined,
|
|
'us.anthropic.claude-opus-4-6-v1',
|
|
);
|
|
expect(params.thinking).toEqual({ type: 'enabled', budget_tokens: 5000 });
|
|
});
|
|
|
|
it('keeps disabled thinking unchanged for Claude Opus 4.8 on Bedrock invokeModel', async () => {
|
|
const config: BedrockClaudeMessagesCompletionOptions = {
|
|
region: 'us-east-1',
|
|
thinking: { type: 'disabled' },
|
|
};
|
|
const params = await BEDROCK_MODEL.CLAUDE_MESSAGES.params(
|
|
config,
|
|
'hi',
|
|
undefined,
|
|
'us.anthropic.claude-opus-4-8',
|
|
);
|
|
expect(params.thinking).toEqual({ type: 'disabled' });
|
|
});
|
|
|
|
it('keeps adaptive thinking unchanged for Claude Opus 4.8 on Bedrock invokeModel', async () => {
|
|
const config: BedrockClaudeMessagesCompletionOptions = {
|
|
region: 'us-east-1',
|
|
thinking: { type: 'adaptive' },
|
|
};
|
|
const params = await BEDROCK_MODEL.CLAUDE_MESSAGES.params(
|
|
config,
|
|
'hi',
|
|
undefined,
|
|
'us.anthropic.claude-opus-4-8',
|
|
);
|
|
expect(params.thinking).toEqual({ type: 'adaptive' });
|
|
});
|
|
|
|
it('silently omits temperature on Claude Opus 4.8 invokeModel path (no per-request warning)', async () => {
|
|
// The shared CLAUDE_MESSAGES.params handler has no per-instance state to
|
|
// dedup a warning across requests, so it normalizes silently; the Anthropic
|
|
// Messages provider surfaces the one-time heads-up instead. This guards
|
|
// against re-introducing the per-request log spam that was flagged in review.
|
|
const warnSpy = vi.spyOn(logger, 'warn');
|
|
const config: BedrockClaudeMessagesCompletionOptions = {
|
|
region: 'us-east-1',
|
|
temperature: 0.5,
|
|
};
|
|
const params = await BEDROCK_MODEL.CLAUDE_MESSAGES.params(
|
|
config,
|
|
'hi',
|
|
undefined,
|
|
'us.anthropic.claude-opus-4-8',
|
|
);
|
|
expect(params.temperature).toBeUndefined();
|
|
const deprecationWarnings = warnSpy.mock.calls.filter((call) =>
|
|
String(call[0] ?? '').includes('deprecated on Claude Opus'),
|
|
);
|
|
expect(deprecationWarnings).toHaveLength(0);
|
|
});
|
|
|
|
it('still forwards temperature for Claude Opus 4.6 on Bedrock invokeModel (regression)', async () => {
|
|
const config: BedrockClaudeMessagesCompletionOptions = {
|
|
region: 'us-east-1',
|
|
temperature: 0,
|
|
};
|
|
const params = await BEDROCK_MODEL.CLAUDE_MESSAGES.params(
|
|
config,
|
|
'hi',
|
|
undefined,
|
|
'us.anthropic.claude-opus-4-6-v1',
|
|
);
|
|
expect(params.temperature).toBe(0);
|
|
});
|
|
|
|
it('should convert lone system message to user message', async () => {
|
|
const config: BedrockClaudeMessagesCompletionOptions = {
|
|
region: 'us-east-1',
|
|
};
|
|
|
|
// Test with string content
|
|
const promptWithStringContent = JSON.stringify([
|
|
{ role: 'system', content: 'You are a helpful assistant.' },
|
|
]);
|
|
const paramsWithString = await modelHandler.params(config, promptWithStringContent);
|
|
expect(paramsWithString.messages).toEqual([
|
|
{
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'You are a helpful assistant.' }],
|
|
},
|
|
]);
|
|
expect(paramsWithString.system).toBeUndefined();
|
|
|
|
// Test with array content
|
|
const promptWithArrayContent = JSON.stringify([
|
|
{
|
|
role: 'system',
|
|
content: [
|
|
{ type: 'text', text: 'You are a helpful assistant.' },
|
|
{ type: 'text', text: 'Additional context.' },
|
|
],
|
|
},
|
|
]);
|
|
const paramsWithArray = await modelHandler.params(config, promptWithArrayContent);
|
|
expect(paramsWithArray.messages).toEqual([
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'text', text: 'You are a helpful assistant.' },
|
|
{ type: 'text', text: 'Additional context.' },
|
|
],
|
|
},
|
|
]);
|
|
expect(paramsWithArray.system).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('BEDROCK_MODEL AI21', () => {
|
|
const modelHandler = BEDROCK_MODEL.AI21;
|
|
|
|
it('should include AI21-specific parameters', async () => {
|
|
const config: BedrockAI21GenerationOptions = {
|
|
region: 'us-east-1',
|
|
max_tokens: 100,
|
|
temperature: 0.7,
|
|
top_p: 0.9,
|
|
stop: ['END'],
|
|
frequency_penalty: 0.5,
|
|
presence_penalty: 0.3,
|
|
};
|
|
|
|
const prompt = 'Write a short story about a robot.';
|
|
const params = await modelHandler.params(config, prompt);
|
|
|
|
expect(params).toEqual({
|
|
messages: [{ role: 'user', content: prompt }],
|
|
max_tokens: 100,
|
|
temperature: 0.7,
|
|
top_p: 0.9,
|
|
stop: ['END'],
|
|
frequency_penalty: 0.5,
|
|
presence_penalty: 0.3,
|
|
});
|
|
});
|
|
|
|
it('should use default values when config is not provided', async () => {
|
|
const config = {};
|
|
const prompt = 'Tell me a joke.';
|
|
|
|
const params = await modelHandler.params(config, prompt);
|
|
|
|
expect(params).toEqual({
|
|
messages: [{ role: 'user', content: prompt }],
|
|
temperature: 0,
|
|
top_p: 1.0,
|
|
});
|
|
});
|
|
|
|
it('should handle output correctly', async () => {
|
|
const mockResponse = {
|
|
choices: [{ message: { content: 'This is a test response.' } }],
|
|
};
|
|
expect(modelHandler.output({}, mockResponse)).toBe('This is a test response.');
|
|
});
|
|
|
|
it('should throw an error for API errors', async () => {
|
|
const mockErrorResponse = { error: 'API Error' };
|
|
expect(() => modelHandler.output({}, mockErrorResponse)).toThrow('AI21 API error: API Error');
|
|
});
|
|
});
|
|
|
|
describe('BEDROCK_MODEL TITAN_TEXT', () => {
|
|
const modelHandler = BEDROCK_MODEL.TITAN_TEXT;
|
|
|
|
it('should extract outputText from the first result', () => {
|
|
const mockResponse = { results: [{ outputText: 'This is a test response.' }] };
|
|
expect(modelHandler.output({}, mockResponse)).toBe('This is a test response.');
|
|
});
|
|
|
|
it('should return undefined when results are missing instead of throwing', () => {
|
|
expect(modelHandler.output({}, {})).toBeUndefined();
|
|
expect(modelHandler.output({}, { results: [] })).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('BEDROCK_MODEL COHERE_COMMAND', () => {
|
|
const modelHandler = BEDROCK_MODEL.COHERE_COMMAND;
|
|
|
|
it('should extract text from the first generation', () => {
|
|
const mockResponse = { generations: [{ text: 'This is a test response.' }] };
|
|
expect(modelHandler.output({}, mockResponse)).toBe('This is a test response.');
|
|
});
|
|
|
|
it('should return undefined when generations are missing instead of throwing', () => {
|
|
expect(modelHandler.output({}, {})).toBeUndefined();
|
|
expect(modelHandler.output({}, { generations: [] })).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('getCredentials', () => {
|
|
it('should return credentials if accessKeyId and secretAccessKey are provided', async () => {
|
|
const provider = new (class extends AwsBedrockGenericProvider {
|
|
constructor() {
|
|
super('test-model', {
|
|
config: {
|
|
accessKeyId: 'test-access-key',
|
|
secretAccessKey: 'test-secret-key',
|
|
},
|
|
});
|
|
}
|
|
})();
|
|
|
|
const credentials = await provider.getCredentials();
|
|
expect(credentials).toEqual({
|
|
accessKeyId: 'test-access-key',
|
|
secretAccessKey: 'test-secret-key',
|
|
});
|
|
});
|
|
|
|
it('should return undefined if accessKeyId or secretAccessKey is missing', async () => {
|
|
const provider = new (class extends AwsBedrockGenericProvider {
|
|
constructor() {
|
|
super('test-model', {
|
|
config: {
|
|
accessKeyId: 'test-access-key',
|
|
},
|
|
});
|
|
}
|
|
})();
|
|
|
|
const credentials = await provider.getCredentials();
|
|
expect(credentials).toBeUndefined();
|
|
});
|
|
|
|
it('should return credentials when accessKeyId and secretAccessKey are provided', async () => {
|
|
const provider = new TestBedrockProvider({
|
|
accessKeyId: 'test-key',
|
|
secretAccessKey: 'test-secret',
|
|
sessionToken: 'test-token',
|
|
});
|
|
|
|
const credentials = await provider.getCredentials();
|
|
expect(credentials).toEqual({
|
|
accessKeyId: 'test-key',
|
|
secretAccessKey: 'test-secret',
|
|
sessionToken: 'test-token',
|
|
});
|
|
});
|
|
|
|
it('should return SSO credential provider when profile is specified', async () => {
|
|
const provider = new TestBedrockProvider({
|
|
profile: 'test-profile',
|
|
});
|
|
|
|
const credentials = await provider.getCredentials();
|
|
expect(credentialProviderSsoFactory.fromSSO).toHaveBeenCalledWith({
|
|
profile: 'test-profile',
|
|
});
|
|
expect(credentials).toBe('sso-provider');
|
|
});
|
|
|
|
it('should return undefined when no credentials are provided', async () => {
|
|
const provider = new TestBedrockProvider({});
|
|
const credentials = await provider.getCredentials();
|
|
expect(credentials).toBeUndefined();
|
|
});
|
|
|
|
it('should return undefined for API key authentication when apiKey is provided in config', async () => {
|
|
const provider = new TestBedrockProvider({
|
|
apiKey: 'test-api-key',
|
|
});
|
|
const credentials = await provider.getCredentials();
|
|
expect(credentials).toBeUndefined();
|
|
});
|
|
|
|
it('should return undefined for API key authentication when AWS_BEARER_TOKEN_BEDROCK env var is set', async () => {
|
|
mockProcessEnv({ AWS_BEARER_TOKEN_BEDROCK: 'test-env-api-key' });
|
|
const provider = new TestBedrockProvider({});
|
|
const credentials = await provider.getCredentials();
|
|
expect(credentials).toBeUndefined();
|
|
mockProcessEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined });
|
|
});
|
|
|
|
it('should prioritize config apiKey over environment variable', async () => {
|
|
mockProcessEnv({ AWS_BEARER_TOKEN_BEDROCK: 'test-env-api-key' });
|
|
const provider = new TestBedrockProvider({
|
|
apiKey: 'test-config-api-key',
|
|
});
|
|
const credentials = await provider.getCredentials();
|
|
expect(credentials).toBeUndefined(); // API key auth returns undefined for credentials
|
|
mockProcessEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined });
|
|
});
|
|
|
|
it('should prioritize explicit credentials over API key', async () => {
|
|
const provider = new TestBedrockProvider({
|
|
apiKey: 'test-api-key',
|
|
accessKeyId: 'test-access-key',
|
|
secretAccessKey: 'test-secret-key',
|
|
});
|
|
const credentials = await provider.getCredentials();
|
|
expect(credentials).toEqual({
|
|
accessKeyId: 'test-access-key',
|
|
secretAccessKey: 'test-secret-key',
|
|
sessionToken: undefined,
|
|
}); // Explicit credentials take priority
|
|
});
|
|
|
|
it('should use API key when no explicit credentials are provided', async () => {
|
|
const provider = new TestBedrockProvider({
|
|
apiKey: 'test-api-key',
|
|
// No accessKeyId/secretAccessKey provided
|
|
});
|
|
const credentials = await provider.getCredentials();
|
|
expect(credentials).toBeUndefined(); // API key auth returns undefined for credentials
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('addConfigParam', () => {
|
|
it('should add config value if provided', async () => {
|
|
const params: any = {};
|
|
addConfigParam(params, 'key', 'configValue');
|
|
expect(params.key).toBe('configValue');
|
|
});
|
|
|
|
it('should add env value if config value is not provided', async () => {
|
|
const params: any = {};
|
|
mockProcessEnv({ TEST_ENV_KEY: 'envValue' });
|
|
addConfigParam(params, 'key', undefined, process.env.TEST_ENV_KEY);
|
|
expect(params.key).toBe('envValue');
|
|
mockProcessEnv({ TEST_ENV_KEY: undefined });
|
|
});
|
|
|
|
it('should add default value if neither config nor env value is provided', async () => {
|
|
const params: any = {};
|
|
addConfigParam(params, 'key', undefined, undefined, 'defaultValue');
|
|
expect(params.key).toBe('defaultValue');
|
|
});
|
|
|
|
it('should prioritize config value over env and default values', async () => {
|
|
const params: any = {};
|
|
mockProcessEnv({ TEST_ENV_KEY: 'envValue' });
|
|
addConfigParam(params, 'key', 'configValue', process.env.TEST_ENV_KEY, 'defaultValue');
|
|
expect(params.key).toBe('configValue');
|
|
mockProcessEnv({ TEST_ENV_KEY: undefined });
|
|
});
|
|
|
|
it('should prioritize env value over default value if config value is not provided', async () => {
|
|
const params: any = {};
|
|
mockProcessEnv({ TEST_ENV_KEY: 'envValue' });
|
|
addConfigParam(params, 'key', undefined, process.env.TEST_ENV_KEY, 'defaultValue');
|
|
expect(params.key).toBe('envValue');
|
|
mockProcessEnv({ TEST_ENV_KEY: undefined });
|
|
});
|
|
|
|
it('should parse env value if default value is a number', async () => {
|
|
const params: any = {};
|
|
mockProcessEnv({ TEST_ENV_KEY: '42' });
|
|
addConfigParam(params, 'key', undefined, process.env.TEST_ENV_KEY, 0);
|
|
expect(params.key).toBe(42);
|
|
mockProcessEnv({ TEST_ENV_KEY: undefined });
|
|
});
|
|
|
|
it('should handle undefined config, env, and default values gracefully', async () => {
|
|
const params: any = {};
|
|
addConfigParam(params, 'key', undefined, undefined, undefined);
|
|
expect(params.key).toBeUndefined();
|
|
});
|
|
|
|
it('should correctly parse non-number string values', async () => {
|
|
const params: any = {};
|
|
mockProcessEnv({ TEST_ENV_KEY: 'nonNumberString' });
|
|
addConfigParam(params, 'key', undefined, process.env.TEST_ENV_KEY, 0);
|
|
expect(params.key).toBe(0);
|
|
mockProcessEnv({ TEST_ENV_KEY: undefined });
|
|
});
|
|
|
|
it('should correctly parse empty string values', async () => {
|
|
const params: any = {};
|
|
mockProcessEnv({ TEST_ENV_KEY: '' });
|
|
addConfigParam(params, 'key', undefined, process.env.TEST_ENV_KEY, 'defaultValue');
|
|
expect(params.key).toBe('');
|
|
mockProcessEnv({ TEST_ENV_KEY: undefined });
|
|
});
|
|
|
|
it('should handle env value not set', async () => {
|
|
const params: any = {};
|
|
addConfigParam(params, 'key', undefined, process.env.UNSET_ENV_KEY, 'defaultValue');
|
|
expect(params.key).toBe('defaultValue');
|
|
});
|
|
|
|
it('should handle config values that are objects', async () => {
|
|
const params: any = {};
|
|
const configValue = { nestedKey: 'nestedValue' };
|
|
addConfigParam(params, 'key', configValue);
|
|
expect(params.key).toEqual(configValue);
|
|
});
|
|
|
|
it('should handle config values that are arrays', async () => {
|
|
const params: any = {};
|
|
const configValue = ['value1', 'value2'];
|
|
addConfigParam(params, 'key', configValue);
|
|
expect(params.key).toEqual(configValue);
|
|
});
|
|
|
|
it('should handle special characters in env values', async () => {
|
|
const params: any = {};
|
|
mockProcessEnv({ TEST_ENV_KEY: '!@#$%^&*()_+' });
|
|
addConfigParam(params, 'key', undefined, process.env.TEST_ENV_KEY, 'defaultValue');
|
|
expect(params.key).toBe('!@#$%^&*()_+');
|
|
mockProcessEnv({ TEST_ENV_KEY: undefined });
|
|
});
|
|
});
|
|
|
|
describe('parseValue', () => {
|
|
it('should return the original value if defaultValue is not a number', async () => {
|
|
expect(parseValue('stringValue', 'defaultValue')).toBe('stringValue');
|
|
});
|
|
|
|
it('should return parsed float value if defaultValue is a number', async () => {
|
|
expect(parseValue('42.5', 0)).toBe(42.5);
|
|
});
|
|
|
|
it('should return NaN for non-numeric strings if defaultValue is a number', async () => {
|
|
expect(parseValue('notANumber', 0)).toBe(0);
|
|
});
|
|
|
|
it('should return 0 for an empty string if defaultValue is a number', async () => {
|
|
expect(parseValue('', 0)).toBe(0);
|
|
});
|
|
|
|
it('should return null for a null value if defaultValue is not a number', async () => {
|
|
expect(parseValue(null as never, 'defaultValue')).toBeNull();
|
|
});
|
|
|
|
it('should return undefined for an undefined value if defaultValue is not a number', async () => {
|
|
expect(parseValue(undefined as never, 'defaultValue')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('llama', () => {
|
|
describe('getLlamaModelHandler', () => {
|
|
describe('LLAMA2', () => {
|
|
const handler = getLlamaModelHandler(LlamaVersion.V2);
|
|
|
|
it('should generate correct prompt for a single user message', async () => {
|
|
const config = { temperature: 0.5, top_p: 0.9, max_gen_len: 512 };
|
|
const prompt = 'Describe the purpose of a "hello world" program in one sentence.';
|
|
await expect(handler.params(config, prompt)).resolves.toEqual({
|
|
prompt: `<s>[INST] Describe the purpose of a \"hello world\" program in one sentence. [/INST]`,
|
|
temperature: 0.5,
|
|
top_p: 0.9,
|
|
max_gen_len: 512,
|
|
});
|
|
});
|
|
|
|
it('should handle a system message followed by a user message', async () => {
|
|
const config = {};
|
|
const prompt = JSON.stringify([
|
|
{ role: 'system', content: 'You are a helpful assistant.' },
|
|
{ role: 'user', content: 'What is the capital of France?' },
|
|
]);
|
|
await expect(handler.params(config, prompt)).resolves.toEqual({
|
|
prompt: dedent`<s>[INST] <<SYS>>
|
|
You are a helpful assistant.
|
|
<</SYS>>
|
|
|
|
What is the capital of France? [/INST]`,
|
|
temperature: 0,
|
|
top_p: 1,
|
|
max_gen_len: 1024,
|
|
});
|
|
});
|
|
|
|
it('should handle multiple turns of conversation', async () => {
|
|
const config = {};
|
|
const prompt = JSON.stringify([
|
|
{ role: 'user', content: 'Hello' },
|
|
{ role: 'assistant', content: 'Hi there! How can I assist you today?' },
|
|
{ role: 'user', content: "What's the weather like?" },
|
|
]);
|
|
await expect(handler.params(config, prompt)).resolves.toEqual({
|
|
prompt:
|
|
"<s>[INST] Hello [/INST] Hi there! How can I assist you today? </s><s>[INST] What's the weather like? [/INST]",
|
|
temperature: 0,
|
|
top_p: 1,
|
|
max_gen_len: 1024,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('LLAMA3', () => {
|
|
const handler = getLlamaModelHandler(LlamaVersion.V3);
|
|
|
|
it('should generate correct prompt for a single user message', async () => {
|
|
const config = { temperature: 0.5, top_p: 0.9, max_gen_len: 512 };
|
|
const prompt = 'Describe the purpose of a "hello world" program in one sentence.';
|
|
await expect(handler.params(config, prompt)).resolves.toEqual({
|
|
prompt: dedent`<|begin_of_text|><|start_header_id|>user<|end_header_id|>
|
|
|
|
Describe the purpose of a "hello world" program in one sentence.<|eot_id|><|start_header_id|>assistant<|end_header_id|>`,
|
|
temperature: 0.5,
|
|
top_p: 0.9,
|
|
max_gen_len: 512,
|
|
});
|
|
});
|
|
|
|
it('should handle a system message followed by a user message', async () => {
|
|
const config = {};
|
|
const prompt = JSON.stringify([
|
|
{ role: 'system', content: 'You are a helpful assistant.' },
|
|
{ role: 'user', content: 'What is the capital of France?' },
|
|
]);
|
|
await expect(handler.params(config, prompt)).resolves.toEqual({
|
|
prompt: dedent`<|begin_of_text|><|start_header_id|>system<|end_header_id|>
|
|
|
|
You are a helpful assistant.<|eot_id|><|start_header_id|>user<|end_header_id|>
|
|
|
|
What is the capital of France?<|eot_id|><|start_header_id|>assistant<|end_header_id|>`,
|
|
temperature: 0,
|
|
top_p: 1,
|
|
max_gen_len: 1024,
|
|
});
|
|
});
|
|
|
|
it('should handle multiple turns of conversation', async () => {
|
|
const config = {};
|
|
const prompt = JSON.stringify([
|
|
{ role: 'user', content: 'Hello' },
|
|
{ role: 'assistant', content: 'Hi there! How can I assist you today?' },
|
|
{ role: 'user', content: "What's the weather like?" },
|
|
]);
|
|
await expect(handler.params(config, prompt)).resolves.toEqual({
|
|
prompt: dedent`<|begin_of_text|><|start_header_id|>user<|end_header_id|>
|
|
|
|
Hello<|eot_id|><|start_header_id|>assistant<|end_header_id|>
|
|
|
|
Hi there! How can I assist you today?<|eot_id|><|start_header_id|>user<|end_header_id|>
|
|
|
|
What's the weather like?<|eot_id|><|start_header_id|>assistant<|end_header_id|>`,
|
|
temperature: 0,
|
|
top_p: 1,
|
|
max_gen_len: 1024,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('LLAMA3_1', () => {
|
|
const handler = getLlamaModelHandler(LlamaVersion.V3_1);
|
|
|
|
it('should generate correct prompt for a single user message', async () => {
|
|
const config = { temperature: 0.5, top_p: 0.9, max_gen_len: 512 };
|
|
const prompt = 'Describe the purpose of a "hello world" program in one sentence.';
|
|
await expect(handler.params(config, prompt)).resolves.toEqual({
|
|
prompt: dedent`<|begin_of_text|><|start_header_id|>user<|end_header_id|>
|
|
|
|
Describe the purpose of a "hello world" program in one sentence.<|eot_id|><|start_header_id|>assistant<|end_header_id|>`,
|
|
temperature: 0.5,
|
|
top_p: 0.9,
|
|
max_gen_len: 512,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('LLAMA3_2', () => {
|
|
const handler = getLlamaModelHandler(LlamaVersion.V3_2);
|
|
|
|
it('should generate correct prompt for a single user message', async () => {
|
|
const config = { temperature: 0.5, top_p: 0.9, max_gen_len: 512 };
|
|
const prompt = 'Describe the purpose of a "hello world" program in one sentence.';
|
|
await expect(handler.params(config, prompt)).resolves.toEqual({
|
|
prompt: dedent`<|begin_of_text|><|start_header_id|>user<|end_header_id|>
|
|
|
|
Describe the purpose of a "hello world" program in one sentence.<|eot_id|><|start_header_id|>assistant<|end_header_id|>`,
|
|
temperature: 0.5,
|
|
top_p: 0.9,
|
|
max_gen_len: 512,
|
|
});
|
|
});
|
|
|
|
it('should use max_gen_len parameter', async () => {
|
|
const config = { max_gen_len: 1000 };
|
|
const prompt = 'Test prompt';
|
|
const params = await handler.params(config, prompt);
|
|
expect(params).toHaveProperty('max_gen_len', 1000);
|
|
});
|
|
});
|
|
|
|
describe('LLAMA3_3', () => {
|
|
const handler = getLlamaModelHandler(LlamaVersion.V3_3);
|
|
|
|
it('should generate correct prompt for a single user message', async () => {
|
|
const config = { temperature: 0.5, top_p: 0.9, max_gen_len: 512 };
|
|
const prompt = 'Describe the purpose of a "hello world" program in one sentence.';
|
|
await expect(handler.params(config, prompt)).resolves.toEqual({
|
|
prompt: dedent`<|begin_of_text|><|start_header_id|>user<|end_header_id|>
|
|
|
|
Describe the purpose of a "hello world" program in one sentence.<|eot_id|><|start_header_id|>assistant<|end_header_id|>`,
|
|
temperature: 0.5,
|
|
top_p: 0.9,
|
|
max_gen_len: 512,
|
|
});
|
|
});
|
|
|
|
it('should use max_gen_len parameter', async () => {
|
|
const config = { max_gen_len: 1000 };
|
|
const prompt = 'Test prompt';
|
|
const params = await handler.params(config, prompt);
|
|
expect(params).toHaveProperty('max_gen_len', 1000);
|
|
});
|
|
});
|
|
|
|
describe('LLAMA4', () => {
|
|
const handler = getLlamaModelHandler(LlamaVersion.V4);
|
|
|
|
it('should generate correct prompt for a single user message', async () => {
|
|
const config = { temperature: 0.5, top_p: 0.9, max_gen_len: 512 };
|
|
const prompt = 'Describe the purpose of a "hello world" program in one sentence.';
|
|
await expect(handler.params(config, prompt)).resolves.toEqual({
|
|
prompt: dedent`<|begin_of_text|><|header_start|>user<|header_end|>
|
|
|
|
Describe the purpose of a "hello world" program in one sentence.<|eot|><|header_start|>assistant<|header_end|>`,
|
|
temperature: 0.5,
|
|
top_p: 0.9,
|
|
max_gen_len: 512,
|
|
});
|
|
});
|
|
|
|
it('should handle a system message followed by a user message', async () => {
|
|
const config = {};
|
|
const prompt = JSON.stringify([
|
|
{ role: 'system', content: 'You are a helpful assistant.' },
|
|
{ role: 'user', content: 'What is the capital of France?' },
|
|
]);
|
|
await expect(handler.params(config, prompt)).resolves.toEqual({
|
|
prompt: dedent`<|begin_of_text|><|header_start|>system<|header_end|>
|
|
|
|
You are a helpful assistant.<|eot|><|header_start|>user<|header_end|>
|
|
|
|
What is the capital of France?<|eot|><|header_start|>assistant<|header_end|>`,
|
|
temperature: 0,
|
|
top_p: 1,
|
|
max_gen_len: 1024,
|
|
});
|
|
});
|
|
|
|
it('should handle multiple turns of conversation', async () => {
|
|
const config = {};
|
|
const prompt = JSON.stringify([
|
|
{ role: 'user', content: 'Hello' },
|
|
{ role: 'assistant', content: 'Hi there! How can I assist you today?' },
|
|
{ role: 'user', content: "What's the weather like?" },
|
|
]);
|
|
await expect(handler.params(config, prompt)).resolves.toEqual({
|
|
prompt: dedent`<|begin_of_text|><|header_start|>user<|header_end|>
|
|
|
|
Hello<|eot|><|header_start|>assistant<|header_end|>
|
|
|
|
Hi there! How can I assist you today?<|eot|><|header_start|>user<|header_end|>
|
|
|
|
What's the weather like?<|eot|><|header_start|>assistant<|header_end|>`,
|
|
temperature: 0,
|
|
top_p: 1,
|
|
max_gen_len: 1024,
|
|
});
|
|
});
|
|
});
|
|
|
|
it('should throw an error for unsupported LLAMA version', async () => {
|
|
expect(() => getLlamaModelHandler(1 as LlamaVersion)).toThrow('Unsupported LLAMA version: 1');
|
|
});
|
|
|
|
it('should handle output correctly', async () => {
|
|
const handler = getLlamaModelHandler(LlamaVersion.V2);
|
|
expect(handler.output({}, { generation: 'Test response' })).toBe('Test response');
|
|
expect(handler.output({}, {})).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('formatPromptLlama2Chat', () => {
|
|
it('should format a single user message correctly', async () => {
|
|
const messages: LlamaMessage[] = [
|
|
{
|
|
role: 'user',
|
|
content: 'Describe the purpose of a "hello world" program in one sentence.',
|
|
},
|
|
];
|
|
const expectedPrompt =
|
|
'<s>[INST] Describe the purpose of a "hello world" program in one sentence. [/INST]';
|
|
expect(formatPromptLlama2Chat(messages)).toBe(expectedPrompt);
|
|
});
|
|
|
|
it('should handle a system message followed by a user message', async () => {
|
|
const messages: LlamaMessage[] = [
|
|
{ role: 'system', content: 'You are a helpful assistant.' },
|
|
{ role: 'user', content: 'What is the capital of France?' },
|
|
];
|
|
const expectedPrompt = dedent`
|
|
<s>[INST] <<SYS>>
|
|
You are a helpful assistant.
|
|
<</SYS>>
|
|
|
|
What is the capital of France? [/INST]
|
|
`;
|
|
expect(formatPromptLlama2Chat(messages)).toBe(expectedPrompt);
|
|
});
|
|
|
|
it('should handle a system message, user message, and assistant response', async () => {
|
|
const messages: LlamaMessage[] = [
|
|
{ role: 'system', content: 'You are a helpful assistant.' },
|
|
{ role: 'user', content: 'What is the capital of France?' },
|
|
{ role: 'assistant', content: 'The capital of France is Paris.' },
|
|
];
|
|
const expectedPrompt = dedent`
|
|
<s>[INST] <<SYS>>
|
|
You are a helpful assistant.
|
|
<</SYS>>
|
|
|
|
What is the capital of France? [/INST] The capital of France is Paris. </s>
|
|
`;
|
|
expect(formatPromptLlama2Chat(messages)).toBe(expectedPrompt);
|
|
});
|
|
|
|
it('should handle multiple turns of conversation', async () => {
|
|
// see https://huggingface.co/blog/llama2#how-to-prompt-llama-2
|
|
const messages: LlamaMessage[] = [
|
|
{ role: 'system', content: 'You are a helpful assistant.' },
|
|
{ role: 'user', content: 'Hello' },
|
|
{ role: 'assistant', content: 'Hi there! How can I assist you today?' },
|
|
{ role: 'user', content: "What's the weather like?" },
|
|
];
|
|
const expectedPrompt = dedent`
|
|
<s>[INST] <<SYS>>
|
|
You are a helpful assistant.
|
|
<</SYS>>
|
|
|
|
Hello [/INST] Hi there! How can I assist you today? </s><s>[INST] What's the weather like? [/INST]
|
|
`;
|
|
expect(formatPromptLlama2Chat(messages)).toBe(expectedPrompt);
|
|
});
|
|
|
|
it('should handle only a system message correctly', async () => {
|
|
const messages: LlamaMessage[] = [
|
|
{ role: 'system', content: 'You are a helpful assistant.' },
|
|
];
|
|
const expectedPrompt = `${dedent`
|
|
<s>[INST] <<SYS>>
|
|
You are a helpful assistant.
|
|
<</SYS>>
|
|
|
|
`}\n\n`;
|
|
expect(formatPromptLlama2Chat(messages)).toBe(expectedPrompt);
|
|
});
|
|
});
|
|
|
|
describe('formatPromptLlama3Instruct', () => {
|
|
it('should format a single user message correctly', async () => {
|
|
const messages: LlamaMessage[] = [{ role: 'user', content: 'Hello, how are you?' }];
|
|
const expected = dedent`
|
|
<|begin_of_text|><|start_header_id|>user<|end_header_id|>
|
|
|
|
Hello, how are you?<|eot_id|><|start_header_id|>assistant<|end_header_id|>`;
|
|
expect(formatPromptLlama3Instruct(messages)).toBe(expected);
|
|
});
|
|
|
|
it('should format multiple messages correctly', async () => {
|
|
const messages: LlamaMessage[] = [
|
|
{ role: 'user', content: 'Hello' },
|
|
{ role: 'assistant', content: 'Hi there! How can I help you?' },
|
|
{ role: 'user', content: "What's the weather like?" },
|
|
];
|
|
const expected = dedent`
|
|
<|begin_of_text|><|start_header_id|>user<|end_header_id|>
|
|
|
|
Hello<|eot_id|><|start_header_id|>assistant<|end_header_id|>
|
|
|
|
Hi there! How can I help you?<|eot_id|><|start_header_id|>user<|end_header_id|>
|
|
|
|
What's the weather like?<|eot_id|><|start_header_id|>assistant<|end_header_id|>`;
|
|
expect(formatPromptLlama3Instruct(messages)).toBe(expected);
|
|
});
|
|
|
|
it('should handle system messages correctly', async () => {
|
|
const messages: LlamaMessage[] = [
|
|
{ role: 'system', content: 'You are a helpful assistant.' },
|
|
{ role: 'user', content: 'Hello' },
|
|
];
|
|
const expected = dedent`
|
|
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
|
|
|
|
You are a helpful assistant.<|eot_id|><|start_header_id|>user<|end_header_id|>
|
|
|
|
Hello<|eot_id|><|start_header_id|>assistant<|end_header_id|>`;
|
|
expect(formatPromptLlama3Instruct(messages)).toBe(expected);
|
|
});
|
|
});
|
|
|
|
describe('formatPromptLlama4', () => {
|
|
it('should format a single user message correctly', async () => {
|
|
const messages: LlamaMessage[] = [{ role: 'user', content: 'Hello, how are you?' }];
|
|
const expected = dedent`<|begin_of_text|><|header_start|>user<|header_end|>
|
|
|
|
Hello, how are you?<|eot|><|header_start|>assistant<|header_end|>`;
|
|
expect(formatPromptLlama4(messages)).toBe(expected);
|
|
});
|
|
|
|
it('should format multiple messages correctly', async () => {
|
|
const messages: LlamaMessage[] = [
|
|
{ role: 'user', content: 'Hello' },
|
|
{ role: 'assistant', content: 'Hi there! How can I help you?' },
|
|
{ role: 'user', content: "What's the weather like?" },
|
|
];
|
|
const expected = dedent`<|begin_of_text|><|header_start|>user<|header_end|>
|
|
|
|
Hello<|eot|><|header_start|>assistant<|header_end|>
|
|
|
|
Hi there! How can I help you?<|eot|><|header_start|>user<|header_end|>
|
|
|
|
What's the weather like?<|eot|><|header_start|>assistant<|header_end|>`;
|
|
expect(formatPromptLlama4(messages)).toBe(expected);
|
|
});
|
|
|
|
it('should handle system messages correctly', async () => {
|
|
const messages: LlamaMessage[] = [
|
|
{ role: 'system', content: 'You are a helpful assistant.' },
|
|
{ role: 'user', content: 'Hello' },
|
|
];
|
|
const expected = dedent`<|begin_of_text|><|header_start|>system<|header_end|>
|
|
|
|
You are a helpful assistant.<|eot|><|header_start|>user<|header_end|>
|
|
|
|
Hello<|eot|><|header_start|>assistant<|header_end|>`;
|
|
expect(formatPromptLlama4(messages)).toBe(expected);
|
|
});
|
|
});
|
|
|
|
describe('extractTextContent', () => {
|
|
it('should return trimmed string when content is a string', () => {
|
|
expect(extractTextContent(' Hello world ')).toBe('Hello world');
|
|
});
|
|
|
|
it('should extract text from array with text type blocks', () => {
|
|
const content = [
|
|
{ type: 'text', text: 'Hello' },
|
|
{ type: 'text', text: 'world' },
|
|
];
|
|
expect(extractTextContent(content)).toBe('Hello world');
|
|
});
|
|
|
|
it('should extract text from array without type field', () => {
|
|
const content = [{ text: 'Hello' }, { text: 'world' }];
|
|
expect(extractTextContent(content)).toBe('Hello world');
|
|
});
|
|
|
|
it('should handle string items in array', () => {
|
|
const content = ['Hello', 'world'] as any;
|
|
expect(extractTextContent(content)).toBe('Hello world');
|
|
});
|
|
|
|
it('should throw error when content contains images', () => {
|
|
const content = [
|
|
{ type: 'text', text: 'Describe this image' },
|
|
{ type: 'image', image: { format: 'jpeg', source: { bytes: 'base64data' } } },
|
|
];
|
|
expect(() => extractTextContent(content, 'meta.llama3-2-11b')).toThrow(
|
|
/Multimodal content \(images\) detected/,
|
|
);
|
|
expect(() => extractTextContent(content, 'meta.llama3-2-11b')).toThrow(
|
|
/bedrock:converse:meta\.llama3-2-11b/,
|
|
);
|
|
});
|
|
|
|
it('should throw error when content contains image_url', () => {
|
|
const content = [
|
|
{ type: 'text', text: 'Describe this' },
|
|
{ type: 'image_url', image_url: { url: 'data:image/jpeg;base64,abc' } },
|
|
];
|
|
expect(() => extractTextContent(content)).toThrow(/Multimodal content \(images\) detected/);
|
|
});
|
|
|
|
it('should throw error when content has image property without type', () => {
|
|
const content = [{ text: 'Hello' }, { image: { format: 'png', source: { data: 'base64' } } }];
|
|
expect(() => extractTextContent(content)).toThrow(/Multimodal content \(images\) detected/);
|
|
});
|
|
|
|
it('should include model name in error message when provided', () => {
|
|
const content = [{ type: 'image', image: {} }];
|
|
expect(() => extractTextContent(content, 'us.meta.llama3-2-90b-instruct-v1:0')).toThrow(
|
|
/us\.meta\.llama3-2-90b-instruct-v1:0/,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('extractTextAndImages', () => {
|
|
it('should return text and empty images array for string content', () => {
|
|
const result = extractTextAndImages('Hello world');
|
|
expect(result).toEqual({ text: 'Hello world', images: [] });
|
|
});
|
|
|
|
it('should extract text from array with text blocks', () => {
|
|
const content = [
|
|
{ type: 'text', text: 'Hello' },
|
|
{ type: 'text', text: ' world' },
|
|
];
|
|
const result = extractTextAndImages(content);
|
|
expect(result).toEqual({ text: 'Hello world', images: [] });
|
|
});
|
|
|
|
it('should extract image data and insert <|image|> token from source.bytes format', () => {
|
|
const content = [
|
|
{ type: 'text', text: 'Describe this: ' },
|
|
{ type: 'image', source: { bytes: 'base64ImageData' } },
|
|
];
|
|
const result = extractTextAndImages(content);
|
|
expect(result.text).toBe('Describe this: <|image|>');
|
|
expect(result.images).toEqual(['base64ImageData']);
|
|
});
|
|
|
|
it('should extract image data from source.data (Anthropic format)', () => {
|
|
const content = [
|
|
{ type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: 'base64Data' } },
|
|
{ type: 'text', text: ' What is this?' },
|
|
];
|
|
const result = extractTextAndImages(content);
|
|
expect(result.text).toBe('<|image|> What is this?');
|
|
expect(result.images).toEqual(['base64Data']);
|
|
});
|
|
|
|
it('should extract image data from image_url format (OpenAI compatible)', () => {
|
|
const content = [
|
|
{ type: 'text', text: 'Look at this: ' },
|
|
{ type: 'image_url', image_url: { url: 'data:image/png;base64,pngBase64Data' } },
|
|
];
|
|
const result = extractTextAndImages(content);
|
|
expect(result.text).toBe('Look at this: <|image|>');
|
|
expect(result.images).toEqual(['pngBase64Data']);
|
|
});
|
|
|
|
it('should extract image from data URL in source.bytes', () => {
|
|
const content = [{ type: 'image', source: { bytes: 'data:image/jpeg;base64,jpegDataHere' } }];
|
|
const result = extractTextAndImages(content);
|
|
expect(result.text).toBe('<|image|>');
|
|
expect(result.images).toEqual(['jpegDataHere']);
|
|
});
|
|
|
|
it('should handle multiple images in correct order', () => {
|
|
const content = [
|
|
{ type: 'text', text: 'Image 1: ' },
|
|
{ type: 'image', source: { bytes: 'image1data' } },
|
|
{ type: 'text', text: ' Image 2: ' },
|
|
{ type: 'image', source: { bytes: 'image2data' } },
|
|
];
|
|
const result = extractTextAndImages(content);
|
|
expect(result.text).toBe('Image 1: <|image|> Image 2: <|image|>');
|
|
expect(result.images).toEqual(['image1data', 'image2data']);
|
|
});
|
|
|
|
it('should handle block.image with source.data', () => {
|
|
const content = [{ type: 'image', image: { source: { data: 'nestedImageData' } } }];
|
|
const result = extractTextAndImages(content);
|
|
expect(result.text).toBe('<|image|>');
|
|
expect(result.images).toEqual(['nestedImageData']);
|
|
});
|
|
|
|
it('should handle Buffer source.bytes', () => {
|
|
const content = [{ type: 'image', source: { bytes: Buffer.from('hello') } }];
|
|
const result = extractTextAndImages(content);
|
|
expect(result.text).toBe('<|image|>');
|
|
expect(result.images).toEqual([Buffer.from('hello').toString('base64')]);
|
|
});
|
|
|
|
it('should extract base64 from data URL in source.data', () => {
|
|
const content = [
|
|
{ type: 'image', source: { data: 'data:image/jpeg;base64,actualBase64Data' } },
|
|
];
|
|
const result = extractTextAndImages(content);
|
|
expect(result.text).toBe('<|image|>');
|
|
expect(result.images).toEqual(['actualBase64Data']);
|
|
});
|
|
});
|
|
|
|
describe('formatPromptLlama32Vision', () => {
|
|
it('should format text-only message correctly', () => {
|
|
const messages: LlamaMessage[] = [{ role: 'user', content: 'Hello' }];
|
|
const result = formatPromptLlama32Vision(messages);
|
|
expect(result.prompt).toContain('<|begin_of_text|>');
|
|
expect(result.prompt).toContain('Hello');
|
|
expect(result.prompt).toContain('<|start_header_id|>assistant<|end_header_id|>');
|
|
expect(result.images).toEqual([]);
|
|
});
|
|
|
|
it('should format message with image and return images array', () => {
|
|
const messages: LlamaMessage[] = [
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'image', source: { bytes: 'imageBase64' } },
|
|
{ type: 'text', text: 'What is this?' },
|
|
],
|
|
},
|
|
];
|
|
const result = formatPromptLlama32Vision(messages);
|
|
expect(result.prompt).toContain('<|image|>');
|
|
expect(result.prompt).toContain('What is this?');
|
|
expect(result.images).toEqual(['imageBase64']);
|
|
});
|
|
|
|
it('should collect images from multiple messages', () => {
|
|
const messages: LlamaMessage[] = [
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'image', source: { data: 'img1' } },
|
|
{ type: 'text', text: 'First image' },
|
|
],
|
|
},
|
|
{ role: 'assistant', content: 'I see a cat' },
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'text', text: 'And this: ' },
|
|
{ type: 'image', source: { data: 'img2' } },
|
|
],
|
|
},
|
|
];
|
|
const result = formatPromptLlama32Vision(messages);
|
|
expect(result.images).toEqual(['img1', 'img2']);
|
|
expect(result.prompt).toContain('First image');
|
|
expect(result.prompt).toContain('I see a cat');
|
|
expect(result.prompt).toContain('And this:');
|
|
});
|
|
});
|
|
|
|
describe('getLlamaModelHandler LLAMA3_2 with images', () => {
|
|
it('should include images array in params when multimodal content provided (11B)', async () => {
|
|
const handler = getLlamaModelHandler(LlamaVersion.V3_2);
|
|
const prompt = JSON.stringify([
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'image', source: { bytes: 'testImageData' } },
|
|
{ type: 'text', text: 'What is in this image?' },
|
|
],
|
|
},
|
|
]);
|
|
const params = await handler.params(
|
|
{},
|
|
prompt,
|
|
undefined,
|
|
'us.meta.llama3-2-11b-instruct-v1:0',
|
|
);
|
|
expect(params.images).toEqual(['testImageData']);
|
|
expect(params.prompt).toContain('<|image|>');
|
|
expect(params.prompt).toContain('What is in this image?');
|
|
});
|
|
|
|
it('should not include images array when no images in content', async () => {
|
|
const handler = getLlamaModelHandler(LlamaVersion.V3_2);
|
|
const prompt = JSON.stringify([{ role: 'user', content: 'Just text' }]);
|
|
const params = await handler.params(
|
|
{},
|
|
prompt,
|
|
undefined,
|
|
'us.meta.llama3-2-11b-instruct-v1:0',
|
|
);
|
|
expect(params.images).toBeUndefined();
|
|
expect(params.prompt).toContain('Just text');
|
|
});
|
|
|
|
it('should handle image_url format in LLAMA3_2 (90B)', async () => {
|
|
const handler = getLlamaModelHandler(LlamaVersion.V3_2);
|
|
const prompt = JSON.stringify([
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'text', text: 'Describe: ' },
|
|
{ type: 'image_url', image_url: { url: 'data:image/png;base64,pngData' } },
|
|
],
|
|
},
|
|
]);
|
|
const params = await handler.params(
|
|
{},
|
|
prompt,
|
|
undefined,
|
|
'us.meta.llama3-2-90b-instruct-v1:0',
|
|
);
|
|
expect(params.images).toEqual(['pngData']);
|
|
expect(params.prompt).toContain('<|image|>');
|
|
});
|
|
|
|
it('should use text-only formatting for 1B and 3B models', async () => {
|
|
const handler = getLlamaModelHandler(LlamaVersion.V3_2);
|
|
const prompt = JSON.stringify([{ role: 'user', content: 'Just text' }]);
|
|
const params = await handler.params(
|
|
{},
|
|
prompt,
|
|
undefined,
|
|
'us.meta.llama3-2-3b-instruct-v1:0',
|
|
);
|
|
expect(params.images).toBeUndefined();
|
|
expect(params.prompt).toContain('Just text');
|
|
// Should use Llama 3 formatting, not vision formatting
|
|
expect(params.prompt).not.toContain('<|image|>');
|
|
});
|
|
|
|
it('should throw error when images are provided to 1B/3B text-only models', async () => {
|
|
const handler = getLlamaModelHandler(LlamaVersion.V3_2);
|
|
const prompt = JSON.stringify([
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'image', source: { bytes: 'testImageData' } },
|
|
{ type: 'text', text: 'What is in this image?' },
|
|
],
|
|
},
|
|
]);
|
|
await expect(
|
|
handler.params({}, prompt, undefined, 'us.meta.llama3-2-3b-instruct-v1:0'),
|
|
).rejects.toThrow(/Multimodal content \(images\) detected/);
|
|
});
|
|
});
|
|
|
|
describe('formatPromptLlama3Instruct with multimodal content', () => {
|
|
it('should throw helpful error when message contains images', () => {
|
|
const messages: LlamaMessage[] = [
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'image', image: { format: 'jpeg', source: { bytes: 'data' } } },
|
|
{ type: 'text', text: 'Describe this image' },
|
|
],
|
|
},
|
|
];
|
|
expect(() => formatPromptLlama3Instruct(messages, 'meta.llama3-2-11b')).toThrow(
|
|
/Multimodal content \(images\) detected/,
|
|
);
|
|
});
|
|
|
|
it('should work with text-only array content', () => {
|
|
const messages: LlamaMessage[] = [
|
|
{
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'Hello world' }],
|
|
},
|
|
];
|
|
const result = formatPromptLlama3Instruct(messages);
|
|
expect(result).toContain('Hello world');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('BEDROCK_MODEL AMAZON_NOVA', () => {
|
|
const modelHandler = BEDROCK_MODEL.AMAZON_NOVA;
|
|
|
|
it('should format system message correctly when using JSON array input', async () => {
|
|
const config = {};
|
|
const prompt = JSON.stringify([
|
|
{ role: 'system', content: 'You are a helpful assistant.' },
|
|
{ role: 'user', content: 'Hello!' },
|
|
]);
|
|
|
|
const params = await modelHandler.params(config, prompt);
|
|
|
|
expect(params).toEqual({
|
|
messages: [
|
|
{
|
|
role: 'user',
|
|
content: [{ text: 'Hello!' }],
|
|
},
|
|
],
|
|
system: [{ text: 'You are a helpful assistant.' }],
|
|
inferenceConfig: {
|
|
temperature: 0,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should handle messages without system prompt', async () => {
|
|
const config = {};
|
|
const prompt = JSON.stringify([
|
|
{ role: 'user', content: 'Hello!' },
|
|
{ role: 'assistant', content: 'Hi there!' },
|
|
]);
|
|
|
|
const params = await modelHandler.params(config, prompt);
|
|
|
|
expect(params).toEqual({
|
|
messages: [
|
|
{
|
|
role: 'user',
|
|
content: [{ text: 'Hello!' }],
|
|
},
|
|
{
|
|
role: 'assistant',
|
|
content: [{ text: 'Hi there!' }],
|
|
},
|
|
],
|
|
inferenceConfig: {
|
|
temperature: 0,
|
|
},
|
|
});
|
|
expect(params.system).toBeUndefined();
|
|
});
|
|
|
|
it('should handle complex message content arrays', async () => {
|
|
const config = {};
|
|
const prompt = JSON.stringify([
|
|
{ role: 'system', content: 'You are a helpful assistant.' },
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{ text: 'What is this image?' },
|
|
{
|
|
image: {
|
|
format: 'jpeg',
|
|
source: {
|
|
bytes: 'base64_encoded_image',
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
const params = await modelHandler.params(config, prompt);
|
|
|
|
expect(params).toEqual({
|
|
messages: [
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{ text: 'What is this image?' },
|
|
{
|
|
image: {
|
|
format: 'jpeg',
|
|
source: {
|
|
bytes: 'base64_encoded_image',
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
system: [{ text: 'You are a helpful assistant.' }],
|
|
inferenceConfig: {
|
|
temperature: 0,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should handle invalid JSON gracefully', async () => {
|
|
const config = {};
|
|
const prompt = 'Invalid JSON';
|
|
|
|
const params = await modelHandler.params(config, prompt);
|
|
|
|
expect(params).toEqual({
|
|
messages: [
|
|
{
|
|
role: 'user',
|
|
content: [{ text: 'Invalid JSON' }],
|
|
},
|
|
],
|
|
inferenceConfig: {
|
|
temperature: 0,
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('BEDROCK_MODEL MISTRAL', () => {
|
|
const modelHandler = BEDROCK_MODEL.MISTRAL;
|
|
|
|
describe('params', () => {
|
|
it('should include Mistral-specific parameters', async () => {
|
|
const config = {
|
|
max_tokens: 200,
|
|
temperature: 0.7,
|
|
top_p: 0.9,
|
|
top_k: 50,
|
|
};
|
|
const prompt = 'What is the capital of France?';
|
|
const stop = ['END'];
|
|
|
|
const params = await modelHandler.params(config, prompt, stop);
|
|
|
|
expect(params).toEqual({
|
|
prompt,
|
|
stop,
|
|
max_tokens: 200,
|
|
temperature: 0.7,
|
|
top_p: 0.9,
|
|
top_k: 50,
|
|
});
|
|
});
|
|
|
|
it('should use default values when config is not provided', async () => {
|
|
const config = {};
|
|
const prompt = 'Hello, how are you?';
|
|
const stop: string[] = [];
|
|
|
|
const params = await modelHandler.params(config, prompt, stop);
|
|
|
|
// top_k is intentionally omitted: the Bedrock Mistral InvokeModel API validates
|
|
// `top_k >= 1` and rejects `top_k: 0` with a ValidationException, so there is no safe
|
|
// hardcoded default — let the model use its own.
|
|
expect(params).toEqual({
|
|
prompt,
|
|
stop,
|
|
max_tokens: 1024,
|
|
temperature: 0,
|
|
top_p: 1,
|
|
});
|
|
expect(params).not.toHaveProperty('top_k');
|
|
});
|
|
});
|
|
|
|
describe('output', () => {
|
|
it('should extract output from outputs[0].text format', async () => {
|
|
const mockResponse = {
|
|
outputs: [{ text: 'This is a test response.' }],
|
|
};
|
|
|
|
expect(modelHandler.output({}, mockResponse)).toBe('This is a test response.');
|
|
});
|
|
|
|
it('should return undefined for unrecognized formats', async () => {
|
|
const mockResponse = { something: 'else' };
|
|
|
|
const result = modelHandler.output({}, mockResponse);
|
|
expect(result).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('tokenUsage', () => {
|
|
it('should use explicit token usage when available', async () => {
|
|
const mockResponse = {
|
|
usage: {
|
|
prompt_tokens: 25,
|
|
completion_tokens: 50,
|
|
total_tokens: 75,
|
|
},
|
|
};
|
|
|
|
const result = modelHandler.tokenUsage!(mockResponse, 'Some input text');
|
|
|
|
expect(result).toEqual({
|
|
prompt: 25,
|
|
completion: 50,
|
|
total: 75,
|
|
numRequests: 1,
|
|
});
|
|
});
|
|
|
|
it('should return undefined token counts when not provided by the API', async () => {
|
|
const mockResponse = {
|
|
outputs: [{ text: 'This is a test response with several words to estimate tokens.' }],
|
|
};
|
|
|
|
const result = modelHandler.tokenUsage!(mockResponse, 'What is the capital of France?');
|
|
|
|
// Verify structure with undefined values
|
|
expect(result).toHaveProperty('prompt', undefined);
|
|
expect(result).toHaveProperty('completion', undefined);
|
|
expect(result).toHaveProperty('total', undefined);
|
|
expect(result).toHaveProperty('numRequests', 1);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('BEDROCK_MODEL OPENAI', () => {
|
|
const modelHandler = BEDROCK_MODEL.OPENAI;
|
|
|
|
describe('params', () => {
|
|
it('should include OpenAI-specific parameters', async () => {
|
|
const config: BedrockOpenAIGenerationOptions = {
|
|
region: 'us-east-1',
|
|
max_completion_tokens: 150,
|
|
temperature: 0.8,
|
|
top_p: 0.95,
|
|
frequency_penalty: 0.2,
|
|
presence_penalty: 0.1,
|
|
stop: ['END', 'STOP'],
|
|
};
|
|
const prompt = 'What is artificial intelligence?';
|
|
const stop = ['FINISH'];
|
|
|
|
const params = await modelHandler.params(config, prompt, stop);
|
|
|
|
expect(params).toEqual({
|
|
messages: [{ role: 'user', content: 'What is artificial intelligence?' }],
|
|
max_completion_tokens: 150,
|
|
temperature: 0.8,
|
|
top_p: 0.95,
|
|
frequency_penalty: 0.2,
|
|
presence_penalty: 0.1,
|
|
stop: ['FINISH'], // stop parameter takes precedence
|
|
});
|
|
});
|
|
|
|
it('should use config.stop when stop parameter is not provided', async () => {
|
|
const config = {
|
|
stop: ['CONFIG_END'],
|
|
temperature: 0.5,
|
|
};
|
|
const prompt = 'Test prompt';
|
|
|
|
const params = await modelHandler.params(config, prompt);
|
|
|
|
expect(params).toEqual({
|
|
messages: [{ role: 'user', content: 'Test prompt' }],
|
|
temperature: 0.5,
|
|
top_p: 1.0,
|
|
stop: ['CONFIG_END'],
|
|
});
|
|
});
|
|
|
|
it('should use default values when config is not provided', async () => {
|
|
const config = {};
|
|
const prompt = 'Hello, how are you?';
|
|
|
|
const params = await modelHandler.params(config, prompt);
|
|
|
|
expect(params).toEqual({
|
|
messages: [{ role: 'user', content: 'Hello, how are you?' }],
|
|
temperature: 0.1,
|
|
top_p: 1.0,
|
|
});
|
|
});
|
|
|
|
it('should handle JSON message array input', async () => {
|
|
const config = { temperature: 0.7 };
|
|
const prompt = JSON.stringify([
|
|
{ role: 'system', content: 'You are a helpful assistant.' },
|
|
{ role: 'user', content: 'What is machine learning?' },
|
|
]);
|
|
|
|
const params = await modelHandler.params(config, prompt);
|
|
|
|
expect(params).toEqual({
|
|
messages: [
|
|
{ role: 'system', content: 'You are a helpful assistant.' },
|
|
{ role: 'user', content: 'What is machine learning?' },
|
|
],
|
|
temperature: 0.7,
|
|
top_p: 1.0,
|
|
});
|
|
});
|
|
|
|
it('should pass reasoning_effort as a native parameter without modifying messages', async () => {
|
|
const config = {
|
|
temperature: 0.7,
|
|
reasoning_effort: 'high' as const,
|
|
};
|
|
const prompt = 'Test prompt';
|
|
|
|
const params = await modelHandler.params(config, prompt);
|
|
|
|
expect(params).toEqual({
|
|
messages: [{ role: 'user', content: 'Test prompt' }],
|
|
temperature: 0.7,
|
|
top_p: 1.0,
|
|
reasoning_effort: 'high',
|
|
});
|
|
});
|
|
|
|
it('should keep an existing system message intact when reasoning_effort is set', async () => {
|
|
const config = {
|
|
temperature: 0.7,
|
|
reasoning_effort: 'medium' as const,
|
|
};
|
|
const prompt = JSON.stringify([
|
|
{ role: 'system', content: 'You are a helpful assistant.' },
|
|
{ role: 'user', content: 'What is machine learning?' },
|
|
]);
|
|
|
|
const params = await modelHandler.params(config, prompt);
|
|
|
|
expect(params).toEqual({
|
|
messages: [
|
|
{ role: 'system', content: 'You are a helpful assistant.' },
|
|
{ role: 'user', content: 'What is machine learning?' },
|
|
],
|
|
temperature: 0.7,
|
|
top_p: 1.0,
|
|
reasoning_effort: 'medium',
|
|
});
|
|
});
|
|
|
|
it('should forward the configured reasoning_effort verbatim', async () => {
|
|
const config = {
|
|
reasoning_effort: 'low' as const,
|
|
};
|
|
const prompt = 'Test prompt';
|
|
|
|
const params = await modelHandler.params(config, prompt);
|
|
|
|
expect(params.reasoning_effort).toBe('low');
|
|
});
|
|
|
|
it('should not include reasoning_effort when it is not specified', async () => {
|
|
const config = { temperature: 0.5 };
|
|
const prompt = 'Test prompt';
|
|
|
|
const params = await modelHandler.params(config, prompt);
|
|
|
|
expect(params).toEqual({
|
|
messages: [{ role: 'user', content: 'Test prompt' }],
|
|
temperature: 0.5,
|
|
top_p: 1.0,
|
|
});
|
|
expect(params).not.toHaveProperty('reasoning_effort');
|
|
});
|
|
});
|
|
|
|
describe('output', () => {
|
|
it('should extract output from OpenAI chat completion format', async () => {
|
|
const mockResponse = {
|
|
choices: [
|
|
{
|
|
message: {
|
|
content: 'This is a test response from OpenAI model.',
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
expect(modelHandler.output({}, mockResponse)).toBe(
|
|
'This is a test response from OpenAI model.',
|
|
);
|
|
});
|
|
|
|
it('should return the model output verbatim by default (reasoning preserved for assertions/red-team)', async () => {
|
|
// An eval framework must not hide application-visible content by default. The raw
|
|
// <reasoning>...</reasoning> block the API returned stays in `output` unless the user
|
|
// explicitly opts into a transformation via showThinking.
|
|
const mockResponse = {
|
|
choices: [{ message: { content: '<reasoning>Think think</reasoning>The answer is 42.' } }],
|
|
};
|
|
|
|
expect(modelHandler.output({}, mockResponse)).toBe(
|
|
'<reasoning>Think think</reasoning>The answer is 42.',
|
|
);
|
|
});
|
|
|
|
it('should strip the <reasoning> block when showThinking is false (parity with the openai: providers)', async () => {
|
|
const mockResponse = {
|
|
choices: [{ message: { content: '<reasoning>Think think</reasoning>The answer is 42.' } }],
|
|
};
|
|
|
|
expect(modelHandler.output({ showThinking: false }, mockResponse)).toBe('The answer is 42.');
|
|
});
|
|
|
|
it('should surface reasoning as Thinking: when showThinking is true', async () => {
|
|
const mockResponse = {
|
|
choices: [{ message: { content: '<reasoning>Think think</reasoning>The answer is 42.' } }],
|
|
};
|
|
|
|
expect(modelHandler.output({ showThinking: true }, mockResponse)).toBe(
|
|
'Thinking: Think think\n\nThe answer is 42.',
|
|
);
|
|
});
|
|
|
|
it('should strip a multi-line <reasoning> block and leading whitespace when showThinking is false', async () => {
|
|
const mockResponse = {
|
|
choices: [
|
|
{
|
|
message: {
|
|
content: '<reasoning>line one\nline two</reasoning>\n\nFinal answer',
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
expect(modelHandler.output({ showThinking: false }, mockResponse)).toBe('Final answer');
|
|
});
|
|
|
|
it('should leave content untouched when no reasoning block is present (any showThinking value)', async () => {
|
|
const mockResponse = {
|
|
choices: [{ message: { content: 'Just the answer.' } }],
|
|
};
|
|
|
|
expect(modelHandler.output({}, mockResponse)).toBe('Just the answer.');
|
|
expect(modelHandler.output({ showThinking: true }, mockResponse)).toBe('Just the answer.');
|
|
expect(modelHandler.output({ showThinking: false }, mockResponse)).toBe('Just the answer.');
|
|
});
|
|
|
|
it('should handle a reasoning-only response across all showThinking values', async () => {
|
|
const mockResponse = {
|
|
choices: [{ message: { content: '<reasoning>just thinking</reasoning>' } }],
|
|
};
|
|
|
|
// Default: verbatim.
|
|
expect(modelHandler.output({}, mockResponse)).toBe('<reasoning>just thinking</reasoning>');
|
|
// showThinking:false would strip to '', so it falls back to the full content instead of
|
|
// silently dropping the whole response.
|
|
expect(modelHandler.output({ showThinking: false }, mockResponse)).toBe(
|
|
'<reasoning>just thinking</reasoning>',
|
|
);
|
|
// showThinking:true still surfaces the reasoning even when the answer is empty.
|
|
expect(modelHandler.output({ showThinking: true }, mockResponse)).toBe(
|
|
'Thinking: just thinking\n\n',
|
|
);
|
|
});
|
|
|
|
it('should return content verbatim for an unterminated <reasoning> tag (no data loss)', async () => {
|
|
const mockResponse = {
|
|
choices: [{ message: { content: '<reasoning>oops no closing tag, the answer is 42' } }],
|
|
};
|
|
|
|
expect(modelHandler.output({}, mockResponse)).toBe(
|
|
'<reasoning>oops no closing tag, the answer is 42',
|
|
);
|
|
expect(modelHandler.output({ showThinking: false }, mockResponse)).toBe(
|
|
'<reasoning>oops no closing tag, the answer is 42',
|
|
);
|
|
});
|
|
|
|
it('should only strip the leading <reasoning> block when showThinking is false, leaving later ones as answer content', async () => {
|
|
const mockResponse = {
|
|
choices: [
|
|
{
|
|
message: {
|
|
content:
|
|
'<reasoning>first</reasoning>answer with <reasoning>literal</reasoning> data',
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
// Non-greedy match strips only the first block; subsequent ones are part of the answer.
|
|
expect(modelHandler.output({ showThinking: false }, mockResponse)).toBe(
|
|
'answer with <reasoning>literal</reasoning> data',
|
|
);
|
|
});
|
|
|
|
it('should not strip a mid-content <reasoning> block when showThinking is false (regex is start-anchored)', async () => {
|
|
const mockResponse = {
|
|
choices: [{ message: { content: 'The answer <reasoning>is</reasoning> here.' } }],
|
|
};
|
|
|
|
expect(modelHandler.output({ showThinking: false }, mockResponse)).toBe(
|
|
'The answer <reasoning>is</reasoning> here.',
|
|
);
|
|
});
|
|
|
|
it('should throw an error for API errors', async () => {
|
|
const mockErrorResponse = { error: 'API Error occurred' };
|
|
expect(() => modelHandler.output({}, mockErrorResponse)).toThrow(
|
|
'OpenAI API error: API Error occurred',
|
|
);
|
|
});
|
|
|
|
it('should return undefined for unrecognized formats', async () => {
|
|
const mockResponse = { something: 'else' };
|
|
const result = modelHandler.output({}, mockResponse);
|
|
expect(result).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('tokenUsage', () => {
|
|
it('should extract token usage from OpenAI response format', async () => {
|
|
const mockResponse = {
|
|
usage: {
|
|
prompt_tokens: 25,
|
|
completion_tokens: 75,
|
|
total_tokens: 100,
|
|
},
|
|
};
|
|
|
|
const result = modelHandler.tokenUsage!(mockResponse, 'Test prompt');
|
|
expect(result).toEqual({
|
|
prompt: 25,
|
|
completion: 75,
|
|
total: 100,
|
|
numRequests: 1,
|
|
});
|
|
});
|
|
|
|
it('should handle string token counts', async () => {
|
|
const mockResponse = {
|
|
usage: {
|
|
prompt_tokens: '30',
|
|
completion_tokens: '60',
|
|
total_tokens: '90',
|
|
},
|
|
};
|
|
|
|
const result = modelHandler.tokenUsage!(mockResponse, 'Test prompt');
|
|
expect(result).toEqual({
|
|
prompt: 30,
|
|
completion: 60,
|
|
total: 90,
|
|
numRequests: 1,
|
|
});
|
|
});
|
|
|
|
it('should surface reasoning and cached token details for parity with the OpenAI provider', async () => {
|
|
const mockResponse = {
|
|
usage: {
|
|
prompt_tokens: 19,
|
|
completion_tokens: 22,
|
|
total_tokens: 41,
|
|
completion_tokens_details: { reasoning_tokens: 12 },
|
|
prompt_tokens_details: { cached_tokens: 8 },
|
|
},
|
|
};
|
|
|
|
const result = modelHandler.tokenUsage!(mockResponse, 'Test prompt');
|
|
expect(result).toEqual({
|
|
prompt: 19,
|
|
completion: 22,
|
|
total: 41,
|
|
numRequests: 1,
|
|
completionDetails: { reasoning: 12, cacheReadInputTokens: 8 },
|
|
});
|
|
});
|
|
|
|
it('should surface reasoning alone when no cached-token detail is present', async () => {
|
|
const result = modelHandler.tokenUsage!(
|
|
{
|
|
usage: {
|
|
prompt_tokens: 10,
|
|
completion_tokens: 20,
|
|
total_tokens: 30,
|
|
completion_tokens_details: { reasoning_tokens: 7 },
|
|
},
|
|
},
|
|
'Test prompt',
|
|
);
|
|
// No prompt_tokens_details => cacheReadInputTokens must be absent (not 0).
|
|
expect(result).toEqual({
|
|
prompt: 10,
|
|
completion: 20,
|
|
total: 30,
|
|
numRequests: 1,
|
|
completionDetails: { reasoning: 7 },
|
|
});
|
|
});
|
|
|
|
it('should surface cached input tokens alone when no reasoning detail is present', async () => {
|
|
const result = modelHandler.tokenUsage!(
|
|
{
|
|
usage: {
|
|
prompt_tokens: 10,
|
|
completion_tokens: 20,
|
|
total_tokens: 30,
|
|
prompt_tokens_details: { cached_tokens: 5 },
|
|
},
|
|
},
|
|
'Test prompt',
|
|
);
|
|
// No completion_tokens_details => reasoning must be absent.
|
|
expect(result).toEqual({
|
|
prompt: 10,
|
|
completion: 20,
|
|
total: 30,
|
|
numRequests: 1,
|
|
completionDetails: { cacheReadInputTokens: 5 },
|
|
});
|
|
});
|
|
|
|
it('should include reasoning:0 but drop cached_tokens:0 (asymmetric guards: !== undefined vs > 0)', async () => {
|
|
const result = modelHandler.tokenUsage!(
|
|
{
|
|
usage: {
|
|
prompt_tokens: 10,
|
|
completion_tokens: 20,
|
|
total_tokens: 30,
|
|
completion_tokens_details: { reasoning_tokens: 0 },
|
|
prompt_tokens_details: { cached_tokens: 0 },
|
|
},
|
|
},
|
|
'Test prompt',
|
|
);
|
|
// reasoning:0 is reported (guard is `!== undefined`); cached:0 is dropped (guard is `> 0`).
|
|
expect(result).toEqual({
|
|
prompt: 10,
|
|
completion: 20,
|
|
total: 30,
|
|
numRequests: 1,
|
|
completionDetails: { reasoning: 0 },
|
|
});
|
|
});
|
|
|
|
it('should omit completionDetails entirely when only cached_tokens:0 is present', async () => {
|
|
const result = modelHandler.tokenUsage!(
|
|
{
|
|
usage: {
|
|
prompt_tokens: 10,
|
|
completion_tokens: 20,
|
|
total_tokens: 30,
|
|
prompt_tokens_details: { cached_tokens: 0 },
|
|
},
|
|
},
|
|
'Test prompt',
|
|
);
|
|
// cached:0 dropped and no reasoning => no completionDetails key at all.
|
|
expect(result).toEqual({
|
|
prompt: 10,
|
|
completion: 20,
|
|
total: 30,
|
|
numRequests: 1,
|
|
});
|
|
});
|
|
|
|
it('should return undefined token counts when usage is not provided', async () => {
|
|
const mockResponse = {
|
|
choices: [
|
|
{
|
|
message: {
|
|
content: 'Response without usage info',
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
const result = modelHandler.tokenUsage!(mockResponse, 'Test prompt');
|
|
expect(result).toEqual({
|
|
prompt: undefined,
|
|
completion: undefined,
|
|
total: undefined,
|
|
numRequests: 1,
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('BEDROCK_MODEL MISTRAL_LARGE_2407', () => {
|
|
const modelHandler = BEDROCK_MODEL.MISTRAL_LARGE_2407;
|
|
|
|
describe('params', () => {
|
|
it('should use the chat-completion request format without top_k', async () => {
|
|
const config = {
|
|
max_tokens: 200,
|
|
temperature: 0.7,
|
|
top_p: 0.9,
|
|
top_k: 50, // This should be ignored
|
|
};
|
|
const prompt = 'What is the capital of France?';
|
|
const stop = ['END'];
|
|
|
|
const params = await modelHandler.params(config, prompt, stop);
|
|
|
|
expect(params).toEqual({
|
|
messages: [{ role: 'user', content: prompt }],
|
|
stop,
|
|
max_tokens: 200,
|
|
temperature: 0.7,
|
|
top_p: 0.9,
|
|
// top_k should not be included
|
|
});
|
|
expect(params).not.toHaveProperty('top_k');
|
|
});
|
|
|
|
it('should use default values when config is not provided', async () => {
|
|
const config = {};
|
|
const prompt = 'Hello, how are you?';
|
|
const stop: string[] = [];
|
|
|
|
const params = await modelHandler.params(config, prompt, stop);
|
|
|
|
expect(params).toEqual({
|
|
messages: [{ role: 'user', content: prompt }],
|
|
max_tokens: 1024,
|
|
temperature: 0,
|
|
top_p: 1,
|
|
});
|
|
expect(params).not.toHaveProperty('top_k');
|
|
});
|
|
});
|
|
|
|
describe('output', () => {
|
|
it('should extract output from chat completion format', async () => {
|
|
const mockResponse = {
|
|
id: 'b8f7363d-4aed-42cf-879a-7a8db4f37be3',
|
|
object: 'chat.completion',
|
|
created: 1743034280,
|
|
model: 'mistral-large-2407',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
message: {
|
|
role: 'assistant',
|
|
content: 'This is a test response in chat completion format.',
|
|
},
|
|
finish_reason: 'stop',
|
|
},
|
|
],
|
|
};
|
|
|
|
expect(modelHandler.output({}, mockResponse)).toBe(
|
|
'This is a test response in chat completion format.',
|
|
);
|
|
});
|
|
|
|
it('should return undefined for unrecognized formats', async () => {
|
|
const mockResponse = { something: 'else' };
|
|
|
|
const result = modelHandler.output({}, mockResponse);
|
|
expect(result).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('tokenUsage', () => {
|
|
it('should extract token usage from chat completion format', async () => {
|
|
const mockResponse = {
|
|
id: 'b8f7363d-4aed-42cf-879a-7a8db4f37be3',
|
|
object: 'chat.completion',
|
|
created: 1743034280,
|
|
model: 'mistral-large-2407',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
message: {
|
|
role: 'assistant',
|
|
content: 'Test response',
|
|
},
|
|
finish_reason: 'stop',
|
|
},
|
|
],
|
|
prompt_tokens: 30,
|
|
completion_tokens: 45,
|
|
};
|
|
|
|
const result = modelHandler.tokenUsage!(mockResponse, 'Test prompt');
|
|
expect(result).toEqual({
|
|
prompt: 30,
|
|
completion: 45,
|
|
total: 75,
|
|
numRequests: 1,
|
|
});
|
|
});
|
|
|
|
it('should return undefined token counts when not provided by the API', async () => {
|
|
const mockResponse = {
|
|
choices: [
|
|
{
|
|
message: {
|
|
content: 'This is a test response',
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
const result = modelHandler.tokenUsage!(mockResponse, 'Test prompt');
|
|
expect(result).toHaveProperty('prompt', undefined);
|
|
expect(result).toHaveProperty('completion', undefined);
|
|
expect(result).toHaveProperty('total', undefined);
|
|
expect(result).toHaveProperty('numRequests', 1);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('BEDROCK_MODEL MISTRAL_CHAT', () => {
|
|
const modelHandler = BEDROCK_MODEL.MISTRAL_CHAT;
|
|
|
|
it('should preserve structured chat prompts for newer Mistral models', async () => {
|
|
const prompt = JSON.stringify([
|
|
{ role: 'system', content: 'You are helpful.' },
|
|
{ role: 'user', content: 'Summarize this.' },
|
|
]);
|
|
|
|
const params = await modelHandler.params({}, prompt, []);
|
|
|
|
expect(params).toEqual({
|
|
messages: [
|
|
{ role: 'system', content: 'You are helpful.' },
|
|
{ role: 'user', content: 'Summarize this.' },
|
|
],
|
|
max_tokens: 1024,
|
|
temperature: 0,
|
|
top_p: 1,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('BEDROCK_MODEL DEEPSEEK', () => {
|
|
const modelHandler = BEDROCK_MODEL.DEEPSEEK;
|
|
|
|
describe('params', () => {
|
|
it('should wrap prompt with thinking tags', async () => {
|
|
const config = {
|
|
max_tokens: 1000,
|
|
temperature: 0.8,
|
|
top_p: 0.95,
|
|
};
|
|
const prompt = 'Solve this complex problem';
|
|
|
|
const params = await modelHandler.params(config, prompt);
|
|
|
|
expect(params).toEqual({
|
|
prompt: '\nSolve this complex problem\n<think>\n',
|
|
max_tokens: 1000,
|
|
temperature: 0.8,
|
|
top_p: 0.95,
|
|
});
|
|
});
|
|
|
|
it('should use default values when config is not provided', async () => {
|
|
const config = {};
|
|
const prompt = 'Test prompt';
|
|
|
|
const params = await modelHandler.params(config, prompt);
|
|
|
|
expect(params).toEqual({
|
|
prompt: '\nTest prompt\n<think>\n',
|
|
temperature: 0,
|
|
top_p: 1.0,
|
|
});
|
|
});
|
|
|
|
it('should respect environment variables', async () => {
|
|
mockProcessEnv({ AWS_BEDROCK_MAX_TOKENS: '2000' });
|
|
mockProcessEnv({ AWS_BEDROCK_TEMPERATURE: '0.5' });
|
|
mockProcessEnv({ AWS_BEDROCK_TOP_P: '0.9' });
|
|
|
|
const config = {};
|
|
const prompt = 'Test with env vars';
|
|
|
|
const params = await modelHandler.params(config, prompt);
|
|
|
|
expect(params).toEqual({
|
|
prompt: '\nTest with env vars\n<think>\n',
|
|
max_tokens: 2000,
|
|
temperature: 0.5,
|
|
top_p: 0.9,
|
|
});
|
|
|
|
mockProcessEnv({ AWS_BEDROCK_MAX_TOKENS: undefined });
|
|
mockProcessEnv({ AWS_BEDROCK_TEMPERATURE: undefined });
|
|
mockProcessEnv({ AWS_BEDROCK_TOP_P: undefined });
|
|
});
|
|
});
|
|
|
|
describe('output', () => {
|
|
it('should extract text from DeepSeek response with thinking', async () => {
|
|
const mockResponse = {
|
|
choices: [
|
|
{
|
|
text: '<think>Let me think about this problem...</think>\nThe answer is 42.',
|
|
},
|
|
],
|
|
};
|
|
const config = { showThinking: true };
|
|
|
|
const result = modelHandler.output(config, mockResponse);
|
|
|
|
expect(result).toBe('<think>Let me think about this problem...</think>\nThe answer is 42.');
|
|
});
|
|
|
|
it('should hide thinking when showThinking is false', async () => {
|
|
const mockResponse = {
|
|
choices: [
|
|
{
|
|
text: '<think>Let me think about this problem...</think>\nThe answer is 42.',
|
|
},
|
|
],
|
|
};
|
|
const config = { showThinking: false };
|
|
|
|
const result = modelHandler.output(config, mockResponse);
|
|
|
|
expect(result).toBe('The answer is 42.');
|
|
});
|
|
|
|
it('should return full response when no thinking tags present', async () => {
|
|
const mockResponse = {
|
|
choices: [
|
|
{
|
|
text: 'Direct response without thinking',
|
|
},
|
|
],
|
|
};
|
|
const config = { showThinking: false };
|
|
|
|
const result = modelHandler.output(config, mockResponse);
|
|
|
|
expect(result).toBe('Direct response without thinking');
|
|
});
|
|
|
|
it('should handle error in response', async () => {
|
|
const mockResponse = {
|
|
error: 'API error occurred',
|
|
};
|
|
|
|
expect(() => modelHandler.output({}, mockResponse)).toThrow(
|
|
'DeepSeek API error: API error occurred',
|
|
);
|
|
});
|
|
|
|
it('should return undefined for unrecognized response format', async () => {
|
|
const mockResponse = { something: 'else' };
|
|
const result = modelHandler.output({}, mockResponse);
|
|
expect(result).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('tokenUsage', () => {
|
|
it('should extract token usage from DeepSeek response', async () => {
|
|
const mockResponse = {
|
|
usage: {
|
|
prompt_tokens: 30,
|
|
completion_tokens: 70,
|
|
total_tokens: 100,
|
|
},
|
|
};
|
|
|
|
const usage = modelHandler.tokenUsage(mockResponse, 'test');
|
|
|
|
expect(usage).toEqual({
|
|
prompt: 30,
|
|
completion: 70,
|
|
total: 100,
|
|
numRequests: 1,
|
|
});
|
|
});
|
|
|
|
it('should handle string token values', async () => {
|
|
const mockResponse = {
|
|
usage: {
|
|
prompt_tokens: '30',
|
|
completion_tokens: '70',
|
|
total_tokens: '100',
|
|
},
|
|
};
|
|
|
|
const usage = modelHandler.tokenUsage(mockResponse, 'test');
|
|
|
|
expect(usage).toEqual({
|
|
prompt: 30,
|
|
completion: 70,
|
|
total: 100,
|
|
numRequests: 1,
|
|
});
|
|
});
|
|
|
|
it('should return undefined values when token counts are not provided', async () => {
|
|
const mockResponse = {};
|
|
|
|
const usage = modelHandler.tokenUsage(mockResponse, 'test');
|
|
|
|
expect(usage).toEqual({
|
|
prompt: undefined,
|
|
completion: undefined,
|
|
total: undefined,
|
|
numRequests: 1,
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('BEDROCK_MODEL DEEPSEEK_CHAT', () => {
|
|
const modelHandler = BEDROCK_MODEL.DEEPSEEK_CHAT;
|
|
|
|
describe('params', () => {
|
|
it('should use the chat-completion request format for V3 models', async () => {
|
|
const params = await modelHandler.params(
|
|
{
|
|
max_tokens: 1000,
|
|
temperature: 0.8,
|
|
top_p: 0.95,
|
|
},
|
|
'Solve this problem',
|
|
['END'],
|
|
);
|
|
|
|
expect(params).toEqual({
|
|
messages: [{ role: 'user', content: 'Solve this problem' }],
|
|
max_tokens: 1000,
|
|
temperature: 0.8,
|
|
top_p: 0.95,
|
|
stop: ['END'],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('output', () => {
|
|
it('should extract output from chat completion format', async () => {
|
|
expect(
|
|
modelHandler.output(
|
|
{},
|
|
{
|
|
choices: [
|
|
{
|
|
message: {
|
|
content: 'This is a V3 response.',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
),
|
|
).toBe('This is a V3 response.');
|
|
});
|
|
});
|
|
|
|
describe('tokenUsage', () => {
|
|
it('should extract token usage from usage objects', async () => {
|
|
expect(
|
|
modelHandler.tokenUsage!(
|
|
{
|
|
usage: {
|
|
prompt_tokens: 12,
|
|
completion_tokens: 18,
|
|
total_tokens: 30,
|
|
},
|
|
},
|
|
'prompt',
|
|
),
|
|
).toEqual({
|
|
prompt: 12,
|
|
completion: 18,
|
|
total: 30,
|
|
numRequests: 1,
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('BEDROCK_MODEL token counting functionality', () => {
|
|
describe('MISTRAL model handler', () => {
|
|
const modelHandler = BEDROCK_MODEL.MISTRAL;
|
|
|
|
it('should extract token usage from API response when available', async () => {
|
|
const mockResponse = {
|
|
usage: {
|
|
prompt_tokens: 25,
|
|
completion_tokens: 50,
|
|
total_tokens: 75,
|
|
},
|
|
};
|
|
|
|
const result = modelHandler.tokenUsage!(mockResponse, 'Test prompt');
|
|
expect(result).toEqual({
|
|
prompt: 25,
|
|
completion: 50,
|
|
total: 75,
|
|
numRequests: 1,
|
|
});
|
|
});
|
|
|
|
it('should handle string token counts', async () => {
|
|
const mockResponse = {
|
|
usage: {
|
|
prompt_tokens: '25',
|
|
completion_tokens: '50',
|
|
total_tokens: '75',
|
|
},
|
|
};
|
|
|
|
const result = modelHandler.tokenUsage!(mockResponse, 'Test prompt');
|
|
expect(result).toEqual({
|
|
prompt: 25,
|
|
completion: 50,
|
|
total: 75,
|
|
numRequests: 1,
|
|
});
|
|
});
|
|
|
|
it('should prefer API-reported total_tokens when it differs from prompt + completion', async () => {
|
|
const mockResponse = {
|
|
usage: {
|
|
prompt_tokens: 25,
|
|
completion_tokens: 50,
|
|
total_tokens: 99,
|
|
},
|
|
};
|
|
|
|
const result = modelHandler.tokenUsage!(mockResponse, 'Test prompt');
|
|
expect(result).toEqual({
|
|
prompt: 25,
|
|
completion: 50,
|
|
total: 99,
|
|
numRequests: 1,
|
|
});
|
|
});
|
|
|
|
it('should return undefined token counts when not provided by the API', async () => {
|
|
const mockResponse = {
|
|
outputs: [{ text: 'This is a generated response' }],
|
|
};
|
|
|
|
const result = modelHandler.tokenUsage!(mockResponse, 'Test prompt');
|
|
expect(result).toHaveProperty('prompt', undefined);
|
|
expect(result).toHaveProperty('completion', undefined);
|
|
expect(result).toHaveProperty('total', undefined);
|
|
expect(result).toHaveProperty('numRequests', 1);
|
|
});
|
|
});
|
|
|
|
describe('MISTRAL_LARGE_2407 model handler', () => {
|
|
const modelHandler = BEDROCK_MODEL.MISTRAL_LARGE_2407;
|
|
|
|
it('should extract token usage from chat completion format', async () => {
|
|
const mockResponse = {
|
|
id: 'b8f7363d-4aed-42cf-879a-7a8db4f37be3',
|
|
object: 'chat.completion',
|
|
created: 1743034280,
|
|
model: 'mistral-large-2407',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
message: {
|
|
role: 'assistant',
|
|
content: 'Test response',
|
|
},
|
|
finish_reason: 'stop',
|
|
},
|
|
],
|
|
prompt_tokens: 30,
|
|
completion_tokens: 45,
|
|
};
|
|
|
|
const result = modelHandler.tokenUsage!(mockResponse, 'Test prompt');
|
|
expect(result).toEqual({
|
|
prompt: 30,
|
|
completion: 45,
|
|
total: 75,
|
|
numRequests: 1,
|
|
});
|
|
});
|
|
|
|
it('should handle string token counts', async () => {
|
|
const mockResponse = {
|
|
prompt_tokens: '30',
|
|
completion_tokens: '45',
|
|
};
|
|
|
|
const result = modelHandler.tokenUsage!(mockResponse, 'Test prompt');
|
|
expect(result).toEqual({
|
|
prompt: 30,
|
|
completion: 45,
|
|
total: 75, // 30 + 45 = 75, not "3045"
|
|
numRequests: 1,
|
|
});
|
|
});
|
|
|
|
it('should prefer API-reported total_tokens when chat completion totals differ', async () => {
|
|
const mockResponse = {
|
|
usage: {
|
|
prompt_tokens: 30,
|
|
completion_tokens: 45,
|
|
total_tokens: 101,
|
|
},
|
|
};
|
|
|
|
const result = modelHandler.tokenUsage!(mockResponse, 'Test prompt');
|
|
expect(result).toEqual({
|
|
prompt: 30,
|
|
completion: 45,
|
|
total: 101,
|
|
numRequests: 1,
|
|
});
|
|
});
|
|
|
|
it('should return undefined token counts when not provided by the API', async () => {
|
|
const mockResponse = {
|
|
choices: [
|
|
{
|
|
message: {
|
|
content: 'This is a test response',
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
const result = modelHandler.tokenUsage!(mockResponse, 'Test prompt');
|
|
expect(result).toHaveProperty('prompt', undefined);
|
|
expect(result).toHaveProperty('completion', undefined);
|
|
expect(result).toHaveProperty('total', undefined);
|
|
expect(result).toHaveProperty('numRequests', 1);
|
|
});
|
|
});
|
|
|
|
describe('Llama model handler', () => {
|
|
it('should extract token usage from Llama response', async () => {
|
|
const mockResponse = {
|
|
generation: 'Test response',
|
|
prompt_token_count: 10,
|
|
generation_token_count: 20,
|
|
};
|
|
|
|
const handler = getLlamaModelHandler(LlamaVersion.V3);
|
|
const result = handler.tokenUsage!(mockResponse, 'Test prompt');
|
|
|
|
expect(result).toEqual({
|
|
prompt: 10,
|
|
completion: 20,
|
|
total: 30,
|
|
numRequests: 1,
|
|
});
|
|
});
|
|
|
|
it('should handle string token counts', async () => {
|
|
const mockResponse = {
|
|
generation: 'Test response',
|
|
prompt_token_count: '10',
|
|
generation_token_count: '20',
|
|
};
|
|
|
|
const handler = getLlamaModelHandler(LlamaVersion.V3);
|
|
const result = handler.tokenUsage!(mockResponse, 'Test prompt');
|
|
|
|
expect(result).toEqual({
|
|
prompt: 10,
|
|
completion: 20,
|
|
total: 30,
|
|
numRequests: 1,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Claude model handlers', () => {
|
|
it('should handle new-style token fields in Claude Messages', async () => {
|
|
const mockResponse = {
|
|
usage: {
|
|
input_tokens: 15,
|
|
output_tokens: 25,
|
|
},
|
|
};
|
|
|
|
const result = BEDROCK_MODEL.CLAUDE_MESSAGES.tokenUsage!(mockResponse, 'Test prompt');
|
|
expect(result).toEqual({
|
|
prompt: 15,
|
|
completion: 25,
|
|
total: 40,
|
|
numRequests: 1,
|
|
});
|
|
});
|
|
|
|
it('should handle string token counts in Claude Messages', async () => {
|
|
const mockResponse = {
|
|
usage: {
|
|
input_tokens: '15',
|
|
output_tokens: '25',
|
|
},
|
|
};
|
|
|
|
const result = BEDROCK_MODEL.CLAUDE_MESSAGES.tokenUsage!(mockResponse, 'Test prompt');
|
|
expect(result).toEqual({
|
|
prompt: 15,
|
|
completion: 25,
|
|
total: 40,
|
|
numRequests: 1,
|
|
});
|
|
});
|
|
|
|
it('should handle old-style token fields in Claude Completion', async () => {
|
|
const mockResponse = {
|
|
usage: {
|
|
prompt_tokens: 20,
|
|
completion_tokens: 30,
|
|
total_tokens: 50,
|
|
},
|
|
};
|
|
|
|
const result = BEDROCK_MODEL.CLAUDE_COMPLETION.tokenUsage!(mockResponse, 'Test prompt');
|
|
expect(result).toEqual({
|
|
prompt: 20,
|
|
completion: 30,
|
|
total: 50,
|
|
numRequests: 1,
|
|
});
|
|
});
|
|
|
|
it('should handle string token counts in Claude Completion', async () => {
|
|
const mockResponse = {
|
|
usage: {
|
|
prompt_tokens: '20',
|
|
completion_tokens: '30',
|
|
total_tokens: '50',
|
|
},
|
|
};
|
|
|
|
const result = BEDROCK_MODEL.CLAUDE_COMPLETION.tokenUsage!(mockResponse, 'Test prompt');
|
|
expect(result).toEqual({
|
|
prompt: 20,
|
|
completion: 30,
|
|
total: 50,
|
|
numRequests: 1,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('AMAZON_NOVA model handler', () => {
|
|
it('should handle numeric token counts', async () => {
|
|
const mockResponse = {
|
|
usage: {
|
|
inputTokens: 100,
|
|
outputTokens: 200,
|
|
totalTokens: 300,
|
|
},
|
|
};
|
|
|
|
const result = BEDROCK_MODEL.AMAZON_NOVA.tokenUsage!(mockResponse, 'Test prompt');
|
|
expect(result).toEqual({
|
|
prompt: 100,
|
|
completion: 200,
|
|
total: 300,
|
|
numRequests: 1,
|
|
});
|
|
});
|
|
|
|
it('should handle string token counts', async () => {
|
|
const mockResponse = {
|
|
usage: {
|
|
inputTokens: '113',
|
|
outputTokens: '335',
|
|
totalTokens: '448',
|
|
},
|
|
};
|
|
|
|
const result = BEDROCK_MODEL.AMAZON_NOVA.tokenUsage!(mockResponse, 'Test prompt');
|
|
expect(result).toEqual({
|
|
prompt: 113,
|
|
completion: 335,
|
|
total: 448, // 113 + 335 = 448, not "113335"
|
|
numRequests: 1,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('COHERE model handlers', () => {
|
|
it('should handle numeric token counts in COHERE_COMMAND', async () => {
|
|
const mockResponse = {
|
|
meta: {
|
|
billed_units: {
|
|
input_tokens: 50,
|
|
output_tokens: 100,
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = BEDROCK_MODEL.COHERE_COMMAND.tokenUsage!(mockResponse, 'Test prompt');
|
|
expect(result).toEqual({
|
|
prompt: 50,
|
|
completion: 100,
|
|
total: 150,
|
|
numRequests: 1,
|
|
});
|
|
});
|
|
|
|
it('should handle string token counts in COHERE_COMMAND', async () => {
|
|
const mockResponse = {
|
|
meta: {
|
|
billed_units: {
|
|
input_tokens: '50',
|
|
output_tokens: '100',
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = BEDROCK_MODEL.COHERE_COMMAND.tokenUsage!(mockResponse, 'Test prompt');
|
|
expect(result).toEqual({
|
|
prompt: 50,
|
|
completion: 100,
|
|
total: 150, // 50 + 100 = 150, not "50100"
|
|
numRequests: 1,
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('AWS_BEDROCK_MODELS mapping', () => {
|
|
it('maps Fable to Runtime and keeps Messages-only Mythos out of the registry', () => {
|
|
expect(AWS_BEDROCK_MODELS['anthropic.claude-fable-5']).toBe(BEDROCK_MODEL.CLAUDE_MESSAGES);
|
|
expect(AWS_BEDROCK_MODELS['us.anthropic.claude-fable-5']).toBe(BEDROCK_MODEL.CLAUDE_MESSAGES);
|
|
expect(AWS_BEDROCK_MODELS['eu.anthropic.claude-fable-5']).toBe(BEDROCK_MODEL.CLAUDE_MESSAGES);
|
|
expect(AWS_BEDROCK_MODELS['global.anthropic.claude-fable-5']).toBe(
|
|
BEDROCK_MODEL.CLAUDE_MESSAGES,
|
|
);
|
|
expect(AWS_BEDROCK_MODELS['anthropic.claude-mythos-5']).toBeUndefined();
|
|
expect(getHandlerForModel('anthropic.claude-fable-5')).toBe(BEDROCK_MODEL.CLAUDE_MESSAGES);
|
|
expect(() => getHandlerForModel('anthropic.claude-mythos-5')).toThrow(/Anthropic Messages API/);
|
|
expect(() => getHandlerForModel('us.anthropic.claude-mythos-5')).toThrow(
|
|
/Anthropic Messages API/,
|
|
);
|
|
});
|
|
|
|
it('maps Claude Sonnet 5 across the base and regional inference profiles', () => {
|
|
// Sonnet 5 mirrors the Claude 5-generation profile set: base + us./eu./global.
|
|
expect(AWS_BEDROCK_MODELS['anthropic.claude-sonnet-5']).toBe(BEDROCK_MODEL.CLAUDE_MESSAGES);
|
|
expect(AWS_BEDROCK_MODELS['us.anthropic.claude-sonnet-5']).toBe(BEDROCK_MODEL.CLAUDE_MESSAGES);
|
|
expect(AWS_BEDROCK_MODELS['eu.anthropic.claude-sonnet-5']).toBe(BEDROCK_MODEL.CLAUDE_MESSAGES);
|
|
expect(AWS_BEDROCK_MODELS['global.anthropic.claude-sonnet-5']).toBe(
|
|
BEDROCK_MODEL.CLAUDE_MESSAGES,
|
|
);
|
|
expect(getHandlerForModel('us.anthropic.claude-sonnet-5')).toBe(BEDROCK_MODEL.CLAUDE_MESSAGES);
|
|
});
|
|
|
|
it('should have the correct model mappings', async () => {
|
|
expect(AWS_BEDROCK_MODELS['anthropic.claude-3-5-sonnet-20241022-v2:0']).toBe(
|
|
BEDROCK_MODEL.CLAUDE_MESSAGES,
|
|
);
|
|
expect(AWS_BEDROCK_MODELS['us.anthropic.claude-3-5-sonnet-20241022-v2:0']).toBe(
|
|
BEDROCK_MODEL.CLAUDE_MESSAGES,
|
|
);
|
|
expect(AWS_BEDROCK_MODELS['anthropic.claude-3-7-sonnet-20250219-v1:0']).toBe(
|
|
BEDROCK_MODEL.CLAUDE_MESSAGES,
|
|
);
|
|
expect(AWS_BEDROCK_MODELS['us.anthropic.claude-3-7-sonnet-20250219-v1:0']).toBe(
|
|
BEDROCK_MODEL.CLAUDE_MESSAGES,
|
|
);
|
|
expect(AWS_BEDROCK_MODELS['eu.anthropic.claude-3-7-sonnet-20250219-v1:0']).toBe(
|
|
BEDROCK_MODEL.CLAUDE_MESSAGES,
|
|
);
|
|
expect(AWS_BEDROCK_MODELS['meta.llama3-1-405b-instruct-v1:0']).toBe(BEDROCK_MODEL.LLAMA3_1);
|
|
expect(AWS_BEDROCK_MODELS['meta.llama3-3-70b-instruct-v1:0']).toBe(BEDROCK_MODEL.LLAMA3_3);
|
|
expect(AWS_BEDROCK_MODELS['mistral.mistral-large-2407-v1:0']).toBe(
|
|
BEDROCK_MODEL.MISTRAL_LARGE_2407,
|
|
);
|
|
expect(AWS_BEDROCK_MODELS['meta.llama4-scout-17b-instruct-v1:0']).toBe(BEDROCK_MODEL.LLAMA4);
|
|
expect(AWS_BEDROCK_MODELS['meta.llama4-maverick-17b-instruct-v1:0']).toBe(BEDROCK_MODEL.LLAMA4);
|
|
expect(AWS_BEDROCK_MODELS['us.meta.llama4-scout-17b-instruct-v1:0']).toBe(BEDROCK_MODEL.LLAMA4);
|
|
expect(AWS_BEDROCK_MODELS['us.meta.llama4-maverick-17b-instruct-v1:0']).toBe(
|
|
BEDROCK_MODEL.LLAMA4,
|
|
);
|
|
});
|
|
|
|
it('should support newer model IDs via region prefixes', async () => {
|
|
[
|
|
'us.meta.llama3-2-3b-instruct-v1:0',
|
|
'eu.meta.llama3-2-3b-instruct-v1:0',
|
|
'us.anthropic.claude-3-7-sonnet-20250219-v1:0',
|
|
'us.meta.llama4-scout-17b-instruct-v1:0',
|
|
'eu.meta.llama4-maverick-17b-instruct-v1:0',
|
|
].forEach((modelId) => {
|
|
// Check if the model starts with a region prefix
|
|
const baseModelId = modelId.split('.').slice(1).join('.');
|
|
|
|
// Check if there's a match for the base model
|
|
const handler =
|
|
AWS_BEDROCK_MODELS[baseModelId] ||
|
|
(baseModelId.startsWith('meta.llama3-2') ? BEDROCK_MODEL.LLAMA3_2 : null) ||
|
|
(baseModelId.startsWith('meta.llama4') ? BEDROCK_MODEL.LLAMA4 : null) ||
|
|
(baseModelId.startsWith('anthropic.claude') ? BEDROCK_MODEL.CLAUDE_MESSAGES : null);
|
|
|
|
expect(handler).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
it('should map DeepSeek models correctly', async () => {
|
|
expect(AWS_BEDROCK_MODELS['deepseek.r1-v1:0']).toBe(BEDROCK_MODEL.DEEPSEEK);
|
|
expect(AWS_BEDROCK_MODELS['deepseek.v3-v1:0']).toBe(BEDROCK_MODEL.DEEPSEEK_CHAT);
|
|
expect(AWS_BEDROCK_MODELS['deepseek.v3.2']).toBe(BEDROCK_MODEL.DEEPSEEK_CHAT);
|
|
expect(AWS_BEDROCK_MODELS['us.deepseek.r1-v1:0']).toBe(BEDROCK_MODEL.DEEPSEEK);
|
|
});
|
|
|
|
it('should map newer Mistral models correctly', async () => {
|
|
[
|
|
'mistral.devstral-2-123b',
|
|
'mistral.magistral-small-2509',
|
|
'mistral.ministral-3-14b-instruct',
|
|
'mistral.ministral-3-8b-instruct',
|
|
'mistral.ministral-3-3b-instruct',
|
|
'mistral.mistral-large-3-675b-instruct',
|
|
'mistral.pixtral-large-2502-v1:0',
|
|
'mistral.voxtral-mini-3b-2507',
|
|
'mistral.voxtral-small-24b-2507',
|
|
'us.mistral.pixtral-large-2502-v1:0',
|
|
'eu.mistral.pixtral-large-2502-v1:0',
|
|
].forEach((modelId) => {
|
|
expect(AWS_BEDROCK_MODELS[modelId]).toBe(BEDROCK_MODEL.MISTRAL_CHAT);
|
|
});
|
|
});
|
|
|
|
it('should map OpenAI models correctly', async () => {
|
|
expect(AWS_BEDROCK_MODELS['openai.gpt-oss-120b-1:0']).toBe(BEDROCK_MODEL.OPENAI);
|
|
expect(AWS_BEDROCK_MODELS['openai.gpt-oss-20b-1:0']).toBe(BEDROCK_MODEL.OPENAI);
|
|
expect(AWS_BEDROCK_MODELS['openai.gpt-oss-safeguard-120b']).toBe(BEDROCK_MODEL.OPENAI);
|
|
expect(AWS_BEDROCK_MODELS['openai.gpt-oss-safeguard-20b']).toBe(BEDROCK_MODEL.OPENAI);
|
|
});
|
|
|
|
it('should not register frontier gpt-5.x models for the InvokeModel path', async () => {
|
|
// Frontier models are served via the OpenAI-compatible Responses API, not InvokeModel,
|
|
// so they must not appear in the InvokeModel model map.
|
|
expect(AWS_BEDROCK_MODELS['openai.gpt-5.5']).toBeUndefined();
|
|
expect(AWS_BEDROCK_MODELS['openai.gpt-5.4']).toBeUndefined();
|
|
});
|
|
|
|
describe('getHandlerForModel OpenAI routing', () => {
|
|
it('should resolve versioned gpt-oss runtime ids to the InvokeModel OpenAI handler', () => {
|
|
expect(getHandlerForModel('openai.gpt-oss-120b-1:0')).toBe(BEDROCK_MODEL.OPENAI);
|
|
expect(getHandlerForModel('openai.gpt-oss-20b-1:0')).toBe(BEDROCK_MODEL.OPENAI);
|
|
// Registered bare safeguard ids resolve via the exact-match lookup.
|
|
expect(getHandlerForModel('openai.gpt-oss-safeguard-120b')).toBe(BEDROCK_MODEL.OPENAI);
|
|
});
|
|
|
|
it('should reject the bare Mantle gpt-oss id instead of sending it to InvokeModel', () => {
|
|
// `openai.gpt-oss-120b` (no version suffix) is the Bedrock Mantle id; the bedrock:
|
|
// provider uses the InvokeModel runtime API, so it must reject it with the runtime id.
|
|
expect(() => getHandlerForModel('openai.gpt-oss-120b')).toThrow(/Mantle id/);
|
|
expect(() => getHandlerForModel('openai.gpt-oss-120b')).toThrow(/openai\.gpt-oss-120b-1:0/);
|
|
expect(() => getHandlerForModel('openai.gpt-oss-20b')).toThrow(/Mantle id/);
|
|
});
|
|
|
|
it('should throw a clear error for frontier gpt-5.x ids on the InvokeModel path', () => {
|
|
// Frontier models are routed to the Responses provider before reaching this handler;
|
|
// a direct/forced InvokeModel resolution should explain that rather than silently try.
|
|
expect(() => getHandlerForModel('openai.gpt-5.5')).toThrow(/Responses API/);
|
|
expect(() => getHandlerForModel('openai.gpt-5.4')).toThrow(/Responses API/);
|
|
});
|
|
|
|
it('should suggest the bare frontier id when a region/geo-prefixed frontier id is forced down InvokeModel', () => {
|
|
// AWS offers no Geo/Global inference profiles for the frontier models; the error should
|
|
// point at the supported bare id rather than echo the invalid prefixed one.
|
|
expect(() => getHandlerForModel('us.openai.gpt-5.5')).toThrow(/bedrock:openai\.gpt-5\.5/);
|
|
expect(() => getHandlerForModel('global.openai.gpt-5.4')).toThrow(/bedrock:openai\.gpt-5\.4/);
|
|
});
|
|
|
|
it('should still throw for genuinely unknown models', () => {
|
|
expect(() => getHandlerForModel('totally.unknown-model')).toThrow(
|
|
'Unknown Amazon Bedrock model',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('should map APAC regional models correctly', async () => {
|
|
expect(AWS_BEDROCK_MODELS['apac.amazon.nova-lite-v1:0']).toBe(BEDROCK_MODEL.AMAZON_NOVA);
|
|
expect(AWS_BEDROCK_MODELS['apac.amazon.nova-micro-v1:0']).toBe(BEDROCK_MODEL.AMAZON_NOVA);
|
|
expect(AWS_BEDROCK_MODELS['apac.amazon.nova-pro-v1:0']).toBe(BEDROCK_MODEL.AMAZON_NOVA);
|
|
expect(AWS_BEDROCK_MODELS['apac.amazon.nova-premier-v1:0']).toBe(BEDROCK_MODEL.AMAZON_NOVA);
|
|
expect(AWS_BEDROCK_MODELS['apac.anthropic.claude-3-5-sonnet-20240620-v1:0']).toBe(
|
|
BEDROCK_MODEL.CLAUDE_MESSAGES,
|
|
);
|
|
expect(AWS_BEDROCK_MODELS['apac.anthropic.claude-3-haiku-20240307-v1:0']).toBe(
|
|
BEDROCK_MODEL.CLAUDE_MESSAGES,
|
|
);
|
|
expect(AWS_BEDROCK_MODELS['apac.anthropic.claude-opus-4-1-20250805-v1:0']).toBe(
|
|
BEDROCK_MODEL.CLAUDE_MESSAGES,
|
|
);
|
|
expect(AWS_BEDROCK_MODELS['apac.anthropic.claude-sonnet-4-20250514-v1:0']).toBe(
|
|
BEDROCK_MODEL.CLAUDE_MESSAGES,
|
|
);
|
|
expect(AWS_BEDROCK_MODELS['apac.meta.llama4-scout-17b-instruct-v1:0']).toBe(
|
|
BEDROCK_MODEL.LLAMA4,
|
|
);
|
|
expect(AWS_BEDROCK_MODELS['apac.meta.llama4-maverick-17b-instruct-v1:0']).toBe(
|
|
BEDROCK_MODEL.LLAMA4,
|
|
);
|
|
});
|
|
|
|
it('should map EU regional models correctly', async () => {
|
|
expect(AWS_BEDROCK_MODELS['eu.amazon.nova-lite-v1:0']).toBe(BEDROCK_MODEL.AMAZON_NOVA);
|
|
expect(AWS_BEDROCK_MODELS['eu.amazon.nova-micro-v1:0']).toBe(BEDROCK_MODEL.AMAZON_NOVA);
|
|
expect(AWS_BEDROCK_MODELS['eu.amazon.nova-pro-v1:0']).toBe(BEDROCK_MODEL.AMAZON_NOVA);
|
|
expect(AWS_BEDROCK_MODELS['eu.amazon.nova-premier-v1:0']).toBe(BEDROCK_MODEL.AMAZON_NOVA);
|
|
expect(AWS_BEDROCK_MODELS['eu.anthropic.claude-3-5-sonnet-20240620-v1:0']).toBe(
|
|
BEDROCK_MODEL.CLAUDE_MESSAGES,
|
|
);
|
|
expect(AWS_BEDROCK_MODELS['eu.anthropic.claude-3-7-sonnet-20250219-v1:0']).toBe(
|
|
BEDROCK_MODEL.CLAUDE_MESSAGES,
|
|
);
|
|
expect(AWS_BEDROCK_MODELS['eu.anthropic.claude-3-haiku-20240307-v1:0']).toBe(
|
|
BEDROCK_MODEL.CLAUDE_MESSAGES,
|
|
);
|
|
expect(AWS_BEDROCK_MODELS['eu.anthropic.claude-opus-4-1-20250805-v1:0']).toBe(
|
|
BEDROCK_MODEL.CLAUDE_MESSAGES,
|
|
);
|
|
expect(AWS_BEDROCK_MODELS['eu.anthropic.claude-sonnet-4-20250514-v1:0']).toBe(
|
|
BEDROCK_MODEL.CLAUDE_MESSAGES,
|
|
);
|
|
expect(AWS_BEDROCK_MODELS['eu.meta.llama3-2-1b-instruct-v1:0']).toBe(BEDROCK_MODEL.LLAMA3_2);
|
|
expect(AWS_BEDROCK_MODELS['eu.meta.llama3-2-3b-instruct-v1:0']).toBe(BEDROCK_MODEL.LLAMA3_2);
|
|
expect(AWS_BEDROCK_MODELS['eu.meta.llama4-scout-17b-instruct-v1:0']).toBe(BEDROCK_MODEL.LLAMA4);
|
|
expect(AWS_BEDROCK_MODELS['eu.meta.llama4-maverick-17b-instruct-v1:0']).toBe(
|
|
BEDROCK_MODEL.LLAMA4,
|
|
);
|
|
});
|
|
|
|
it('should map US regional models correctly', async () => {
|
|
expect(AWS_BEDROCK_MODELS['us.amazon.nova-lite-v1:0']).toBe(BEDROCK_MODEL.AMAZON_NOVA);
|
|
expect(AWS_BEDROCK_MODELS['us.amazon.nova-micro-v1:0']).toBe(BEDROCK_MODEL.AMAZON_NOVA);
|
|
expect(AWS_BEDROCK_MODELS['us.amazon.nova-pro-v1:0']).toBe(BEDROCK_MODEL.AMAZON_NOVA);
|
|
expect(AWS_BEDROCK_MODELS['us.amazon.nova-premier-v1:0']).toBe(BEDROCK_MODEL.AMAZON_NOVA);
|
|
expect(AWS_BEDROCK_MODELS['us.anthropic.claude-3-5-haiku-20241022-v1:0']).toBe(
|
|
BEDROCK_MODEL.CLAUDE_MESSAGES,
|
|
);
|
|
expect(AWS_BEDROCK_MODELS['us.anthropic.claude-3-5-sonnet-20240620-v1:0']).toBe(
|
|
BEDROCK_MODEL.CLAUDE_MESSAGES,
|
|
);
|
|
expect(AWS_BEDROCK_MODELS['us.anthropic.claude-3-5-sonnet-20241022-v2:0']).toBe(
|
|
BEDROCK_MODEL.CLAUDE_MESSAGES,
|
|
);
|
|
expect(AWS_BEDROCK_MODELS['us.anthropic.claude-3-7-sonnet-20250219-v1:0']).toBe(
|
|
BEDROCK_MODEL.CLAUDE_MESSAGES,
|
|
);
|
|
expect(AWS_BEDROCK_MODELS['us.anthropic.claude-3-haiku-20240307-v1:0']).toBe(
|
|
BEDROCK_MODEL.CLAUDE_MESSAGES,
|
|
);
|
|
expect(AWS_BEDROCK_MODELS['us.anthropic.claude-opus-4-1-20250805-v1:0']).toBe(
|
|
BEDROCK_MODEL.CLAUDE_MESSAGES,
|
|
);
|
|
expect(AWS_BEDROCK_MODELS['us.anthropic.claude-sonnet-4-20250514-v1:0']).toBe(
|
|
BEDROCK_MODEL.CLAUDE_MESSAGES,
|
|
);
|
|
expect(AWS_BEDROCK_MODELS['us.deepseek.r1-v1:0']).toBe(BEDROCK_MODEL.DEEPSEEK);
|
|
expect(AWS_BEDROCK_MODELS['us.meta.llama3-1-405b-instruct-v1:0']).toBe(BEDROCK_MODEL.LLAMA3_1);
|
|
expect(AWS_BEDROCK_MODELS['us.meta.llama3-1-70b-instruct-v1:0']).toBe(BEDROCK_MODEL.LLAMA3_1);
|
|
expect(AWS_BEDROCK_MODELS['us.meta.llama3-1-8b-instruct-v1:0']).toBe(BEDROCK_MODEL.LLAMA3_1);
|
|
expect(AWS_BEDROCK_MODELS['us.meta.llama3-2-11b-instruct-v1:0']).toBe(BEDROCK_MODEL.LLAMA3_2);
|
|
expect(AWS_BEDROCK_MODELS['us.meta.llama3-2-1b-instruct-v1:0']).toBe(BEDROCK_MODEL.LLAMA3_2);
|
|
expect(AWS_BEDROCK_MODELS['us.meta.llama3-2-3b-instruct-v1:0']).toBe(BEDROCK_MODEL.LLAMA3_2);
|
|
expect(AWS_BEDROCK_MODELS['us.meta.llama3-2-90b-instruct-v1:0']).toBe(BEDROCK_MODEL.LLAMA3_2);
|
|
expect(AWS_BEDROCK_MODELS['us.meta.llama3-3-70b-instruct-v1:0']).toBe(BEDROCK_MODEL.LLAMA3_3);
|
|
expect(AWS_BEDROCK_MODELS['us.meta.llama4-scout-17b-instruct-v1:0']).toBe(BEDROCK_MODEL.LLAMA4);
|
|
expect(AWS_BEDROCK_MODELS['us.meta.llama4-maverick-17b-instruct-v1:0']).toBe(
|
|
BEDROCK_MODEL.LLAMA4,
|
|
);
|
|
});
|
|
|
|
it('should map US Gov Cloud models correctly', async () => {
|
|
expect(AWS_BEDROCK_MODELS['us-gov.anthropic.claude-3-5-sonnet-20240620-v1:0']).toBe(
|
|
BEDROCK_MODEL.CLAUDE_MESSAGES,
|
|
);
|
|
expect(AWS_BEDROCK_MODELS['us-gov.anthropic.claude-3-haiku-20240307-v1:0']).toBe(
|
|
BEDROCK_MODEL.CLAUDE_MESSAGES,
|
|
);
|
|
});
|
|
|
|
it('does not list models that are EOL on Bedrock (removed from all regions)', () => {
|
|
// Verified via `aws bedrock list-foundation-models` across all commercial regions: these
|
|
// are no longer offered anywhere, so they must not appear in the supported-models list.
|
|
// (Claude 3.5/3.7 Sonnet are intentionally kept — still offered in APAC regions.)
|
|
for (const id of RETIRED_BEDROCK_MODEL_IDS) {
|
|
expect(AWS_BEDROCK_MODELS[id]).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it.each(RETIRED_BEDROCK_MODEL_IDS)('rejects retired direct model id %s', (modelName) => {
|
|
expect(() => getHandlerForModel(modelName)).toThrow(
|
|
`Unknown Amazon Bedrock model: ${modelName}`,
|
|
);
|
|
});
|
|
|
|
it('keeps Claude 3.5/3.7 Sonnet (still offered in APAC regions)', () => {
|
|
expect(AWS_BEDROCK_MODELS['anthropic.claude-3-5-sonnet-20240620-v1:0']).toBe(
|
|
BEDROCK_MODEL.CLAUDE_MESSAGES,
|
|
);
|
|
expect(AWS_BEDROCK_MODELS['anthropic.claude-3-5-sonnet-20241022-v2:0']).toBe(
|
|
BEDROCK_MODEL.CLAUDE_MESSAGES,
|
|
);
|
|
expect(AWS_BEDROCK_MODELS['anthropic.claude-3-7-sonnet-20250219-v1:0']).toBe(
|
|
BEDROCK_MODEL.CLAUDE_MESSAGES,
|
|
);
|
|
});
|
|
|
|
it('should map Claude Opus 4.7 models correctly', async () => {
|
|
// Base model ID (no -v1 suffix for 4.7+ — verified via `aws bedrock list-foundation-models`)
|
|
expect(AWS_BEDROCK_MODELS['anthropic.claude-opus-4-7']).toBe(BEDROCK_MODEL.CLAUDE_MESSAGES);
|
|
|
|
// Cross-region inference profiles (verified via `aws bedrock list-inference-profiles`).
|
|
// Opus 4.7 uses the newer `jp.`/`global.` scheme instead of the older `apac.` prefix.
|
|
expect(AWS_BEDROCK_MODELS['us.anthropic.claude-opus-4-7']).toBe(BEDROCK_MODEL.CLAUDE_MESSAGES);
|
|
expect(AWS_BEDROCK_MODELS['eu.anthropic.claude-opus-4-7']).toBe(BEDROCK_MODEL.CLAUDE_MESSAGES);
|
|
expect(AWS_BEDROCK_MODELS['jp.anthropic.claude-opus-4-7']).toBe(BEDROCK_MODEL.CLAUDE_MESSAGES);
|
|
expect(AWS_BEDROCK_MODELS['global.anthropic.claude-opus-4-7']).toBe(
|
|
BEDROCK_MODEL.CLAUDE_MESSAGES,
|
|
);
|
|
|
|
// Sanity check: the -v1 suffix variant (which Anthropic/AWS docs do not publish) is NOT
|
|
// registered. Regressing this would silently route requests through the generic fallback.
|
|
expect(AWS_BEDROCK_MODELS['anthropic.claude-opus-4-7-v1']).toBeUndefined();
|
|
});
|
|
|
|
it('should map Claude Opus 4.8 models correctly', async () => {
|
|
// Base model ID (no -v1 suffix, mirroring Opus 4.7).
|
|
expect(AWS_BEDROCK_MODELS['anthropic.claude-opus-4-8']).toBe(BEDROCK_MODEL.CLAUDE_MESSAGES);
|
|
|
|
// Cross-region inference profiles mirror the Opus 4.7 set (`us.`/`eu.`/`jp.`/`global.`,
|
|
// no older `apac.` prefix).
|
|
expect(AWS_BEDROCK_MODELS['us.anthropic.claude-opus-4-8']).toBe(BEDROCK_MODEL.CLAUDE_MESSAGES);
|
|
expect(AWS_BEDROCK_MODELS['eu.anthropic.claude-opus-4-8']).toBe(BEDROCK_MODEL.CLAUDE_MESSAGES);
|
|
expect(AWS_BEDROCK_MODELS['jp.anthropic.claude-opus-4-8']).toBe(BEDROCK_MODEL.CLAUDE_MESSAGES);
|
|
expect(AWS_BEDROCK_MODELS['global.anthropic.claude-opus-4-8']).toBe(
|
|
BEDROCK_MODEL.CLAUDE_MESSAGES,
|
|
);
|
|
|
|
// Sanity check: the -v1 suffix variant is NOT registered.
|
|
expect(AWS_BEDROCK_MODELS['anthropic.claude-opus-4-8-v1']).toBeUndefined();
|
|
});
|
|
|
|
it('should map Nova 2 models correctly', async () => {
|
|
// Base model ID
|
|
expect(AWS_BEDROCK_MODELS['amazon.nova-2-lite-v1:0']).toBe(BEDROCK_MODEL.AMAZON_NOVA_2);
|
|
|
|
// Regional model IDs
|
|
expect(AWS_BEDROCK_MODELS['us.amazon.nova-2-lite-v1:0']).toBe(BEDROCK_MODEL.AMAZON_NOVA_2);
|
|
expect(AWS_BEDROCK_MODELS['eu.amazon.nova-2-lite-v1:0']).toBe(BEDROCK_MODEL.AMAZON_NOVA_2);
|
|
expect(AWS_BEDROCK_MODELS['apac.amazon.nova-2-lite-v1:0']).toBe(BEDROCK_MODEL.AMAZON_NOVA_2);
|
|
|
|
// Global cross-region inference
|
|
expect(AWS_BEDROCK_MODELS['global.amazon.nova-2-lite-v1:0']).toBe(BEDROCK_MODEL.AMAZON_NOVA_2);
|
|
|
|
// Note: Nova 2 Sonic uses bidirectional streaming API like Nova Sonic v1,
|
|
// so it's handled separately via NovaSonicProvider in registry.ts
|
|
});
|
|
});
|
|
|
|
describe('AwsBedrockCompletionProvider', () => {
|
|
const mockInvokeModel = vi.fn();
|
|
let originalModelHandler: IBedrockModel;
|
|
let mockCache: any;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
|
|
BedrockRuntimeMock.mockImplementation(function () {
|
|
return {
|
|
invokeModel: mockInvokeModel.mockResolvedValue({
|
|
body: {
|
|
transformToString: () => JSON.stringify({ completion: 'test response' }),
|
|
},
|
|
}),
|
|
};
|
|
});
|
|
|
|
mockCache = {
|
|
get: vi.fn().mockResolvedValue(null),
|
|
set: vi.fn().mockResolvedValue(null),
|
|
};
|
|
|
|
vi.mocked(getCache).mockResolvedValue(mockCache as any);
|
|
vi.mocked(isCacheEnabled).mockImplementation(function () {
|
|
return false;
|
|
});
|
|
|
|
originalModelHandler = AWS_BEDROCK_MODELS['us.anthropic.claude-3-7-sonnet-20250219-v1:0'];
|
|
|
|
AWS_BEDROCK_MODELS['us.anthropic.claude-3-7-sonnet-20250219-v1:0'] = {
|
|
params: vi.fn().mockImplementation(function (config) {
|
|
return {
|
|
prompt: 'formatted prompt',
|
|
...config,
|
|
};
|
|
}),
|
|
output: vi.fn().mockReturnValue('processed output'),
|
|
tokenUsage: vi.fn().mockReturnValue({
|
|
prompt: 10,
|
|
completion: 20,
|
|
total: 30,
|
|
numRequests: 1,
|
|
}),
|
|
};
|
|
});
|
|
|
|
afterEach(() => {
|
|
AWS_BEDROCK_MODELS['us.anthropic.claude-3-7-sonnet-20250219-v1:0'] = originalModelHandler;
|
|
});
|
|
|
|
it('calculates regional pricing for Claude Fable 5 Runtime responses', async () => {
|
|
const responseJson = JSON.stringify({
|
|
content: [{ type: 'text', text: 'ok' }],
|
|
usage: { input_tokens: 100, output_tokens: 50 },
|
|
});
|
|
const body = Object.assign(new TextEncoder().encode(responseJson), {
|
|
transformToString: () => responseJson,
|
|
});
|
|
mockInvokeModel.mockResolvedValueOnce({
|
|
body,
|
|
});
|
|
const provider = new AwsBedrockCompletionProvider('anthropic.claude-fable-5', {
|
|
config: { region: 'us-east-1' },
|
|
});
|
|
|
|
const result = await provider.callApi('hello');
|
|
|
|
expect(result.output).toBe('ok');
|
|
expect(result.cost).toBeCloseTo(0.00385, 6);
|
|
});
|
|
|
|
it('calculates pricing for OpenAI-compatible Runtime responses', async () => {
|
|
const responseJson = JSON.stringify({
|
|
choices: [{ message: { content: 'ok' } }],
|
|
usage: { prompt_tokens: 10000, completion_tokens: 5000, total_tokens: 15000 },
|
|
});
|
|
const body = Object.assign(new TextEncoder().encode(responseJson), {
|
|
transformToString: () => responseJson,
|
|
});
|
|
mockInvokeModel.mockResolvedValueOnce({
|
|
body,
|
|
});
|
|
const provider = new AwsBedrockCompletionProvider('zai.glm-5', {
|
|
config: { region: 'us-east-1' },
|
|
});
|
|
|
|
const result = await provider.callApi('hello');
|
|
|
|
expect(result.output).toBe('ok');
|
|
expect(result.cost).toBeCloseTo((10000 / 1e6) * 1.0 + (5000 / 1e6) * 3.2, 6);
|
|
});
|
|
|
|
it('uses current GPT-OSS Runtime pricing in the shared cost fallback', async () => {
|
|
const responseJson = JSON.stringify({
|
|
choices: [{ message: { content: 'ok' } }],
|
|
usage: {
|
|
prompt_tokens: 1_000_000,
|
|
completion_tokens: 1_000_000,
|
|
total_tokens: 2_000_000,
|
|
},
|
|
});
|
|
const body = Object.assign(new TextEncoder().encode(responseJson), {
|
|
transformToString: () => responseJson,
|
|
});
|
|
mockInvokeModel.mockResolvedValueOnce({ body });
|
|
const provider = new AwsBedrockCompletionProvider('openai.gpt-oss-120b-1:0', {
|
|
config: { region: 'us-east-1' },
|
|
});
|
|
|
|
const result = await provider.callApi('hello');
|
|
|
|
expect(result.output).toBe('ok');
|
|
expect(result.cost).toBeCloseTo(0.75, 6);
|
|
});
|
|
|
|
it('does not apply the stale Converse Command R+ rate to Runtime responses', async () => {
|
|
const responseJson = JSON.stringify({
|
|
text: 'ok',
|
|
meta: {
|
|
billed_units: { input_tokens: 1_000_000, output_tokens: 1_000_000 },
|
|
},
|
|
});
|
|
const body = Object.assign(new TextEncoder().encode(responseJson), {
|
|
transformToString: () => responseJson,
|
|
});
|
|
mockInvokeModel.mockResolvedValueOnce({ body });
|
|
const provider = new AwsBedrockCompletionProvider('cohere.command-r-plus-v1:0', {
|
|
config: { region: 'us-east-1' },
|
|
});
|
|
|
|
const result = await provider.callApi('hello');
|
|
|
|
expect(result.output).toBe('ok');
|
|
expect(result.cost).toBeUndefined();
|
|
});
|
|
|
|
it('does not apply the unverified Converse Claude Sonnet rate to Runtime responses', async () => {
|
|
const responseJson = JSON.stringify({
|
|
content: [{ type: 'text', text: 'ok' }],
|
|
usage: { input_tokens: 200_001, output_tokens: 1_000 },
|
|
});
|
|
const body = Object.assign(new TextEncoder().encode(responseJson), {
|
|
transformToString: () => responseJson,
|
|
});
|
|
mockInvokeModel.mockResolvedValueOnce({ body });
|
|
const provider = new AwsBedrockCompletionProvider('anthropic.claude-sonnet-4-6', {
|
|
config: { region: 'us-east-1' },
|
|
});
|
|
|
|
const result = await provider.callApi('hello');
|
|
|
|
expect(result.output).toBe('ok');
|
|
expect(result.cost).toBeUndefined();
|
|
});
|
|
|
|
it('does not apply the unverified Converse Claude Opus rate to Runtime responses', async () => {
|
|
const responseJson = JSON.stringify({
|
|
content: [{ type: 'text', text: 'ok' }],
|
|
usage: {
|
|
input_tokens: 10000,
|
|
output_tokens: 5000,
|
|
cache_read_input_tokens: 1000,
|
|
cache_creation_input_tokens: 2000,
|
|
},
|
|
});
|
|
const body = Object.assign(new TextEncoder().encode(responseJson), {
|
|
transformToString: () => responseJson,
|
|
});
|
|
mockInvokeModel.mockResolvedValueOnce({
|
|
body,
|
|
});
|
|
const provider = new AwsBedrockCompletionProvider('anthropic.claude-opus-4-8', {
|
|
config: { region: 'us-east-1' },
|
|
});
|
|
|
|
const result = await provider.callApi('hello');
|
|
|
|
expect(result.output).toBe('ok');
|
|
expect(result.cost).toBeUndefined();
|
|
});
|
|
|
|
it('should pass base config to model.params when context is not provided', async () => {
|
|
const provider = new (class extends AwsBedrockCompletionProvider {
|
|
constructor() {
|
|
super('us.anthropic.claude-3-7-sonnet-20250219-v1:0', {
|
|
config: {
|
|
region: 'us-east-1',
|
|
temperature: 0.5,
|
|
} as BedrockClaudeMessagesCompletionOptions,
|
|
});
|
|
}
|
|
})();
|
|
|
|
await provider.callApi('test prompt');
|
|
|
|
expect(
|
|
AWS_BEDROCK_MODELS['us.anthropic.claude-3-7-sonnet-20250219-v1:0'].params,
|
|
).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
region: 'us-east-1',
|
|
temperature: 0.5,
|
|
}),
|
|
'test prompt',
|
|
expect.any(Array),
|
|
'us.anthropic.claude-3-7-sonnet-20250219-v1:0',
|
|
undefined, // vars not provided when context is undefined
|
|
);
|
|
});
|
|
|
|
it('should merge context.prompt.config with base config', async () => {
|
|
const provider = new (class extends AwsBedrockCompletionProvider {
|
|
constructor() {
|
|
super('us.anthropic.claude-3-7-sonnet-20250219-v1:0', {
|
|
config: {
|
|
region: 'us-east-1',
|
|
temperature: 0.5,
|
|
} as BedrockClaudeMessagesCompletionOptions,
|
|
});
|
|
}
|
|
})();
|
|
|
|
const context = {
|
|
prompt: {
|
|
raw: 'test prompt',
|
|
label: 'test',
|
|
config: {
|
|
temperature: 0.7,
|
|
max_tokens: 100,
|
|
},
|
|
},
|
|
vars: {},
|
|
};
|
|
|
|
await provider.callApi('test prompt', context);
|
|
|
|
expect(
|
|
AWS_BEDROCK_MODELS['us.anthropic.claude-3-7-sonnet-20250219-v1:0'].params,
|
|
).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
region: 'us-east-1', // From base config
|
|
temperature: 0.7, // Overridden by context
|
|
max_tokens: 100, // Added by context
|
|
}),
|
|
'test prompt',
|
|
expect.any(Array),
|
|
'us.anthropic.claude-3-7-sonnet-20250219-v1:0',
|
|
{}, // vars from context
|
|
);
|
|
});
|
|
|
|
it('should prioritize context.prompt.config values over base config', async () => {
|
|
const provider = new (class extends AwsBedrockCompletionProvider {
|
|
constructor() {
|
|
super('us.anthropic.claude-3-7-sonnet-20250219-v1:0', {
|
|
config: {
|
|
region: 'us-east-1',
|
|
temperature: 0.5,
|
|
max_tokens: 50,
|
|
top_p: 0.8,
|
|
} as BedrockClaudeMessagesCompletionOptions,
|
|
});
|
|
}
|
|
})();
|
|
|
|
const context = {
|
|
prompt: {
|
|
raw: 'test prompt',
|
|
label: 'test',
|
|
config: {
|
|
temperature: 0.9,
|
|
max_tokens: 200,
|
|
},
|
|
},
|
|
vars: {},
|
|
};
|
|
|
|
await provider.callApi('test prompt', context);
|
|
|
|
expect(
|
|
AWS_BEDROCK_MODELS['us.anthropic.claude-3-7-sonnet-20250219-v1:0'].params,
|
|
).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
region: 'us-east-1', // From base config
|
|
temperature: 0.9, // Overridden by context
|
|
max_tokens: 200, // Overridden by context
|
|
top_p: 0.8, // From base config (unchanged)
|
|
}),
|
|
'test prompt',
|
|
expect.any(Array),
|
|
'us.anthropic.claude-3-7-sonnet-20250219-v1:0',
|
|
{}, // vars from context
|
|
);
|
|
});
|
|
|
|
it('should pass merged prompt-level config to model.output (e.g. showThinking)', async () => {
|
|
// Exercise the cached path, which reaches model.output with the effective config.
|
|
mockCache.get = vi.fn().mockResolvedValue(JSON.stringify({ completion: 'cached' }));
|
|
vi.mocked(isCacheEnabled).mockImplementation(() => true);
|
|
|
|
const provider = new (class extends AwsBedrockCompletionProvider {
|
|
constructor() {
|
|
super('us.anthropic.claude-3-7-sonnet-20250219-v1:0', {
|
|
config: { region: 'us-east-1', showThinking: false } as any,
|
|
});
|
|
}
|
|
})();
|
|
|
|
const context = {
|
|
prompt: {
|
|
raw: 'test prompt',
|
|
label: 'test',
|
|
// Prompt-level override should reach output(), not just params().
|
|
config: { showThinking: true } as any,
|
|
},
|
|
vars: {},
|
|
};
|
|
|
|
await provider.callApi('test prompt', context as any);
|
|
|
|
expect(
|
|
AWS_BEDROCK_MODELS['us.anthropic.claude-3-7-sonnet-20250219-v1:0'].output,
|
|
).toHaveBeenCalledWith(expect.objectContaining({ showThinking: true }), expect.anything());
|
|
});
|
|
|
|
it('should pass merged prompt-level config to model.output on the live (non-cached) path', async () => {
|
|
// Default beforeEach has caching disabled, so this exercises the live InvokeModel path.
|
|
// The live path both logs via body.transformToString() and decodes via TextDecoder, so the
|
|
// mock body must be a real byte view that also carries transformToString().
|
|
const liveBytes = new TextEncoder().encode(JSON.stringify({ completion: 'live' }));
|
|
(liveBytes as any).transformToString = () => JSON.stringify({ completion: 'live' });
|
|
mockInvokeModel.mockResolvedValueOnce({ body: liveBytes });
|
|
|
|
const provider = new (class extends AwsBedrockCompletionProvider {
|
|
constructor() {
|
|
super('us.anthropic.claude-3-7-sonnet-20250219-v1:0', {
|
|
config: { region: 'us-east-1', showThinking: false } as any,
|
|
});
|
|
}
|
|
})();
|
|
|
|
const context = {
|
|
prompt: {
|
|
raw: 'test prompt',
|
|
label: 'test',
|
|
config: { showThinking: true } as any,
|
|
},
|
|
vars: {},
|
|
};
|
|
|
|
await provider.callApi('test prompt', context as any);
|
|
|
|
expect(
|
|
AWS_BEDROCK_MODELS['us.anthropic.claude-3-7-sonnet-20250219-v1:0'].output,
|
|
).toHaveBeenCalledWith(expect.objectContaining({ showThinking: true }), expect.anything());
|
|
});
|
|
|
|
it('should set cached flag when returning cached response', async () => {
|
|
const mockCachedResponseData = { completion: 'cached response' };
|
|
|
|
mockCache.get = vi.fn().mockResolvedValue(JSON.stringify(mockCachedResponseData));
|
|
vi.mocked(isCacheEnabled).mockImplementation(function () {
|
|
return true;
|
|
});
|
|
|
|
const provider = new (class extends AwsBedrockCompletionProvider {
|
|
constructor() {
|
|
super('us.anthropic.claude-3-7-sonnet-20250219-v1:0', {
|
|
config: {
|
|
region: 'us-east-1',
|
|
} as BedrockClaudeMessagesCompletionOptions,
|
|
});
|
|
}
|
|
})();
|
|
|
|
const result = await provider.callApi('test prompt');
|
|
|
|
expect(result.cached).toBe(true);
|
|
expect(result.output).toBe('processed output');
|
|
expect(mockCache.get).toHaveBeenCalled();
|
|
// Verify invokeModel was not called because cache was used
|
|
expect(mockInvokeModel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should hash prompt and secret values in the cache key', async () => {
|
|
const modelName = 'us.anthropic.claude-3-7-sonnet-20250219-v1:0';
|
|
const prompt = 'PFQA_BEDROCK_PROMPT_SENTINEL';
|
|
const apiKey = 'PFQA_BEDROCK_SECRET_SENTINEL';
|
|
const otherApiKey = 'PFQA_BEDROCK_OTHER_SECRET_SENTINEL';
|
|
const cachedResponseData = { completion: 'cached response' };
|
|
|
|
mockCache.get = vi.fn().mockResolvedValue(JSON.stringify(cachedResponseData));
|
|
vi.mocked(isCacheEnabled).mockReturnValue(true);
|
|
vi.mocked(AWS_BEDROCK_MODELS[modelName].params).mockImplementation(
|
|
async (config, promptText) => ({
|
|
prompt: promptText,
|
|
...config,
|
|
}),
|
|
);
|
|
|
|
const provider = new AwsBedrockCompletionProvider(modelName, {
|
|
config: {
|
|
region: 'us-east-1',
|
|
apiKey,
|
|
} as BedrockClaudeMessagesCompletionOptions,
|
|
});
|
|
|
|
const result = await provider.callApi(prompt);
|
|
|
|
expect(result.cached).toBe(true);
|
|
expect(result.output).toBe('processed output');
|
|
expect(mockInvokeModel).not.toHaveBeenCalled();
|
|
expect(mockCache.get).toHaveBeenCalledTimes(1);
|
|
|
|
const cacheKey = mockCache.get.mock.calls[0][0] as string;
|
|
const cacheKeyPrefix = `bedrock:${modelName}:us-east-1:`;
|
|
expect(cacheKey.startsWith(cacheKeyPrefix)).toBe(true);
|
|
expect(cacheKey.slice(cacheKeyPrefix.length)).toMatch(/^[a-f0-9]{64}:[a-f0-9]{64}$/);
|
|
expect(cacheKey).not.toContain(prompt);
|
|
expect(cacheKey).not.toContain(apiKey);
|
|
expect(cacheKey).not.toContain(otherApiKey);
|
|
|
|
const otherProvider = new AwsBedrockCompletionProvider(modelName, {
|
|
config: {
|
|
region: 'us-east-1',
|
|
apiKey: otherApiKey,
|
|
} as BedrockClaudeMessagesCompletionOptions,
|
|
});
|
|
|
|
await otherProvider.callApi(prompt);
|
|
|
|
const otherCacheKey = mockCache.get.mock.calls[1][0] as string;
|
|
expect(otherCacheKey.startsWith(cacheKeyPrefix)).toBe(true);
|
|
expect(otherCacheKey.slice(cacheKeyPrefix.length)).toMatch(/^[a-f0-9]{64}:[a-f0-9]{64}$/);
|
|
expect(otherCacheKey).not.toBe(cacheKey);
|
|
expect(otherCacheKey).not.toContain(prompt);
|
|
expect(otherCacheKey).not.toContain(apiKey);
|
|
expect(otherCacheKey).not.toContain(otherApiKey);
|
|
});
|
|
|
|
it('should separate cache keys across Bedrock auth configuration', () => {
|
|
const params = { prompt: 'PFQA_BEDROCK_PROMPT_SENTINEL' };
|
|
const region = 'us-east-1';
|
|
const baseConfig = {
|
|
region,
|
|
} as BedrockClaudeMessagesCompletionOptions;
|
|
|
|
const cacheKeys = [
|
|
createBedrockCacheKeyHash({
|
|
config: {
|
|
...baseConfig,
|
|
apiKey: 'PFQA_BEDROCK_API_KEY_A',
|
|
} as BedrockClaudeMessagesCompletionOptions,
|
|
params,
|
|
region,
|
|
}),
|
|
createBedrockCacheKeyHash({
|
|
config: {
|
|
...baseConfig,
|
|
accessKeyId: 'PFQA_BEDROCK_ACCESS_KEY_A',
|
|
secretAccessKey: 'PFQA_BEDROCK_SECRET_ACCESS_KEY_A',
|
|
} as BedrockClaudeMessagesCompletionOptions,
|
|
params,
|
|
region,
|
|
}),
|
|
createBedrockCacheKeyHash({
|
|
config: {
|
|
...baseConfig,
|
|
accessKeyId: 'PFQA_BEDROCK_ACCESS_KEY_A',
|
|
secretAccessKey: 'PFQA_BEDROCK_SECRET_ACCESS_KEY_A',
|
|
sessionToken: 'PFQA_BEDROCK_SESSION_TOKEN_A',
|
|
} as BedrockClaudeMessagesCompletionOptions,
|
|
params,
|
|
region,
|
|
}),
|
|
createBedrockCacheKeyHash({
|
|
config: {
|
|
...baseConfig,
|
|
profile: 'promptfoo-profile-b',
|
|
} as BedrockClaudeMessagesCompletionOptions,
|
|
params,
|
|
region,
|
|
}),
|
|
createBedrockCacheKeyHash({
|
|
config: {
|
|
...baseConfig,
|
|
endpoint: 'https://bedrock-runtime.us-west-2.amazonaws.com',
|
|
} as BedrockClaudeMessagesCompletionOptions,
|
|
params,
|
|
region,
|
|
}),
|
|
];
|
|
|
|
expect(new Set(cacheKeys).size).toBe(cacheKeys.length);
|
|
for (const cacheKey of cacheKeys) {
|
|
expect(cacheKey).toMatch(/^[a-f0-9]{64}:[a-f0-9]{64}$/);
|
|
expect(cacheKey).not.toContain('PFQA_BEDROCK');
|
|
expect(cacheKey).not.toContain('promptfoo-profile');
|
|
expect(cacheKey).not.toContain('bedrock-runtime');
|
|
}
|
|
});
|
|
|
|
it('should prefer explicit credentials over bearer auth in cache metadata', () => {
|
|
const restoreEnv = mockProcessEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined });
|
|
try {
|
|
const params = { prompt: 'PFQA_BEDROCK_PROMPT_SENTINEL' };
|
|
const region = 'us-east-1';
|
|
const sharedBearerConfig = {
|
|
region,
|
|
apiKey: 'PFQA_BEDROCK_SHARED_BEARER',
|
|
} as BedrockClaudeMessagesCompletionOptions;
|
|
|
|
const explicitCredentialsA = createBedrockCacheKeyHash({
|
|
config: {
|
|
...sharedBearerConfig,
|
|
accessKeyId: 'PFQA_BEDROCK_ACCESS_KEY_A',
|
|
secretAccessKey: 'PFQA_BEDROCK_SECRET_ACCESS_KEY_A',
|
|
} as BedrockClaudeMessagesCompletionOptions,
|
|
params,
|
|
region,
|
|
});
|
|
const explicitCredentialsB = createBedrockCacheKeyHash({
|
|
config: {
|
|
...sharedBearerConfig,
|
|
accessKeyId: 'PFQA_BEDROCK_ACCESS_KEY_B',
|
|
secretAccessKey: 'PFQA_BEDROCK_SECRET_ACCESS_KEY_B',
|
|
} as BedrockClaudeMessagesCompletionOptions,
|
|
params,
|
|
region,
|
|
});
|
|
|
|
expect(explicitCredentialsA).not.toBe(explicitCredentialsB);
|
|
for (const cacheKey of [explicitCredentialsA, explicitCredentialsB]) {
|
|
expect(cacheKey).toMatch(/^[a-f0-9]{64}:[a-f0-9]{64}$/);
|
|
expect(cacheKey).not.toContain('PFQA_BEDROCK');
|
|
}
|
|
} finally {
|
|
restoreEnv();
|
|
}
|
|
});
|
|
|
|
it('should keep Bedrock auth cache metadata stable across module reloads', async () => {
|
|
const restoreEnv = mockProcessEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined });
|
|
try {
|
|
const params = { prompt: 'PFQA_BEDROCK_PROMPT_SENTINEL' };
|
|
const region = 'us-east-1';
|
|
const config = {
|
|
region,
|
|
accessKeyId: 'PFQA_BEDROCK_RELOAD_ACCESS_KEY',
|
|
secretAccessKey: 'PFQA_BEDROCK_RELOAD_SECRET_KEY',
|
|
} as BedrockClaudeMessagesCompletionOptions;
|
|
|
|
async function getCacheKeyFromFreshModule() {
|
|
vi.resetModules();
|
|
const { createBedrockCacheKeyHash: createFreshBedrockCacheKeyHash } = await import(
|
|
'../../../src/providers/bedrock/base'
|
|
);
|
|
return createFreshBedrockCacheKeyHash({ config, params, region });
|
|
}
|
|
|
|
const firstCacheKey = await getCacheKeyFromFreshModule();
|
|
const secondCacheKey = await getCacheKeyFromFreshModule();
|
|
|
|
expect(firstCacheKey).toBe(secondCacheKey);
|
|
expect(firstCacheKey).toMatch(/^[a-f0-9]{64}:[a-f0-9]{64}$/);
|
|
expect(firstCacheKey).not.toContain('PFQA_BEDROCK');
|
|
} finally {
|
|
restoreEnv();
|
|
}
|
|
});
|
|
|
|
it('should ignore undefined auth config values in cache auth metadata', () => {
|
|
const restoreEnv = mockProcessEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined });
|
|
|
|
try {
|
|
const params = { prompt: 'PFQA_BEDROCK_PROMPT_SENTINEL' };
|
|
const region = 'us-east-1';
|
|
const baseConfig = {
|
|
region,
|
|
} as BedrockClaudeMessagesCompletionOptions;
|
|
|
|
const defaultCacheKey = createBedrockCacheKeyHash({ config: baseConfig, params, region });
|
|
const undefinedBearerCacheKey = createBedrockCacheKeyHash({
|
|
config: {
|
|
...baseConfig,
|
|
apiKey: undefined,
|
|
} as BedrockClaudeMessagesCompletionOptions,
|
|
params,
|
|
region,
|
|
});
|
|
const incompleteExplicitCacheKey = createBedrockCacheKeyHash({
|
|
config: {
|
|
...baseConfig,
|
|
accessKeyId: 'PFQA_BEDROCK_ACCESS_KEY_ONLY',
|
|
secretAccessKey: undefined,
|
|
} as BedrockClaudeMessagesCompletionOptions,
|
|
params,
|
|
region,
|
|
});
|
|
|
|
expect(undefinedBearerCacheKey).toBe(defaultCacheKey);
|
|
expect(incompleteExplicitCacheKey).toBe(defaultCacheKey);
|
|
} finally {
|
|
restoreEnv();
|
|
}
|
|
});
|
|
|
|
it('should separate cache keys by effective bearer token env value', () => {
|
|
let restoreEnv = mockProcessEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined });
|
|
|
|
try {
|
|
const params = { prompt: 'PFQA_BEDROCK_PROMPT_SENTINEL' };
|
|
const region = 'us-east-1';
|
|
const config = {
|
|
region,
|
|
} as BedrockClaudeMessagesCompletionOptions;
|
|
|
|
restoreEnv();
|
|
restoreEnv = mockProcessEnv({
|
|
AWS_BEARER_TOKEN_BEDROCK: 'PFQA_BEDROCK_ENV_BEARER_A',
|
|
});
|
|
const bearerACacheKey = createBedrockCacheKeyHash({ config, params, region });
|
|
restoreEnv();
|
|
restoreEnv = mockProcessEnv({
|
|
AWS_BEARER_TOKEN_BEDROCK: 'PFQA_BEDROCK_ENV_BEARER_B',
|
|
});
|
|
const bearerBCacheKey = createBedrockCacheKeyHash({ config, params, region });
|
|
|
|
expect(bearerACacheKey).not.toBe(bearerBCacheKey);
|
|
for (const cacheKey of [bearerACacheKey, bearerBCacheKey]) {
|
|
expect(cacheKey).toMatch(/^[a-f0-9]{64}:[a-f0-9]{64}$/);
|
|
expect(cacheKey).not.toContain('PFQA_BEDROCK_ENV_BEARER_A');
|
|
expect(cacheKey).not.toContain('PFQA_BEDROCK_ENV_BEARER_B');
|
|
}
|
|
} finally {
|
|
restoreEnv();
|
|
}
|
|
});
|
|
|
|
it('should preserve non-auth request fields named like credentials in request hashes', () => {
|
|
const region = 'us-east-1';
|
|
const config = {
|
|
region,
|
|
apiKey: 'PFQA_BEDROCK_AUTH_SECRET',
|
|
} as BedrockClaudeMessagesCompletionOptions;
|
|
|
|
const requestA = createBedrockCacheKeyHash({
|
|
config,
|
|
params: {
|
|
prompt: 'Shared prompt',
|
|
additionalModelRequestFields: {
|
|
toolSchema: {
|
|
apiKey: 'request-visible-api-key-a',
|
|
},
|
|
},
|
|
},
|
|
region,
|
|
});
|
|
const requestB = createBedrockCacheKeyHash({
|
|
config,
|
|
params: {
|
|
prompt: 'Shared prompt',
|
|
additionalModelRequestFields: {
|
|
toolSchema: {
|
|
apiKey: 'request-visible-api-key-b',
|
|
},
|
|
},
|
|
},
|
|
region,
|
|
});
|
|
|
|
expect(requestA).not.toBe(requestB);
|
|
expect(requestA).toMatch(/^[a-f0-9]{64}:[a-f0-9]{64}$/);
|
|
expect(requestB).toMatch(/^[a-f0-9]{64}:[a-f0-9]{64}$/);
|
|
expect(requestA).not.toContain('request-visible-api-key-a');
|
|
expect(requestB).not.toContain('request-visible-api-key-b');
|
|
});
|
|
});
|
|
|
|
describe('BEDROCK_MODEL.QWEN', () => {
|
|
const qwenHandler = BEDROCK_MODEL.QWEN;
|
|
|
|
describe('params', () => {
|
|
it('should format prompt and parameters correctly', async () => {
|
|
const config = {
|
|
max_tokens: 2048,
|
|
temperature: 0.7,
|
|
top_p: 0.9,
|
|
showThinking: true,
|
|
};
|
|
const prompt = 'Write a Python function';
|
|
const stop = ['</code>'];
|
|
|
|
const result = await qwenHandler.params(
|
|
config,
|
|
prompt,
|
|
stop,
|
|
'qwen.qwen3-coder-480b-a35b-v1:0',
|
|
);
|
|
|
|
expect(result.messages).toEqual([{ role: 'user', content: 'Write a Python function' }]);
|
|
expect(result.max_tokens).toBe(2048);
|
|
expect(result.temperature).toBe(0.7);
|
|
expect(result.top_p).toBe(0.9);
|
|
expect(result.stop).toEqual(['</code>']);
|
|
});
|
|
|
|
it('should handle tool configuration', async () => {
|
|
const config = {
|
|
max_tokens: 1024,
|
|
tools: [
|
|
{
|
|
type: 'function' as const,
|
|
function: {
|
|
name: 'calculate',
|
|
description: 'Perform calculations',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
expression: { type: 'string' },
|
|
},
|
|
required: ['expression'],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
tool_choice: 'auto' as const,
|
|
};
|
|
const prompt = 'Calculate 5 + 3';
|
|
|
|
const result = await qwenHandler.params(config, prompt);
|
|
|
|
expect(result.tools).toEqual(config.tools);
|
|
expect(result.tool_choice).toBe('auto');
|
|
});
|
|
|
|
it('should use environment variables for defaults', async () => {
|
|
mockProcessEnv({ AWS_BEDROCK_TEMPERATURE: '0.5' });
|
|
mockProcessEnv({ AWS_BEDROCK_TOP_P: '0.8' });
|
|
|
|
const config = {};
|
|
const prompt = 'Test prompt';
|
|
|
|
const result = await qwenHandler.params(config, prompt);
|
|
|
|
expect(result.temperature).toBe(0.5);
|
|
expect(result.top_p).toBe(0.8);
|
|
|
|
mockProcessEnv({ AWS_BEDROCK_TEMPERATURE: undefined });
|
|
mockProcessEnv({ AWS_BEDROCK_TOP_P: undefined });
|
|
});
|
|
});
|
|
|
|
describe('output', () => {
|
|
it('should handle normal text response', async () => {
|
|
const config = {};
|
|
const responseJson = {
|
|
choices: [
|
|
{
|
|
message: {
|
|
content:
|
|
'Here is a Python function:\n\ndef factorial(n):\n if n <= 1:\n return 1\n return n * factorial(n - 1)',
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
const result = qwenHandler.output(config, responseJson);
|
|
|
|
expect(result).toBe(
|
|
'Here is a Python function:\n\ndef factorial(n):\n if n <= 1:\n return 1\n return n * factorial(n - 1)',
|
|
);
|
|
});
|
|
|
|
it('should handle tool call response', async () => {
|
|
const config = {};
|
|
const responseJson = {
|
|
choices: [
|
|
{
|
|
message: {
|
|
content: null,
|
|
tool_calls: [
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'calculate',
|
|
arguments: '{"expression": "15 * 8 + 42"}',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
const result = qwenHandler.output(config, responseJson);
|
|
|
|
expect(result).toContain('Called function calculate');
|
|
expect(result).toContain('15 * 8 + 42');
|
|
});
|
|
|
|
it('should handle thinking mode response', async () => {
|
|
const config = { showThinking: true };
|
|
const responseJson = {
|
|
choices: [
|
|
{
|
|
message: {
|
|
content: '<think>Let me think about this step by step...</think>The answer is 42',
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
const result = qwenHandler.output(config, responseJson);
|
|
|
|
expect(result).toBe('<think>Let me think about this step by step...</think>The answer is 42');
|
|
});
|
|
|
|
it('should hide thinking when showThinking is false', async () => {
|
|
const config = { showThinking: false };
|
|
const responseJson = {
|
|
choices: [
|
|
{
|
|
message: {
|
|
content: '<think>Let me think about this step by step...</think>The answer is 42',
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
const result = qwenHandler.output(config, responseJson);
|
|
|
|
expect(result).toBe('The answer is 42');
|
|
expect(result).not.toContain('think>');
|
|
});
|
|
|
|
it('should handle error response', async () => {
|
|
const config = {};
|
|
const responseJson = {
|
|
error: {
|
|
message: 'Model not found',
|
|
code: 'ModelNotFoundException',
|
|
},
|
|
};
|
|
|
|
expect(() => qwenHandler.output(config, responseJson)).toThrow(
|
|
'Qwen API error: [object Object]',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('tokenUsage', () => {
|
|
it('should extract token usage from response', async () => {
|
|
const responseJson = {
|
|
usage: {
|
|
prompt_tokens: 50,
|
|
completion_tokens: 100,
|
|
total_tokens: 150,
|
|
},
|
|
};
|
|
|
|
const result = qwenHandler.tokenUsage(responseJson, '');
|
|
|
|
expect(result).toEqual({
|
|
total: 150,
|
|
prompt: 50,
|
|
completion: 100,
|
|
numRequests: 1,
|
|
});
|
|
});
|
|
|
|
it('should handle missing usage data', async () => {
|
|
const responseJson = {};
|
|
|
|
const result = qwenHandler.tokenUsage(responseJson, '');
|
|
|
|
expect(result).toEqual({
|
|
total: undefined,
|
|
prompt: undefined,
|
|
completion: undefined,
|
|
numRequests: 1,
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Qwen model mapping', () => {
|
|
it('should include all Qwen models in AWS_BEDROCK_MODELS', async () => {
|
|
const qwenModels = [
|
|
'qwen.qwen3-coder-next',
|
|
'qwen.qwen3-coder-480b-a35b-v1:0',
|
|
'qwen.qwen3-coder-30b-a3b-v1:0',
|
|
'qwen.qwen3-next-80b-a3b',
|
|
'qwen.qwen3-vl-235b-a22b',
|
|
'qwen.qwen3-235b-a22b-2507-v1:0',
|
|
'qwen.qwen3-32b-v1:0',
|
|
];
|
|
|
|
qwenModels.forEach((modelId) => {
|
|
expect(AWS_BEDROCK_MODELS[modelId]).toBeDefined();
|
|
expect(AWS_BEDROCK_MODELS[modelId]).toBe(BEDROCK_MODEL.QWEN);
|
|
});
|
|
});
|
|
|
|
it('should recognize qwen models by prefix', async () => {
|
|
const provider = new AwsBedrockCompletionProvider('qwen.qwen3-coder-480b-a35b-v1:0');
|
|
expect(provider.modelName).toBe('qwen.qwen3-coder-480b-a35b-v1:0');
|
|
});
|
|
});
|
|
|
|
describe('coerceStrToNum', () => {
|
|
it('should convert string numbers to numeric values', async () => {
|
|
expect(coerceStrToNum('42')).toBe(42);
|
|
expect(coerceStrToNum('3.14')).toBe(3.14);
|
|
expect(coerceStrToNum('-10')).toBe(-10);
|
|
expect(coerceStrToNum('0')).toBe(0);
|
|
});
|
|
|
|
it('should return original value for numbers', async () => {
|
|
expect(coerceStrToNum(42)).toBe(42);
|
|
expect(coerceStrToNum(3.14)).toBe(3.14);
|
|
expect(coerceStrToNum(-10)).toBe(-10);
|
|
expect(coerceStrToNum(0)).toBe(0);
|
|
});
|
|
|
|
it('should handle undefined values', async () => {
|
|
expect(coerceStrToNum(undefined)).toBeUndefined();
|
|
});
|
|
|
|
it('should convert invalid string numbers to NaN', async () => {
|
|
expect(Number.isNaN(coerceStrToNum('not-a-number') as number)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('BEDROCK_MODEL OPENAI_COMPAT', () => {
|
|
const modelHandler = BEDROCK_MODEL.OPENAI_COMPAT;
|
|
|
|
describe('params', () => {
|
|
it('sends max_tokens (not max_completion_tokens) with the OpenAI chat schema', async () => {
|
|
const params = await modelHandler.params(
|
|
{ max_tokens: 256, temperature: 0.4, top_p: 0.9 },
|
|
'Hello there',
|
|
['STOP'],
|
|
);
|
|
|
|
expect(params).toEqual({
|
|
messages: [{ role: 'user', content: 'Hello there' }],
|
|
max_tokens: 256,
|
|
temperature: 0.4,
|
|
top_p: 0.9,
|
|
stop: ['STOP'],
|
|
});
|
|
expect(params).not.toHaveProperty('max_completion_tokens');
|
|
});
|
|
|
|
it('omits temperature and top_p when not configured', async () => {
|
|
const params = await modelHandler.params({}, 'Hi', []);
|
|
expect(params).toEqual({ messages: [{ role: 'user', content: 'Hi' }] });
|
|
expect(params).not.toHaveProperty('temperature');
|
|
expect(params).not.toHaveProperty('top_p');
|
|
});
|
|
|
|
it('passes through reasoning_effort and OpenAI-format tools', async () => {
|
|
const tools = [
|
|
{
|
|
type: 'function' as const,
|
|
function: { name: 'calc', description: 'do math', parameters: { type: 'object' } },
|
|
},
|
|
];
|
|
const params = await modelHandler.params(
|
|
{ reasoning_effort: 'high', tools, tool_choice: 'auto' },
|
|
'compute',
|
|
[],
|
|
);
|
|
expect(params.reasoning_effort).toBe('high');
|
|
expect(params.tools).toEqual(tools);
|
|
expect(params.tool_choice).toBe('auto');
|
|
});
|
|
|
|
it('does not let an explicit empty stop array shadow config.stop', async () => {
|
|
const params = await modelHandler.params({ stop: ['CONFIG_STOP'] }, 'Hi', []);
|
|
expect(params.stop).toEqual(['CONFIG_STOP']);
|
|
});
|
|
|
|
it('prefers explicit config.stop over the environment-derived stop argument', async () => {
|
|
const params = await modelHandler.params({ stop: ['CONFIG_STOP'] }, 'Hi', [
|
|
'ENVIRONMENT_STOP',
|
|
]);
|
|
expect(params.stop).toEqual(['CONFIG_STOP']);
|
|
});
|
|
|
|
it('applies env-var fallbacks for max_tokens/temperature/top_p when not configured', async () => {
|
|
const restore = mockProcessEnv({
|
|
AWS_BEDROCK_MAX_TOKENS: '512',
|
|
AWS_BEDROCK_TEMPERATURE: '0.2',
|
|
AWS_BEDROCK_TOP_P: '0.8',
|
|
});
|
|
try {
|
|
const params = await modelHandler.params({}, 'Hi', []);
|
|
expect(params).toEqual({
|
|
messages: [{ role: 'user', content: 'Hi' }],
|
|
max_tokens: 512,
|
|
temperature: 0.2,
|
|
top_p: 0.8,
|
|
});
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('output', () => {
|
|
it('extracts content from the chat-completion response', () => {
|
|
expect(modelHandler.output({}, { choices: [{ message: { content: 'final answer' } }] })).toBe(
|
|
'final answer',
|
|
);
|
|
});
|
|
|
|
it('returns reasoning verbatim by default and strips it when showThinking is false', () => {
|
|
const withThink = { choices: [{ message: { content: '<think>steps</think>answer' } }] };
|
|
const withReasoning = {
|
|
choices: [{ message: { content: '<reasoning>steps</reasoning>answer' } }],
|
|
};
|
|
// Default: raw output preserved.
|
|
expect(modelHandler.output({}, withThink)).toBe('<think>steps</think>answer');
|
|
expect(modelHandler.output({}, withReasoning)).toBe('<reasoning>steps</reasoning>answer');
|
|
// showThinking: false strips either tag style.
|
|
expect(modelHandler.output({ showThinking: false }, withThink)).toBe('answer');
|
|
expect(modelHandler.output({ showThinking: false }, withReasoning)).toBe('answer');
|
|
});
|
|
|
|
it('falls back to raw content when stripping a reasoning-only response would empty it', () => {
|
|
// A reasoning-only/truncated turn must not become an empty string under showThinking:false.
|
|
const reasoningOnly = {
|
|
choices: [{ message: { content: '<think>only reasoning</think>' } }],
|
|
};
|
|
expect(modelHandler.output({ showThinking: false }, reasoningOnly)).toBe(
|
|
'<think>only reasoning</think>',
|
|
);
|
|
});
|
|
|
|
it('does not strip an unclosed reasoning tag', () => {
|
|
const unclosed = { choices: [{ message: { content: '<think>truncated reasoning' } }] };
|
|
expect(modelHandler.output({ showThinking: false }, unclosed)).toBe(
|
|
'<think>truncated reasoning',
|
|
);
|
|
});
|
|
|
|
it('does not truncate when a reasoning tag appears mid-message (only strips a leading block)', () => {
|
|
// A code sample / preamble containing the tag must be preserved verbatim.
|
|
const midMessage = {
|
|
choices: [
|
|
{ message: { content: 'Here is an example: <think>not real reasoning</think> done.' } },
|
|
],
|
|
};
|
|
expect(modelHandler.output({ showThinking: false }, midMessage)).toBe(
|
|
'Here is an example: <think>not real reasoning</think> done.',
|
|
);
|
|
});
|
|
|
|
it('returns non-string content (e.g. multimodal blocks) unchanged', () => {
|
|
const blocks = [{ type: 'text', text: 'hi' }];
|
|
expect(modelHandler.output({}, { choices: [{ message: { content: blocks } }] })).toBe(blocks);
|
|
});
|
|
|
|
it('returns undefined when the response has no choices', () => {
|
|
expect(modelHandler.output({}, {})).toBeUndefined();
|
|
});
|
|
|
|
it('renders tool calls', () => {
|
|
const response = {
|
|
choices: [
|
|
{
|
|
message: {
|
|
content: '',
|
|
tool_calls: [{ function: { name: 'calc', arguments: '{"x":1}' } }],
|
|
},
|
|
},
|
|
],
|
|
};
|
|
expect(modelHandler.output({}, response)).toBe(
|
|
'Called function calc with arguments: {"x":1}',
|
|
);
|
|
});
|
|
|
|
it('concatenates assistant content with tool calls when both are present', () => {
|
|
const response = {
|
|
choices: [
|
|
{
|
|
message: {
|
|
content: 'Let me check.',
|
|
tool_calls: [{ function: { name: 'calc', arguments: '{"x":1}' } }],
|
|
},
|
|
},
|
|
],
|
|
};
|
|
expect(modelHandler.output({}, response)).toBe(
|
|
'Let me check.\n\nCalled function calc with arguments: {"x":1}',
|
|
);
|
|
});
|
|
|
|
it('strips leading reasoning before rendering tool calls when showThinking is false', () => {
|
|
const response = {
|
|
choices: [
|
|
{
|
|
message: {
|
|
content: '<think>private steps</think>Let me check.',
|
|
tool_calls: [{ function: { name: 'calc', arguments: '{"x":1}' } }],
|
|
},
|
|
},
|
|
],
|
|
};
|
|
expect(modelHandler.output({ showThinking: false }, response)).toBe(
|
|
'Let me check.\n\nCalled function calc with arguments: {"x":1}',
|
|
);
|
|
});
|
|
|
|
it('strips whitespace-prefixed reasoning before rendering tool calls', () => {
|
|
const response = {
|
|
choices: [
|
|
{
|
|
message: {
|
|
content: '\n <reasoning>private steps</reasoning>Let me check.',
|
|
tool_calls: [{ function: { name: 'calc', arguments: '{"x":1}' } }],
|
|
},
|
|
},
|
|
],
|
|
};
|
|
expect(modelHandler.output({ showThinking: false }, response)).toBe(
|
|
'Let me check.\n\nCalled function calc with arguments: {"x":1}',
|
|
);
|
|
});
|
|
|
|
it('ignores empty tool call arrays before stripping reasoning', () => {
|
|
const response = {
|
|
choices: [{ message: { content: '<think>steps</think>answer', tool_calls: [] } }],
|
|
};
|
|
expect(modelHandler.output({ showThinking: false }, response)).toBe('answer');
|
|
});
|
|
|
|
it('does not throw on a malformed tool_call missing its function field', () => {
|
|
const response = { choices: [{ message: { content: '', tool_calls: [{ id: 'x' }] } }] };
|
|
expect(() => modelHandler.output({}, response)).not.toThrow();
|
|
expect(modelHandler.output({}, response)).toContain('Called function');
|
|
});
|
|
|
|
it('throws on an error response', () => {
|
|
expect(() => modelHandler.output({}, { error: 'boom' })).toThrow('Bedrock API error: boom');
|
|
});
|
|
});
|
|
|
|
describe('tokenUsage', () => {
|
|
it('extracts usage from the OpenAI usage object', () => {
|
|
expect(
|
|
modelHandler.tokenUsage!(
|
|
{ usage: { prompt_tokens: 11, completion_tokens: 22, total_tokens: 33 } },
|
|
'prompt',
|
|
),
|
|
).toEqual({ prompt: 11, completion: 22, total: 33, numRequests: 1 });
|
|
});
|
|
|
|
it('returns undefined counts when usage is absent', () => {
|
|
expect(modelHandler.tokenUsage!({}, 'prompt')).toEqual({
|
|
prompt: undefined,
|
|
completion: undefined,
|
|
total: undefined,
|
|
numRequests: 1,
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
const OPENAI_COMPAT_MODEL_IDS = [
|
|
'zai.glm-5',
|
|
'zai.glm-4.7',
|
|
'zai.glm-4.7-flash',
|
|
'minimax.minimax-m2',
|
|
'minimax.minimax-m2.1',
|
|
'minimax.minimax-m2.5',
|
|
'moonshotai.kimi-k2.5',
|
|
'moonshot.kimi-k2-thinking',
|
|
'nvidia.nemotron-nano-9b-v2',
|
|
'nvidia.nemotron-nano-12b-v2',
|
|
'nvidia.nemotron-nano-3-30b',
|
|
'nvidia.nemotron-super-3-120b',
|
|
'us-gov.nvidia.nemotron-nano-9b-v2',
|
|
'us-gov.nvidia.nemotron-nano-12b-v2',
|
|
'us-gov.nvidia.nemotron-nano-3-30b',
|
|
'us-gov.nvidia.nemotron-super-3-120b',
|
|
'google.gemma-3-4b-it',
|
|
'google.gemma-3-12b-it',
|
|
'google.gemma-3-27b-it',
|
|
'us.writer.palmyra-x5-v1:0',
|
|
'us.writer.palmyra-x4-v1:0',
|
|
'writer.palmyra-vision-7b',
|
|
] as const;
|
|
|
|
describe('getHandlerForModel routing for OpenAI-compatible families', () => {
|
|
it.each([
|
|
'us.zai.glm-5',
|
|
'global.zai.glm-5',
|
|
'writer.palmyra-x5-v1:0',
|
|
'writer.palmyra-x4-v1:0',
|
|
'zai.glm-4.6',
|
|
'google.gemma-4-31b',
|
|
])('rejects unsupported direct InvokeModel id %s', (modelName) => {
|
|
expect(() => getHandlerForModel(modelName)).toThrow(
|
|
`Unknown Amazon Bedrock model: ${modelName}`,
|
|
);
|
|
});
|
|
|
|
it.each(OPENAI_COMPAT_MODEL_IDS)('registers and routes %s to OPENAI_COMPAT', (modelName) => {
|
|
expect(AWS_BEDROCK_MODELS[modelName]).toBe(BEDROCK_MODEL.OPENAI_COMPAT);
|
|
expect(getHandlerForModel(modelName)).toBe(BEDROCK_MODEL.OPENAI_COMPAT);
|
|
});
|
|
|
|
it.each([
|
|
'zai',
|
|
'minimax',
|
|
'moonshot',
|
|
'nvidia',
|
|
'writer',
|
|
'gemma',
|
|
] as const)('maps inference-profile ARN with inferenceModelType=%s to OPENAI_COMPAT', (inferenceModelType) => {
|
|
const arn = 'arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/my-profile';
|
|
expect(getHandlerForModel(arn, { inferenceModelType })).toBe(BEDROCK_MODEL.OPENAI_COMPAT);
|
|
});
|
|
});
|