chore(llms): add vitest unit tests for llms package

This commit is contained in:
Simon
2026-06-04 20:27:33 +08:00
parent 874b302860
commit 68ae73d4e6
9 changed files with 997 additions and 4 deletions

View File

@@ -43,6 +43,7 @@
"homepage": "https://alibaba.github.io/page-agent/",
"scripts": {
"build": "vite build",
"test": "vitest run",
"prepublishOnly": "node ../../scripts/pre-publish.js",
"postpublish": "node ../../scripts/post-publish.js"
},

View File

@@ -0,0 +1,369 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as z from 'zod/v4'
import { OpenAIClient } from './OpenAIClient'
import { InvokeError, InvokeErrorTypes } from './errors'
import { parseLLMConfig } from './index'
import type { LLMConfig, Tool } from './types'
// ---------- Fixtures ----------
function makeClient(overrides: Partial<LLMConfig> = {}) {
const fetchMock = vi.fn<typeof fetch>()
const config = parseLLMConfig({
baseURL: 'http://test.local/v1',
model: 'test-model',
apiKey: 'sk-test',
customFetch: fetchMock,
...overrides,
})
const client = new OpenAIClient(config)
return { client, fetchMock }
}
function makeTool(): Tool<{ name: string }, string> {
return {
description: 'greet',
inputSchema: z.object({ name: z.string() }),
execute: vi.fn(async (args) => `hello ${args.name}`),
}
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
function toolCallBody(toolName: string, args: unknown, finishReason = 'tool_calls') {
return {
choices: [
{
finish_reason: finishReason,
message: {
tool_calls: [
{
id: 'call_1',
type: 'function',
function: {
name: toolName,
arguments: typeof args === 'string' ? args : JSON.stringify(args),
},
},
],
},
},
],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
}
}
function abortError(message = 'aborted'): Error {
const err = new Error(message)
err.name = 'AbortError'
return err
}
function getSentBody(fetchMock: ReturnType<typeof vi.fn>): Record<string, unknown> {
const init = fetchMock.mock.calls[0][1] as RequestInit
return JSON.parse(init.body as string)
}
const signal = new AbortController().signal
// ---------- Request construction ----------
describe('OpenAIClient.invoke — request construction', () => {
let setup: ReturnType<typeof makeClient>
const tools = { greet: makeTool() }
beforeEach(() => {
setup = makeClient()
setup.fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'world' })))
})
it('defaults tool_choice to "required" when no toolChoiceName is given', async () => {
await setup.client.invoke([], tools, signal)
expect(getSentBody(setup.fetchMock).tool_choice).toBe('required')
})
it('uses named tool_choice when toolChoiceName is given', async () => {
await setup.client.invoke([], tools, signal, { toolChoiceName: 'greet' })
expect(getSentBody(setup.fetchMock).tool_choice).toEqual({
type: 'function',
function: { name: 'greet' },
})
})
it('falls back to "required" when disableNamedToolChoice is true', async () => {
const { client, fetchMock } = makeClient({ disableNamedToolChoice: true })
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'world' })))
await client.invoke([], tools, signal, { toolChoiceName: 'greet' })
expect(getSentBody(fetchMock).tool_choice).toBe('required')
})
it('sends Authorization header when apiKey is set', async () => {
await setup.client.invoke([], tools, signal)
const init = setup.fetchMock.mock.calls[0][1]!
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer sk-test')
})
it('omits Authorization header when apiKey is empty', async () => {
const { client, fetchMock } = makeClient({ apiKey: '' })
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'world' })))
await client.invoke([], tools, signal)
const init = fetchMock.mock.calls[0][1]!
expect((init.headers as Record<string, string>).Authorization).toBeUndefined()
})
it('applies transformRequestBody (in-place form, returns undefined)', async () => {
const { client, fetchMock } = makeClient({
transformRequestBody: (body) => {
body.custom_flag = 42
return undefined
},
})
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'world' })))
await client.invoke([], tools, signal)
expect(getSentBody(fetchMock).custom_flag).toBe(42)
})
it('applies transformRequestBody (returning a new object)', async () => {
const { client, fetchMock } = makeClient({
transformRequestBody: () => ({ replaced: true }),
})
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'world' })))
await client.invoke([], tools, signal)
expect(getSentBody(fetchMock)).toEqual({ replaced: true })
})
it('wraps transformRequestBody throws as CONFIG_ERROR', async () => {
const { client } = makeClient({
transformRequestBody: () => {
throw new Error('bad transform')
},
})
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
name: 'InvokeError',
type: InvokeErrorTypes.CONFIG_ERROR,
})
})
})
// ---------- Success path ----------
describe('OpenAIClient.invoke — success', () => {
it('returns toolCall, toolResult and usage', async () => {
const { client, fetchMock } = makeClient()
const tool = makeTool()
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'Alice' })))
const result = await client.invoke([], { greet: tool }, signal)
expect(result.toolCall).toEqual({ name: 'greet', args: { name: 'Alice' } })
expect(result.toolResult).toBe('hello Alice')
expect(result.usage).toEqual({
promptTokens: 10,
completionTokens: 5,
totalTokens: 15,
cachedTokens: undefined,
reasoningTokens: undefined,
})
expect(tool.execute).toHaveBeenCalledWith({ name: 'Alice' })
})
it('accepts finish_reason="stop" with tool calls', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'Bob' }, 'stop')))
const result = await client.invoke([], { greet: makeTool() }, signal)
expect(result.toolResult).toBe('hello Bob')
})
})
// ---------- HTTP errors ----------
describe('OpenAIClient.invoke — HTTP errors', () => {
const tools = { greet: makeTool() }
it.each([
[401, InvokeErrorTypes.AUTH_ERROR],
[403, InvokeErrorTypes.AUTH_ERROR],
[429, InvokeErrorTypes.RATE_LIMIT],
[500, InvokeErrorTypes.SERVER_ERROR],
[502, InvokeErrorTypes.SERVER_ERROR],
[418, InvokeErrorTypes.UNKNOWN],
])('maps status %i to %s', async (status, type) => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(jsonResponse({ error: { message: 'nope' } }, status))
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
name: 'InvokeError',
type,
})
})
it('falls back to statusText when body has no error.message', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(
new Response('not json', { status: 500, statusText: 'Internal Boom' })
)
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.SERVER_ERROR,
message: expect.stringContaining('Internal Boom'),
})
})
})
// ---------- Response anomalies ----------
describe('OpenAIClient.invoke — response anomalies', () => {
const tools = { greet: makeTool() }
it('throws CONTEXT_LENGTH on finish_reason="length"', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(
jsonResponse({ choices: [{ finish_reason: 'length', message: {} }] })
)
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.CONTEXT_LENGTH,
})
})
it('throws CONTENT_FILTER on finish_reason="content_filter"', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(
jsonResponse({ choices: [{ finish_reason: 'content_filter', message: {} }] })
)
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.CONTENT_FILTER,
})
})
it('throws INVALID_SCHEMA when there are no choices', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(jsonResponse({}))
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.INVALID_SCHEMA,
})
})
it('throws NO_TOOL_CALL when message has no tool_calls', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(
jsonResponse({ choices: [{ finish_reason: 'tool_calls', message: {} }] })
)
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.NO_TOOL_CALL,
})
})
it('throws INVALID_TOOL_ARGS when arguments are not valid JSON', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', 'not-json{')))
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.INVALID_TOOL_ARGS,
message: expect.stringContaining('JSON'),
})
})
it('throws INVALID_TOOL_ARGS when args fail Zod validation', async () => {
const { client, fetchMock } = makeClient()
// tool expects { name: string }, send { name: 123 }
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 123 })))
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.INVALID_TOOL_ARGS,
})
})
it('throws UNKNOWN when model calls a tool that does not exist', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('mystery', { name: 'x' })))
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.UNKNOWN,
message: expect.stringContaining('mystery'),
})
})
it('wraps tool.execute failures as TOOL_EXECUTION_ERROR', async () => {
const { client, fetchMock } = makeClient()
const tool: Tool<{ name: string }, string> = {
inputSchema: z.object({ name: z.string() }),
execute: vi.fn(async () => {
throw new Error('downstream blew up')
}),
}
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'x' })))
await expect(client.invoke([], { greet: tool }, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.TOOL_EXECUTION_ERROR,
message: expect.stringContaining('downstream blew up'),
})
})
})
// ---------- Abort handling (the important part) ----------
describe('OpenAIClient.invoke — abort', () => {
const tools = { greet: makeTool() }
it('throws AbortError immediately when signal is already aborted', async () => {
const { client, fetchMock } = makeClient()
const controller = new AbortController()
controller.abort()
await expect(client.invoke([], tools, controller.signal)).rejects.toMatchObject({
name: 'AbortError',
})
expect(fetchMock).not.toHaveBeenCalled()
})
it('propagates AbortError thrown by fetch (does NOT wrap as NETWORK_ERROR)', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockRejectedValue(abortError())
const err = await client.invoke([], tools, signal).catch((e) => e)
expect(err).toBeInstanceOf(Error)
expect(err).not.toBeInstanceOf(InvokeError)
expect(err.name).toBe('AbortError')
})
it('propagates AbortError thrown by response.json() (does NOT wrap as INVALID_RESPONSE)', async () => {
const { client, fetchMock } = makeClient()
// Fake Response whose .json() rejects with AbortError mid-read
const fakeResponse = {
ok: true,
status: 200,
statusText: 'OK',
json: () => Promise.reject(abortError()),
} as unknown as Response
fetchMock.mockResolvedValue(fakeResponse)
const err = await client.invoke([], tools, signal).catch((e) => e)
expect(err).not.toBeInstanceOf(InvokeError)
expect(err.name).toBe('AbortError')
})
it('propagates AbortError thrown by tool.execute (does NOT wrap as TOOL_EXECUTION_ERROR)', async () => {
const { client, fetchMock } = makeClient()
const tool: Tool<{ name: string }, string> = {
inputSchema: z.object({ name: z.string() }),
execute: vi.fn(async () => {
throw abortError()
}),
}
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'x' })))
const err = await client.invoke([], { greet: tool }, signal).catch((e) => e)
expect(err).not.toBeInstanceOf(InvokeError)
expect(err.name).toBe('AbortError')
})
// Sanity check: the wrappers we deliberately bypass for AbortError still work for normal errors
it('still wraps non-Abort fetch errors as NETWORK_ERROR', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockRejectedValue(new TypeError('ECONNREFUSED'))
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
name: 'InvokeError',
type: InvokeErrorTypes.NETWORK_ERROR,
})
})
})

View File

@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { InvokeError, InvokeErrorTypes, LLM } from './index'
import type { LLMClient } from './types'
function makeLLM(maxRetries = 2): LLM {
return new LLM({
baseURL: 'http://test.local/v1',
model: 'gpt-5',
maxRetries,
})
}
function abortError(): Error {
const err = new Error('aborted')
err.name = 'AbortError'
return err
}
describe('LLM.invoke retry behavior', () => {
let llm: LLM
let client: { invoke: ReturnType<typeof vi.fn> }
const signal = new AbortController().signal
beforeEach(() => {
llm = makeLLM(2)
client = { invoke: vi.fn() }
llm.client = client as unknown as LLMClient
})
it('returns immediately on first success', async () => {
client.invoke.mockResolvedValueOnce('ok')
const retryListener = vi.fn()
llm.addEventListener('retry', retryListener)
await expect(llm.invoke([], {}, signal)).resolves.toBe('ok')
expect(client.invoke).toHaveBeenCalledOnce()
expect(retryListener).not.toHaveBeenCalled()
})
it('retries up to maxRetries on retryable errors, then throws last error', async () => {
const retryable = new InvokeError(InvokeErrorTypes.NETWORK_ERROR, 'boom')
client.invoke
.mockRejectedValueOnce(retryable)
.mockRejectedValueOnce(retryable)
.mockRejectedValueOnce(retryable)
await expect(llm.invoke([], {}, signal)).rejects.toBe(retryable)
// 1 initial + 2 retries = 3 attempts total
expect(client.invoke).toHaveBeenCalledTimes(3)
})
it('succeeds on retry after transient failure', async () => {
const retryable = new InvokeError(InvokeErrorTypes.RATE_LIMIT, 'slow down')
client.invoke.mockRejectedValueOnce(retryable).mockResolvedValueOnce('ok')
await expect(llm.invoke([], {}, signal)).resolves.toBe('ok')
expect(client.invoke).toHaveBeenCalledTimes(2)
})
it('emits "retry" events with attempt count and lastError', async () => {
const err1 = new InvokeError(InvokeErrorTypes.NETWORK_ERROR, 'first')
const err2 = new InvokeError(InvokeErrorTypes.NETWORK_ERROR, 'second')
client.invoke
.mockRejectedValueOnce(err1)
.mockRejectedValueOnce(err2)
.mockResolvedValueOnce('ok')
const events: { attempt: number; maxAttempts: number; lastError: Error }[] = []
llm.addEventListener('retry', (e) => {
events.push((e as CustomEvent).detail)
})
await llm.invoke([], {}, signal)
expect(events).toEqual([
{ attempt: 1, maxAttempts: 2, lastError: err1 },
{ attempt: 2, maxAttempts: 2, lastError: err2 },
])
})
it('does not retry on AbortError, throws immediately', async () => {
const err = abortError()
client.invoke.mockRejectedValueOnce(err)
await expect(llm.invoke([], {}, signal)).rejects.toBe(err)
expect(client.invoke).toHaveBeenCalledOnce()
})
it('does not retry on non-retryable InvokeError (AUTH_ERROR)', async () => {
const err = new InvokeError(InvokeErrorTypes.AUTH_ERROR, 'bad token')
client.invoke.mockRejectedValueOnce(err)
await expect(llm.invoke([], {}, signal)).rejects.toBe(err)
expect(client.invoke).toHaveBeenCalledOnce()
})
it('does not retry on non-retryable InvokeError (CONFIG_ERROR)', async () => {
const err = new InvokeError(InvokeErrorTypes.CONFIG_ERROR, 'bad config')
client.invoke.mockRejectedValueOnce(err)
await expect(llm.invoke([], {}, signal)).rejects.toBe(err)
expect(client.invoke).toHaveBeenCalledOnce()
})
it('retries plain (non-InvokeError) errors as unknown failures', async () => {
// Plain errors are treated as retryable by withRetry (only InvokeError carries retryable flag)
const plain = new TypeError('weird')
client.invoke.mockRejectedValueOnce(plain).mockResolvedValueOnce('ok')
await expect(llm.invoke([], {}, signal)).resolves.toBe('ok')
expect(client.invoke).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,150 @@
import { describe, expect, it } from 'vitest'
import { modelPatch } from './utils'
/**
* Baseline request body used as starting point for each provider test.
* Mirrors what OpenAIClient builds before calling modelPatch.
*/
function baseBody(model: string) {
return {
model,
temperature: 0.7,
messages: [],
tools: [],
parallel_tool_calls: false,
tool_choice: 'required' as unknown,
}
}
describe('modelPatch', () => {
it('returns body unchanged when model is missing', () => {
const body = { temperature: 0.7 }
expect(modelPatch(body)).toBe(body)
expect(body).toEqual({ temperature: 0.7 })
})
it('qwen: bumps temperature and disables thinking', () => {
const body = baseBody('qwen-max')
modelPatch(body)
expect(body.temperature).toBe(1.0)
expect(body).toMatchObject({ enable_thinking: false })
})
it('qwen: keeps higher caller-provided temperature', () => {
const body = baseBody('qwen-max')
body.temperature = 1.5
modelPatch(body)
expect(body.temperature).toBe(1.5)
})
it('claude: disables thinking and converts tool_choice "required" -> { type: "any" }', () => {
const body = baseBody('claude-3-5-sonnet')
modelPatch(body)
expect(body).toMatchObject({
thinking: { type: 'disabled' },
tool_choice: { type: 'any' },
})
})
it('claude: converts named tool_choice to { type: "tool", name }', () => {
const body = baseBody('claude-3-5-sonnet')
body.tool_choice = { type: 'function', function: { name: 'doStuff' } }
modelPatch(body)
expect(body.tool_choice).toEqual({ type: 'tool', name: 'doStuff' })
})
it('claude-opus-4-7: drops temperature', () => {
const body = baseBody('claude-opus-4-7')
modelPatch(body)
expect(body).not.toHaveProperty('temperature')
})
it('claude-opus-47 (alt id form): drops temperature', () => {
// Provider sometimes ships ids with the dot stripped; modelPatch normalizes.
const body = baseBody('claude-opus-47-20251029')
modelPatch(body)
expect(body).not.toHaveProperty('temperature')
})
it('grok: removes tool_choice and disables reasoning/thinking', () => {
const body = baseBody('grok-4')
modelPatch(body)
expect(body).not.toHaveProperty('tool_choice')
expect(body).toMatchObject({
thinking: { type: 'disabled', effort: 'minimal' },
reasoning: { enabled: false, effort: 'low' },
})
})
it('gpt-5: sets verbosity=low and reasoning_effort=low', () => {
const body = baseBody('gpt-5')
modelPatch(body)
expect(body).toMatchObject({ verbosity: 'low', reasoning_effort: 'low' })
})
it('gpt-5-mini: low effort, temperature=1', () => {
const body = baseBody('gpt-5-mini')
modelPatch(body)
expect(body).toMatchObject({
verbosity: 'low',
reasoning_effort: 'low',
temperature: 1,
})
})
it('gpt-5.1 (gpt-51): disables reasoning', () => {
const body = baseBody('gpt-5.1')
modelPatch(body)
expect(body).toMatchObject({ verbosity: 'low', reasoning_effort: 'none' })
})
it('gpt-5.4 (gpt-54): drops reasoning_effort', () => {
const body = baseBody('gpt-5.4')
modelPatch(body)
expect(body).toMatchObject({ verbosity: 'low' })
expect(body).not.toHaveProperty('reasoning_effort')
})
it('gpt-5.5 (gpt-55): drops reasoning_effort and temperature', () => {
const body = baseBody('gpt-5.5')
modelPatch(body)
expect(body).toMatchObject({ verbosity: 'low' })
expect(body).not.toHaveProperty('reasoning_effort')
expect(body).not.toHaveProperty('temperature')
})
it('gemini: sets reasoning_effort=minimal', () => {
const body = baseBody('gemini-2.5-pro')
modelPatch(body)
expect(body).toMatchObject({ reasoning_effort: 'minimal' })
})
it('deepseek: removes tool_choice', () => {
const body = baseBody('deepseek-chat')
modelPatch(body)
expect(body).not.toHaveProperty('tool_choice')
})
it('minimax: clamps temperature into (0, 1] and removes parallel_tool_calls', () => {
const body = baseBody('minimax-m2')
body.temperature = 0
modelPatch(body)
expect(body.temperature).toBeGreaterThan(0)
expect(body.temperature).toBeLessThanOrEqual(1)
expect(body).not.toHaveProperty('parallel_tool_calls')
})
it('minimax: caps temperature at 1', () => {
const body = baseBody('minimax-m2')
body.temperature = 2
modelPatch(body)
expect(body.temperature).toBe(1)
})
it('normalizes provider-prefixed model id (openai/gpt-5)', () => {
const body = baseBody('openai/gpt-5')
modelPatch(body)
expect(body).toMatchObject({ verbosity: 'low', reasoning_effort: 'low' })
})
})

View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
name: 'llms',
include: ['src/**/*.test.ts'],
// Suppress console output from passing tests; failed tests still get their logs.
silent: 'passed-only',
},
})