kopia lustrzana https://github.com/transitive-bullshit/chatgpt-api
277 wiersze
7.3 KiB
TypeScript
277 wiersze
7.3 KiB
TypeScript
import * as anthropic from '@anthropic-ai/sdk'
|
|
import KeyvRedis from '@keyv/redis'
|
|
import 'dotenv/config'
|
|
import hashObject from 'hash-obj'
|
|
import Redis from 'ioredis'
|
|
import Keyv from 'keyv'
|
|
import defaultKy, { AfterResponseHook, BeforeRequestHook } from 'ky'
|
|
import { OpenAIClient } from 'openai-fetch'
|
|
import pMemoize from 'p-memoize'
|
|
|
|
import * as types from '@/types'
|
|
import { Agentic } from '@/agentic'
|
|
import { normalizeUrl } from '@/url-utils'
|
|
|
|
export const fakeOpenAIAPIKey = 'fake-openai-api-key'
|
|
export const fakeAnthropicAPIKey = 'fake-anthropic-api-key'
|
|
|
|
export const env = process.env.NODE_ENV || 'development'
|
|
export const isTest = env === 'test'
|
|
export const isCI = process.env.CI === 'true'
|
|
export const refreshTestCache = process.env.REFRESH_TEST_CACHE === 'true'
|
|
|
|
if (isCI && refreshTestCache) {
|
|
throw new Error('REFRESH_TEST_CACHE must be disabled in CI')
|
|
}
|
|
|
|
const redis = new Redis(process.env.REDIS_URL_TEST!)
|
|
const keyvRedis = new KeyvRedis(redis)
|
|
const keyv = new Keyv({ store: keyvRedis, namespace: 'agentic-test' })
|
|
|
|
// TODO: this is a lil hacky
|
|
const keyvHas = (keyv.has as any).bind(keyv)
|
|
|
|
keyv.has = async (key, ...rest) => {
|
|
if (refreshTestCache) {
|
|
return undefined
|
|
}
|
|
|
|
// console.log('<<< keyv.has', key)
|
|
const res = await keyvHas(key, ...rest)
|
|
// console.log('>>> keyv.has', key, res)
|
|
return res
|
|
}
|
|
|
|
function getCacheKeyForRequest(request: Request): string | null {
|
|
const method = request.method.toLowerCase()
|
|
|
|
if (method === 'get') {
|
|
const url = normalizeUrl(request.url)
|
|
|
|
if (url) {
|
|
const cacheParams = {
|
|
// TODO: request.headers isn't a normal JS object...
|
|
headers: { ...request.headers }
|
|
}
|
|
|
|
// console.log('getCacheKeyForRequest', { url, cacheParams })
|
|
const cacheKey = getCacheKey(`http:${method} ${url}`, cacheParams)
|
|
return cacheKey
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
const AGENTIC_TEST_CACHE_HEADER = 'x-agentic-test-cache'
|
|
const AGENTIC_TEST_MOCK_HEADER = 'x-agentic-test-mock'
|
|
|
|
/**
|
|
* Custom `ky` instance that caches GET JSON requests.
|
|
*
|
|
* TODO:
|
|
* - support non-GET requests
|
|
* - support non-JSON responses
|
|
*/
|
|
export function createTestKyInstance(
|
|
ky: types.KyInstance = defaultKy
|
|
): types.KyInstance {
|
|
return ky.extend({
|
|
hooks: {
|
|
beforeRequest: [
|
|
async (request) => {
|
|
try {
|
|
const cacheKey = getCacheKeyForRequest(request)
|
|
// console.log(
|
|
// `beforeRequest ${request.method} ${request.url} ⇒ ${cacheKey}`
|
|
// )
|
|
|
|
// console.log({ cacheKey })
|
|
if (!cacheKey) {
|
|
return
|
|
}
|
|
|
|
if (!(await keyv.has(cacheKey))) {
|
|
// console.log('cache miss', cacheKey)
|
|
return
|
|
}
|
|
|
|
const cachedResponse = await keyv.get(cacheKey)
|
|
// console.log({ cachedResponse })
|
|
|
|
if (!cachedResponse) {
|
|
return
|
|
}
|
|
|
|
return new Response(JSON.stringify(cachedResponse), {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
[AGENTIC_TEST_CACHE_HEADER]: '1'
|
|
}
|
|
})
|
|
} catch (err) {
|
|
console.error('ky beforeResponse cache error', err)
|
|
}
|
|
}
|
|
],
|
|
|
|
afterResponse: [
|
|
async (request, _options, response) => {
|
|
try {
|
|
if (response.headers.get(AGENTIC_TEST_CACHE_HEADER)) {
|
|
// console.log('cached')
|
|
return
|
|
}
|
|
|
|
if (response.headers.get(AGENTIC_TEST_MOCK_HEADER)) {
|
|
// console.log('mocked')
|
|
return
|
|
}
|
|
|
|
const contentType = response.headers.get('content-type')
|
|
// console.log(
|
|
// `afterRequest ${request.method} ${request.url} ⇒ ${response.status} ${contentType}`
|
|
// )
|
|
|
|
if (response.status < 200 || response.status >= 300) {
|
|
return
|
|
}
|
|
|
|
if (!contentType?.includes('application/json')) {
|
|
return
|
|
}
|
|
|
|
const cacheKey = getCacheKeyForRequest(request)
|
|
// console.log({ cacheKey })
|
|
if (!cacheKey) {
|
|
return
|
|
}
|
|
|
|
const responseBody = await response.json()
|
|
// console.log({ cacheKey, responseBody })
|
|
|
|
await keyv.set(cacheKey, responseBody)
|
|
} catch (err) {
|
|
console.error('ky afterResponse cache error', err)
|
|
}
|
|
}
|
|
]
|
|
}
|
|
})
|
|
}
|
|
|
|
function defaultBeforeRequest(request: Request): Response {
|
|
return new Response(
|
|
JSON.stringify({
|
|
url: request.url,
|
|
normalizedUrl: normalizeUrl(request.url),
|
|
method: request.method,
|
|
headers: request.headers
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
[AGENTIC_TEST_MOCK_HEADER]: '1'
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
export function mockKyInstance(
|
|
ky: types.KyInstance = defaultKy,
|
|
{
|
|
beforeRequest = defaultBeforeRequest,
|
|
afterResponse = null
|
|
}: {
|
|
beforeRequest?: BeforeRequestHook | null
|
|
afterResponse?: AfterResponseHook | null
|
|
} = {}
|
|
): types.KyInstance {
|
|
return ky.extend({
|
|
hooks: {
|
|
beforeRequest: beforeRequest === null ? [] : [beforeRequest],
|
|
afterResponse: afterResponse === null ? [] : [afterResponse]
|
|
}
|
|
})
|
|
}
|
|
|
|
/*
|
|
* NOTE: ky hooks are appended when doing `ky.extend`, so if you already have a
|
|
* beforeRequest hook, it will be called before any passed to `ky.extend`.
|
|
*
|
|
* For example:
|
|
*
|
|
* ```ts
|
|
* // runs caching first, then mocking
|
|
* const ky0 = mockKyInstance(createTestKyInstance(ky))
|
|
*
|
|
* // runs mocking first, then caching
|
|
* const ky1 = createTestKyInstance(mockKyInstance(ky))
|
|
*
|
|
* // runs throttling first, then mocking
|
|
* const ky2 = mockKyInstance(throttleKy(ky, throttle))
|
|
* ```
|
|
*/
|
|
export const ky = createTestKyInstance()
|
|
|
|
export class OpenAITestClient extends OpenAIClient {
|
|
createChatCompletion = pMemoize(super.createChatCompletion, {
|
|
cacheKey: (params) => getCacheKey('openai:chat', params),
|
|
cache: keyv
|
|
})
|
|
}
|
|
|
|
export class AnthropicTestClient extends anthropic.Client {
|
|
complete = pMemoize(super.complete, {
|
|
cacheKey: (params) => getCacheKey('anthropic:complete', params),
|
|
cache: keyv
|
|
})
|
|
}
|
|
|
|
export function getCacheKey(label: string, params: any): string {
|
|
const hash = hashObject(params, { algorithm: 'sha256' })
|
|
return `${label}:${hash}`
|
|
}
|
|
|
|
export function createOpenAITestClient() {
|
|
const apiKey = isCI
|
|
? fakeOpenAIAPIKey
|
|
: process.env.OPENAI_API_KEY ?? fakeOpenAIAPIKey
|
|
|
|
if (refreshTestCache) {
|
|
if (!process.env.OPENAI_API_KEY) {
|
|
throw new Error(
|
|
'Cannot refresh test cache without OPENAI_API_KEY environment variable.'
|
|
)
|
|
}
|
|
}
|
|
|
|
return new OpenAITestClient({ apiKey })
|
|
}
|
|
|
|
export function createAnthropicTestClient() {
|
|
const apiKey = isCI
|
|
? fakeAnthropicAPIKey
|
|
: process.env.ANTHROPIC_API_KEY ?? fakeAnthropicAPIKey
|
|
|
|
if (refreshTestCache) {
|
|
if (!process.env.ANTHROPIC_API_KEY) {
|
|
throw new Error(
|
|
'Cannot refresh test cache without ANTHROPIC_API_KEY environment variable.'
|
|
)
|
|
}
|
|
}
|
|
|
|
return new AnthropicTestClient(apiKey)
|
|
}
|
|
|
|
export function createTestAgenticRuntime() {
|
|
const openai = createOpenAITestClient()
|
|
const anthropic = createAnthropicTestClient()
|
|
|
|
const agentic = new Agentic({ openai, anthropic, ky })
|
|
return agentic
|
|
}
|