164 lines
5.6 KiB
TypeScript
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)
|
|
})
|