Initial commit

This commit is contained in:
Riley Tomasek
2023-08-09 14:26:13 -04:00
commit 681195203a
63 changed files with 7753 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "restricted",
"baseBranch": "master",
"updateInternalDependencies": "patch",
"ignore": ["examples-basic"]
}
+9
View File
@@ -0,0 +1,9 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+16
View File
@@ -0,0 +1,16 @@
.cache
.DS_Store
.idea
*.log
*.tgz
coverage
dist
lib-cov
logs
node_modules
temp
todo.md
.env*
.dev*
.env.local
.local-files
+8
View File
@@ -0,0 +1,8 @@
*.env
*.log
*.jsonl
**/node_modules
**/dist
**/build
**/dbschema
**/public
+3
View File
@@ -0,0 +1,3 @@
{
"singleQuote": true
}
+31
View File
@@ -0,0 +1,31 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Node ESM",
"compilerOptions": {
"moduleResolution": "node16",
"module": "node16",
"lib": ["ESNext", "DOM"],
"jsx": "preserve",
"outDir": "dist",
"allowJs": true,
"importHelpers": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true,
"checkJs": true,
"noEmit": false,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"incremental": false,
"skipDefaultLibCheck": true
},
"exclude": ["**/node_modules", "**/.*/"]
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "tsconfig",
"version": "0.0.0",
"private": true,
"files": [
"node-esm.json"
],
"publishConfig": {
"access": "public"
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"extends": ["@dexaai/eslint-config", "@dexaai/eslint-config/node"]
}
+28
View File
@@ -0,0 +1,28 @@
{
"name": "examples-basic",
"type": "module",
"version": "0.0.0",
"private": true,
"description": "",
"scripts": {
"clean": "rm -rf .turbo node_modules dist",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@dexaai/agent": "workspace:*",
"@dexaai/datastore": "workspace:*",
"@dexaai/model": "workspace:*",
"tslib": "^2.5.3",
"workgpt": "^0.1.1",
"zod": "^3.21.4"
},
"devDependencies": {
"@dexaai/eslint-config": "^0.2.0",
"@types/node": "^20.3.1",
"dotenv": "^16.3.1",
"eslint": "^8.43.0",
"tsconfig": "workspace:*",
"type-fest": "3.12.0",
"typescript": "^5.1.3"
}
}
+1
View File
@@ -0,0 +1 @@
# Example Usage of LLM Tools
+30
View File
@@ -0,0 +1,30 @@
import 'dotenv/config';
import { Datastore } from '@dexaai/datastore/pinecone';
import { EmbeddingModel } from '@dexaai/model/openai';
// Base Datastore
(async () => {
// Embedding model with cache
const embeddingModel = new EmbeddingModel({
params: { model: 'text-embedding-ada-002' },
context: { test: 'test' },
debug: true,
}).addCache();
await embeddingModel.run({ input: ['cat'] });
// This should be cached
await embeddingModel.run({ input: ['cat'] });
// Datastore with cache
const store = new Datastore({
namespace: 'test',
embeddingModel,
debug: true,
}).addCache();
await store.upsert([
{ id: '1', metadata: { content: 'cat' } },
{ id: '2', metadata: { content: 'dog' } },
]);
await store.query({ query: 'cat' });
// This should be cached
await store.query({ query: 'cat' });
})();
+85
View File
@@ -0,0 +1,85 @@
import 'dotenv/config';
import { Datastore, HybridDatastore } from '@dexaai/datastore/pinecone';
import { EmbeddingModel } from '@dexaai/model/openai';
import { SpladeModel } from '@dexaai/model/custom';
// Base Datastore
(async () => {
const embeddingModel = new EmbeddingModel({
params: { model: 'text-embedding-ada-002' },
context: { test: 'test' },
debug: true,
});
const store = new Datastore({
namespace: 'test',
embeddingModel,
debug: true,
});
await store.upsert([
{ id: '1', metadata: { content: 'cat' } },
{ id: '2', metadata: { content: 'dog' } },
{ id: '3', metadata: { content: 'whale' } },
{ id: '4', metadata: { content: 'shark' } },
{ id: '5', metadata: { content: 'computer' } },
{ id: '6', metadata: { content: 'laptop' } },
{ id: '7', metadata: { content: 'phone' } },
{ id: '8', metadata: { content: 'tablet' } },
]);
const result = await store.query({
query: 'dolphin',
});
console.log(JSON.stringify(result, null, 2));
const result2 = await store.query({
query: 'fox',
});
console.log(JSON.stringify(result2, null, 2));
const result3 = await store.query({
query: 'keyboard',
});
console.log(JSON.stringify(result3, null, 2));
// Hybrid Datastore
const spladeModel = new SpladeModel({
params: { model: 'naver/splade-cocondenser-ensembledistil' },
debug: true,
});
const hStore = new HybridDatastore({
namespace: 'test-hybrid',
embeddingModel,
spladeModel,
context: { baz: 'qux' },
debug: true,
});
await hStore.upsert([
{ id: '1', metadata: { content: 'cat' } },
{ id: '2', metadata: { content: 'dog' } },
{ id: '3', metadata: { content: 'whale' } },
{ id: '4', metadata: { content: 'shark' } },
{ id: '5', metadata: { content: 'computer' } },
{ id: '6', metadata: { content: 'laptop' } },
{ id: '7', metadata: { content: 'phone' } },
{ id: '8', metadata: { content: 'tablet' } },
]);
await hStore.query({
query: 'dolphin',
});
// console.log(JSON.stringify(r, null, 2));
await hStore.query({
query: 'fox',
});
// console.log(JSON.stringify(r2, null, 2));
await hStore.query({
query: 'keyboard',
});
// console.log(JSON.stringify(r3, null, 2));
})();
+13
View File
@@ -0,0 +1,13 @@
import 'dotenv/config';
import { Calculator } from 'workgpt/apis/calculator';
import { FactApi } from 'workgpt/apis/fact';
import { createRunner } from '@dexaai/agent';
const apis = await Promise.all([new Calculator(), new FactApi()]);
const runner = createRunner(apis, undefined, true);
await runner.runWithDirective(
'What is the age of obama multiplied by the age of trump?'
);
console.log('--- Done ---');
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "tsconfig/node-esm.json",
"include": ["./src/**/*"],
"exclude": ["**/node_modules", "**/.*/"],
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"name": "llm-tools",
"type": "module",
"private": true,
"version": "0.0.0",
"description": "",
"repository": "dexaai/llm-tools",
"scripts": {
"build": "turbo run build",
"clean": "turbo run clean && rm -rf node_modules",
"dev": "turbo run dev --parallel",
"format": "prettier --write \"**/*.{ts,tsx}\"",
"lint": "turbo run lint",
"test": "turbo run test",
"typecheck": "turbo run typecheck"
},
"dependencies": {},
"devDependencies": {
"@changesets/cli": "^2.26.1",
"prettier": "^2.8.8",
"tsx": "^3.12.7",
"turbo": "^1.10.5"
},
"packageManager": "pnpm@7.25.0",
"engines": {
"node": ">= 18"
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"extends": ["@dexaai/eslint-config", "@dexaai/eslint-config/node"]
}
+48
View File
@@ -0,0 +1,48 @@
{
"name": "@dexaai/agent",
"type": "module",
"version": "0.0.0",
"description": "",
"repository": "dexaai/llm-tools",
"scripts": {
"build": "tsup",
"clean": "rm -rf .turbo node_modules dist",
"dev": "tsup --watch",
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
"test": "vitest",
"typecheck": "tsc --noEmit"
},
"sideEffects": false,
"files": [
"dist"
],
"types": "./dist/index.d.ts",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"exports": {
"./package.json": "./package.json",
".": {
"types": "./dist/index.d.ts",
"require": "./dist/index.cjs",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"dependencies": {
"@dexaai/model": "workspace:*",
"workgpt": "^0.1.1"
},
"devDependencies": {
"@dexaai/eslint-config": "^0.2.0",
"@types/node": "^20.3.1",
"eslint": "^8.43.0",
"tsconfig": "workspace:*",
"tsup": "^7.0.0",
"typescript": "^5.1.3",
"vite": "^4.3.9",
"vitest": "^0.32.2"
},
"publishConfig": {
"access": "restricted"
}
}
+3
View File
@@ -0,0 +1,3 @@
# Agent
Standardized agents for interacting with OpenAI functions.
+1
View File
@@ -0,0 +1 @@
export { createRunner } from './runner.js';
+58
View File
@@ -0,0 +1,58 @@
import { WorkGptRunner } from 'workgpt/runners/workgpt';
import type { Api } from 'workgpt/apis/base';
import { ChatModel } from '@dexaai/model/openai';
import { ChatAgent } from 'workgpt/chat-agents/base';
import type { ChatRequest, ChatResponse } from 'workgpt/chat-agents/types';
import type { ChatMessage } from '@dexaai/model';
/** Create a WorkGptRunner using an LLM ChatModel. */
export function createRunner(
apis: Api[],
chatModel?: ChatModel,
verbose: boolean = false
): WorkGptRunner {
const model =
chatModel ??
new ChatModel({
params: { model: 'gpt-3.5-turbo-0613' },
});
const agent = new LlmChatAgent(model, verbose);
return new WorkGptRunner({ apis, agent });
}
/** Agent that uses the LLM ChatModel to make requests */
class LlmChatAgent extends ChatAgent {
private readonly chatModel: ChatModel;
constructor(chatModel: ChatModel, verbose: boolean = false) {
super({ verbose });
if (verbose) {
chatModel.addHooks({ afterApiResponse: agentLogger });
}
this.chatModel = chatModel;
}
async call(args: ChatRequest): Promise<ChatResponse> {
const { message } = await this.chatModel.run({
messages: args.messages as ChatMessage[],
functions: args.functions,
});
return message as ChatResponse;
}
}
/** Logging function for hooks to debug agents */
function agentLogger(evt: any) {
const { choices } = evt.response as {
choices: { message: ChatMessage }[];
};
const req = evt.params.messages[evt.params.messages.length - 1];
const name = req.name ? ` (${req.name})` : '';
const message = choices[0].message;
console.log(
`>> ${req.role}: ${name} ${
req.content || JSON.stringify(req.function_call || '')
}`
);
console.log(`<< ${message.role}: ${message.content}`, message.function_call);
}
+7
View File
@@ -0,0 +1,7 @@
import { describe, expect, it } from 'vitest';
describe('should', () => {
it('exported', () => {
expect(1).toEqual(1);
});
});
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "tsconfig/node-esm.json",
"include": ["./src/**/*"],
"exclude": ["**/node_modules", "**/.*/"],
"compilerOptions": {
"types": ["vitest/globals", "node"]
}
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
clean: true,
dts: true,
target: 'es2022',
});
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vite';
export default defineConfig(() => {
return {
mode: 'test',
test: {
environment: 'node',
globals: true,
},
};
});
+3
View File
@@ -0,0 +1,3 @@
{
"extends": ["@dexaai/eslint-config", "@dexaai/eslint-config/node"]
}
+69
View File
@@ -0,0 +1,69 @@
{
"name": "@dexaai/datastore",
"type": "module",
"version": "0.0.0",
"description": "",
"repository": "dexaai/llm-tools",
"scripts": {
"build": "tsup",
"clean": "rm -rf .turbo node_modules dist",
"dev": "tsup --watch",
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
"test": "vitest",
"typecheck": "tsc --noEmit"
},
"sideEffects": false,
"files": [
"dist"
],
"types": "./dist/index.d.ts",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"exports": {
"./package.json": "./package.json",
".": {
"types": "./dist/index.d.ts",
"require": "./dist/index.cjs",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./pinecone": {
"types": "./dist/pinecone/index.d.ts",
"require": "./dist/pinecone/index.cjs",
"import": "./dist/pinecone/index.js",
"default": "./dist/pinecone/index.js"
}
},
"typesVersions": {
"*": {
"pinecone": [
"./dist/pinecone/index.d.ts"
]
}
},
"dependencies": {
"@dexaai/model": "workspace:*",
"keyv": "^4.5.2",
"ky": "^0.33.3",
"p-map": "^6.0.0",
"p-retry": "^5.1.2",
"p-throttle": "^5.1.0",
"pinecone-client": "^1.1.0",
"tkv-cache": "^0.1.2",
"ts-extras": "^0.11.0"
},
"devDependencies": {
"@dexaai/eslint-config": "^0.2.0",
"@types/node": "^20.3.1",
"eslint": "^8.43.0",
"tsconfig": "workspace:*",
"tsup": "^7.0.0",
"type-fest": "3.12.0",
"typescript": "^5.1.3",
"vite": "^4.3.9",
"vitest": "^0.32.2"
},
"publishConfig": {
"access": "restricted"
}
}
+3
View File
@@ -0,0 +1,3 @@
# Datastore
Standardized classes for interacting with vector databases.
+126
View File
@@ -0,0 +1,126 @@
import Keyv from 'keyv';
import { KvCache } from 'tkv-cache';
import type { IEmbeddingModel, ISparseModel } from '@dexaai/model';
import type {
BaseMeta,
Ctx,
DatastoreOpts,
DatastoreProvider,
DatastoreType,
Doc,
Hooks,
HDatastoreOpts,
IDatastore,
Query,
QueryCache,
QueryResult,
} from './types.js';
export abstract class BaseDatastore<DocMeta extends BaseMeta>
implements IDatastore<DocMeta>
{
protected abstract runQuery(
query: Query<DocMeta>,
context?: Ctx
): Promise<QueryResult<DocMeta>>;
abstract upsert(docs: Doc<DocMeta>[], context?: Ctx): Promise<void>;
abstract delete(docIds: string[]): Promise<void>;
abstract deleteAll(): Promise<void>;
abstract datastoreType: DatastoreType;
abstract datastoreProvider: DatastoreProvider;
protected namespace: string;
protected embeddingModel: IEmbeddingModel;
protected cache?: QueryCache<DocMeta>;
protected hooks: Hooks<DocMeta>;
protected context: Ctx;
constructor(args: DatastoreOpts<DocMeta>) {
this.namespace = args.namespace;
this.embeddingModel = args.embeddingModel;
this.cache = args.cache;
this.context = args.context ?? {};
this.hooks = args.debug
? {
afterApiResponse: console.log,
afterCacheHit: console.log,
beforeError: console.error,
...args.hooks,
}
: args.hooks ?? {};
}
/**
* Add a cache to the datastore for storing query responses.
* Defaults to an in-memory Keyv cache.
*/
addCache(args?: {
/** A Keyv instance to use for caching */
keyv?: Keyv<QueryResult<DocMeta>>;
/** Keyv options to create a new Keyv instance */
keyvOpts?: Keyv.Options<QueryResult<DocMeta>>;
/** A function to normalize the cache key */
normalizeKey?: (params: Query<DocMeta>) => Partial<Query<DocMeta>>;
errorHandler?: (error: unknown) => void;
}): this {
const { keyvOpts, normalizeKey, errorHandler } = args ?? {};
const keyv = args?.keyv ?? new Keyv<QueryResult<DocMeta>>(keyvOpts);
this.cache = new KvCache(keyv, normalizeKey, errorHandler);
return this;
}
async query(
query: Query<DocMeta>,
context?: Ctx
): Promise<QueryResult<DocMeta>> {
// Return cached response if available
const cached = await this?.cache?.get(query);
if (cached) {
const mergedContext = { ...this.context, ...context };
await this.hooks.afterCacheHit?.({
timestamp: new Date().toISOString(),
datastoreType: this.datastoreType,
datastoreProvider: this.datastoreProvider,
query,
response: cached,
context: mergedContext,
});
return cached;
}
try {
// Run the query
const response = await this.runQuery(query, context);
// Update the cache
await this?.cache?.set(query, response);
return response;
} catch (error) {
const mergedContext = { ...this.context, ...context };
await this.hooks.beforeError?.({
timestamp: new Date().toISOString(),
datastoreType: this.datastoreType,
datastoreProvider: this.datastoreProvider,
query,
error,
context: mergedContext,
});
throw error;
}
}
}
export abstract class BaseHybridDatastore<DocMeta extends BaseMeta>
extends BaseDatastore<DocMeta>
implements IDatastore<DocMeta>
{
protected spladeModel: ISparseModel;
constructor(args: HDatastoreOpts<DocMeta>) {
const { spladeModel, ...rest } = args;
super(rest);
this.spladeModel = args.spladeModel;
}
}
+13
View File
@@ -0,0 +1,13 @@
export { BaseDatastore, BaseHybridDatastore } from './datastore.js';
export { VectorUtils } from './utils/vectors.js';
export type {
BaseMeta,
Ctx,
DatastoreOpts,
Doc,
HDatastoreOpts,
IDatastore,
Prettify,
Query,
QueryResult,
} from './types.js';
+13
View File
@@ -0,0 +1,13 @@
import { PineconeClient } from 'pinecone-client';
import type { JsonObject } from 'type-fest';
type PineconeConfig = ConstructorParameters<typeof PineconeClient>[0];
/** Create a new Pinecone client instance. */
export function createPineconeClient<Meta extends JsonObject = {}>(
config: PineconeConfig
): PineconeClient<Meta> {
return new PineconeClient<Meta>(config);
}
export type { PineconeClient };
@@ -0,0 +1,271 @@
import { BaseDatastore, BaseHybridDatastore } from '../datastore.js';
import type {
BaseMeta,
Ctx,
DatastoreOpts,
Doc,
HDatastoreOpts,
IDatastore,
Prettify,
Query,
QueryResult,
} from '../types.js';
import type { PineconeClient } from './client.js';
import { createPineconeClient } from './client.js';
export class Datastore<DocMeta extends BaseMeta>
extends BaseDatastore<DocMeta>
implements IDatastore<DocMeta>
{
datastoreType = 'embedding' as const;
datastoreProvider = 'pinecone' as const;
private readonly pinecone: PineconeClient<DocMeta>;
constructor(
args: Prettify<
DatastoreOpts<DocMeta> & {
pinecone?: PineconeClient<DocMeta>;
}
>
) {
const { pinecone, ...rest } = args;
super(rest);
this.pinecone =
pinecone ||
createPineconeClient<DocMeta>({
namespace: this.namespace,
});
}
async runQuery(
query: Query<DocMeta>,
context?: Ctx
): Promise<QueryResult<DocMeta>> {
const mergedContext = { ...this.context, ...context };
// Get the query embedding
let queryEmbedding = query.embedding;
// If no query embedding is provided, run the query embedder
if (!queryEmbedding) {
const {
embeddings: [embedding],
} = await this.embeddingModel.run(
{
input: [query.query],
},
mergedContext
);
queryEmbedding = embedding;
}
// Query Pinecone
const start = Date.now();
const response = await this.pinecone.query({
topK: query.topK ?? 10,
...(typeof query.minScore === 'number'
? { minScore: query.minScore }
: {}),
...(query.filter && { filter: query.filter }),
includeValues: query.includeValues ?? false,
includeMetadata: true,
vector: queryEmbedding,
});
const latency = Date.now() - start;
await this.hooks.afterApiResponse?.({
timestamp: new Date().toISOString(),
datastoreType: this.datastoreType,
datastoreProvider: this.datastoreProvider,
query,
response,
context: mergedContext,
latency,
});
const queryResult: QueryResult<DocMeta> = {
query: query.query,
docs: response.matches,
};
return queryResult;
}
async upsert(docs: Doc<DocMeta>[], context?: Ctx): Promise<void> {
const mergedContext = { ...this.context, ...context };
try {
// Get the text content from the docs
const texts = docs.map((doc) => doc.metadata.content);
// Create the embeddings for the docs
// This relies on the classes to handle batching and throttling
const embeddingRes = await this.embeddingModel.run(
{ input: texts },
mergedContext
);
// Combine the results into Pinecones vector format and upsert
// The Pinecone client will handle batching and throttling
return this.pinecone.upsert({
vectors: docs.map((doc, i) => ({
id: doc.id,
values: embeddingRes.embeddings[i],
metadata: doc.metadata,
})),
});
} catch (error) {
await this.hooks.beforeError?.({
timestamp: new Date().toISOString(),
datastoreType: this.datastoreType,
datastoreProvider: this.datastoreProvider,
upsert: docs,
error,
context: mergedContext,
});
throw error;
}
}
async delete(docIds: string[]): Promise<void> {
return this.pinecone.delete({ ids: docIds });
}
async deleteAll(): Promise<void> {
return this.pinecone.delete({ deleteAll: true });
}
}
export class HybridDatastore<DocMeta extends BaseMeta>
extends BaseHybridDatastore<DocMeta>
implements IDatastore<DocMeta>
{
datastoreType = 'hybrid' as const;
datastoreProvider = 'pinecone' as const;
private readonly pinecone: PineconeClient<DocMeta>;
constructor(
args: Prettify<
HDatastoreOpts<DocMeta> & {
pinecone?: PineconeClient<DocMeta>;
}
>
) {
const { pinecone, ...rest } = args;
super(rest);
this.pinecone =
pinecone ||
createPineconeClient<DocMeta>({
namespace: this.namespace,
});
}
async runQuery(
query: Query<DocMeta>,
context?: Ctx
): Promise<QueryResult<DocMeta>> {
const mergedContext = { ...this.context, ...context };
// Get the query embedding and sparse vector
const queryEmbedding = query.embedding;
const querySparseVector = query.sparseVector;
const [
{
embeddings: [embedding],
},
{
vectors: [sparseVector],
},
] = await Promise.all([
queryEmbedding
? { embeddings: [queryEmbedding] }
: this.embeddingModel.run(
{
input: [query.query],
},
mergedContext
),
querySparseVector
? { vectors: [querySparseVector] }
: this.spladeModel.run(
{
input: [query.query],
},
mergedContext
),
]);
// Query Pinecone
const start = Date.now();
const response = await this.pinecone.query({
topK: query.topK ?? 10,
...(typeof query.minScore === 'number'
? { minScore: query.minScore }
: {}),
...(query.filter && { filter: query.filter }),
includeValues: query.includeValues ?? false,
includeMetadata: true,
vector: embedding,
sparseVector: sparseVector,
});
const latency = Date.now() - start;
await this.hooks.afterApiResponse?.({
timestamp: new Date().toISOString(),
datastoreType: this.datastoreType,
datastoreProvider: this.datastoreProvider,
query,
response,
context: mergedContext,
latency,
});
const queryResult: QueryResult<DocMeta> = {
query: query.query,
docs: response.matches,
};
return queryResult;
}
async upsert(docs: Doc<DocMeta>[], context?: Ctx): Promise<void> {
const mergedContext = { ...this.context, ...context };
try {
// Get the text content from the docs
const texts = docs.map((doc) => doc.metadata.content);
// Create the embeddings and sparse vectors
// This relies on the classes to handle batching and throttling
const [embeddingRes, spladeRes] = await Promise.all([
this.embeddingModel.run({ input: texts }),
this.spladeModel.run({ input: texts }),
]);
// Combine the results into Pinecones vector format and upsert
return this.pinecone.upsert({
vectors: docs.map((doc, i) => ({
id: doc.id,
values: embeddingRes.embeddings[i],
sparseValues: spladeRes.vectors[i],
metadata: doc.metadata,
})),
});
} catch (error) {
await this.hooks.beforeError?.({
timestamp: new Date().toISOString(),
datastoreType: this.datastoreType,
datastoreProvider: this.datastoreProvider,
upsert: docs,
error,
context: mergedContext,
});
throw error;
}
}
async delete(docIds: string[]): Promise<void> {
return this.pinecone.delete({ ids: docIds });
}
async deleteAll(): Promise<void> {
return this.pinecone.delete({ deleteAll: true });
}
}
+3
View File
@@ -0,0 +1,3 @@
export { Datastore, HybridDatastore } from './datastore.js';
export type { Doc, IDatastore } from '../types.js';
+138
View File
@@ -0,0 +1,138 @@
import type {
IEmbeddingModel,
ISparseModel,
SparseValues,
} from '@dexaai/model';
export type DatastoreType = 'embedding' | 'hybrid';
export type DatastoreProvider = 'pinecone' | 'custom';
export type BaseMeta = { content: string };
/** Generic metadata object. */
export type Ctx = { [key: string]: any };
export interface QueryCache<DocMeta extends BaseMeta> {
get(key: Query<DocMeta>): Promise<QueryResult<DocMeta> | null>;
set(key: Query<DocMeta>, value: QueryResult<DocMeta>): Promise<boolean>;
}
export interface Hooks<DocMeta extends BaseMeta> {
afterApiResponse?: (event: {
timestamp: string;
datastoreType: DatastoreType;
datastoreProvider: DatastoreProvider;
query: Query<DocMeta>;
response: unknown;
latency: number;
context: Ctx;
}) => void | Promise<void>;
afterCacheHit?: (event: {
timestamp: string;
datastoreType: DatastoreType;
datastoreProvider: DatastoreProvider;
query: Query<DocMeta>;
response: unknown;
context: Ctx;
}) => void | Promise<void>;
beforeError?: (event: {
timestamp: string;
datastoreType: DatastoreType;
datastoreProvider: DatastoreProvider;
query?: Query<DocMeta>;
upsert?: Doc<DocMeta>[];
error: unknown;
context: Ctx;
}) => void | Promise<void>;
}
export interface IDatastore<DocMeta extends BaseMeta> {
/** Query the DataStore for documents. */
query(query: Query<DocMeta>, context?: Ctx): Promise<QueryResult<DocMeta>>;
/** Insert or update documents in the DataStore. */
upsert(docs: Doc<DocMeta>[], context?: Ctx): Promise<void>;
/** Delete documents by ID from the DataStore. */
delete(docIds: string[]): Promise<void>;
/** Delete all documents from the DataStore. */
deleteAll(): Promise<void>;
}
/**
* Options for creating a Datastore instance.
*/
export interface DatastoreOpts<DocMeta extends BaseMeta> {
namespace: string;
embeddingModel: IEmbeddingModel;
cache?: QueryCache<DocMeta>;
hooks?: Hooks<DocMeta>;
context?: Ctx;
debug?: boolean;
}
/**
* Options for creating a hybrid Datastore instance (with Splade).
*/
export interface HDatastoreOpts<DocMeta extends BaseMeta>
extends DatastoreOpts<DocMeta> {
/**
* Splade instance for creating sparse vectors
*/
spladeModel: ISparseModel;
}
export interface Doc<Meta extends BaseMeta = BaseMeta> {
id: string;
metadata: Meta;
embedding?: number[];
sparseVector?: SparseValues;
score?: number;
}
export interface ScoredDoc<Meta extends BaseMeta = BaseMeta> extends Doc<Meta> {
score: number;
}
export interface Query<Meta extends BaseMeta> {
query: string;
embedding?: number[];
sparseVector?: SparseValues;
topK?: number;
minScore?: number;
filter?: QueryFilter<Meta>;
includeValues?: boolean;
}
export interface QueryResult<Meta extends BaseMeta> {
query: string;
docs: ScoredDoc<Meta>[];
}
/**
* The possible leaf values for filter objects.
* @note Null values aren't supported in metadata for filters, but are allowed here and automatically removed for convenience.
*/
type FilterValue = string | number | boolean | null | string[] | number[];
type FilterOperator =
| '$eq'
| '$ne'
| '$gt'
| '$gte'
| '$lt'
| '$lte'
| '$in'
| '$nin';
/**
* An object of metadata filters.
* @see https://www.pinecone.io/docs/metadata-filtering/
*/
export type QueryFilter<Metadata extends BaseMeta> = {
[key in keyof Metadata | FilterOperator]?:
| FilterValue
| {
[key in keyof Metadata | FilterOperator]?: FilterValue;
};
};
/** Improve preview of union types in autocomplete. */
export type Prettify<T> = { [K in keyof T]: T[K] } & {};
+80
View File
@@ -0,0 +1,80 @@
/** Utilities for working with vectors/embeddings. */
export class VectorUtils {
/** Calculate the cosine similarity between two vectors. */
static cosineSimilarity(a: number[], b: number[]): number {
if (a.length !== b.length) {
throw new Error('Vectors must have the same length.');
}
let dotProduct = 0;
let aMagnitudeSquared = 0;
let bMagnitudeSquared = 0;
for (let i = 0; i < a.length; i++) {
const aValue = a[i];
const bValue = b[i];
dotProduct += aValue * bValue;
aMagnitudeSquared += aValue * aValue;
bMagnitudeSquared += bValue * bValue;
}
// Check for non-zero magnitude after the loop to avoid multiple divisions by zero.
if (aMagnitudeSquared === 0 || bMagnitudeSquared === 0) {
return 0;
}
const magnitudeProduct = aMagnitudeSquared * bMagnitudeSquared;
const magnitudeProductRoot = Math.sqrt(magnitudeProduct);
return dotProduct / magnitudeProductRoot;
}
/** Calculate the dot product of two vectors */
static dotProduct(a: number[], b: number[]): number {
if (a.length !== b.length) {
throw new Error('Vectors must have the same length.');
}
let dotProduct = 0;
for (let i = 0; i < a.length; i++) {
const aValue = a[i];
const bValue = b[i];
dotProduct += aValue * bValue;
}
return dotProduct;
}
/**
* Find the nearest neighbors of a vector in a set of documents with embeddings.
* @param vector The vector to find neighbors for.
* @param docs The set of documents with a vector/embedding to search.
* @param topK The number of neighbors to find.
* @param distanceFunction The distance function to use.
* @returns The k nearest neighbors of the vector with the similarity score added (sorted by similarity).
*/
static nearestNeighbors<D extends { embedding: number[] }>(args: {
vector: number[];
docs: D[];
topK: number;
distanceFunction?: 'cosineSimilarity' | 'dotProduct';
}): (D & { score: number })[] {
const { vector, docs, topK, distanceFunction } = args;
const distance =
distanceFunction === 'cosineSimilarity'
? VectorUtils.cosineSimilarity
: VectorUtils.dotProduct;
const distances: (D & { score: number })[] = docs.map((doc) => ({
...doc,
score: distance(vector, doc.embedding),
}));
// Sort the distances in descending order of similarity (higher similarity means closer)
distances.sort((a, b) => b.score - a.score);
return distances.slice(0, topK);
}
}
+7
View File
@@ -0,0 +1,7 @@
import { describe, expect, it } from 'vitest';
describe('should', () => {
it('exported', () => {
expect(1).toEqual(1);
});
});
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "tsconfig/node-esm.json",
"include": ["./src/**/*"],
"exclude": ["**/node_modules", "**/.*/"],
"compilerOptions": {
"types": ["vitest/globals", "node"]
}
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts', 'src/pinecone/index.ts'],
format: ['esm', 'cjs'],
clean: true,
dts: true,
target: 'es2022',
});
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vite';
export default defineConfig(() => {
return {
mode: 'test',
test: {
environment: 'node',
globals: true,
},
};
});
+3
View File
@@ -0,0 +1,3 @@
{
"extends": ["@dexaai/eslint-config", "@dexaai/eslint-config/node"]
}
+90
View File
@@ -0,0 +1,90 @@
{
"name": "@dexaai/model",
"type": "module",
"version": "0.0.0",
"description": "",
"repository": "dexaai/llm-tools",
"scripts": {
"build": "tsup",
"clean": "rm -rf .turbo node_modules dist",
"dev": "tsup --watch",
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
"test": "vitest",
"typecheck": "tsc --noEmit"
},
"sideEffects": false,
"files": [
"dist"
],
"types": "./dist/index.d.ts",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"exports": {
"./package.json": "./package.json",
".": {
"types": "./dist/index.d.ts",
"require": "./dist/index.cjs",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./openai": {
"types": "./dist/openai/index.d.ts",
"require": "./dist/openai/index.cjs",
"import": "./dist/openai/index.js",
"default": "./dist/openai/index.js"
},
"./custom": {
"types": "./dist/custom/index.d.ts",
"require": "./dist/custom/index.cjs",
"import": "./dist/custom/index.js",
"default": "./dist/custom/index.js"
}
},
"typesVersions": {
"*": {
"openai": [
"./dist/openai/index.d.ts"
],
"custom": [
"./dist/custom/index.d.ts"
],
"tokenizer": [
"./dist/tokenizer/index.d.ts"
]
}
},
"dependencies": {
"@dqbd/tiktoken": "^1.0.7",
"froebel": "^0.23.2",
"keyv": "^4.5.2",
"ky": "^0.33.3",
"openai-fetch": "^1.6.3",
"p-map": "^6.0.0",
"p-retry": "^5.1.2",
"p-throttle": "^5.1.0",
"parse-json": "^7.0.0",
"tkv-cache": "^0.1.2",
"ts-extras": "^0.11.0",
"uuid": "^9.0.0",
"zod": "^3.21.4"
},
"devDependencies": {
"@dexaai/eslint-config": "^0.2.0",
"@types/common-tags": "^1.8.1",
"@types/node": "^20.3.1",
"@types/uuid": "^9.0.2",
"eslint": "^8.43.0",
"tsconfig": "workspace:*",
"tsup": "^7.0.0",
"type-fest": "3.12.0",
"typescript": "^5.1.3",
"vite": "^4.3.9",
"vitest": "^0.32.2"
},
"publishConfig": {
"access": "restricted"
},
"engines": {
"node": ">= 18"
}
}
+3
View File
@@ -0,0 +1,3 @@
# LLM Models
Standardized classes for interacting with large language models.
+3
View File
@@ -0,0 +1,3 @@
export type { ISpladeModel } from './splade.js';
export { SpladeModel } from './splade.js';
+112
View File
@@ -0,0 +1,112 @@
import ky from 'ky';
import pThrottle from 'p-throttle';
import pMap from 'p-map';
import type {
Ctx,
ISparseModel,
SparseConfig,
SparseResponse,
SparseRun,
SparseValues,
} from '../types.js';
import { AbstractModel } from '../model.js';
const SPLADE_MODELS = ['naver/splade-cocondenser-ensembledistil'] as const;
type SpladeModelName = (typeof SPLADE_MODELS)[number];
interface CSparseConfig extends SparseConfig {
model: SpladeModelName;
}
export type ISpladeModel = ISparseModel<
CSparseConfig,
SparseRun,
SparseResponse
>;
interface CMultiSparseConfig extends CSparseConfig {
concurrency?: number;
throttleLimit?: number;
throttleInterval?: number;
}
export class SpladeModel
extends AbstractModel<CMultiSparseConfig, SparseRun, SparseResponse>
implements ISpladeModel
{
modelType = 'sparse-vector' as const;
modelProvider = 'custom' as const;
protected async runModel(
params: SparseRun & CMultiSparseConfig,
context: Ctx
): Promise<SparseResponse> {
const interval = params.throttleInterval ?? 1000 * 60; // 1 minute
const limit = params.throttleLimit ?? 600;
const concurrency = params.concurrency ?? 10;
// Create a throttled version of the function for a single request
const throttled = pThrottle({ limit, interval })(
async (params: { input: string; model: SpladeModelName }) =>
this.runSingle(params, context)
);
// Run the requests in parallel, respecting the maxConcurrentRequests value
const inputs = params.input.map((input) => ({
input,
model: params.model,
}));
const responses = await pMap(inputs, throttled, { concurrency });
return {
vectors: responses.map((r) => r.vector),
tokens: { prompt: 0, completion: 0, total: 0 },
};
}
protected async runSingle(
params: { input: string } & CSparseConfig,
context: Ctx
) {
const start = Date.now();
const vector = await createSpladeVector(params);
const latency = Date.now() - start;
// Don't need tokens for this model
const tokens = { prompt: 0, completion: 0, total: 0 } as const;
await this.hooks.afterApiResponse?.({
timestamp: new Date().toISOString(),
modelType: this.modelType,
modelProvider: this.modelProvider,
// @ts-ignore
params,
response: vector,
tokens,
context,
latency,
});
return { vector, tokens };
}
}
async function createSpladeVector(params: {
input: string;
model: SpladeModelName;
}): Promise<SparseValues> {
try {
const SERVICE_URL = process.env['SPLADE_SERVICE_URL'];
if (!SERVICE_URL) {
throw new Error('Missing process.env.SPLADE_SERVICE_URL');
}
const sparseValues = await ky
.post(SERVICE_URL, {
timeout: 1000 * 60,
json: { text: params.input },
})
.json<SparseValues>();
return sparseValues;
} catch (e) {
// @ts-ignore: TODO: add custom Error class that handles this
throw new Error('Failed to create splade vector', { cause: e });
}
}
+11
View File
@@ -0,0 +1,11 @@
export type {
ChatMessage,
IChatModel,
ICompletionModel,
IEmbeddingModel,
ISparseModel,
ITokenizer,
SparseValues,
} from './types.js';
export { AbstractModel } from './model.js';
+131
View File
@@ -0,0 +1,131 @@
import Keyv from 'keyv';
import { KvCache } from 'tkv-cache';
import type {
Ctx,
Hooks,
IModel,
ModelCache,
ModelConfig,
ModelProvider,
ModelResponse,
ModelRun,
ModelType,
Prettify,
} from './types.js';
export interface ModelArgs<
MConfig extends ModelConfig,
MRun extends ModelRun,
MResponse extends ModelResponse
> {
cache?: ModelCache<MRun & MConfig, MResponse>;
context?: Ctx;
params: MConfig & Partial<MRun>;
hooks?: Hooks<MRun & MConfig, MResponse>;
debug?: boolean;
}
export abstract class AbstractModel<
MConfig extends ModelConfig,
MRun extends ModelRun,
MResponse extends ModelResponse,
AResponse extends any = any
> implements IModel<MConfig, MRun, MResponse>
{
protected abstract runModel(
params: Prettify<MRun & MConfig>,
context: Ctx
): Promise<MResponse>;
abstract modelType: ModelType;
abstract modelProvider: ModelProvider;
protected cache?: ModelCache<MRun & MConfig, MResponse>;
protected context: Ctx;
protected params: MConfig & Partial<MRun>;
protected hooks: Hooks<MRun & MConfig, MResponse, AResponse>;
constructor(args: ModelArgs<MConfig, MRun, MResponse>) {
this.cache = args.cache;
this.context = args.context ?? {};
this.params = args.params;
this.hooks = args.debug
? {
afterApiResponse: console.log,
afterCacheHit: console.log,
beforeError: console.error,
...args.hooks,
}
: args.hooks || {};
}
/**
* Add a cache to the model for storing responses
* Defaults to an in-memory Keyv cache.
*/
addCache(args?: {
/** A Keyv instance to use for caching */
keyv?: Keyv<MResponse>;
/** Keyv options to create a new Keyv instance */
keyvOpts?: Keyv.Options<MResponse>;
/** A function to normalize the cache key */
normalizeKey?: (params: MRun & MConfig) => Partial<MRun & MConfig>;
errorHandler?: (error: unknown) => void;
}): this {
const { keyvOpts, normalizeKey, errorHandler } = args ?? {};
const keyv = args?.keyv ?? new Keyv<MResponse>(keyvOpts);
this.cache = new KvCache(keyv, normalizeKey, errorHandler);
return this;
}
/** Add hooks to the model for logging and debugging */
addHooks(hooks: Hooks<MRun & MConfig, MResponse, AResponse>): this {
this.hooks = { ...this.hooks, ...hooks };
return this;
}
async run(
params: Prettify<MRun & Partial<MConfig>>,
context?: Ctx
): Promise<MResponse> {
// Merge the default params with the provided params
const mergedParams: MRun & MConfig = {
...this.params,
...params,
};
const mergedContext = { ...this.context, ...context };
// Return cached response if available
const cached = await this?.cache?.get(mergedParams);
if (cached) {
await this.hooks.afterCacheHit?.({
timestamp: new Date().toISOString(),
modelType: this.modelType,
modelProvider: this.modelProvider,
params: mergedParams,
response: cached,
context: mergedContext,
});
return cached;
}
try {
// Run the model (e.g. make the API request)
const response = await this.runModel(mergedParams, mergedContext);
// Update the cache
await this?.cache?.set(mergedParams, response);
return response;
} catch (error) {
await this.hooks.beforeError?.({
timestamp: new Date().toISOString(),
modelType: this.modelType,
modelProvider: this.modelProvider,
params: mergedParams,
error,
context: mergedContext,
});
throw error;
}
}
}
+161
View File
@@ -0,0 +1,161 @@
import type {
ChatConfig,
ChatResponse,
ChatRun,
Ctx,
IChatModel,
Prettify,
TokenCounts,
} from '../types.js';
import type { ModelArgs } from '../model.js';
import { AbstractModel } from '../model.js';
import { createOpenAIClient, extractTokens } from './client.js';
import { createTokenizer } from './tokenizer.js';
import type {
ChatMessage,
OpenAIChatModel,
OpenAIChatParams,
OpenAIChatResponse,
OpenAIClient,
} from './client.js';
interface OChatConfig
extends ChatConfig,
Omit<OpenAIChatParams, 'messages' | 'user'> {
/** Handle new chunks from streaming requests. */
handleUpdate?: (chunk: string) => void;
model: OpenAIChatModel;
}
export type IOChatModel = IChatModel<OChatConfig, ChatRun, ChatResponse>;
export class OChatModel
extends AbstractModel<OChatConfig, ChatRun, ChatResponse, OpenAIChatResponse>
implements IOChatModel
{
modelType = 'chat' as const;
modelProvider = 'openai' as const;
openaiClient: OpenAIClient;
constructor(
args: Prettify<
ModelArgs<OChatConfig, ChatRun, ChatResponse> & {
openaiClient?: OpenAIClient;
}
>
) {
const { openaiClient, ...rest } = args;
super(rest);
this.openaiClient = openaiClient || createOpenAIClient();
}
async runModel(
params: ChatRun & OChatConfig,
context: Ctx
): Promise<ChatResponse> {
const { handleUpdate, ...restParams } = params;
// Use non-streaming API if no handler is provided
if (!handleUpdate) {
const start = Date.now();
const { message, response } =
await this.openaiClient.createChatCompletion({
user: typeof context.user === 'string' ? context.user : '',
...restParams,
});
const latency = Date.now() - start;
const tokens = extractTokens(response.usage);
await this.hooks.afterApiResponse?.({
timestamp: new Date().toISOString(),
modelType: this.modelType,
modelProvider: this.modelProvider,
params: restParams,
response,
tokens,
context,
latency,
});
return { message, tokens };
} else {
const start = Date.now();
const stream = await this.openaiClient.streamChatCompletion({
user: typeof context.user === 'string' ? context.user : '',
...restParams,
});
// Keep track of the stream's output
let text = '';
let role = '';
let response = {};
// Get a reader from the stream
const reader = stream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) {
// If the stream is done, break out of the loop and save the conversation
// to the cache before returning.
break;
}
// Only set role once
if (!role && value?.message?.role) {
role = value.message.role;
}
response = { ...response, ...value?.response };
// Send an update to the caller
const messageContent = value?.message?.content;
if (typeof messageContent === 'string') {
text = `${text}${messageContent}`;
try {
// @TODO: Should this be throttled?
handleUpdate(messageContent);
} catch (err) {
console.error('Error handling update', err);
}
}
}
// Once the stream is done, release the reader
reader.releaseLock();
const latency = Date.now() - start;
// Construct the complete message
const message: ChatMessage = {
role: role as ChatMessage['role'],
content: text,
};
// Streamed responses don't include token usage so we have to add it
const tokenizer = createTokenizer(params.model);
const promptTokens = tokenizer.countTokens(params.messages);
const completionTokens = tokenizer.countTokens(message);
const tokens: TokenCounts = {
prompt: promptTokens,
completion: completionTokens,
total: promptTokens + completionTokens,
};
const responseUsage = {
prompt_tokens: tokens.prompt,
completion_tokens: tokens.completion,
total_tokens: tokens.total,
};
response = { ...response, usage: responseUsage };
await this.hooks.afterApiResponse?.({
timestamp: new Date().toISOString(),
modelType: this.modelType,
modelProvider: this.modelProvider,
params: restParams,
response: response as OpenAIChatResponse,
tokens,
context,
latency,
});
return { message, tokens };
}
}
}
+89
View File
@@ -0,0 +1,89 @@
import type { ChatMessage } from 'openai-fetch';
import { OpenAIClient } from 'openai-fetch';
import type { TokenCounts } from '../types.js';
let cachedClient: OpenAIClient | undefined;
/** Create a new openai-fetch OpenAIClient. */
export function createOpenAIClient(
/** Options to pass to the OpenAI client. */
opts?: ConstructorParameters<typeof OpenAIClient>[0],
/** Force a new client to be created. */
forceNew = false
) {
// Only use a cached client if no options are passed.
if (opts === undefined && cachedClient && !forceNew) {
return cachedClient;
}
return new OpenAIClient(opts);
}
export { OpenAIApiError, OpenAIClient } from 'openai-fetch';
export type {
ChatCompletionParams as OpenAIChatParams,
ChatCompletionResponse as OpenAIChatResponse,
ChatMessageFunction as OpenAIChatFunction,
ChatMessage,
CompletionParams as OpenAICompletionParams,
CompletionResponse as OpenAICompletionResponse,
EmbeddingParams as OpenAIEmbeddingParams,
EmbeddingResponse as OpenAIEmbeddingResponse,
} from 'openai-fetch';
/** Extract tokens from an OpenAI API response */
export function extractTokens(
usage: Record<string, number | undefined> | undefined
) {
const tokens: TokenCounts = {
prompt: usage?.['prompt_tokens'] ?? 0,
completion: usage?.['completion_tokens'] ?? 0,
total: usage?.['total_tokens'] ?? 0,
};
return tokens;
}
/** Reformat name to adhere to OpenAI's naming restrictions: /^[a-zA-Z0-9_-]{1,64}$/ */
export function formatName<Msg extends ChatMessage>(message: Msg): Msg {
const { name, role, content } = message;
// Remove the name key if it's empty.
if (!name) return { role, content } as Msg;
// Reformat to meet OpenAI's naming restrictions.
const formattedName = name
.replace(/\s/g, '-')
.replace(/[^a-zA-Z0-9_]/g, '')
.toLowerCase();
return { role, name: formattedName, content } as Msg;
}
const CHAT_MODELS = [
'gpt-3.5-turbo',
'gpt-3.5-turbo-0301',
'gpt-4',
'gpt-4-0314',
'gpt-4-32k',
'gpt-4-32k-0314',
'gpt-4-0613',
'gpt-4-32k-0613',
'gpt-3.5-turbo-0613',
'gpt-3.5-turbo-16k',
] as const;
export type OpenAIChatModel = (typeof CHAT_MODELS)[number];
const EMBEDDING_MODELS = [
'text-embedding-ada-002',
'text-similarity-davinci-001',
'text-similarity-curie-001',
'text-similarity-babbage-001',
'text-similarity-ada-001',
'text-search-davinci-doc-001',
'text-search-curie-doc-001',
'text-search-babbage-doc-001',
'text-search-ada-doc-001',
'code-search-babbage-code-001',
'code-search-ada-code-001',
] as const;
export type OpenAIEmbeddingModel = (typeof EMBEDDING_MODELS)[number];
+93
View File
@@ -0,0 +1,93 @@
import type {
CompletionConfig,
CompletionResponse,
CompletionRun,
Ctx,
ICompletionModel,
Prettify,
} from '../types.js';
import type { ModelArgs } from '../model.js';
import { AbstractModel } from '../model.js';
import type {
OpenAIClient,
OpenAICompletionParams,
OpenAICompletionResponse,
} from './client.js';
import { createOpenAIClient, extractTokens } from './client.js';
interface OCompletionConfig
extends CompletionConfig,
Omit<OpenAICompletionParams, 'prompt' | 'user'> {}
interface OCompletionsResponse extends CompletionResponse {
completions: {
completion: string;
logprobs?: OpenAICompletionResponse['choices'][0]['logprobs'];
}[];
}
export type IOCompletionModel = ICompletionModel<
OCompletionConfig,
CompletionRun,
OCompletionsResponse
>;
export class OCompletionModel
extends AbstractModel<
OCompletionConfig,
CompletionRun,
OCompletionsResponse,
OpenAICompletionResponse
>
implements IOCompletionModel
{
modelType = 'completion' as const;
modelProvider = 'openai' as const;
openaiClient: OpenAIClient;
constructor(
args: Prettify<
ModelArgs<OCompletionConfig, CompletionRun, OCompletionsResponse> & {
openaiClient?: OpenAIClient;
}
>
) {
const { openaiClient, ...rest } = args;
super(rest);
this.openaiClient = openaiClient || createOpenAIClient();
}
async runModel(
params: CompletionRun & OCompletionConfig,
context: Ctx
): Promise<OCompletionsResponse> {
const start = Date.now();
const { response: apiResponse } = await this.openaiClient.createCompletions(
{
user: typeof context.user === 'string' ? context.user : '',
...params,
}
);
const latency = Date.now() - start;
const tokens = extractTokens(apiResponse.usage);
await this.hooks.afterApiResponse?.({
timestamp: new Date().toISOString(),
modelType: this.modelType,
modelProvider: this.modelProvider,
params,
response: apiResponse,
tokens,
context,
latency,
});
const response: OCompletionsResponse = {
tokens,
completions: apiResponse.choices.map((choice) => ({
completion: choice.text || '',
logprobs: choice.logprobs,
})),
};
return response;
}
}
+294
View File
@@ -0,0 +1,294 @@
import pThrottle from 'p-throttle';
import pRetry, { AbortError } from 'p-retry';
import pMap from 'p-map';
import type {
EmbeddingConfig,
Ctx,
EmbeddingRun,
EmbeddingResponse,
IEmbeddingModel,
ITokenizer,
TokenCounts,
} from '../types.js';
import type { ModelArgs } from '../model.js';
import { AbstractModel } from '../model.js';
import { OpenAIApiError, createOpenAIClient, extractTokens } from './client.js';
import type {
OpenAIClient,
OpenAIEmbeddingModel,
OpenAIEmbeddingParams,
OpenAIEmbeddingResponse,
} from './client.js';
import { createTokenizer } from './tokenizer.js';
interface BatchOptions {
maxTokensPerBatch: number;
maxBatchSize: number;
}
interface ThrottleOptions {
maxRequestsPerMin: number;
maxConcurrentRequests: number;
}
interface RetryOptions {
maxRetries: number;
jitter: number;
initialDelay: number;
exponentialBase: number;
}
interface OEmbeddingConfig
extends EmbeddingConfig,
Omit<OpenAIEmbeddingParams, 'input' | 'user'> {
model: OpenAIEmbeddingModel;
batch?: Partial<BatchOptions>;
throttle?: Partial<ThrottleOptions>;
retry?: Partial<RetryOptions>;
}
export type IOEmbeddingModel = IEmbeddingModel<
OEmbeddingConfig,
EmbeddingRun,
EmbeddingResponse
>;
type BulkEmbedder = (
params: EmbeddingRun & OEmbeddingConfig,
context: Ctx
) => Promise<EmbeddingResponse>;
const DEFAULTS = {
/** The interval that the OpenAI API rate limit resets */
throttleInterval: 1000 * 60, // 1 minute
maxBatchSize: 10,
maxTokensPerBatch: 20000,
maxConcurrentRequests: 1,
maxRequestsPerMin: 3500,
maxRetries: 3,
jitter: 0.3,
initialDelay: 1000,
exponentialBase: 3,
} as const;
export class OEmbeddingModel
extends AbstractModel<
OEmbeddingConfig,
EmbeddingRun,
EmbeddingResponse,
OpenAIEmbeddingResponse
>
implements IOEmbeddingModel
{
modelType = 'embedding' as const;
modelProvider = 'openai' as const;
openaiClient: OpenAIClient;
throttledModel: BulkEmbedder;
tokenizer: ITokenizer;
/** Doesn't accept OpenAIClient because retry needs to be handled at the model level. */
constructor(
args: ModelArgs<OEmbeddingConfig, EmbeddingRun, EmbeddingResponse>
) {
const { params, ...rest } = args;
super({ params, ...rest });
this.openaiClient = createOpenAIClient();
this.tokenizer = createTokenizer(params.model);
const interval = DEFAULTS.throttleInterval;
const limit =
this.params.throttle?.maxRequestsPerMin || DEFAULTS.maxRequestsPerMin;
// Create the throttled function
this.throttledModel = pThrottle({ limit, interval })(
async (params: EmbeddingRun & OEmbeddingConfig, context: Ctx) => {
try {
const start = Date.now();
// Make the request to OpenAI API to generate embeddings
const { embeddings, response } =
await this.openaiClient.createEmbeddings({
model: params.model,
input: params.input,
});
const latency = Date.now() - start;
const tokens = extractTokens(response.usage);
await this.hooks.afterApiResponse?.({
timestamp: new Date().toISOString(),
modelType: this.modelType,
modelProvider: this.modelProvider,
params,
response,
tokens,
context,
latency,
});
return { embeddings, tokens };
} catch (error) {
const codesToRetry = [408, 413, 429, 500, 502, 503, 504];
if (
error instanceof OpenAIApiError &&
error.status &&
codesToRetry.includes(error.status)
) {
// If rate limit error, throw an error that can be retried
throw error;
}
// Otherwise, throw an error that will not be retried
throw new AbortError(error as Error);
}
}
);
}
async runModel(
params: EmbeddingRun,
context: Ctx
): Promise<EmbeddingResponse> {
// Batch the inputs for the requests
const batches = batchInputs({
input: params.input,
tokenizer: this.tokenizer,
options: this.params.batch,
}).map((batch) => batch.map((input) => input.text));
const mergedContext = { ...this.context, ...context } as Ctx;
// Make the requests in parallel, respecting concurrency setting
const embeddingBatches = await pMap(
batches,
async (batch: string[]) => {
return pRetry<EmbeddingResponse>(
async () => {
const response = await this.throttledModel(
{
input: batch,
model: this.params.model,
},
mergedContext
);
return response;
},
{
retries: this.params.retry?.maxRetries || DEFAULTS.maxRetries,
onFailedAttempt: async (error) => {
const delay: number = getBackoffTime({
attemptNumber: error.attemptNumber,
...this.params.retry,
});
await new Promise((resolve) => setTimeout(resolve, delay));
},
}
);
},
{
concurrency:
this.params.throttle?.maxConcurrentRequests ||
DEFAULTS.maxConcurrentRequests,
}
);
// Flatten the batches of embeddings into a single array
const embeddings = embeddingBatches.map((batch) => batch.embeddings).flat();
const tokens = embeddingBatches
.map((batch) => batch.tokens)
.reduce((acc, curr) => {
return {
total: acc.total + curr.total,
prompt: acc.prompt + curr.prompt,
completion: 0,
};
}, {} as TokenCounts);
return { embeddings, tokens };
}
}
/**
* Get the time in milliseconds to wait before making another request after an error.
* Use an exponential backoff strategy with a random jitter.
* Adapted from: https://platform.openai.com/docs/guides/rate-limits/retrying-with-exponential-backoff
*/
function getBackoffTime(
args: { attemptNumber: number } & Partial<RetryOptions>
): number {
const {
exponentialBase = DEFAULTS.exponentialBase,
initialDelay = DEFAULTS.initialDelay,
jitter = DEFAULTS.jitter,
} = args;
// Calculate the delay based on exponential backoff strategy
const delay = initialDelay * exponentialBase ** (args.attemptNumber - 1);
// Calculate the random jitter (randomness within a certain range to avoid simultaneous retries)
const randomJitter = delay * jitter * numberBetween(-1, 1);
// The final backoff time includes the jitter
const backoffTime = delay + randomJitter;
return backoffTime;
}
/** Get a random number between the specified range [min, max]. */
function numberBetween(min: number, max: number): number {
return Math.random() * (max - min) + min;
}
type InputBatch = { text: string; tokenCount: number }[];
/**
* Split text inputs into batches, respecting token limits and batch size.
* @throws {Error} If an input exceeds the max tokens per batch.
*/
function batchInputs(args: {
input: string[];
tokenizer: ITokenizer;
options?: Partial<BatchOptions>;
}): InputBatch[] {
const { input: inputs, tokenizer, options } = args;
const {
maxTokensPerBatch = DEFAULTS.maxTokensPerBatch,
maxBatchSize = DEFAULTS.maxBatchSize,
} = options || {};
// State for constructing batches
const batches: InputBatch[] = [];
let currentBatch: InputBatch = [];
let currentBatchTokens = 0;
for (let input of inputs) {
const tokenCount = tokenizer.countTokens(input);
// Ensure that the input does not exceed the max tokens per batch
if (tokenCount > maxTokensPerBatch) {
throw new Error(
`Input exceeds max tokens per batch: ${tokenCount} > ${maxTokensPerBatch}`
);
}
// Add the first input to the batch
if (currentBatch.length === 0) {
currentBatch.push({ text: input, tokenCount });
currentBatchTokens += tokenCount;
continue;
}
// Check if the input would exceed the max tokens per batch or max batch size
const overTokenLimit = currentBatchTokens + tokenCount > maxTokensPerBatch;
const overBatchSize = currentBatch.length + 1 > maxBatchSize;
// Start a new batch if either limit would be exceeded
if (overTokenLimit || overBatchSize) {
batches.push(currentBatch);
currentBatch = [{ text: input, tokenCount }];
currentBatchTokens = tokenCount;
continue;
}
// Add the input to the current batch
currentBatch.push({ text: input, tokenCount });
currentBatchTokens += tokenCount;
}
// Add the last batch
if (currentBatch.length > 0) {
batches.push(currentBatch);
}
return batches;
}
+9
View File
@@ -0,0 +1,9 @@
export type { IOChatModel as IChatModel } from './chat.js';
export type { IOCompletionModel as ICompletionModel } from './completion.js';
export type { IOEmbeddingModel as IEmbeddingModel } from './embedding.js';
export type { ITokenizer } from '../types.js';
export { OChatModel as ChatModel } from './chat.js';
export { OCompletionModel as CompletionModel } from './completion.js';
export { OEmbeddingModel as EmbeddingModel } from './embedding.js';
export { createTokenizer } from './tokenizer.js';
+128
View File
@@ -0,0 +1,128 @@
import { encoding_for_model } from '@dqbd/tiktoken';
import type { Tiktoken, TiktokenModel } from '@dqbd/tiktoken';
import type { ChatMessage } from 'openai-fetch';
import type { ITokenizer, CreateTokenizer } from '../types.js';
// Store instances of the tokenizer to avoid re-creating them for the same model
const tokenizerCache = new Map<string, Tokenizer>();
/** Create a tokenizer for a specific model */
export const createTokenizer: CreateTokenizer = (model: string) => {
// export function createTokenizer(model: ModelName): ITokenizer {
if (tokenizerCache.has(model)) {
return tokenizerCache.get(model)!;
} else {
const tokenizer = new Tokenizer(model);
tokenizerCache.set(model, tokenizer);
return tokenizer;
}
};
const GPT_4_MODELS = [
'gpt-4',
'gpt-4-0314',
'gpt-4-32k',
'gpt-4-32k-0314',
] as const;
type Gpt4ModelName = (typeof GPT_4_MODELS)[number];
class Tokenizer implements ITokenizer {
model: string;
tiktoken: Tiktoken;
constructor(model: string) {
this.model = model;
this.tiktoken = encoding_for_model(model as TiktokenModel);
}
/** Encode text to tokens */
encode(text: string): Uint32Array {
return this.tiktoken.encode(text);
}
/** Decode tokens to text */
decode(tokens: number[] | Uint32Array): string {
const toDecode = Array.isArray(tokens) ? new Uint32Array(tokens) : tokens;
return new TextDecoder().decode(this.tiktoken.decode(toDecode));
}
/**
* Count the number of tokens in a string or ChatMessage(s)
* A single message is counted as a completion and an array as a prompt
**/
countTokens(input?: string | ChatMessage | ChatMessage[]): number {
if (!input) return 0;
if (typeof input === 'string') {
return this.tiktoken.encode(input).length;
} else if (Array.isArray(input)) {
// This is copied from OpenAI's Python implementation: https://github.com/openai/openai-cookbook/blob/main/examples/How_to_format_inputs_to_ChatGPT_models.ipynb
// NOTE: The array version assumes this is for the prompt and adds more tokens.
// Use the single ChatMessage for completion token count.
// The tokensPerName and tokensPerMessage will need to be updated over time
const isGpt4 = this.isGpt4Model(this.model);
const tokensPerMessage = isGpt4 ? 3 : 4;
let numTokens = 0;
for (const message of input) {
numTokens += tokensPerMessage;
if (message.content) {
numTokens += this.countTokens(message.content);
}
// The name+role are handled differently for GPT-3.5 vs GPT-4
if (isGpt4) {
// For 4, the name and role are included
// Details here: https://github.com/openai/openai-python/blob/main/chatml.md
numTokens += 1; // role
if (message.name) {
// No idea why this, but tested with many examples and it works...
numTokens += this.countTokens(`${message.name}`) + 1;
}
} else {
// For 3.5, the name replaces the role if it's present
numTokens += this.countTokens(message.name || message.role);
}
}
// Every reply is primed with assistant
numTokens += 3;
return numTokens;
} else {
return this.countTokens(input.content || '');
}
}
/** Truncate text to a maximum number of tokens */
truncate(args: {
/** Text to truncate */
text: string;
/** Maximum number of tokens to keep (inclusive) */
max: number;
/** Truncate from the start or end of the text */
from?: 'start' | 'end';
}): string {
const { text, max, from = 'start' } = args;
const tokens = this.encode(text);
if (tokens.length <= max) {
return text;
}
const truncatedTokens =
from === 'start'
? tokens.slice(0, max)
: tokens.slice(tokens.length - max);
let truncatedText = this.decode(truncatedTokens);
// Handle edge case where the last token is part of a multi-byte character sequence
truncatedText = truncatedText.replace(/[\uDC00-\uDFFF]$/, '');
return truncatedText;
}
/** Check if the given model is a GPT-4 variant. */
private isGpt4Model(model: string): model is Gpt4ModelName {
return GPT_4_MODELS.includes(model as any);
}
}
+193
View File
@@ -0,0 +1,193 @@
import type { ChatMessage } from 'openai-fetch';
export type { ChatMessage } from 'openai-fetch';
/** Generic metadata object. */
export type Ctx = { [key: string]: any };
/** Token counts for model request. */
export type TokenCounts = { prompt: number; completion: number; total: number };
/** Sparse vectors from SPLADE models. */
export type SparseValues = {
indices: number[];
values: number[];
};
/** Improve preview of union types in autocomplete. */
export type Prettify<T> = { [K in keyof T]: T[K] } & {};
export type ModelType =
| 'base'
| 'completion'
| 'chat'
| 'embedding'
| 'sparse-vector';
export type ModelProvider = 'openai' | 'custom';
/**
* Base model
*/
export interface ModelConfig {
model: string;
}
export interface ModelRun {}
interface ModelParams extends ModelConfig, ModelRun {}
export interface ModelResponse {
tokens: TokenCounts;
}
export interface IModel<
MConfig extends ModelConfig,
MRun extends ModelRun,
ModelResp extends ModelResponse
> {
modelType: ModelType;
modelProvider: ModelProvider;
run(params: MRun & Partial<MConfig>, context?: Ctx): Promise<ModelResp>;
}
/**
* Chat model
*/
export interface ChatRun extends ModelRun {
messages: ChatMessage[];
}
export interface ChatConfig extends ModelConfig {}
export interface ChatResponse extends ModelResponse {
message: ChatMessage;
}
export interface IChatModel<
CConfig extends ChatConfig,
CRun extends ChatRun,
CResponse extends ChatResponse
> extends IModel<CConfig, CRun, CResponse> {
modelType: 'chat';
}
/**
* Completion model
*/
export interface CompletionRun extends ModelRun {
prompt: string[];
}
export interface CompletionConfig extends ModelConfig {}
export interface CompletionResponse extends ModelResponse {
completions: { completion: string }[];
}
export interface ICompletionModel<
CConfig extends CompletionConfig,
CRun extends CompletionRun,
CResponse extends CompletionResponse
> extends IModel<CConfig, CRun, CResponse> {
modelType: 'completion';
}
/**
* Embedding model
*/
export interface EmbeddingRun extends ModelRun {
input: string[];
}
export interface EmbeddingConfig extends ModelConfig {}
export interface EmbeddingResponse extends ModelResponse {
embeddings: number[][];
}
export interface IEmbeddingModel<
EConfig extends EmbeddingConfig = EmbeddingConfig,
ERun extends EmbeddingRun = EmbeddingRun,
EResponse extends EmbeddingResponse = EmbeddingResponse
> extends IModel<EConfig, ERun, EResponse> {
modelType: 'embedding';
}
/**
* Sparse vector model (SPLADE)
*/
export interface SparseRun extends ModelRun {
input: string[];
}
export interface SparseConfig extends ModelConfig {}
export interface SparseResponse extends ModelResponse {
vectors: SparseValues[];
}
export interface ISparseModel<
SConfig extends SparseConfig = SparseConfig,
SRun extends SparseRun = SparseRun,
SResponse extends SparseResponse = SparseResponse
> extends IModel<SConfig, SRun, SResponse> {
modelType: 'sparse-vector';
}
/**
* Hooks for logging and debugging
*/
export interface Hooks<
MParams extends ModelParams,
MResponse extends ModelResponse,
AResponse extends any = any
> {
afterApiResponse?: (event: {
timestamp: string;
modelType: ModelType;
modelProvider: ModelProvider;
params: MParams;
response: AResponse;
tokens: TokenCounts;
latency: number;
context: Ctx;
}) => void | Promise<void>;
afterCacheHit?: (event: {
timestamp: string;
modelType: ModelType;
modelProvider: ModelProvider;
params: MParams;
response: MResponse;
context: Ctx;
}) => void | Promise<void>;
beforeError?: (event: {
timestamp: string;
modelType: ModelType;
modelProvider: ModelProvider;
params: MParams;
error: unknown;
context: Ctx;
}) => void | Promise<void>;
}
/**
* Cache for storing model responses
*/
export interface ModelCache<
MParams extends ModelParams,
MResponse extends ModelResponse
> {
get(key: MParams): Promise<MResponse | null>;
set(key: MParams, value: MResponse): Promise<boolean>;
}
/**
* Generic interface for a model tokenizer
*/
export interface ITokenizer {
/** Tokenize a string into an array of integer tokens */
encode(text: string): Uint32Array;
/** Decode an array of integer tokens into a string */
decode(tokens: number[] | Uint32Array): string;
/**
* Count the number of tokens in a string or ChatMessage(s).
* A single ChatMessage is counted as a completion and an array as a prompt.
* Strings are counted as is.
*/
countTokens(input?: string | ChatMessage | ChatMessage[]): number;
/** Truncate a string to a maximum number of tokens */
truncate(args: {
/** Text to truncate */
text: string;
/** Maximum number of tokens to keep (inclusive) */
max: number;
/** Truncate from the start or end of the text */
from?: 'start' | 'end';
}): string;
}
export type CreateTokenizer = (model: string) => ITokenizer;
+7
View File
@@ -0,0 +1,7 @@
import { describe, expect, it } from 'vitest';
describe('should', () => {
it('exported', () => {
expect(1).toEqual(1);
});
});
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "tsconfig/node-esm.json",
"include": ["./src/**/*"],
"exclude": ["**/node_modules", "**/.*/"],
"compilerOptions": {
"types": ["vitest/globals", "node"]
}
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts', 'src/openai/index.ts', 'src/custom/index.ts'],
format: ['esm', 'cjs'],
clean: true,
dts: true,
target: 'es2022',
});
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vite';
export default defineConfig(() => {
return {
mode: 'test',
test: {
environment: 'node',
globals: true,
},
};
});
+5144
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
packages:
- 'config/*'
- 'examples/**'
- 'packages/*'
Executable
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
set -o errexit # abort on nonzero exitstatus
set -o nounset # abort on unbound variable
set -o pipefail # don't hide errors within pipes
pnpm changeset version
git add .
git commit -m "Publish updated packages"
pnpm changeset tag
git push
git push --tags
pnpm publish -r
+11
View File
@@ -0,0 +1,11 @@
# LLM Tools
## Publishing Updates
```bash
# Decide what to publish
pnpm changeset
# Update version numbers, add tags, and publish
./publish.sh
```
+26
View File
@@ -0,0 +1,26 @@
{
"$schema": "https://turborepo.org/schema.json",
"pipeline": {
"build": {
"outputs": ["dist/**"],
"dependsOn": ["^build"]
},
"test": {
"dependsOn": [],
"inputs": ["**/*.test.ts", "**/*.test.tsx"]
},
"lint": {
"dependsOn": [],
"outputs": ["node_modules/.cache/eslint/**"]
},
"typecheck": {
"dependsOn": []
},
"dev": {
"cache": false
},
"clean": {
"cache": false
}
}
}