Files
flynn/src/models/bedrock.test.ts
T
William Valentin 0eb1f7a073 feat: add Gemini and Bedrock model providers
Add native GeminiClient using @google/generative-ai SDK and BedrockClient
using @aws-sdk/client-bedrock-runtime. Replace the previous Gemini fallback
(OpenAI-compatible shim) with the real implementation. Add OpenRouter as a
provider option (OpenAI-compatible with custom baseURL). Update model costs,
doctor CLI checks, and client factory tests.
2026-02-06 16:51:32 -08:00

181 lines
5.1 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { BedrockClient } from './bedrock.js';
import type { ChatStreamEvent } from './types.js';
const mockSend = vi.fn().mockResolvedValue({
output: {
message: {
content: [{ text: 'Hello from Bedrock!' }],
},
},
stopReason: 'end_turn',
usage: { inputTokens: 10, outputTokens: 5 },
});
vi.mock('@aws-sdk/client-bedrock-runtime', () => ({
BedrockRuntimeClient: vi.fn().mockImplementation(() => ({
send: mockSend,
})),
ConverseCommand: vi.fn().mockImplementation((params) => params),
ConverseStreamCommand: vi.fn().mockImplementation((params) => params),
}));
describe('BedrockClient', () => {
beforeEach(() => {
vi.clearAllMocks();
mockSend.mockResolvedValue({
output: {
message: {
content: [{ text: 'Hello from Bedrock!' }],
},
},
stopReason: 'end_turn',
usage: { inputTokens: 10, outputTokens: 5 },
});
});
it('sends messages and returns response', async () => {
const client = new BedrockClient({
model: 'anthropic.claude-3-sonnet',
region: 'us-east-1',
});
const response = await client.chat({
messages: [{ role: 'user', content: 'Hello' }],
});
expect(response.content).toBe('Hello from Bedrock!');
expect(response.stopReason).toBe('end_turn');
expect(response.usage.inputTokens).toBe(10);
expect(response.usage.outputTokens).toBe(5);
});
it('parses tool use response', async () => {
mockSend.mockResolvedValueOnce({
output: {
message: {
content: [{
toolUse: {
toolUseId: 'tool_01',
name: 'shell.exec',
input: { command: 'ls' },
},
}],
},
},
stopReason: 'tool_use',
usage: { inputTokens: 20, outputTokens: 15 },
});
const client = new BedrockClient({
model: 'anthropic.claude-3-sonnet',
region: 'us-east-1',
});
const response = await client.chat({
messages: [{ role: 'user', content: 'list files' }],
tools: [{
name: 'shell.exec',
description: 'Run shell command',
input_schema: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] },
}],
});
expect(response.stopReason).toBe('tool_use');
expect(response.toolCalls).toHaveLength(1);
expect(response.toolCalls![0].name).toBe('shell.exec');
expect(response.toolCalls![0].args).toEqual({ command: 'ls' });
});
it('uses default region when none provided', async () => {
const client = new BedrockClient({
model: 'anthropic.claude-3-sonnet',
});
const response = await client.chat({
messages: [{ role: 'user', content: 'Hello' }],
});
expect(response.content).toBe('Hello from Bedrock!');
});
it('passes system prompt to API', async () => {
const client = new BedrockClient({
model: 'anthropic.claude-3-sonnet',
region: 'us-east-1',
});
await client.chat({
messages: [{ role: 'user', content: 'Hello' }],
system: 'You are a helpful assistant.',
});
expect(mockSend).toHaveBeenCalledTimes(1);
// ConverseCommand is called with params that include system
const { ConverseCommand } = await import('@aws-sdk/client-bedrock-runtime');
expect(ConverseCommand).toHaveBeenCalledWith(
expect.objectContaining({
system: [{ text: 'You are a helpful assistant.' }],
}),
);
});
});
describe('BedrockClient streaming', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('streams content events', async () => {
mockSend.mockResolvedValueOnce({
stream: (async function* () {
yield { contentBlockDelta: { delta: { text: 'Hello ' } } };
yield { contentBlockDelta: { delta: { text: 'from Bedrock!' } } };
yield { metadata: { usage: { inputTokens: 10, outputTokens: 5 } } };
})(),
});
const client = new BedrockClient({
model: 'anthropic.claude-3-sonnet',
region: 'us-east-1',
});
const chunks: string[] = [];
let finalUsage: { inputTokens: number; outputTokens: number } | undefined;
for await (const event of client.chatStream({
messages: [{ role: 'user', content: 'Hello' }],
})) {
if (event.type === 'content' && event.content) {
chunks.push(event.content);
}
if (event.type === 'done' && event.usage) {
finalUsage = event.usage;
}
}
expect(chunks.join('')).toBe('Hello from Bedrock!');
expect(finalUsage).toEqual({ inputTokens: 10, outputTokens: 5 });
});
it('yields error event on failure', async () => {
mockSend.mockRejectedValueOnce(new Error('Service unavailable'));
const client = new BedrockClient({
model: 'anthropic.claude-3-sonnet',
region: 'us-east-1',
});
const events: ChatStreamEvent[] = [];
for await (const event of client.chatStream({
messages: [{ role: 'user', content: 'Hello' }],
})) {
events.push(event);
}
expect(events).toHaveLength(1);
expect(events[0].type).toBe('error');
expect(events[0].error?.message).toBe('Service unavailable');
});
});