chore(llms): add vitest unit tests for llms package
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user