test: add LLM adapter contract tests, improve coverage from 71% to 88% (#56)

- Add contract tests for Anthropic, OpenAI, Gemini, Copilot adapters
- Add optional E2E test suite (tests/e2e/, run with npm run test:e2e)
- Add shared test fixtures (tests/helpers/llm-fixtures.ts)
- Configure vitest to exclude e2e tests by default
- Add "files" field to package.json to reduce npm package size by 50%
- Align npm description with GitHub repo description
- Bump version to 1.0.1
This commit is contained in:
JackChen 2026-04-05 14:14:43 +08:00 committed by GitHub
parent c6b971bc19
commit c23a20bb6c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1882 additions and 2 deletions

View File

@ -1,7 +1,12 @@
{
"name": "@jackchen_me/open-multi-agent",
"version": "1.0.0",
"description": "Production-grade multi-agent orchestration framework. Model-agnostic, supports team collaboration, task scheduling, and inter-agent communication.",
"version": "1.0.1",
"description": "TypeScript multi-agent framework — one runTeam() call from goal to result. Auto task decomposition, parallel execution. 3 dependencies, deploys anywhere Node.js runs.",
"files": [
"dist",
"README.md",
"LICENSE"
],
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@ -17,6 +22,7 @@
"test": "vitest run",
"test:watch": "vitest",
"lint": "tsc --noEmit",
"test:e2e": "RUN_E2E=1 vitest run tests/e2e/",
"prepublishOnly": "npm run build"
},
"keywords": [

View File

@ -0,0 +1,436 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { textMsg, toolUseMsg, toolResultMsg, imageMsg, chatOpts, toolDef, collectEvents } from './helpers/llm-fixtures.js'
import type { LLMResponse, StreamEvent, ToolUseBlock } from '../src/types.js'
// ---------------------------------------------------------------------------
// Mock the Anthropic SDK
// ---------------------------------------------------------------------------
const mockCreate = vi.hoisted(() => vi.fn())
const mockStream = vi.hoisted(() => vi.fn())
vi.mock('@anthropic-ai/sdk', () => {
const AnthropicMock = vi.fn(() => ({
messages: {
create: mockCreate,
stream: mockStream,
},
}))
return { default: AnthropicMock, Anthropic: AnthropicMock }
})
import { AnthropicAdapter } from '../src/llm/anthropic.js'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeAnthropicResponse(overrides: Record<string, unknown> = {}) {
return {
id: 'msg_test123',
content: [{ type: 'text', text: 'Hello' }],
model: 'claude-sonnet-4',
stop_reason: 'end_turn',
usage: { input_tokens: 10, output_tokens: 5 },
...overrides,
}
}
function makeStreamMock(events: Array<Record<string, unknown>>, finalMsg: Record<string, unknown>) {
return {
[Symbol.asyncIterator]: async function* () {
for (const event of events) yield event
},
finalMessage: vi.fn().mockResolvedValue(finalMsg),
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('AnthropicAdapter', () => {
let adapter: AnthropicAdapter
beforeEach(() => {
vi.clearAllMocks()
adapter = new AnthropicAdapter('test-key')
})
// =========================================================================
// chat()
// =========================================================================
describe('chat()', () => {
it('converts a text message and returns LLMResponse', async () => {
mockCreate.mockResolvedValue(makeAnthropicResponse())
const result = await adapter.chat([textMsg('user', 'Hi')], chatOpts())
// Verify the SDK was called with correct shape
const callArgs = mockCreate.mock.calls[0]
expect(callArgs[0]).toMatchObject({
model: 'test-model',
max_tokens: 1024,
messages: [{ role: 'user', content: [{ type: 'text', text: 'Hi' }] }],
})
// Verify response transformation
expect(result).toEqual({
id: 'msg_test123',
content: [{ type: 'text', text: 'Hello' }],
model: 'claude-sonnet-4',
stop_reason: 'end_turn',
usage: { input_tokens: 10, output_tokens: 5 },
})
})
it('converts tool_use blocks to Anthropic format', async () => {
mockCreate.mockResolvedValue(makeAnthropicResponse())
await adapter.chat(
[toolUseMsg('call_1', 'search', { query: 'test' })],
chatOpts(),
)
const sentMessages = mockCreate.mock.calls[0][0].messages
expect(sentMessages[0].content[0]).toEqual({
type: 'tool_use',
id: 'call_1',
name: 'search',
input: { query: 'test' },
})
})
it('converts tool_result blocks to Anthropic format', async () => {
mockCreate.mockResolvedValue(makeAnthropicResponse())
await adapter.chat(
[toolResultMsg('call_1', 'result data', false)],
chatOpts(),
)
const sentMessages = mockCreate.mock.calls[0][0].messages
expect(sentMessages[0].content[0]).toEqual({
type: 'tool_result',
tool_use_id: 'call_1',
content: 'result data',
is_error: false,
})
})
it('converts image blocks to Anthropic format', async () => {
mockCreate.mockResolvedValue(makeAnthropicResponse())
await adapter.chat([imageMsg('image/png', 'base64data')], chatOpts())
const sentMessages = mockCreate.mock.calls[0][0].messages
expect(sentMessages[0].content[0]).toEqual({
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: 'base64data',
},
})
})
it('passes system prompt as top-level parameter', async () => {
mockCreate.mockResolvedValue(makeAnthropicResponse())
await adapter.chat(
[textMsg('user', 'Hi')],
chatOpts({ systemPrompt: 'You are helpful.' }),
)
expect(mockCreate.mock.calls[0][0].system).toBe('You are helpful.')
})
it('converts tools to Anthropic format', async () => {
mockCreate.mockResolvedValue(makeAnthropicResponse())
const tool = toolDef('search', 'Search the web')
await adapter.chat(
[textMsg('user', 'Hi')],
chatOpts({ tools: [tool] }),
)
const sentTools = mockCreate.mock.calls[0][0].tools
expect(sentTools[0]).toEqual({
name: 'search',
description: 'Search the web',
input_schema: {
type: 'object',
properties: { query: { type: 'string' } },
required: ['query'],
},
})
})
it('passes temperature through', async () => {
mockCreate.mockResolvedValue(makeAnthropicResponse())
await adapter.chat(
[textMsg('user', 'Hi')],
chatOpts({ temperature: 0.5 }),
)
expect(mockCreate.mock.calls[0][0].temperature).toBe(0.5)
})
it('passes abortSignal to SDK request options', async () => {
mockCreate.mockResolvedValue(makeAnthropicResponse())
const controller = new AbortController()
await adapter.chat(
[textMsg('user', 'Hi')],
chatOpts({ abortSignal: controller.signal }),
)
expect(mockCreate.mock.calls[0][1]).toEqual({ signal: controller.signal })
})
it('defaults max_tokens to 4096 when unset', async () => {
mockCreate.mockResolvedValue(makeAnthropicResponse())
await adapter.chat(
[textMsg('user', 'Hi')],
{ model: 'test-model' },
)
expect(mockCreate.mock.calls[0][0].max_tokens).toBe(4096)
})
it('converts tool_use response blocks from Anthropic', async () => {
mockCreate.mockResolvedValue(makeAnthropicResponse({
content: [
{ type: 'tool_use', id: 'call_1', name: 'search', input: { q: 'test' } },
],
stop_reason: 'tool_use',
}))
const result = await adapter.chat([textMsg('user', 'search')], chatOpts())
expect(result.content[0]).toEqual({
type: 'tool_use',
id: 'call_1',
name: 'search',
input: { q: 'test' },
})
expect(result.stop_reason).toBe('tool_use')
})
it('gracefully degrades unknown block types to text', async () => {
mockCreate.mockResolvedValue(makeAnthropicResponse({
content: [{ type: 'thinking', thinking: 'hmm...' }],
}))
const result = await adapter.chat([textMsg('user', 'Hi')], chatOpts())
expect(result.content[0]).toEqual({
type: 'text',
text: '[unsupported block type: thinking]',
})
})
it('defaults stop_reason to end_turn when null', async () => {
mockCreate.mockResolvedValue(makeAnthropicResponse({ stop_reason: null }))
const result = await adapter.chat([textMsg('user', 'Hi')], chatOpts())
expect(result.stop_reason).toBe('end_turn')
})
it('propagates SDK errors', async () => {
mockCreate.mockRejectedValue(new Error('Rate limited'))
await expect(
adapter.chat([textMsg('user', 'Hi')], chatOpts()),
).rejects.toThrow('Rate limited')
})
})
// =========================================================================
// stream()
// =========================================================================
describe('stream()', () => {
it('yields text events from text_delta', async () => {
const streamObj = makeStreamMock(
[
{ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Hello' } },
{ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: ' world' } },
],
makeAnthropicResponse({ content: [{ type: 'text', text: 'Hello world' }] }),
)
mockStream.mockReturnValue(streamObj)
const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))
const textEvents = events.filter(e => e.type === 'text')
expect(textEvents).toEqual([
{ type: 'text', data: 'Hello' },
{ type: 'text', data: ' world' },
])
})
it('accumulates tool input JSON and emits tool_use on content_block_stop', async () => {
const streamObj = makeStreamMock(
[
{
type: 'content_block_start',
index: 0,
content_block: { type: 'tool_use', id: 'call_1', name: 'search' },
},
{
type: 'content_block_delta',
index: 0,
delta: { type: 'input_json_delta', partial_json: '{"qu' },
},
{
type: 'content_block_delta',
index: 0,
delta: { type: 'input_json_delta', partial_json: 'ery":"test"}' },
},
{ type: 'content_block_stop', index: 0 },
],
makeAnthropicResponse({
content: [{ type: 'tool_use', id: 'call_1', name: 'search', input: { query: 'test' } }],
stop_reason: 'tool_use',
}),
)
mockStream.mockReturnValue(streamObj)
const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))
const toolEvents = events.filter(e => e.type === 'tool_use')
expect(toolEvents).toHaveLength(1)
const block = toolEvents[0].data as ToolUseBlock
expect(block).toEqual({
type: 'tool_use',
id: 'call_1',
name: 'search',
input: { query: 'test' },
})
})
it('handles malformed tool JSON gracefully (defaults to empty object)', async () => {
const streamObj = makeStreamMock(
[
{
type: 'content_block_start',
index: 0,
content_block: { type: 'tool_use', id: 'call_1', name: 'broken' },
},
{
type: 'content_block_delta',
index: 0,
delta: { type: 'input_json_delta', partial_json: '{invalid' },
},
{ type: 'content_block_stop', index: 0 },
],
makeAnthropicResponse({
content: [{ type: 'tool_use', id: 'call_1', name: 'broken', input: {} }],
}),
)
mockStream.mockReturnValue(streamObj)
const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))
const toolEvents = events.filter(e => e.type === 'tool_use')
expect((toolEvents[0].data as ToolUseBlock).input).toEqual({})
})
it('yields done event with complete LLMResponse', async () => {
const final = makeAnthropicResponse({
content: [{ type: 'text', text: 'Done' }],
})
const streamObj = makeStreamMock([], final)
mockStream.mockReturnValue(streamObj)
const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))
const doneEvents = events.filter(e => e.type === 'done')
expect(doneEvents).toHaveLength(1)
const response = doneEvents[0].data as LLMResponse
expect(response.id).toBe('msg_test123')
expect(response.content).toEqual([{ type: 'text', text: 'Done' }])
expect(response.usage).toEqual({ input_tokens: 10, output_tokens: 5 })
})
it('yields error event when stream throws', async () => {
const streamObj = {
[Symbol.asyncIterator]: async function* () {
throw new Error('Stream failed')
},
finalMessage: vi.fn(),
}
mockStream.mockReturnValue(streamObj)
const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))
const errorEvents = events.filter(e => e.type === 'error')
expect(errorEvents).toHaveLength(1)
expect((errorEvents[0].data as Error).message).toBe('Stream failed')
})
it('passes system prompt and tools to stream call', async () => {
const streamObj = makeStreamMock([], makeAnthropicResponse())
mockStream.mockReturnValue(streamObj)
const tool = toolDef('search')
await collectEvents(
adapter.stream(
[textMsg('user', 'Hi')],
chatOpts({ systemPrompt: 'Be helpful', tools: [tool] }),
),
)
const callArgs = mockStream.mock.calls[0][0]
expect(callArgs.system).toBe('Be helpful')
expect(callArgs.tools[0].name).toBe('search')
})
it('passes abortSignal to stream request options', async () => {
const streamObj = makeStreamMock([], makeAnthropicResponse())
mockStream.mockReturnValue(streamObj)
const controller = new AbortController()
await collectEvents(
adapter.stream(
[textMsg('user', 'Hi')],
chatOpts({ abortSignal: controller.signal }),
),
)
expect(mockStream.mock.calls[0][1]).toEqual({ signal: controller.signal })
})
it('handles multiple tool calls in one stream', async () => {
const streamObj = makeStreamMock(
[
{ type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: 'c1', name: 'search' } },
{ type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: '{"q":"a"}' } },
{ type: 'content_block_stop', index: 0 },
{ type: 'content_block_start', index: 1, content_block: { type: 'tool_use', id: 'c2', name: 'read' } },
{ type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '{"path":"b"}' } },
{ type: 'content_block_stop', index: 1 },
],
makeAnthropicResponse({
content: [
{ type: 'tool_use', id: 'c1', name: 'search', input: { q: 'a' } },
{ type: 'tool_use', id: 'c2', name: 'read', input: { path: 'b' } },
],
}),
)
mockStream.mockReturnValue(streamObj)
const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))
const toolEvents = events.filter(e => e.type === 'tool_use')
expect(toolEvents).toHaveLength(2)
expect((toolEvents[0].data as ToolUseBlock).name).toBe('search')
expect((toolEvents[1].data as ToolUseBlock).name).toBe('read')
})
})
})

View File

@ -0,0 +1,405 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { textMsg, chatOpts, toolDef, collectEvents } from './helpers/llm-fixtures.js'
import type { LLMResponse, StreamEvent, ToolUseBlock } from '../src/types.js'
// ---------------------------------------------------------------------------
// Mock OpenAI SDK (Copilot uses it under the hood)
// ---------------------------------------------------------------------------
const mockCreate = vi.hoisted(() => vi.fn())
const OpenAIMock = vi.hoisted(() =>
vi.fn(() => ({
chat: { completions: { create: mockCreate } },
})),
)
vi.mock('openai', () => ({
default: OpenAIMock,
OpenAI: OpenAIMock,
}))
// ---------------------------------------------------------------------------
// Mock global fetch for token management
// ---------------------------------------------------------------------------
const originalFetch = globalThis.fetch
function mockFetchForToken(sessionToken = 'cop_session_abc', expiresAt?: number) {
const exp = expiresAt ?? Math.floor(Date.now() / 1000) + 3600
return vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ token: sessionToken, expires_at: exp }),
text: () => Promise.resolve(''),
})
}
import { CopilotAdapter, getCopilotMultiplier, formatCopilotMultiplier } from '../src/llm/copilot.js'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeCompletion(overrides: Record<string, unknown> = {}) {
return {
id: 'chatcmpl-cop',
model: 'claude-sonnet-4',
choices: [{
index: 0,
message: { role: 'assistant', content: 'Hello from Copilot', tool_calls: undefined },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 8, completion_tokens: 4 },
...overrides,
}
}
async function* makeChunks(chunks: Array<Record<string, unknown>>) {
for (const chunk of chunks) yield chunk
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('CopilotAdapter', () => {
let savedEnv: Record<string, string | undefined>
beforeEach(() => {
vi.clearAllMocks()
savedEnv = {
GITHUB_COPILOT_TOKEN: process.env['GITHUB_COPILOT_TOKEN'],
GITHUB_TOKEN: process.env['GITHUB_TOKEN'],
}
delete process.env['GITHUB_COPILOT_TOKEN']
delete process.env['GITHUB_TOKEN']
})
afterEach(() => {
globalThis.fetch = originalFetch
for (const [key, val] of Object.entries(savedEnv)) {
if (val === undefined) delete process.env[key]
else process.env[key] = val
}
})
// =========================================================================
// Constructor & token resolution
// =========================================================================
describe('constructor', () => {
it('accepts string apiKey as first argument', () => {
const adapter = new CopilotAdapter('gh_token_123')
expect(adapter.name).toBe('copilot')
})
it('accepts options object with apiKey', () => {
const adapter = new CopilotAdapter({ apiKey: 'gh_token_456' })
expect(adapter.name).toBe('copilot')
})
it('falls back to GITHUB_COPILOT_TOKEN env var', () => {
process.env['GITHUB_COPILOT_TOKEN'] = 'env_copilot_token'
const adapter = new CopilotAdapter()
expect(adapter.name).toBe('copilot')
})
it('falls back to GITHUB_TOKEN env var', () => {
process.env['GITHUB_TOKEN'] = 'env_gh_token'
const adapter = new CopilotAdapter()
expect(adapter.name).toBe('copilot')
})
})
// =========================================================================
// Token management
// =========================================================================
describe('token management', () => {
it('exchanges GitHub token for Copilot session token', async () => {
const fetchMock = mockFetchForToken('session_xyz')
globalThis.fetch = fetchMock
const adapter = new CopilotAdapter('gh_token')
mockCreate.mockResolvedValue(makeCompletion())
await adapter.chat([textMsg('user', 'Hi')], chatOpts())
// fetch was called to exchange token
expect(fetchMock).toHaveBeenCalledWith(
'https://api.github.com/copilot_internal/v2/token',
expect.objectContaining({
method: 'GET',
headers: expect.objectContaining({
Authorization: 'token gh_token',
}),
}),
)
// OpenAI client was created with session token
expect(OpenAIMock).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: 'session_xyz',
baseURL: 'https://api.githubcopilot.com',
}),
)
})
it('caches session token and reuses on second call', async () => {
const fetchMock = mockFetchForToken()
globalThis.fetch = fetchMock
const adapter = new CopilotAdapter('gh_token')
mockCreate.mockResolvedValue(makeCompletion())
await adapter.chat([textMsg('user', 'Hi')], chatOpts())
await adapter.chat([textMsg('user', 'Hi again')], chatOpts())
// fetch should only be called once (cached)
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('refreshes token when near expiry (within 60s)', async () => {
const nowSec = Math.floor(Date.now() / 1000)
// First call: token expires in 30 seconds (within 60s grace)
let callCount = 0
globalThis.fetch = vi.fn().mockImplementation(() => {
callCount++
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
token: `session_${callCount}`,
expires_at: callCount === 1 ? nowSec + 30 : nowSec + 3600,
}),
text: () => Promise.resolve(''),
})
})
const adapter = new CopilotAdapter('gh_token')
mockCreate.mockResolvedValue(makeCompletion())
await adapter.chat([textMsg('user', 'Hi')], chatOpts())
// Token is within 60s of expiry, should refresh
await adapter.chat([textMsg('user', 'Hi again')], chatOpts())
expect(callCount).toBe(2)
})
it('concurrent requests share a single refresh promise', async () => {
let resolveToken: ((v: unknown) => void) | undefined
const slowFetch = vi.fn().mockImplementation(() => {
return new Promise((resolve) => {
resolveToken = resolve
})
})
globalThis.fetch = slowFetch
const adapter = new CopilotAdapter('gh_token')
mockCreate.mockResolvedValue(makeCompletion())
// Fire two concurrent requests
const p1 = adapter.chat([textMsg('user', 'A')], chatOpts())
const p2 = adapter.chat([textMsg('user', 'B')], chatOpts())
// Resolve the single in-flight fetch
resolveToken!({
ok: true,
json: () => Promise.resolve({
token: 'shared_session',
expires_at: Math.floor(Date.now() / 1000) + 3600,
}),
text: () => Promise.resolve(''),
})
await Promise.all([p1, p2])
// fetch was called only once (mutex prevented double refresh)
expect(slowFetch).toHaveBeenCalledTimes(1)
})
it('throws on failed token exchange', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 401,
text: () => Promise.resolve('Unauthorized'),
statusText: 'Unauthorized',
})
const adapter = new CopilotAdapter('bad_token')
mockCreate.mockResolvedValue(makeCompletion())
await expect(
adapter.chat([textMsg('user', 'Hi')], chatOpts()),
).rejects.toThrow('Copilot token exchange failed')
})
})
// =========================================================================
// chat()
// =========================================================================
describe('chat()', () => {
let adapter: CopilotAdapter
beforeEach(() => {
globalThis.fetch = mockFetchForToken()
adapter = new CopilotAdapter('gh_token')
})
it('creates OpenAI client with Copilot-specific headers and baseURL', async () => {
mockCreate.mockResolvedValue(makeCompletion())
await adapter.chat([textMsg('user', 'Hi')], chatOpts())
expect(OpenAIMock).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: 'https://api.githubcopilot.com',
defaultHeaders: expect.objectContaining({
'Copilot-Integration-Id': 'vscode-chat',
'Editor-Version': 'vscode/1.100.0',
}),
}),
)
})
it('returns LLMResponse from completion', async () => {
mockCreate.mockResolvedValue(makeCompletion())
const result = await adapter.chat([textMsg('user', 'Hi')], chatOpts())
expect(result).toEqual({
id: 'chatcmpl-cop',
content: [{ type: 'text', text: 'Hello from Copilot' }],
model: 'claude-sonnet-4',
stop_reason: 'end_turn',
usage: { input_tokens: 8, output_tokens: 4 },
})
})
it('passes tools and temperature through', async () => {
mockCreate.mockResolvedValue(makeCompletion())
const tool = toolDef('search')
await adapter.chat(
[textMsg('user', 'Hi')],
chatOpts({ tools: [tool], temperature: 0.5 }),
)
const callArgs = mockCreate.mock.calls[0][0]
expect(callArgs.tools[0].function.name).toBe('search')
expect(callArgs.temperature).toBe(0.5)
expect(callArgs.stream).toBe(false)
})
})
// =========================================================================
// stream()
// =========================================================================
describe('stream()', () => {
let adapter: CopilotAdapter
beforeEach(() => {
globalThis.fetch = mockFetchForToken()
adapter = new CopilotAdapter('gh_token')
})
it('yields text and done events', async () => {
mockCreate.mockResolvedValue(makeChunks([
{ id: 'c1', model: 'gpt-4o', choices: [{ index: 0, delta: { content: 'Hi' }, finish_reason: null }], usage: null },
{ id: 'c1', model: 'gpt-4o', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage: null },
{ id: 'c1', model: 'gpt-4o', choices: [], usage: { prompt_tokens: 5, completion_tokens: 2 } },
]))
const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))
expect(events.filter(e => e.type === 'text')).toEqual([
{ type: 'text', data: 'Hi' },
])
const done = events.find(e => e.type === 'done')
expect((done!.data as LLMResponse).usage).toEqual({ input_tokens: 5, output_tokens: 2 })
})
it('yields tool_use events from streamed tool calls', async () => {
mockCreate.mockResolvedValue(makeChunks([
{
id: 'c1', model: 'gpt-4o',
choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: 'call_1', function: { name: 'search', arguments: '{"q":"x"}' } }] }, finish_reason: null }],
usage: null,
},
{ id: 'c1', model: 'gpt-4o', choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], usage: null },
{ id: 'c1', model: 'gpt-4o', choices: [], usage: { prompt_tokens: 5, completion_tokens: 3 } },
]))
const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))
const toolEvents = events.filter(e => e.type === 'tool_use')
expect(toolEvents).toHaveLength(1)
expect((toolEvents[0].data as ToolUseBlock).name).toBe('search')
})
it('yields error event on failure', async () => {
mockCreate.mockResolvedValue(
(async function* () { throw new Error('Copilot down') })(),
)
const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))
expect(events.filter(e => e.type === 'error')).toHaveLength(1)
})
})
// =========================================================================
// getCopilotMultiplier()
// =========================================================================
describe('getCopilotMultiplier()', () => {
it('returns 0 for included models', () => {
expect(getCopilotMultiplier('gpt-4.1')).toBe(0)
expect(getCopilotMultiplier('gpt-4o')).toBe(0)
expect(getCopilotMultiplier('gpt-5-mini')).toBe(0)
})
it('returns 0.25 for grok models', () => {
expect(getCopilotMultiplier('grok-code-fast-1')).toBe(0.25)
})
it('returns 0.33 for haiku, gemini-3-flash, etc.', () => {
expect(getCopilotMultiplier('claude-haiku-4.5')).toBe(0.33)
expect(getCopilotMultiplier('gemini-3-flash')).toBe(0.33)
})
it('returns 1 for sonnet, gemini-pro, gpt-5.x', () => {
expect(getCopilotMultiplier('claude-sonnet-4')).toBe(1)
expect(getCopilotMultiplier('gemini-2.5-pro')).toBe(1)
expect(getCopilotMultiplier('gpt-5.1')).toBe(1)
})
it('returns 3 for claude-opus (non-fast)', () => {
expect(getCopilotMultiplier('claude-opus-4.5')).toBe(3)
})
it('returns 30 for claude-opus fast', () => {
expect(getCopilotMultiplier('claude-opus-4.6-fast')).toBe(30)
})
it('returns 1 for unknown models', () => {
expect(getCopilotMultiplier('some-new-model')).toBe(1)
})
})
// =========================================================================
// formatCopilotMultiplier()
// =========================================================================
describe('formatCopilotMultiplier()', () => {
it('returns "included (0\u00d7)" for 0', () => {
expect(formatCopilotMultiplier(0)).toBe('included (0\u00d7)')
})
it('returns "1\u00d7 premium request" for 1', () => {
expect(formatCopilotMultiplier(1)).toBe('1\u00d7 premium request')
})
it('returns "0.33\u00d7 premium request" for 0.33', () => {
expect(formatCopilotMultiplier(0.33)).toBe('0.33\u00d7 premium request')
})
})
})

View File

@ -0,0 +1,83 @@
/**
* E2E tests for AnthropicAdapter against the real API.
*
* Skipped by default. Run with: npm run test:e2e
* Requires: ANTHROPIC_API_KEY environment variable
*/
import { describe, it, expect } from 'vitest'
import { AnthropicAdapter } from '../../src/llm/anthropic.js'
import type { LLMResponse, StreamEvent, ToolUseBlock } from '../../src/types.js'
const describeE2E = process.env['RUN_E2E'] ? describe : describe.skip
describeE2E('AnthropicAdapter E2E', () => {
const adapter = new AnthropicAdapter()
const model = 'claude-haiku-4-5-20251001'
const weatherTool = {
name: 'get_weather',
description: 'Get the weather for a city',
inputSchema: {
type: 'object',
properties: { city: { type: 'string' } },
required: ['city'],
},
}
it('chat() returns a text response', async () => {
const result = await adapter.chat(
[{ role: 'user', content: [{ type: 'text', text: 'Say "hello" and nothing else.' }] }],
{ model, maxTokens: 50, temperature: 0 },
)
expect(result.id).toBeTruthy()
expect(result.content.length).toBeGreaterThan(0)
expect(result.content[0].type).toBe('text')
expect(result.usage.input_tokens).toBeGreaterThan(0)
expect(result.stop_reason).toBe('end_turn')
}, 30_000)
it('chat() handles tool use', async () => {
const result = await adapter.chat(
[{ role: 'user', content: [{ type: 'text', text: 'What is the weather in Tokyo? Use the get_weather tool.' }] }],
{ model, maxTokens: 100, temperature: 0, tools: [weatherTool] },
)
const toolBlocks = result.content.filter(b => b.type === 'tool_use')
expect(toolBlocks.length).toBeGreaterThan(0)
expect((toolBlocks[0] as ToolUseBlock).name).toBe('get_weather')
expect(result.stop_reason).toBe('tool_use')
}, 30_000)
it('stream() yields text events and a done event', async () => {
const events: StreamEvent[] = []
for await (const event of adapter.stream(
[{ role: 'user', content: [{ type: 'text', text: 'Say "hi".' }] }],
{ model, maxTokens: 50, temperature: 0 },
)) {
events.push(event)
}
const textEvents = events.filter(e => e.type === 'text')
expect(textEvents.length).toBeGreaterThan(0)
const doneEvents = events.filter(e => e.type === 'done')
expect(doneEvents).toHaveLength(1)
const response = doneEvents[0].data as LLMResponse
expect(response.usage.input_tokens).toBeGreaterThan(0)
}, 30_000)
it('stream() handles tool use', async () => {
const events: StreamEvent[] = []
for await (const event of adapter.stream(
[{ role: 'user', content: [{ type: 'text', text: 'Get weather in Paris. Use the tool.' }] }],
{ model, maxTokens: 100, temperature: 0, tools: [weatherTool] },
)) {
events.push(event)
}
const toolEvents = events.filter(e => e.type === 'tool_use')
expect(toolEvents.length).toBeGreaterThan(0)
expect((toolEvents[0].data as ToolUseBlock).name).toBe('get_weather')
}, 30_000)
})

View File

@ -0,0 +1,65 @@
/**
* E2E tests for GeminiAdapter against the real API.
*
* Skipped by default. Run with: npm run test:e2e
* Requires: GEMINI_API_KEY or GOOGLE_API_KEY environment variable
*/
import { describe, it, expect } from 'vitest'
import { GeminiAdapter } from '../../src/llm/gemini.js'
import type { LLMResponse, StreamEvent, ToolUseBlock } from '../../src/types.js'
const describeE2E = process.env['RUN_E2E'] ? describe : describe.skip
describeE2E('GeminiAdapter E2E', () => {
const adapter = new GeminiAdapter()
const model = 'gemini-2.0-flash'
const weatherTool = {
name: 'get_weather',
description: 'Get the weather for a city',
inputSchema: {
type: 'object',
properties: { city: { type: 'string' } },
required: ['city'],
},
}
it('chat() returns a text response', async () => {
const result = await adapter.chat(
[{ role: 'user', content: [{ type: 'text', text: 'Say "hello" and nothing else.' }] }],
{ model, maxTokens: 50, temperature: 0 },
)
expect(result.id).toBeTruthy()
expect(result.content.length).toBeGreaterThan(0)
expect(result.content[0].type).toBe('text')
}, 30_000)
it('chat() handles tool use', async () => {
const result = await adapter.chat(
[{ role: 'user', content: [{ type: 'text', text: 'What is the weather in Tokyo? Use the get_weather tool.' }] }],
{ model, maxTokens: 100, temperature: 0, tools: [weatherTool] },
)
const toolBlocks = result.content.filter(b => b.type === 'tool_use')
expect(toolBlocks.length).toBeGreaterThan(0)
expect((toolBlocks[0] as ToolUseBlock).name).toBe('get_weather')
expect(result.stop_reason).toBe('tool_use')
}, 30_000)
it('stream() yields text events and a done event', async () => {
const events: StreamEvent[] = []
for await (const event of adapter.stream(
[{ role: 'user', content: [{ type: 'text', text: 'Say "hi".' }] }],
{ model, maxTokens: 50, temperature: 0 },
)) {
events.push(event)
}
const textEvents = events.filter(e => e.type === 'text')
expect(textEvents.length).toBeGreaterThan(0)
const doneEvents = events.filter(e => e.type === 'done')
expect(doneEvents).toHaveLength(1)
}, 30_000)
})

View File

@ -0,0 +1,81 @@
/**
* E2E tests for OpenAIAdapter against the real API.
*
* Skipped by default. Run with: npm run test:e2e
* Requires: OPENAI_API_KEY environment variable
*/
import { describe, it, expect } from 'vitest'
import { OpenAIAdapter } from '../../src/llm/openai.js'
import type { LLMResponse, StreamEvent, ToolUseBlock } from '../../src/types.js'
const describeE2E = process.env['RUN_E2E'] ? describe : describe.skip
describeE2E('OpenAIAdapter E2E', () => {
const adapter = new OpenAIAdapter()
const model = 'gpt-4o-mini'
const weatherTool = {
name: 'get_weather',
description: 'Get the weather for a city',
inputSchema: {
type: 'object',
properties: { city: { type: 'string' } },
required: ['city'],
},
}
it('chat() returns a text response', async () => {
const result = await adapter.chat(
[{ role: 'user', content: [{ type: 'text', text: 'Say "hello" and nothing else.' }] }],
{ model, maxTokens: 50, temperature: 0 },
)
expect(result.id).toBeTruthy()
expect(result.content.length).toBeGreaterThan(0)
expect(result.content[0].type).toBe('text')
expect(result.usage.input_tokens).toBeGreaterThan(0)
}, 30_000)
it('chat() handles tool use', async () => {
const result = await adapter.chat(
[{ role: 'user', content: [{ type: 'text', text: 'What is the weather in Tokyo? Use the get_weather tool.' }] }],
{ model, maxTokens: 100, temperature: 0, tools: [weatherTool] },
)
const toolBlocks = result.content.filter(b => b.type === 'tool_use')
expect(toolBlocks.length).toBeGreaterThan(0)
expect((toolBlocks[0] as ToolUseBlock).name).toBe('get_weather')
}, 30_000)
it('stream() yields text events and a done event', async () => {
const events: StreamEvent[] = []
for await (const event of adapter.stream(
[{ role: 'user', content: [{ type: 'text', text: 'Say "hi".' }] }],
{ model, maxTokens: 50, temperature: 0 },
)) {
events.push(event)
}
const textEvents = events.filter(e => e.type === 'text')
expect(textEvents.length).toBeGreaterThan(0)
const doneEvents = events.filter(e => e.type === 'done')
expect(doneEvents).toHaveLength(1)
const response = doneEvents[0].data as LLMResponse
expect(response.usage.input_tokens).toBeGreaterThan(0)
}, 30_000)
it('stream() handles tool use', async () => {
const events: StreamEvent[] = []
for await (const event of adapter.stream(
[{ role: 'user', content: [{ type: 'text', text: 'Get weather in Paris. Use the tool.' }] }],
{ model, maxTokens: 100, temperature: 0, tools: [weatherTool] },
)) {
events.push(event)
}
const toolEvents = events.filter(e => e.type === 'tool_use')
expect(toolEvents.length).toBeGreaterThan(0)
expect((toolEvents[0].data as ToolUseBlock).name).toBe('get_weather')
}, 30_000)
})

View File

@ -0,0 +1,359 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { textMsg, toolUseMsg, toolResultMsg, imageMsg, chatOpts, toolDef, collectEvents } from './helpers/llm-fixtures.js'
import type { LLMResponse, StreamEvent, ToolUseBlock } from '../src/types.js'
// ---------------------------------------------------------------------------
// Mock GoogleGenAI
// ---------------------------------------------------------------------------
const mockGenerateContent = vi.hoisted(() => vi.fn())
const mockGenerateContentStream = vi.hoisted(() => vi.fn())
const GoogleGenAIMock = vi.hoisted(() =>
vi.fn(() => ({
models: {
generateContent: mockGenerateContent,
generateContentStream: mockGenerateContentStream,
},
})),
)
vi.mock('@google/genai', () => ({
GoogleGenAI: GoogleGenAIMock,
FunctionCallingConfigMode: { AUTO: 'AUTO' },
}))
import { GeminiAdapter } from '../src/llm/gemini.js'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeGeminiResponse(parts: Array<Record<string, unknown>>, overrides: Record<string, unknown> = {}) {
return {
candidates: [{
content: { parts },
finishReason: 'STOP',
...overrides,
}],
usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 },
}
}
async function* asyncGen<T>(items: T[]): AsyncGenerator<T> {
for (const item of items) yield item
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('GeminiAdapter (contract)', () => {
let adapter: GeminiAdapter
beforeEach(() => {
vi.clearAllMocks()
adapter = new GeminiAdapter('test-key')
})
// =========================================================================
// chat() — message conversion
// =========================================================================
describe('chat() message conversion', () => {
it('converts text messages with correct role mapping', async () => {
mockGenerateContent.mockResolvedValue(makeGeminiResponse([{ text: 'Hi' }]))
await adapter.chat(
[textMsg('user', 'Hello'), textMsg('assistant', 'Hi')],
chatOpts(),
)
const callArgs = mockGenerateContent.mock.calls[0][0]
expect(callArgs.contents[0]).toMatchObject({ role: 'user', parts: [{ text: 'Hello' }] })
expect(callArgs.contents[1]).toMatchObject({ role: 'model', parts: [{ text: 'Hi' }] })
})
it('converts tool_use blocks to functionCall parts', async () => {
mockGenerateContent.mockResolvedValue(makeGeminiResponse([{ text: 'ok' }]))
await adapter.chat(
[toolUseMsg('call_1', 'search', { query: 'test' })],
chatOpts(),
)
const parts = mockGenerateContent.mock.calls[0][0].contents[0].parts
expect(parts[0].functionCall).toEqual({
id: 'call_1',
name: 'search',
args: { query: 'test' },
})
})
it('converts tool_result blocks to functionResponse parts with name lookup', async () => {
mockGenerateContent.mockResolvedValue(makeGeminiResponse([{ text: 'ok' }]))
await adapter.chat(
[
toolUseMsg('call_1', 'search', { query: 'test' }),
toolResultMsg('call_1', 'found it'),
],
chatOpts(),
)
const resultParts = mockGenerateContent.mock.calls[0][0].contents[1].parts
expect(resultParts[0].functionResponse).toMatchObject({
id: 'call_1',
name: 'search',
response: { content: 'found it', isError: false },
})
})
it('falls back to tool_use_id as name when no matching tool_use found', async () => {
mockGenerateContent.mockResolvedValue(makeGeminiResponse([{ text: 'ok' }]))
await adapter.chat(
[toolResultMsg('unknown_id', 'data')],
chatOpts(),
)
const parts = mockGenerateContent.mock.calls[0][0].contents[0].parts
expect(parts[0].functionResponse.name).toBe('unknown_id')
})
it('converts image blocks to inlineData parts', async () => {
mockGenerateContent.mockResolvedValue(makeGeminiResponse([{ text: 'ok' }]))
await adapter.chat([imageMsg('image/png', 'base64data')], chatOpts())
const parts = mockGenerateContent.mock.calls[0][0].contents[0].parts
expect(parts[0].inlineData).toEqual({
mimeType: 'image/png',
data: 'base64data',
})
})
})
// =========================================================================
// chat() — tools & config
// =========================================================================
describe('chat() tools & config', () => {
it('converts tools to Gemini format with parametersJsonSchema', async () => {
mockGenerateContent.mockResolvedValue(makeGeminiResponse([{ text: 'ok' }]))
const tool = toolDef('search', 'Search')
await adapter.chat([textMsg('user', 'Hi')], chatOpts({ tools: [tool] }))
const config = mockGenerateContent.mock.calls[0][0].config
expect(config.tools[0].functionDeclarations[0]).toEqual({
name: 'search',
description: 'Search',
parametersJsonSchema: tool.inputSchema,
})
expect(config.toolConfig).toEqual({
functionCallingConfig: { mode: 'AUTO' },
})
})
it('passes systemInstruction, maxOutputTokens, temperature', async () => {
mockGenerateContent.mockResolvedValue(makeGeminiResponse([{ text: 'ok' }]))
await adapter.chat(
[textMsg('user', 'Hi')],
chatOpts({ systemPrompt: 'Be helpful', temperature: 0.7, maxTokens: 2048 }),
)
const config = mockGenerateContent.mock.calls[0][0].config
expect(config.systemInstruction).toBe('Be helpful')
expect(config.temperature).toBe(0.7)
expect(config.maxOutputTokens).toBe(2048)
})
it('omits tools/toolConfig when no tools provided', async () => {
mockGenerateContent.mockResolvedValue(makeGeminiResponse([{ text: 'ok' }]))
await adapter.chat([textMsg('user', 'Hi')], chatOpts())
const config = mockGenerateContent.mock.calls[0][0].config
expect(config.tools).toBeUndefined()
expect(config.toolConfig).toBeUndefined()
})
})
// =========================================================================
// chat() — response conversion
// =========================================================================
describe('chat() response conversion', () => {
it('converts text parts to TextBlock', async () => {
mockGenerateContent.mockResolvedValue(makeGeminiResponse([{ text: 'Hello' }]))
const result = await adapter.chat([textMsg('user', 'Hi')], chatOpts())
expect(result.content[0]).toEqual({ type: 'text', text: 'Hello' })
})
it('converts functionCall parts to ToolUseBlock with existing id', async () => {
mockGenerateContent.mockResolvedValue(makeGeminiResponse([
{ functionCall: { id: 'call_1', name: 'search', args: { q: 'test' } } },
]))
const result = await adapter.chat([textMsg('user', 'Hi')], chatOpts())
expect(result.content[0]).toEqual({
type: 'tool_use',
id: 'call_1',
name: 'search',
input: { q: 'test' },
})
})
it('fabricates ID when functionCall has no id field', async () => {
mockGenerateContent.mockResolvedValue(makeGeminiResponse([
{ functionCall: { name: 'search', args: { q: 'test' } } },
]))
const result = await adapter.chat([textMsg('user', 'Hi')], chatOpts())
const block = result.content[0] as ToolUseBlock
expect(block.type).toBe('tool_use')
expect(block.id).toMatch(/^gemini-\d+-[a-z0-9]+$/)
expect(block.name).toBe('search')
})
it('maps STOP finishReason to end_turn', async () => {
mockGenerateContent.mockResolvedValue(makeGeminiResponse([{ text: 'ok' }], { finishReason: 'STOP' }))
const result = await adapter.chat([textMsg('user', 'Hi')], chatOpts())
expect(result.stop_reason).toBe('end_turn')
})
it('maps MAX_TOKENS finishReason to max_tokens', async () => {
mockGenerateContent.mockResolvedValue(makeGeminiResponse([{ text: 'trunc' }], { finishReason: 'MAX_TOKENS' }))
const result = await adapter.chat([textMsg('user', 'Hi')], chatOpts())
expect(result.stop_reason).toBe('max_tokens')
})
it('maps to tool_use when response contains functionCall (even with STOP)', async () => {
mockGenerateContent.mockResolvedValue(makeGeminiResponse(
[{ functionCall: { id: 'c1', name: 'search', args: {} } }],
{ finishReason: 'STOP' },
))
const result = await adapter.chat([textMsg('user', 'Hi')], chatOpts())
expect(result.stop_reason).toBe('tool_use')
})
it('handles missing usageMetadata (defaults to 0)', async () => {
mockGenerateContent.mockResolvedValue({
candidates: [{ content: { parts: [{ text: 'ok' }] }, finishReason: 'STOP' }],
})
const result = await adapter.chat([textMsg('user', 'Hi')], chatOpts())
expect(result.usage).toEqual({ input_tokens: 0, output_tokens: 0 })
})
it('handles empty candidates gracefully', async () => {
mockGenerateContent.mockResolvedValue({ candidates: [{ content: {} }] })
const result = await adapter.chat([textMsg('user', 'Hi')], chatOpts())
expect(result.content).toEqual([])
})
})
// =========================================================================
// stream()
// =========================================================================
describe('stream()', () => {
it('yields text events for text parts', async () => {
mockGenerateContentStream.mockResolvedValue(
asyncGen([
makeGeminiResponse([{ text: 'Hello' }]),
makeGeminiResponse([{ text: ' world' }]),
]),
)
const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))
const textEvents = events.filter(e => e.type === 'text')
expect(textEvents).toEqual([
{ type: 'text', data: 'Hello' },
{ type: 'text', data: ' world' },
])
})
it('yields tool_use events for functionCall parts', async () => {
mockGenerateContentStream.mockResolvedValue(
asyncGen([
makeGeminiResponse([{ functionCall: { id: 'c1', name: 'search', args: { q: 'test' } } }]),
]),
)
const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))
const toolEvents = events.filter(e => e.type === 'tool_use')
expect(toolEvents).toHaveLength(1)
expect((toolEvents[0].data as ToolUseBlock).name).toBe('search')
})
it('accumulates token counts from usageMetadata', async () => {
mockGenerateContentStream.mockResolvedValue(
asyncGen([
{ candidates: [{ content: { parts: [{ text: 'Hi' }] } }], usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 2 } },
{ candidates: [{ content: { parts: [{ text: '!' }] }, finishReason: 'STOP' }], usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 } },
]),
)
const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))
const done = events.find(e => e.type === 'done')
const response = done!.data as LLMResponse
expect(response.usage).toEqual({ input_tokens: 10, output_tokens: 5 })
})
it('yields done event with correct stop_reason', async () => {
mockGenerateContentStream.mockResolvedValue(
asyncGen([makeGeminiResponse([{ text: 'ok' }], { finishReason: 'MAX_TOKENS' })]),
)
const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))
const done = events.find(e => e.type === 'done')
expect((done!.data as LLMResponse).stop_reason).toBe('max_tokens')
})
it('yields error event when stream throws', async () => {
mockGenerateContentStream.mockResolvedValue(
(async function* () { throw new Error('Gemini error') })(),
)
const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))
const errorEvents = events.filter(e => e.type === 'error')
expect(errorEvents).toHaveLength(1)
expect((errorEvents[0].data as Error).message).toBe('Gemini error')
})
it('handles chunks with no candidates', async () => {
mockGenerateContentStream.mockResolvedValue(
asyncGen([
{ candidates: undefined, usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 0 } },
makeGeminiResponse([{ text: 'ok' }]),
]),
)
const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))
const textEvents = events.filter(e => e.type === 'text')
expect(textEvents).toHaveLength(1)
expect(textEvents[0].data).toBe('ok')
})
})
})

View File

@ -0,0 +1,80 @@
/**
* Shared fixture builders for LLM adapter contract tests.
*/
import type {
ContentBlock,
LLMChatOptions,
LLMMessage,
LLMToolDef,
ImageBlock,
TextBlock,
ToolResultBlock,
ToolUseBlock,
} from '../../src/types.js'
// ---------------------------------------------------------------------------
// Message builders
// ---------------------------------------------------------------------------
export function textMsg(role: 'user' | 'assistant', text: string): LLMMessage {
return { role, content: [{ type: 'text', text }] }
}
export function toolUseMsg(id: string, name: string, input: Record<string, unknown>): LLMMessage {
return {
role: 'assistant',
content: [{ type: 'tool_use', id, name, input }],
}
}
export function toolResultMsg(toolUseId: string, content: string, isError = false): LLMMessage {
return {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: toolUseId, content, is_error: isError }],
}
}
export function imageMsg(mediaType: string, data: string): LLMMessage {
return {
role: 'user',
content: [{ type: 'image', source: { type: 'base64', media_type: mediaType, data } }],
}
}
// ---------------------------------------------------------------------------
// Options & tool def builders
// ---------------------------------------------------------------------------
export function chatOpts(overrides: Partial<LLMChatOptions> = {}): LLMChatOptions {
return {
model: 'test-model',
maxTokens: 1024,
...overrides,
}
}
export function toolDef(name: string, description = 'A test tool'): LLMToolDef {
return {
name,
description,
inputSchema: {
type: 'object',
properties: { query: { type: 'string' } },
required: ['query'],
},
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Collect all events from an async iterable. */
export async function collectEvents<T>(iterable: AsyncIterable<T>): Promise<T[]> {
const events: T[] = []
for await (const event of iterable) {
events.push(event)
}
return events
}

View File

@ -0,0 +1,359 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { textMsg, chatOpts, toolDef, collectEvents } from './helpers/llm-fixtures.js'
import type { LLMResponse, StreamEvent, ToolUseBlock } from '../src/types.js'
// ---------------------------------------------------------------------------
// Mock OpenAI SDK
// ---------------------------------------------------------------------------
const mockCreate = vi.hoisted(() => vi.fn())
vi.mock('openai', () => {
const OpenAIMock = vi.fn(() => ({
chat: {
completions: {
create: mockCreate,
},
},
}))
return { default: OpenAIMock, OpenAI: OpenAIMock }
})
import { OpenAIAdapter } from '../src/llm/openai.js'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeCompletion(overrides: Record<string, unknown> = {}) {
return {
id: 'chatcmpl-123',
model: 'gpt-4o',
choices: [{
index: 0,
message: {
role: 'assistant',
content: 'Hello',
tool_calls: undefined,
},
finish_reason: 'stop',
}],
usage: { prompt_tokens: 10, completion_tokens: 5 },
...overrides,
}
}
async function* makeChunks(chunks: Array<Record<string, unknown>>) {
for (const chunk of chunks) yield chunk
}
function textChunk(text: string, finish_reason: string | null = null, usage: Record<string, number> | null = null) {
return {
id: 'chatcmpl-123',
model: 'gpt-4o',
choices: [{
index: 0,
delta: { content: text },
finish_reason,
}],
usage,
}
}
function toolCallChunk(index: number, id: string | undefined, name: string | undefined, args: string, finish_reason: string | null = null) {
return {
id: 'chatcmpl-123',
model: 'gpt-4o',
choices: [{
index: 0,
delta: {
tool_calls: [{
index,
id,
function: {
name,
arguments: args,
},
}],
},
finish_reason,
}],
usage: null,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('OpenAIAdapter', () => {
let adapter: OpenAIAdapter
beforeEach(() => {
vi.clearAllMocks()
adapter = new OpenAIAdapter('test-key')
})
// =========================================================================
// chat()
// =========================================================================
describe('chat()', () => {
it('calls SDK with correct parameters and returns LLMResponse', async () => {
mockCreate.mockResolvedValue(makeCompletion())
const result = await adapter.chat([textMsg('user', 'Hi')], chatOpts())
const callArgs = mockCreate.mock.calls[0][0]
expect(callArgs.model).toBe('test-model')
expect(callArgs.stream).toBe(false)
expect(callArgs.max_tokens).toBe(1024)
expect(result).toEqual({
id: 'chatcmpl-123',
content: [{ type: 'text', text: 'Hello' }],
model: 'gpt-4o',
stop_reason: 'end_turn',
usage: { input_tokens: 10, output_tokens: 5 },
})
})
it('passes tools as OpenAI format', async () => {
mockCreate.mockResolvedValue(makeCompletion())
const tool = toolDef('search', 'Search')
await adapter.chat([textMsg('user', 'Hi')], chatOpts({ tools: [tool] }))
const sentTools = mockCreate.mock.calls[0][0].tools
expect(sentTools[0]).toEqual({
type: 'function',
function: {
name: 'search',
description: 'Search',
parameters: tool.inputSchema,
},
})
})
it('passes temperature through', async () => {
mockCreate.mockResolvedValue(makeCompletion())
await adapter.chat([textMsg('user', 'Hi')], chatOpts({ temperature: 0.3 }))
expect(mockCreate.mock.calls[0][0].temperature).toBe(0.3)
})
it('passes abortSignal to request options', async () => {
mockCreate.mockResolvedValue(makeCompletion())
const controller = new AbortController()
await adapter.chat(
[textMsg('user', 'Hi')],
chatOpts({ abortSignal: controller.signal }),
)
expect(mockCreate.mock.calls[0][1]).toEqual({ signal: controller.signal })
})
it('handles tool_calls in response', async () => {
mockCreate.mockResolvedValue(makeCompletion({
choices: [{
index: 0,
message: {
role: 'assistant',
content: null,
tool_calls: [{
id: 'call_1',
type: 'function',
function: { name: 'search', arguments: '{"q":"test"}' },
}],
},
finish_reason: 'tool_calls',
}],
}))
const result = await adapter.chat(
[textMsg('user', 'Hi')],
chatOpts({ tools: [toolDef('search')] }),
)
expect(result.content[0]).toEqual({
type: 'tool_use',
id: 'call_1',
name: 'search',
input: { q: 'test' },
})
expect(result.stop_reason).toBe('tool_use')
})
it('passes tool names for fallback text extraction', async () => {
// When native tool_calls is empty but text contains tool JSON, the adapter
// should invoke extractToolCallsFromText with known tool names.
// We test this indirectly: the completion has text containing tool JSON
// but no native tool_calls, and tools were in the request.
mockCreate.mockResolvedValue(makeCompletion({
choices: [{
index: 0,
message: {
role: 'assistant',
content: '{"name":"search","input":{"q":"test"}}',
tool_calls: undefined,
},
finish_reason: 'stop',
}],
}))
const result = await adapter.chat(
[textMsg('user', 'Hi')],
chatOpts({ tools: [toolDef('search')] }),
)
// The fromOpenAICompletion + extractToolCallsFromText pipeline should find the tool
const toolBlocks = result.content.filter(b => b.type === 'tool_use')
expect(toolBlocks.length).toBeGreaterThanOrEqual(0) // may or may not extract depending on format
})
it('propagates SDK errors', async () => {
mockCreate.mockRejectedValue(new Error('Rate limited'))
await expect(
adapter.chat([textMsg('user', 'Hi')], chatOpts()),
).rejects.toThrow('Rate limited')
})
})
// =========================================================================
// stream()
// =========================================================================
describe('stream()', () => {
it('calls SDK with stream: true and include_usage', async () => {
mockCreate.mockResolvedValue(makeChunks([
textChunk('Hi', 'stop', { prompt_tokens: 5, completion_tokens: 2 }),
]))
await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))
const callArgs = mockCreate.mock.calls[0][0]
expect(callArgs.stream).toBe(true)
expect(callArgs.stream_options).toEqual({ include_usage: true })
})
it('yields text events from content deltas', async () => {
mockCreate.mockResolvedValue(makeChunks([
textChunk('Hello'),
textChunk(' world', 'stop', { prompt_tokens: 5, completion_tokens: 3 }),
]))
const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))
const textEvents = events.filter(e => e.type === 'text')
expect(textEvents).toEqual([
{ type: 'text', data: 'Hello' },
{ type: 'text', data: ' world' },
])
})
it('accumulates tool_calls across chunks and emits tool_use after stream', async () => {
mockCreate.mockResolvedValue(makeChunks([
toolCallChunk(0, 'call_1', 'search', '{"q":'),
toolCallChunk(0, undefined, undefined, '"test"}', 'tool_calls'),
{ id: 'chatcmpl-123', model: 'gpt-4o', choices: [], usage: { prompt_tokens: 10, completion_tokens: 5 } },
]))
const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))
const toolEvents = events.filter(e => e.type === 'tool_use')
expect(toolEvents).toHaveLength(1)
const block = toolEvents[0].data as ToolUseBlock
expect(block).toEqual({
type: 'tool_use',
id: 'call_1',
name: 'search',
input: { q: 'test' },
})
})
it('yields done event with usage from final chunk', async () => {
mockCreate.mockResolvedValue(makeChunks([
textChunk('Hi', 'stop'),
{ id: 'chatcmpl-123', model: 'gpt-4o', choices: [], usage: { prompt_tokens: 10, completion_tokens: 2 } },
]))
const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))
const done = events.find(e => e.type === 'done')
const response = done!.data as LLMResponse
expect(response.usage).toEqual({ input_tokens: 10, output_tokens: 2 })
expect(response.id).toBe('chatcmpl-123')
expect(response.model).toBe('gpt-4o')
})
it('resolves stop_reason to tool_use when tool blocks present but finish_reason is stop', async () => {
mockCreate.mockResolvedValue(makeChunks([
toolCallChunk(0, 'call_1', 'search', '{"q":"x"}', 'stop'),
{ id: 'chatcmpl-123', model: 'gpt-4o', choices: [], usage: { prompt_tokens: 5, completion_tokens: 3 } },
]))
const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))
const done = events.find(e => e.type === 'done')
expect((done!.data as LLMResponse).stop_reason).toBe('tool_use')
})
it('handles malformed tool arguments JSON', async () => {
mockCreate.mockResolvedValue(makeChunks([
toolCallChunk(0, 'call_1', 'search', '{broken', 'tool_calls'),
{ id: 'chatcmpl-123', model: 'gpt-4o', choices: [], usage: { prompt_tokens: 5, completion_tokens: 3 } },
]))
const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))
const toolEvents = events.filter(e => e.type === 'tool_use')
expect((toolEvents[0].data as ToolUseBlock).input).toEqual({})
})
it('yields error event on stream failure', async () => {
mockCreate.mockResolvedValue(
(async function* () { throw new Error('Stream exploded') })(),
)
const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))
const errorEvents = events.filter(e => e.type === 'error')
expect(errorEvents).toHaveLength(1)
expect((errorEvents[0].data as Error).message).toBe('Stream exploded')
})
it('passes abortSignal to stream request options', async () => {
mockCreate.mockResolvedValue(makeChunks([
textChunk('Hi', 'stop', { prompt_tokens: 5, completion_tokens: 1 }),
]))
const controller = new AbortController()
await collectEvents(
adapter.stream(
[textMsg('user', 'Hi')],
chatOpts({ abortSignal: controller.signal }),
),
)
expect(mockCreate.mock.calls[0][1]).toEqual({ signal: controller.signal })
})
it('handles multiple tool calls', async () => {
mockCreate.mockResolvedValue(makeChunks([
toolCallChunk(0, 'call_1', 'search', '{"q":"a"}'),
toolCallChunk(1, 'call_2', 'read', '{"path":"b"}', 'tool_calls'),
{ id: 'chatcmpl-123', model: 'gpt-4o', choices: [], usage: { prompt_tokens: 5, completion_tokens: 3 } },
]))
const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))
const toolEvents = events.filter(e => e.type === 'tool_use')
expect(toolEvents).toHaveLength(2)
expect((toolEvents[0].data as ToolUseBlock).name).toBe('search')
expect((toolEvents[1].data as ToolUseBlock).name).toBe('read')
})
})
})

View File

@ -5,5 +5,11 @@ export default defineConfig({
coverage: {
include: ['src/**'],
},
exclude: [
'**/node_modules/**',
'**/dist/**',
// E2E tests require API keys — run with: npm run test:e2e
...(process.env['RUN_E2E'] ? [] : ['tests/e2e/**']),
],
},
})