Files
2026-07-13 13:34:48 +08:00

164 lines
5.6 KiB
TypeScript

import { describe, test, expect, vi, beforeEach } from 'vitest'
import { Cognitive } from '../src/client'
import { getTestClient } from './client'
import MODELS from './models.json'
import { RemoteModelProvider } from '../src/models'
import { GenerateContentOutput } from '../src/schemas.gen'
const RandomResponse = {
output: {
botpress: { cost: 123 },
choices: [{ role: 'assistant', content: 'This is the LLM response', stopReason: 'stop', index: 1 }],
id: '123456',
model: '',
provider: '',
usage: { inputCost: 1, inputTokens: 2, outputCost: 3, outputTokens: 4 },
} satisfies GenerateContentOutput,
meta: {},
} as const
// Simple mock for the provider
class MockProvider extends RemoteModelProvider {
fetchModelPreferences = vi.fn().mockResolvedValue(null)
fetchInstalledModels = vi.fn().mockResolvedValue(MODELS)
saveModelPreferences = vi.fn().mockResolvedValue(void 0)
}
class TestClient {
callAction = vi.fn().mockImplementation(() => {
if (this.axiosInstance.defaults?.signal?.aborted) {
throw this.axiosInstance.defaults?.signal.reason ?? 'Aborted'
}
return Promise.resolve(RandomResponse)
})
getBot = vi.fn()
getFile = vi.fn()
axiosInstance = {
defaults: { signal: new AbortController().signal },
}
config = { headers: { 'x-bot-id': 'test' } }
clone = () => this
}
describe('constructor', () => {
test('valid client', () => {
// Just check that no error is thrown
const provider = new MockProvider(getTestClient())
expect(() => new Cognitive({ client: getTestClient(), provider })).not.toThrow()
})
})
describe('client', () => {
let bp: TestClient
let client: Cognitive
let provider: MockProvider
beforeEach(() => {
vi.clearAllMocks()
bp = new TestClient()
provider = new MockProvider(bp)
client = new Cognitive({ client: bp, provider })
})
describe('predict (request)', () => {
test('fetches models when preferences are not available and saves the preferences', async () => {
await client.generateContent({ messages: [], model: 'best' })
expect(provider.fetchModelPreferences).toHaveBeenCalled()
expect(provider.fetchInstalledModels).toHaveBeenCalled()
expect(provider.saveModelPreferences).toHaveBeenCalled()
})
test('fetches model preferences the first time generateContent is called', async () => {
await client.generateContent({ messages: [], model: 'fast' })
// fetchInstalledModels is called because fetchModelPreferences returned null
expect(provider.fetchInstalledModels).toHaveBeenCalledTimes(1)
// A second call won't fetch again if preferences are cached
await client.generateContent({ messages: [], model: 'fast' })
expect(provider.fetchInstalledModels).toHaveBeenCalledTimes(1)
})
})
describe('predict (fallback)', () => {
test('when model is unavailable, registers the downtime, saves it, and selects another model', async () => {
client = new Cognitive({ client: bp, provider })
bp.callAction.mockRejectedValueOnce({
isApiError: true,
code: 400,
id: '123',
type: 'Runtime',
metadata: { subtype: 'UPSTREAM_PROVIDER_FAILED' },
})
provider.fetchModelPreferences.mockResolvedValue({
best: ['a:a', 'b:b'],
})
// First generate call triggers fallback
await client.generateContent({ messages: [], model: 'a:a' })
expect(bp.callAction).toHaveBeenCalledTimes(2)
expect(provider.saveModelPreferences).toHaveBeenCalledOnce()
expect(provider.saveModelPreferences.mock.calls[0]?.[0].best).toMatchObject(['a:a', 'b:b'])
expect(provider.saveModelPreferences.mock.calls[0]?.[0].downtimes[0].ref).toBe('a:a')
})
})
describe('predict (abort)', () => {
test('abort request', async () => {
const ac = new AbortController()
ac.abort('Manual abort')
await expect(client.generateContent({ messages: [], signal: ac.signal })).rejects.toMatch('Manual abort')
})
})
describe('predict (response)', () => {
test('request cost and metrics are returned', async () => {
const resp = await client.generateContent({ messages: [] })
expect(resp.meta.cost.input).toBe(1)
expect(resp.meta.cost.output).toBe(3)
expect(resp.meta.tokens.input).toBe(2)
expect(resp.meta.tokens.output).toBe(4)
expect(resp.output.choices[0]?.content).toBe('This is the LLM response')
})
})
describe('getModelDetails', () => {
test('fetches model details', async () => {
const details = await client.getModelDetails('best')
expect(details).toMatchInlineSnapshot(`
{
"description": "GPT-4o (“o” for “omni”) is OpenAI's most advanced model. It is multimodal (accepting text or image inputs and outputting text), and it has the same high intelligence as GPT-4 Turbo but is cheaper and more efficient.",
"id": "gpt-4o-2024-11-20",
"input": {
"costPer1MTokens": 2.5,
"maxTokens": 128000,
},
"integration": "openai",
"name": "GPT-4o (November 2024)",
"output": {
"costPer1MTokens": 10,
"maxTokens": 16384,
},
"ref": "openai:gpt-4o-2024-11-20",
"tags": [
"recommended",
"vision",
"general-purpose",
"coding",
"agents",
"function-calling",
],
}
`)
})
})
})
test('isCognitiveClient', () => {
const client = getTestClient()
expect(Cognitive.isCognitiveClient(client)).toBe(false)
expect(Cognitive.isCognitiveClient(new Cognitive({ client, provider: new MockProvider(client) }))).toBe(true)
})