├── .editorconfig ├── .env.example ├── .eslintrc ├── .github ├── funding.yml └── workflows │ └── test.yml ├── .gitignore ├── .gitmodules ├── .husky └── pre-commit ├── .npmrc ├── .prettierignore ├── .prettierrc.cjs ├── bin └── generate.ts ├── e2e ├── index.ts └── retrieval.ts ├── license ├── media └── screenshot.jpg ├── package.json ├── pnpm-lock.yaml ├── prisma └── schema.prisma ├── readme.md ├── src ├── generated │ ├── oai-routes.ts │ └── oai.ts ├── lib │ ├── config.ts │ ├── create-thread.ts │ ├── db.ts │ ├── oai.test.ts │ ├── prisma-json-types.d.ts │ ├── queue.ts │ ├── retrieval.ts │ ├── storage.test.ts │ ├── storage.ts │ ├── types.ts │ └── utils.ts ├── readme.md ├── runner │ ├── index.ts │ └── models.ts └── server │ ├── assistant-files.ts │ ├── assistants.ts │ ├── files.ts │ ├── index.ts │ ├── message-files.ts │ ├── messages.ts │ ├── run-steps.ts │ ├── runs.ts │ └── threads.ts ├── tsconfig.dist.json ├── tsconfig.json └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | tab_width = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.js] 13 | quote_type = single 14 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # This is an example .env file. 3 | # 4 | # All of these environment vars must be defined either in your environment or in 5 | # a local .env file in order to run the examples for this project. 6 | # ------------------------------------------------------------------------------ 7 | 8 | # OpenAI API key for handling the underlying model calls 9 | OPENAI_API_KEY= 10 | 11 | # Postgres connection string: 'postgres://user:password@host:port/dbname' 12 | DATABASE_URL= 13 | 14 | # redis connection settings (defaults to localhost:6379 with a default user) 15 | #REDIS_HOST= 16 | #REDIS_PORT= 17 | #REDIS_USERNAME= 18 | #REDIS_PASSWORD= 19 | 20 | # s3 connection settings (compatible with cloudflare r2) 21 | S3_BUCKET= 22 | S3_REGION='auto' 23 | # example: "https://.r2.cloudflarestorage.com" 24 | S3_ENDPOINT= 25 | ACCESS_KEY_ID= 26 | SECRET_ACCESS_KEY= 27 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": ["@dexaai/eslint-config", "@dexaai/eslint-config/node"], 4 | "ignorePatterns": ["node_modules", "dist", ".next"], 5 | "rules": { 6 | "no-process-env": "off", 7 | "no-console": "off", 8 | "import/order": "off" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.github/funding.yml: -------------------------------------------------------------------------------- 1 | github: [transitive-bullshit] 2 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | name: Test Node.js ${{ matrix.node-version }} 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | fail-fast: true 12 | matrix: 13 | node-version: 14 | - 18 15 | - 22 16 | - 23 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: pnpm/action-setup@v4 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: 'pnpm' 25 | 26 | - run: pnpm install --frozen-lockfile --strict-peer-dependencies 27 | - run: pnpm build 28 | - run: pnpm test 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | *.swp 4 | .idea 5 | 6 | # ignore editor workspace files 7 | .vscode/ 8 | .idea/ 9 | *.sublime-workspace 10 | *.sublime-project 11 | 12 | # dependencies 13 | node_modules/ 14 | .pnp/ 15 | .pnp.js 16 | 17 | # testing 18 | /coverage 19 | 20 | # next.js 21 | .next/ 22 | out/ 23 | 24 | # production 25 | build/ 26 | 27 | # misc 28 | .DS_Store 29 | *.pem 30 | 31 | # debug 32 | npm-debug.log* 33 | yarn-debug.log* 34 | yarn-error.log* 35 | .pnpm-debug.log* 36 | 37 | # local env files 38 | .env*.local 39 | 40 | # vercel 41 | .vercel 42 | 43 | # typescript 44 | *.tsbuildinfo 45 | next-env.d.ts 46 | 47 | # local env files 48 | .env 49 | .env.local 50 | .env.build 51 | .env.development.local 52 | .env.test.local 53 | .env.production.local 54 | 55 | .turbo 56 | 57 | dist/ 58 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "openai-openapi"] 2 | path = openai-openapi 3 | url = git@github.com:openai/openai-openapi.git 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm run precommit 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | enable-pre-post-scripts=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | .next/ 3 | src/generated/oai.ts 4 | src/generated/oai-routes.ts 5 | openai-openapi/ -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [require('@trivago/prettier-plugin-sort-imports')], 3 | singleQuote: true, 4 | jsxSingleQuote: true, 5 | semi: false, 6 | useTabs: false, 7 | tabWidth: 2, 8 | bracketSpacing: true, 9 | bracketSameLine: false, 10 | arrowParens: 'always', 11 | trailingComma: 'none', 12 | importOrder: ['^node:.*', '', '^(~/(.*)$)', '^[./]'], 13 | importOrderSeparation: true, 14 | importOrderSortSpecifiers: true, 15 | importOrderGroupNamespaceSpecifiers: true 16 | } 17 | -------------------------------------------------------------------------------- /bin/generate.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises' 2 | 3 | import * as prettier from 'prettier' 4 | import SwaggerParser from '@apidevtools/swagger-parser' 5 | import { convertParametersToJSONSchema } from 'openapi-jsonschema-parameters' 6 | import type { OpenAPIV3 } from 'openapi-types' 7 | import { 8 | FetchingJSONSchemaStore, 9 | InputData, 10 | JSONSchemaInput, 11 | quicktype 12 | } from 'quicktype-core' 13 | 14 | const srcFile = './openai-openapi/openapi.yaml' 15 | const destFolder = './src/generated' 16 | const destFileSchemas = `${destFolder}/oai.ts` 17 | const destfileRoutes = `${destFolder}/oai-routes.ts` 18 | 19 | const jsonContentType = 'application/json' 20 | const multipartFormData = 'multipart/form-data' 21 | 22 | const header = `/** 23 | * This file is auto-generated by OpenOpenAI using OpenAI's OpenAPI spec as a 24 | * source of truth. 25 | * 26 | * DO NOT EDIT THIS FILE MANUALLY if you want your changes to persist. 27 | */` 28 | 29 | // Notes: 30 | // `json-schema-to-zod` doesn't seem to output subtypes 31 | // `quicktype` doesn't seem to output zod .describes 32 | 33 | // The subset of API paths that we care about, which determines the subset of types 34 | // that we need to generate. 35 | const openaiOpenAPIPaths: Record = { 36 | '/chat/completions': false, 37 | '/completions': false, 38 | '/edits': false, 39 | '/images/generations': false, 40 | '/images/edits': false, 41 | '/images/variations': false, 42 | '/embeddings': false, 43 | '/audio/speech': false, 44 | '/audio/transcriptions': false, 45 | '/audio/translations': false, 46 | '/files': true, 47 | '/files/{file_id}': true, 48 | '/files/{file_id}/content': true, 49 | '/fine_tuning/jobs': false, 50 | '/fine_tuning/jobs/{fine_tuning_job_id}': false, 51 | '/fine_tuning/jobs/{fine_tuning_job_id}/events': false, 52 | '/fine_tuning/jobs/{fine_tuning_job_id}/cancel': false, 53 | '/fine-tunes': false, 54 | '/fine-tunes/{fine_tune_id}': false, 55 | '/fine-tunes/{fine_tune_id}/cancel': false, 56 | '/fine-tunes/{fine_tune_id}/events': false, 57 | '/models': false, 58 | '/models/{model}': false, 59 | '/moderations': false, 60 | '/assistants': true, 61 | '/assistants/{assistant_id}': true, 62 | '/threads': true, 63 | '/threads/{thread_id}': true, 64 | '/threads/{thread_id}/messages': true, 65 | '/threads/{thread_id}/messages/{message_id}': true, 66 | '/threads/runs': true, 67 | '/threads/{thread_id}/runs': true, 68 | '/threads/{thread_id}/runs/{run_id}': true, 69 | '/threads/{thread_id}/runs/{run_id}/submit_tool_outputs': true, 70 | '/threads/{thread_id}/runs/{run_id}/cancel': true, 71 | '/threads/{thread_id}/runs/{run_id}/steps': true, 72 | '/threads/{thread_id}/runs/{run_id}/steps/{step_id}': true, 73 | '/assistants/{assistant_id}/files': true, 74 | '/assistants/{assistant_id}/files/{file_id}': true, 75 | '/threads/{thread_id}/messages/{message_id}/files': true, 76 | '/threads/{thread_id}/messages/{message_id}/files/{file_id}': true 77 | } 78 | 79 | async function main() { 80 | const parser = new SwaggerParser() 81 | const spec = (await parser.bundle(srcFile)) as OpenAPIV3.Document 82 | 83 | if (spec.openapi !== '3.0.0') { 84 | console.error(`Unexpected OpenAI OpenAPI version "${spec.openapi}"`) 85 | console.error('The OpenAI API likely received a major update.') 86 | process.exit(1) 87 | } 88 | 89 | const pathsToProcess: string[] = [] 90 | 91 | for (const path in spec.paths) { 92 | // if (!path.startsWith('/threads/{thread_id}/messages')) continue // TODO 93 | 94 | const openaiOpenAPIPath = openaiOpenAPIPaths[path] 95 | if (openaiOpenAPIPath === undefined) { 96 | console.error(`Unexpected OpenAI OpenAPI path: ${path}`) 97 | console.error('The OpenAI API likely received a major update.') 98 | process.exit(1) 99 | } 100 | 101 | if (openaiOpenAPIPath) { 102 | pathsToProcess.push(path) 103 | } 104 | } 105 | 106 | for (const path of Object.keys(openaiOpenAPIPaths)) { 107 | if (!spec.paths[path]) { 108 | console.error(`Missing expected OpenAI OpenAPI path: ${path}`) 109 | console.error('The OpenAI API likely received a major update.') 110 | process.exit(1) 111 | } 112 | } 113 | 114 | // console.log(pathsToProcess) 115 | 116 | const componentsToProcess = new Set() 117 | const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore()) 118 | 119 | const subpaths = [ 120 | ['responses', '200', 'content', jsonContentType, 'schema'], 121 | ['requestBody', 'content', jsonContentType, 'schema'], 122 | ['requestBody', 'content', multipartFormData, 'schema'] 123 | ] 124 | 125 | for (const path of pathsToProcess) { 126 | const pathItem = spec.paths[path] 127 | if (!pathItem) { 128 | throw new Error() 129 | } 130 | 131 | // console.log(JSON.stringify(pathItem, null, 2)) 132 | 133 | const httpMethods = Object.keys(pathItem) 134 | for (const httpMethod of httpMethods) { 135 | const operation = pathItem[httpMethod] 136 | 137 | for (const subpath of subpaths) { 138 | const resolved = new Set() 139 | getAndResolve(operation, subpath, parser.$refs, resolved) 140 | 141 | for (const ref of resolved) { 142 | componentsToProcess.add(ref) 143 | } 144 | } 145 | 146 | if (operation.parameters) { 147 | const name = `${titleCase(operation.operationId)}Params` 148 | const params = convertParametersToJSONSchema(operation.parameters) 149 | 150 | if (params.body) { 151 | const schema = JSON.stringify( 152 | dereference(params.body, parser.$refs), 153 | null, 154 | 2 155 | ) 156 | // console.log(name, 'body', schema) 157 | await schemaInput.addSource({ 158 | name: `${name}Body`, 159 | schema 160 | }) 161 | } 162 | 163 | if (params.formData) { 164 | const schema = JSON.stringify( 165 | dereference(params.formData, parser.$refs), 166 | null, 167 | 2 168 | ) 169 | // console.log(name, 'formData', schema) 170 | await schemaInput.addSource({ 171 | name: `${name}FormData`, 172 | schema 173 | }) 174 | } 175 | 176 | if (params.headers) { 177 | const schema = JSON.stringify( 178 | dereference(params.headers, parser.$refs), 179 | null, 180 | 2 181 | ) 182 | // console.log(name, 'headers', schema) 183 | await schemaInput.addSource({ 184 | name: `${name}Headers`, 185 | schema 186 | }) 187 | } 188 | 189 | if (params.path) { 190 | const schema = JSON.stringify( 191 | dereference(params.path, parser.$refs), 192 | null, 193 | 2 194 | ) 195 | // console.log(name, 'path', schema) 196 | await schemaInput.addSource({ 197 | name: `${name}Path`, 198 | schema 199 | }) 200 | } 201 | 202 | if (params.query) { 203 | const schema = JSON.stringify( 204 | dereference(params.query, parser.$refs), 205 | null, 206 | 2 207 | ) 208 | // console.log(name, 'query', schema) 209 | await schemaInput.addSource({ 210 | name: `${name}Query`, 211 | schema 212 | }) 213 | } 214 | } 215 | } 216 | } 217 | 218 | const componentNames = Array.from(componentsToProcess) 219 | .map((ref) => ref.split('/').pop()) 220 | .sort() 221 | console.log(componentNames) 222 | console.log() 223 | 224 | const proccessedComponents = new Set() 225 | const componentToRefs: Record< 226 | string, 227 | { dereferenced: any; refs: Set } 228 | > = {} 229 | 230 | for (const ref of componentsToProcess) { 231 | const component = parser.$refs.get(ref) 232 | if (!component) continue // TODO 233 | 234 | const resolved = new Set() 235 | const dereferenced = dereference(component, parser.$refs, resolved) 236 | if (!dereferenced) continue // TODO 237 | 238 | componentToRefs[ref] = { dereferenced, refs: resolved } 239 | } 240 | 241 | const sortedComponents = Object.keys(componentToRefs).sort( 242 | (a, b) => componentToRefs[b].refs.size - componentToRefs[a].refs.size 243 | ) 244 | 245 | for (const ref of sortedComponents) { 246 | const name = ref.split('/').pop()! 247 | if (!name) throw new Error() 248 | 249 | const { dereferenced, refs } = componentToRefs[ref] 250 | if (proccessedComponents.has(ref)) { 251 | continue 252 | } 253 | 254 | for (const r of refs) { 255 | if (proccessedComponents.has(r)) { 256 | continue 257 | } 258 | 259 | proccessedComponents.add(r) 260 | } 261 | 262 | proccessedComponents.add(ref) 263 | 264 | // console.log(ref, name, dereferenced) 265 | await schemaInput.addSource({ 266 | name, 267 | schema: JSON.stringify(dereferenced, null, 2) 268 | }) 269 | } 270 | 271 | const inputData = new InputData() 272 | inputData.addInput(schemaInput) 273 | 274 | const generatedSource = await quicktype({ 275 | inputData, 276 | lang: 'TypeScript Zod' 277 | // combineClasses: true 278 | }) 279 | 280 | const schemasSource = [header] 281 | .concat(generatedSource.lines) 282 | .join('\n') 283 | .replace( 284 | 'import * as z from "zod"', 285 | "import { z } from '@hono/zod-openapi'" 286 | ) 287 | .replaceAll(/OpenAi/g, 'OpenAI') 288 | // remove inferred types, as we don't use them 289 | // .replaceAll(/^.* = z.infer<[^>]*>;?$/gm, '') 290 | .trim() 291 | 292 | const prettySchemasSource0 = prettify(schemasSource) 293 | // simplify a lot of the unnecessary nullable unions 294 | .replaceAll(/z\s*\.union\(\[\s*z\.null\(\),\s*([^\]]*)\s*\]\)/gm, '$1') 295 | .replaceAll(/z\s*\.union\(\[\s*([^,]*),\s*z\.null\(\)\s*\]\)/gm, '$1') 296 | // replace single value enums with literals 297 | .replaceAll(/z\s*\.enum\(\[\s*('[^']*')\s*\]\)/gm, 'z.literal($1)') 298 | // temporary bug fix for zod-openapi not recognizing numbers in query params 299 | .replaceAll('limit: z.number().optional()', 'limit: z.string().optional()') 300 | // fix for [CreateFileRequestSchema](https://github.com/openai/openai-openapi/issues/123) 301 | .replace(/\bfile: z.string\(\)/, 'file: z.any()') 302 | 303 | const prettySchemasSource = prettify(prettySchemasSource0) 304 | await fs.writeFile(destFileSchemas, prettySchemasSource) 305 | 306 | // --------------------------------------------------------------------------- 307 | 308 | // QuickType will sometimes rename schemas for various reasons, so we need to 309 | // keep these identifiers in order to resolve them later. 310 | const renamedSchemas = new Set( 311 | prettySchemasSource 312 | .match(/export const (.*)ClassSchema =/g) 313 | ?.map((line) => line.replace(/export const (.*)ClassSchema =/, '$1')) 314 | ) 315 | 316 | function resolveSchemaName(name: string) { 317 | return renamedSchemas.has(name) ? `${name}ClassSchema` : `${name}Schema` 318 | } 319 | 320 | // NOTE: We're looping through the paths a second time here to handle route 321 | // info, because we can only do so after we've resolved the generated schema 322 | // names via QuickType. 323 | const routesOutput: string[] = [] 324 | 325 | for (const path of pathsToProcess) { 326 | const pathItem = spec.paths[path] 327 | if (!pathItem) { 328 | throw new Error() 329 | } 330 | 331 | const httpMethods = Object.keys(pathItem) 332 | for (const httpMethod of httpMethods) { 333 | const operation = pathItem[httpMethod] 334 | const createRouteParams: any = { 335 | method: httpMethod, 336 | path, 337 | summary: operation.summary, 338 | description: operation.description, 339 | request: {}, 340 | responses: {} 341 | } 342 | 343 | for (const subpath of subpaths) { 344 | const resolved = new Set() 345 | const resolvedOperation = getAndResolve( 346 | operation, 347 | subpath, 348 | parser.$refs, 349 | resolved 350 | ) 351 | 352 | if (!resolvedOperation || !resolved.size) continue 353 | const ref = Array.from(resolved)[0] 354 | const shortName = ref.split('/').pop()! 355 | const name = resolveSchemaName(shortName) 356 | 357 | if (subpath[0] === 'responses') { 358 | createRouteParams.responses = { 359 | ...createRouteParams.responses, 360 | [subpath[1]]: { 361 | description: resolvedOperation.responses[subpath[1]].description, 362 | [subpath[2]]: { 363 | [subpath[3]]: { 364 | [subpath[4]]: `oai.${name}.openapi('${shortName}')` 365 | } 366 | } 367 | } 368 | } 369 | } else if (subpath[0] === 'requestBody') { 370 | createRouteParams.request.body = { 371 | description: resolvedOperation.requestBody.description, 372 | required: resolvedOperation.requestBody.required, 373 | [subpath[1]]: { 374 | [subpath[2]]: { 375 | [subpath[3]]: `oai.${name}.openapi('${shortName}')` 376 | } 377 | } 378 | } 379 | } 380 | } 381 | 382 | if (operation.parameters) { 383 | const namePrefix = `${titleCase(operation.operationId)}Params` 384 | const params = convertParametersToJSONSchema(operation.parameters) 385 | 386 | if (params.body) { 387 | const schema = JSON.stringify( 388 | dereference(params.body, parser.$refs), 389 | null, 390 | 2 391 | ) 392 | // console.log(namePrefix, 'body', schema) 393 | await schemaInput.addSource({ 394 | name: `${namePrefix}Body`, 395 | schema 396 | }) 397 | 398 | const name = resolveSchemaName(`${namePrefix}Body`) 399 | createRouteParams.request.body = `oai.${name}` 400 | } 401 | 402 | if (params.formData) { 403 | const schema = JSON.stringify( 404 | dereference(params.formData, parser.$refs), 405 | null, 406 | 2 407 | ) 408 | // console.log(namePrefix, 'formData', schema) 409 | await schemaInput.addSource({ 410 | name: `${namePrefix}FormData`, 411 | schema 412 | }) 413 | 414 | // TODO: this seems unsupported in zod-to-openapi 415 | // const name = resolveSchemaName(`${namePrefix}FormData`) 416 | // createRouteParams.request.body = `oai.${name}` 417 | } 418 | 419 | if (params.headers) { 420 | const schema = JSON.stringify( 421 | dereference(params.headers, parser.$refs), 422 | null, 423 | 2 424 | ) 425 | // console.log(namePrefix, 'headers', schema) 426 | await schemaInput.addSource({ 427 | name: `${namePrefix}Headers`, 428 | schema 429 | }) 430 | 431 | const name = resolveSchemaName(`${namePrefix}Headers`) 432 | createRouteParams.request.headers = `oai.${name}` 433 | } 434 | 435 | if (params.path) { 436 | const schema = JSON.stringify( 437 | dereference(params.path, parser.$refs), 438 | null, 439 | 2 440 | ) 441 | // console.log(namePrefix, 'path', schema) 442 | await schemaInput.addSource({ 443 | name: `${namePrefix}Path`, 444 | schema 445 | }) 446 | 447 | const name = resolveSchemaName(`${namePrefix}Path`) 448 | createRouteParams.request.params = `oai.${name}` 449 | } 450 | 451 | if (params.query) { 452 | const schema = JSON.stringify( 453 | dereference(params.query, parser.$refs), 454 | null, 455 | 2 456 | ) 457 | // console.log(namePrefix, 'query', schema) 458 | await schemaInput.addSource({ 459 | name: `${namePrefix}Query`, 460 | schema 461 | }) 462 | 463 | const name = resolveSchemaName(`${namePrefix}Query`) 464 | createRouteParams.request.query = `oai.${name}` 465 | } 466 | } 467 | 468 | const routeOutput = `export const ${ 469 | operation.operationId 470 | } = createRoute(${JSON.stringify(createRouteParams, null, 2)})` 471 | .replaceAll(/"(oai\.[^"]*)"/g, '$1') 472 | .replaceAll(/'(oai\.[^']*)'/g, '$1') 473 | 474 | // ListFilesParamsQuery => ListFilesParamsQueryClassSchema 475 | routesOutput.push(routeOutput) 476 | } 477 | } 478 | 479 | const routesSource = [ 480 | header, 481 | "import { createRoute } from '@hono/zod-openapi'", 482 | "import * as oai from './oai'" 483 | ] 484 | .concat(routesOutput) 485 | .join('\n\n') 486 | const prettyRoutesSource = prettify(routesSource) 487 | await fs.writeFile(destfileRoutes, prettyRoutesSource) 488 | } 489 | 490 | function prettify(source: string): string { 491 | return prettier.format(source, { 492 | parser: 'typescript', 493 | semi: false, 494 | singleQuote: true, 495 | jsxSingleQuote: true, 496 | bracketSpacing: true, 497 | bracketSameLine: false, 498 | arrowParens: 'always', 499 | trailingComma: 'none' 500 | }) 501 | } 502 | 503 | function titleCase(identifier: string): string { 504 | return `${identifier.slice(0, 1).toUpperCase()}${identifier.slice(1)}` 505 | } 506 | 507 | function getAndResolve( 508 | obj: any, 509 | keys: string[], 510 | refs: SwaggerParser.$Refs, 511 | resolved?: Set 512 | ): T | null { 513 | if (obj === undefined) return null 514 | if (typeof obj !== 'object') return null 515 | if (obj.$ref) { 516 | const derefed = refs.get(obj.$ref) 517 | resolved?.add(obj.$ref) 518 | if (!derefed) { 519 | return null 520 | } 521 | obj = derefed 522 | } 523 | 524 | if (!keys.length) { 525 | return dereference(obj, refs, resolved) as T 526 | } 527 | 528 | const key = keys[0] 529 | const value = obj[key] 530 | keys = keys.slice(1) 531 | if (value === undefined) { 532 | return null 533 | } 534 | 535 | const resolvedValue = getAndResolve(value, keys, refs, resolved) 536 | return { 537 | ...obj, 538 | [key]: resolvedValue 539 | } 540 | } 541 | 542 | function dereference( 543 | obj: T, 544 | refs: SwaggerParser.$Refs, 545 | resolved?: Set 546 | ): T { 547 | if (!obj) return obj 548 | 549 | if (Array.isArray(obj)) { 550 | return obj.map((item) => dereference(item, refs, resolved)) as T 551 | } else if (typeof obj === 'object') { 552 | if ('$ref' in obj) { 553 | const ref = obj.$ref as string 554 | const derefed = refs.get(ref as string) 555 | if (!derefed) { 556 | return obj 557 | } 558 | resolved?.add(ref) 559 | derefed.title = ref.split('/').pop()! 560 | return dereference(derefed, refs, resolved) 561 | } else { 562 | return Object.fromEntries( 563 | Object.entries(obj).map(([key, value]) => [ 564 | key, 565 | dereference(value, refs, resolved) 566 | ]) 567 | ) as T 568 | } 569 | } else { 570 | return obj 571 | } 572 | } 573 | 574 | main() 575 | -------------------------------------------------------------------------------- /e2e/index.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | 3 | import { createAIFunction } from '@dexaai/dexter/prompt' 4 | import { sha256 } from 'crypto-hash' 5 | import delay from 'delay' 6 | import 'dotenv/config' 7 | import OpenAI from 'openai' 8 | import { oraPromise } from 'ora' 9 | import pMap from 'p-map' 10 | import plur from 'plur' 11 | import { z } from 'zod' 12 | 13 | import type { Run } from '~/lib/db' 14 | 15 | /** 16 | * This file contains an end-to-end Assistants example using an external 17 | * `get_weather` function. 18 | * 19 | * To run it against the offical OpenAI API: 20 | * ```bash 21 | * npx tsx e2e 22 | * ``` 23 | * 24 | * To run it against your custom, local API: 25 | * ```bash 26 | * OPENAI_API_BASE_URL='http://127.0.0.1:3000' npx tsx e2e 27 | * ``` 28 | */ 29 | async function main() { 30 | const defaultBaseUrl = 'https://api.openai.com/v1' 31 | const baseUrl = process.env.OPENAI_API_BASE_URL ?? defaultBaseUrl 32 | const isOfficalAPI = baseUrl === defaultBaseUrl 33 | const testId = 34 | process.env.TEST_ID ?? 35 | `test_${(await sha256(Date.now().toString())).slice(0, 24)}` 36 | const metadata = { testId, isOfficalAPI } 37 | const cleanupTest = !process.env.NO_TEST_CLEANUP 38 | 39 | console.log('baseUrl', baseUrl) 40 | console.log('testId', testId) 41 | console.log() 42 | 43 | const openai = new OpenAI({ 44 | baseURL: baseUrl 45 | }) 46 | 47 | const getWeather = createAIFunction( 48 | { 49 | name: 'get_weather', 50 | description: 'Gets the weather for a given location', 51 | argsSchema: z.object({ 52 | location: z 53 | .string() 54 | .describe('The city and state e.g. San Francisco, CA'), 55 | unit: z 56 | .enum(['c', 'f']) 57 | .optional() 58 | .default('f') 59 | .describe('The unit of temperature to use') 60 | }) 61 | }, 62 | // Fake weather API implementation which returns a random temperature 63 | // after a short delay 64 | async function getWeather(args) { 65 | await delay(500) 66 | 67 | return { 68 | location: args.location, 69 | unit: args.unit, 70 | temperature: (Math.random() * 100) | 0 71 | } 72 | } 73 | ) 74 | 75 | let assistant: Awaited< 76 | ReturnType 77 | > | null = null 78 | let thread: Awaited> | null = 79 | null 80 | 81 | try { 82 | assistant = await openai.beta.assistants.create({ 83 | name: `test ${testId}`, 84 | model: 'gpt-4-1106-preview', 85 | instructions: 'You are a helpful assistant.', 86 | metadata, 87 | tools: [ 88 | { 89 | type: 'function', 90 | function: getWeather.spec 91 | } 92 | ] 93 | }) 94 | assert(assistant) 95 | console.log('created assistant', assistant) 96 | 97 | thread = await openai.beta.threads.create({ 98 | metadata, 99 | messages: [ 100 | { 101 | role: 'user', 102 | content: 'What is the weather in San Francisco today?', 103 | metadata 104 | } 105 | ] 106 | }) 107 | assert(thread) 108 | console.log('created thread', thread) 109 | 110 | let listMessages = await openai.beta.threads.messages.list(thread.id) 111 | assert(listMessages?.data) 112 | console.log('messages', prettifyMessages(listMessages.data)) 113 | 114 | let run = await openai.beta.threads.runs.create(thread.id, { 115 | assistant_id: assistant.id, 116 | metadata, 117 | instructions: assistant.instructions, 118 | model: assistant.model, 119 | tools: assistant.tools 120 | }) 121 | assert(run?.id) 122 | console.log('created run', run) 123 | 124 | let listRunSteps = await openai.beta.threads.runs.steps.list( 125 | thread.id, 126 | run.id 127 | ) 128 | assert(listRunSteps?.data) 129 | console.log('runSteps', listRunSteps.data) 130 | 131 | async function waitForRunStatus( 132 | status: Run['status'], 133 | { intervalMs = 500 }: { intervalMs?: number } = {} 134 | ) { 135 | assert(run?.id) 136 | 137 | return oraPromise(async () => { 138 | while (run.status !== status) { 139 | await delay(intervalMs) 140 | 141 | assert(thread?.id) 142 | assert(run?.id) 143 | 144 | run = await openai.beta.threads.runs.retrieve(thread.id, run.id) 145 | 146 | assert(run?.id) 147 | } 148 | }, `waiting for run "${run.id}" to have status "${status}"...`) 149 | } 150 | 151 | await waitForRunStatus('requires_action') 152 | console.log('run', run) 153 | 154 | listRunSteps = await openai.beta.threads.runs.steps.list(thread.id, run.id) 155 | assert(listRunSteps?.data) 156 | console.log('runSteps', listRunSteps.data) 157 | 158 | if (run.status !== 'requires_action') { 159 | throw new Error( 160 | `run "${run.id}" status expected to be "requires_action"; found "${run.status}"` 161 | ) 162 | } 163 | 164 | if (!run.required_action) { 165 | throw new Error( 166 | `run "${run.id}" expected to have "required_action"; none found` 167 | ) 168 | } 169 | 170 | if (run.required_action.type !== 'submit_tool_outputs') { 171 | throw new Error( 172 | `run "${run.id}" expected to have "required_action.type" of "submit_tool_outputs; found "${run.required_action.type}"` 173 | ) 174 | } 175 | 176 | if (!run.required_action.submit_tool_outputs?.tool_calls?.length) { 177 | throw new Error( 178 | `run "${run.id}" expected to have non-empty "required_action.submit_tool_outputs"` 179 | ) 180 | } 181 | 182 | // Resolve tool calls 183 | const toolCalls = run.required_action.submit_tool_outputs.tool_calls 184 | 185 | const toolOutputs = await oraPromise( 186 | pMap( 187 | toolCalls, 188 | async (toolCall) => { 189 | if (toolCall.type !== 'function') { 190 | throw new Error( 191 | `run "${run.id}" invalid submit_tool_outputs tool_call type "${toolCall.type}"` 192 | ) 193 | } 194 | 195 | if (!toolCall.function) { 196 | throw new Error( 197 | `run "${run.id}" invalid submit_tool_outputs tool_call function"` 198 | ) 199 | } 200 | 201 | if (toolCall.function.name !== getWeather.spec.name) { 202 | throw new Error( 203 | `run "${run.id}" invalid submit_tool_outputs tool_call function name "${toolCall.function.name}"` 204 | ) 205 | } 206 | 207 | const toolCallResult = await getWeather(toolCall.function.arguments) 208 | return { 209 | output: JSON.stringify(toolCallResult), 210 | tool_call_id: toolCall.id 211 | } 212 | }, 213 | { concurrency: 4 } 214 | ), 215 | `run "${run.id}" resolving ${toolCalls.length} tool ${plur( 216 | 'call', 217 | toolCalls.length 218 | )}` 219 | ) 220 | 221 | console.log(`submitting tool outputs for run "${run.id}"`, toolOutputs) 222 | run = await openai.beta.threads.runs.submitToolOutputs(thread.id, run.id, { 223 | tool_outputs: toolOutputs 224 | }) 225 | assert(run) 226 | console.log('run', run) 227 | 228 | listRunSteps = await openai.beta.threads.runs.steps.list(thread.id, run.id) 229 | assert(listRunSteps?.data) 230 | console.log('runSteps', listRunSteps.data) 231 | 232 | await waitForRunStatus('completed') 233 | console.log('run', run) 234 | 235 | listRunSteps = await openai.beta.threads.runs.steps.list(thread.id, run.id) 236 | assert(listRunSteps?.data) 237 | console.log('runSteps', listRunSteps.data) 238 | 239 | thread = await openai.beta.threads.retrieve(thread.id) 240 | assert(thread) 241 | console.log('thread', thread) 242 | 243 | listMessages = await openai.beta.threads.messages.list(thread.id) 244 | assert(listMessages?.data) 245 | console.log('messages', prettifyMessages(listMessages.data)) 246 | } catch (err) { 247 | console.error(err) 248 | process.exit(1) 249 | } finally { 250 | if (cleanupTest) { 251 | // TODO: there's no way to delete messages, runs, or run steps... 252 | // maybe deleting the thread implicitly causes a cascade of deletes? 253 | // TODO: test this assumption 254 | if (thread?.id) { 255 | await openai.beta.threads.del(thread.id) 256 | } 257 | 258 | if (assistant?.id) { 259 | await openai.beta.assistants.del(assistant.id) 260 | } 261 | } 262 | } 263 | } 264 | 265 | // Make message content easier to read in the console 266 | function prettifyMessages(messages: any[]) { 267 | return messages.map((message) => ({ 268 | ...message, 269 | content: message.content?.[0]?.text?.value ?? message.content 270 | })) 271 | } 272 | 273 | main() 274 | -------------------------------------------------------------------------------- /e2e/retrieval.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import fs from 'node:fs' 3 | 4 | import { sha256 } from 'crypto-hash' 5 | import delay from 'delay' 6 | import 'dotenv/config' 7 | import OpenAI from 'openai' 8 | import { oraPromise } from 'ora' 9 | 10 | import type { Run } from '~/lib/db' 11 | 12 | /** 13 | * This file contains an end-to-end Assistants example using the built-in 14 | * `retrieval` tool which summarizes an attached markdown file. 15 | * 16 | * To run it against the offical OpenAI API: 17 | * ```bash 18 | * npx tsx e2e/retrieval.ts 19 | * ``` 20 | * 21 | * To run it against your custom, local API: 22 | * ```bash 23 | * OPENAI_API_BASE_URL='http://127.0.0.1:3000' npx tsx e2e/retrieval.ts 24 | * ``` 25 | */ 26 | async function main() { 27 | const defaultBaseUrl = 'https://api.openai.com/v1' 28 | const baseUrl = process.env.OPENAI_API_BASE_URL ?? defaultBaseUrl 29 | const isOfficalAPI = baseUrl === defaultBaseUrl 30 | const testId = 31 | process.env.TEST_ID ?? 32 | `test_${(await sha256(Date.now().toString())).slice(0, 24)}` 33 | const metadata = { testId, isOfficalAPI } 34 | const cleanupTest = !process.env.NO_TEST_CLEANUP 35 | 36 | console.log('baseUrl', baseUrl) 37 | console.log('testId', testId) 38 | console.log() 39 | 40 | const openai = new OpenAI({ 41 | baseURL: baseUrl 42 | }) 43 | 44 | let assistant: Awaited< 45 | ReturnType 46 | > | null = null 47 | let thread: Awaited> | null = 48 | null 49 | 50 | const readmeFileStream = fs.createReadStream('readme.md', 'utf8') 51 | 52 | const readmeFile = await openai.files.create({ 53 | file: readmeFileStream, 54 | purpose: 'assistants' 55 | }) 56 | console.log('created readme file', readmeFile) 57 | 58 | try { 59 | assistant = await openai.beta.assistants.create({ 60 | model: 'gpt-4-1106-preview', 61 | instructions: 'You are a helpful assistant.', 62 | metadata, 63 | tools: [ 64 | { 65 | type: 'retrieval' 66 | } 67 | ], 68 | file_ids: [readmeFile.id] 69 | }) 70 | assert(assistant) 71 | console.log('created assistant', assistant) 72 | 73 | thread = await openai.beta.threads.create({ 74 | metadata, 75 | messages: [ 76 | { 77 | role: 'user', 78 | content: 79 | 'Give me a concise summary of the attached file using markdown.', 80 | metadata 81 | } 82 | ] 83 | }) 84 | assert(thread) 85 | console.log('created thread', thread) 86 | 87 | let listMessages = await openai.beta.threads.messages.list(thread.id) 88 | assert(listMessages?.data) 89 | console.log('messages', prettifyMessages(listMessages.data)) 90 | 91 | let run = await openai.beta.threads.runs.create(thread.id, { 92 | assistant_id: assistant.id, 93 | metadata, 94 | instructions: assistant.instructions, 95 | model: assistant.model, 96 | tools: assistant.tools 97 | }) 98 | assert(run?.id) 99 | console.log('created run', run) 100 | 101 | let listRunSteps = await openai.beta.threads.runs.steps.list( 102 | thread.id, 103 | run.id 104 | ) 105 | assert(listRunSteps?.data) 106 | console.log('runSteps', listRunSteps.data) 107 | 108 | async function waitForRunStatus( 109 | status: Run['status'], 110 | { intervalMs = 500 }: { intervalMs?: number } = {} 111 | ) { 112 | assert(run?.id) 113 | 114 | return oraPromise(async () => { 115 | while (run.status !== status) { 116 | if ( 117 | status !== run.status && 118 | (run.status === 'cancelled' || 119 | run.status === 'cancelling' || 120 | run.status === 'failed' || 121 | run.status === 'expired') 122 | ) { 123 | throw new Error( 124 | `Error run "${run.id}" status reached terminal status "${run.status}" while waiting for status "${status}"` 125 | ) 126 | } 127 | 128 | await delay(intervalMs) 129 | 130 | assert(thread?.id) 131 | assert(run?.id) 132 | 133 | run = await openai.beta.threads.runs.retrieve(thread.id, run.id) 134 | 135 | assert(run?.id) 136 | } 137 | }, `waiting for run "${run.id}" to have status "${status}"...`) 138 | } 139 | 140 | await waitForRunStatus('completed') 141 | console.log('run', run) 142 | 143 | listRunSteps = await openai.beta.threads.runs.steps.list(thread.id, run.id) 144 | assert(listRunSteps?.data) 145 | console.log('runSteps', listRunSteps.data) 146 | 147 | thread = await openai.beta.threads.retrieve(thread.id) 148 | assert(thread) 149 | console.log('thread', thread) 150 | 151 | listMessages = await openai.beta.threads.messages.list(thread.id) 152 | assert(listMessages?.data) 153 | console.log('messages', prettifyMessages(listMessages.data)) 154 | } catch (err) { 155 | console.error(err) 156 | process.exit(1) 157 | } finally { 158 | if (cleanupTest) { 159 | // TODO: there's no way to delete messages, runs, or run steps... 160 | // maybe deleting the thread implicitly causes a cascade of deletes? 161 | // TODO: test this assumption 162 | if (thread?.id) { 163 | await openai.beta.threads.del(thread.id) 164 | } 165 | 166 | if (assistant?.id) { 167 | await openai.beta.assistants.del(assistant.id) 168 | } 169 | } 170 | } 171 | } 172 | 173 | // Make message content easier to read in the console 174 | function prettifyMessages(messages: any[]) { 175 | return messages.map((message) => ({ 176 | ...message, 177 | content: message.content?.[0]?.text?.value ?? message.content 178 | })) 179 | } 180 | 181 | main() 182 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Travis Fischer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /media/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/transitive-bullshit/OpenOpenAI/5b6a2a19cadcd3e43013299c53e17f45d54f58ca/media/screenshot.jpg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "openopenai", 3 | "private": true, 4 | "description": "Self-hosted version of OpenAI's new stateful Assistants API.", 5 | "author": "Travis Fischer ", 6 | "repository": "transitive-bullshit/OpenOpenAI", 7 | "license": "MIT", 8 | "type": "module", 9 | "engines": { 10 | "node": ">=18" 11 | }, 12 | "packageManager": "pnpm@10.6.5", 13 | "scripts": { 14 | "build": "tsc --project tsconfig.dist.json", 15 | "postbuild": "resolve-tspaths --project tsconfig.dist.json", 16 | "prebuild": "run-s clean generate", 17 | "clean": "del dist", 18 | "dev": "tsc --watch", 19 | "prepare": "husky install", 20 | "precommit": "lint-staged", 21 | "release": "np", 22 | "generate": "prisma generate", 23 | "pretest": "run-s generate", 24 | "test": "run-p test:*", 25 | "test:format": "prettier --check \"**/*.{js,ts,tsx}\"", 26 | "test:lint": "eslint .", 27 | "test:unit": "dotenv -e .env -- vitest", 28 | "test:typecheck": "tsc --noEmit" 29 | }, 30 | "dependencies": { 31 | "@aws-sdk/client-s3": "^3.449.0", 32 | "@dexaai/dexter": "^1.1.0", 33 | "@fastify/deepmerge": "^1.3.0", 34 | "@hono/node-server": "^1.2.0", 35 | "@hono/zod-openapi": "^0.8.3", 36 | "@prisma/client": "^5.5.2", 37 | "bullmq": "^4.13.2", 38 | "crypto-hash": "^3.0.0", 39 | "exit-hook": "^4.0.0", 40 | "file-type": "^18.7.0", 41 | "hono": "^3.9.2", 42 | "http-errors": "^2.0.0", 43 | "human-signals": "^6.0.0", 44 | "p-map": "^6.0.0", 45 | "zod": "^3.22.4" 46 | }, 47 | "devDependencies": { 48 | "@apidevtools/swagger-parser": "^10.1.0", 49 | "@dexaai/eslint-config": "^0.4.0", 50 | "@total-typescript/ts-reset": "^0.5.1", 51 | "@trivago/prettier-plugin-sort-imports": "^4.2.1", 52 | "@types/http-errors": "^2.0.4", 53 | "@types/node": "^20.9.0", 54 | "del-cli": "^5.1.0", 55 | "delay": "^6.0.0", 56 | "dotenv": "^16.3.1", 57 | "dotenv-cli": "^7.3.0", 58 | "eslint": "^8.53.0", 59 | "husky": "^8.0.3", 60 | "lint-staged": "^15.0.2", 61 | "np": "^8.0.4", 62 | "npm-run-all": "^4.1.5", 63 | "openai": "^4.17.3", 64 | "openapi-jsonschema-parameters": "^12.1.3", 65 | "openapi-types": "^12.1.3", 66 | "ora": "^7.0.1", 67 | "plur": "^5.1.0", 68 | "prettier": "^2.8.8", 69 | "prisma": "^5.5.2", 70 | "prisma-json-types-generator": "^3.0.3", 71 | "quicktype-core": "^23.0.77", 72 | "resolve-tspaths": "^0.8.17", 73 | "tsx": "^4.0.0", 74 | "type-fest": "^4.7.1", 75 | "typescript": "^5.2.2", 76 | "vite": "^4.5.0", 77 | "vitest": "^0.34.6" 78 | }, 79 | "lint-staged": { 80 | "*.{ts,tsx}": [ 81 | "eslint --fix", 82 | "prettier --ignore-unknown --write" 83 | ] 84 | }, 85 | "keywords": [ 86 | "openai", 87 | "ai", 88 | "assistant", 89 | "gpt", 90 | "agent", 91 | "agi", 92 | "llms", 93 | "chatgpt" 94 | ] 95 | } 96 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "postgresql" 7 | url = env("DATABASE_URL") 8 | } 9 | 10 | generator json { 11 | // https://github.com/arthurfiorette/prisma-json-types-generator 12 | provider = "prisma-json-types-generator" 13 | } 14 | 15 | model File { 16 | id String @id @default(cuid()) 17 | created_at DateTime @default(now()) 18 | updated_at DateTime @updatedAt @ignore 19 | 20 | bytes Int 21 | filename String 22 | purpose String 23 | status FileStatus? 24 | status_details String? 25 | /// [FileObject] 26 | object String @default("file") 27 | 28 | @@index([purpose]) 29 | } 30 | 31 | model Assistant { 32 | id String @id @default(cuid()) 33 | created_at DateTime @default(now()) 34 | updated_at DateTime @updatedAt @ignore 35 | 36 | description String? 37 | file_ids String[] @default([]) 38 | instructions String? 39 | /// [Metadata] 40 | metadata Json? 41 | model String 42 | name String? 43 | /// [Tool] 44 | tools Json[] @default([]) 45 | /// [AssistantObject] 46 | object String @default("assistant") 47 | 48 | files AssistantFile[] 49 | messages Message[] 50 | runs Run[] 51 | run_steps RunStep[] 52 | } 53 | 54 | model AssistantFile { 55 | id String @id @default(cuid()) 56 | created_at DateTime @default(now()) 57 | updated_at DateTime @updatedAt @ignore 58 | 59 | assistant_id String 60 | /// [AssistantFileObject] 61 | object String @default("assistant.file") 62 | 63 | assistant Assistant @relation(fields: [assistant_id], references: [id], onDelete: Cascade) 64 | 65 | @@index([assistant_id, id]) 66 | } 67 | 68 | model Thread { 69 | id String @id @default(cuid()) 70 | created_at DateTime @default(now()) 71 | updated_at DateTime @updatedAt @ignore 72 | 73 | /// [Metadata] 74 | metadata Json? 75 | /// [ThreadObject] 76 | object String @default("thread") 77 | 78 | messages Message[] 79 | runs Run[] 80 | run_steps RunStep[] 81 | } 82 | 83 | model Message { 84 | id String @id @default(cuid()) 85 | created_at DateTime @default(now()) 86 | updated_at DateTime @updatedAt @ignore 87 | 88 | /// [MessageContent] 89 | content Json[] 90 | file_ids String[] @default([]) 91 | /// [Metadata] 92 | metadata Json? 93 | role Role 94 | assistant_id String? 95 | thread_id String 96 | // if this message was created during a run 97 | run_id String? 98 | /// [MessageObject] 99 | object String @default("thread.message") 100 | 101 | files MessageFile[] 102 | 103 | thread Thread @relation(fields: [thread_id], references: [id], onDelete: Cascade) 104 | assistant Assistant? @relation(fields: [assistant_id], references: [id], onDelete: Cascade) 105 | run Run? @relation(fields: [run_id], references: [id]) 106 | } 107 | 108 | model MessageFile { 109 | id String @id @default(cuid()) 110 | created_at DateTime @default(now()) 111 | updated_at DateTime @updatedAt @ignore 112 | 113 | message_id String 114 | /// [MessageFileObject] 115 | object String @default("thread.message.file") 116 | 117 | message Message @relation(fields: [message_id], references: [id], onDelete: Cascade) 118 | } 119 | 120 | model Run { 121 | id String @id @default(cuid()) 122 | created_at DateTime @default(now()) 123 | updated_at DateTime @updatedAt @ignore 124 | 125 | instructions String 126 | model String 127 | file_ids String[] @default([]) 128 | /// [Metadata] 129 | metadata Json? 130 | /// [LastError] 131 | last_error Json? 132 | /// [RequiredAction] 133 | required_action Json? 134 | /// [Tool] 135 | tools Json[] @default([]) 136 | status RunStatus 137 | started_at DateTime? 138 | completed_at DateTime? 139 | cancelled_at DateTime? 140 | expires_at DateTime? 141 | failed_at DateTime? 142 | assistant_id String 143 | thread_id String 144 | /// [RunObject] 145 | object String @default("thread.run") 146 | 147 | messages Message[] 148 | run_steps RunStep[] 149 | 150 | thread Thread @relation(fields: [thread_id], references: [id], onDelete: Cascade) 151 | assistant Assistant @relation(fields: [assistant_id], references: [id], onDelete: Cascade) 152 | } 153 | 154 | model RunStep { 155 | id String @id @default(cuid()) 156 | created_at DateTime @default(now()) 157 | updated_at DateTime @updatedAt @ignore 158 | 159 | /// [Metadata] 160 | metadata Json? 161 | /// [LastError] 162 | last_error Json? 163 | /// [StepDetails] 164 | step_details Json? 165 | status RunStepStatus 166 | type RunStepType 167 | completed_at DateTime? 168 | cancelled_at DateTime? 169 | expires_at DateTime? 170 | failed_at DateTime? 171 | assistant_id String 172 | thread_id String 173 | run_id String 174 | // if this step created a message 175 | message_id String? 176 | /// [RunStepObject] 177 | object String @default("thread.run.step") 178 | 179 | thread Thread @relation(fields: [thread_id], references: [id], onDelete: Cascade) 180 | assistant Assistant @relation(fields: [assistant_id], references: [id], onDelete: Cascade) 181 | run Run @relation(fields: [run_id], references: [id], onDelete: Cascade) 182 | 183 | @@index([run_id]) 184 | @@index([run_id, type]) 185 | } 186 | 187 | // TODO: currently unused 188 | // model ToolCall { 189 | // id String @id @default(cuid()) 190 | // created_at DateTime @default(now()) 191 | // updated_at DateTime @updatedAt @ignore 192 | 193 | // type ToolCallType 194 | // /// [RunToolCall] 195 | // function Json 196 | 197 | // assistant_id String 198 | // thread_id String 199 | // run_id String 200 | // } 201 | 202 | enum FileStatus { 203 | error 204 | processed 205 | uploaded 206 | } 207 | 208 | enum Role { 209 | assistant 210 | user 211 | system 212 | function 213 | tool 214 | } 215 | 216 | enum RunStatus { 217 | cancelled 218 | cancelling 219 | completed 220 | expired 221 | failed 222 | in_progress 223 | queued 224 | requires_action 225 | } 226 | 227 | enum RunStepStatus { 228 | cancelled 229 | completed 230 | expired 231 | failed 232 | in_progress 233 | } 234 | 235 | enum RunStepType { 236 | message_creation 237 | tool_calls 238 | } 239 | 240 | enum ToolCallType { 241 | function 242 | code_interpreter 243 | retrieval 244 | } 245 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # OpenOpenAI 2 | 3 |

4 | Example usage 5 |

6 | 7 |

8 | Build Status 9 | MIT License 10 | Prettier Code Formatting 11 |

12 | 13 | - [Intro](#intro) 14 | - [Why?](#why) 15 | - [Stack](#stack) 16 | - [Development](#development) 17 | - [Environment Variables](#environment-variables) 18 | - [Services](#services) 19 | - [E2E Examples](#e2e-examples) 20 | - [Custom Function Example](#custom-function-example) 21 | - [Retrieval Tool Example](#retrieval-tool-example) 22 | - [Server routes](#server-routes) 23 | - [TODO](#todo) 24 | - [License](#license) 25 | 26 | ## Intro 27 | 28 | **This project is a self-hosted version of OpenAI's new stateful Assistants API.** 💪 29 | 30 | All [API route definitions](./src/generated/oai-routes.ts) and [types](./src/generated/oai.ts) are **100% auto-generated** from OpenAI's official OpenAPI spec, so all it takes to switch between the official API and your custom API is changing the `baseURL`. 🤯 31 | 32 | This means that all API parameters, responses, and types are wire-compatible with the official OpenAI API, and the fact that they're auto-generated means that it will be relatively easy to keep them in sync over time. 33 | 34 | Here's an example using the official Node.js `openai` package: 35 | 36 | ```ts 37 | import OpenAI from 'openai' 38 | 39 | // The only difference is the `baseURL` pointing to your custom API server 🔥 40 | const openai = new OpenAI({ 41 | baseURL: 'http://localhost:3000' 42 | }) 43 | 44 | // Since the custom API is spec-compliant with OpenAI, you can use the sdk normally 💯 45 | const assistant = await openai.beta.assistants.create({ 46 | model: 'gpt-4-1106-preview', 47 | instructions: 'You are a helpful assistant.' 48 | }) 49 | ``` 50 | 51 |
52 | Python example 53 | 54 | Here's the same example using the official Python `openai` package: 55 | 56 | ```py 57 | from openai import OpenAI 58 | 59 | client = OpenAI( 60 | base_url: "http://localhost:3000" 61 | ) 62 | 63 | # Now you can use the sdk normally! 64 | # (only file and beta assistant resources are currently supported) 65 | # You can even switch back and forth between the official and custom APIs! 66 | assistant = client.beta.assistants.create( 67 | model="gpt-4-1106-preview", 68 | description="You are a helpful assistant." 69 | ) 70 | ``` 71 | 72 |
73 | 74 | Note that this project is not meant to be a full recreation of the entire OpenAI API. Rather, **it is focused only on the stateful portions of the new Assistants API**. The following resource types are supported: 75 | 76 | - Assistants 77 | - AssistantFiles 78 | - Files 79 | - Messages 80 | - MessageFiles 81 | - Threads 82 | - Runs 83 | - RunSteps 84 | 85 | See the official [OpenAI Assistants Guide](https://platform.openai.com/docs/assistants/how-it-works) for more info on how Assistants work. 86 | 87 | ## Why? 88 | 89 | Being able to run your own, custom OpenAI Assistants that are **100% compatible with the official OpenAI Assistants** unlocks all sorts of useful possibilities: 90 | 91 | - Using OpenAI Assistants with **custom models** (OSS ftw!) 💪 92 | - **Fully customizable RAG** via the built-in retrieval tool (LangChain and LlamaIndex integrations [coming soon](https://github.com/transitive-bullshit/OpenOpenAI/issues/2)) 93 | - Using a **custom code interpreter** like [open-interpreter](https://github.com/KillianLucas/open-interpreter) 🔥 94 | - **Self-hosting / on-premise** deployments of Assistants 95 | - Full control over **assistant evals** 96 | - Developing & testing GPTs in fully **sandboxed environments** 97 | - Sandboxed testing of **custom Actions** before deploying to the OpenAI "GPT Store" 98 | 99 | Most importantly, if the OpenAI "GPT Store" ends up gaining traction with ChatGPT's 100M weekly active users, then **the ability to reliably run, debug, and customize OpenAI-compatible Assistants** will end up being incredibly important in the future. 100 | 101 | I could even imagine a future Assistant store which is fully compatible with OpenAI's GPTs, but instead of relying on OpenAI as the gatekeeper, it could be **fully or partially decentralized**. 💯 102 | 103 | ## Stack 104 | 105 | - [Postgres](https://www.postgresql.org) - Primary datastore via [Prisma](https://www.prisma.io) ([schema file](./prisma/schema.prisma)) 106 | - [Redis](https://redis.io) - Backing store for the async task queue used to process thread runs via [BullMQ](https://bullmq.io) 107 | - [S3](https://aws.amazon.com/s3) - Stores uploaded files 108 | - Any S3-compatible storage provider is supported, such as [Cloudflare R2](https://developers.cloudflare.com/r2/) 109 | - [Hono](https://hono.dev) - Serves the REST API via [@hono/zod-openapi](https://github.com/honojs/middleware/tree/main/packages/zod-openapi) 110 | - We're using the [Node.js](https://hono.dev/getting-started/nodejs) adaptor by default, but Hono supports many environments including CF workers, Vercel, Netlify, Deno, Bun, Lambda, etc. 111 | - [Dexter](https://github.com/dexaai/dexter) - Production RAG by [Dexa](https://dexa.ai) 112 | - [TypeScript](https://www.typescriptlang.org) 💕 113 | 114 | ## Development 115 | 116 | Prerequisites: 117 | 118 | - [node](https://nodejs.org/en) >= 18 119 | - [pnpm](https://pnpm.io) >= 8 120 | 121 | Install deps: 122 | 123 | ```bash 124 | pnpm install 125 | ``` 126 | 127 | Generate the prisma types locally: 128 | 129 | ```bash 130 | pnpm generate 131 | ``` 132 | 133 | ### Environment Variables 134 | 135 | ```bash 136 | cp .env.example .env 137 | ``` 138 | 139 | - **Postgres** 140 | - `DATABASE_URL` - Postgres connection string 141 | - [On macOS](https://wiki.postgresql.org/wiki/Homebrew): `brew install postgresql && brew services start postgresql` 142 | - You'll need to run `npx prisma db push` to set up your database according to our [prisma schema](./prisma/schema.prisma) 143 | - **OpenAI** 144 | - `OPENAI_API_KEY` - OpenAI API key for running the underlying chat completion calls 145 | - This is required for now, but depending on [how interested people are](https://github.com/transitive-bullshit/OpenOpenAI/issues/1), it won't be hard to add support for local models and other providers 146 | - **Redis** 147 | - [On macOS](https://redis.io/docs/install/install-redis/install-redis-on-mac-os/): `brew install redis && brew services start redis` 148 | - If you have a local redis instance running, the default redis env vars should work without touching them 149 | - `REDIS_HOST` - Optional; defaults to `localhost` 150 | - `REDIS_PORT` - Optional; defaults to `6379` 151 | - `REDIS_USERNAME` - Optional; defaults to `default` 152 | - `REDIS_PASSWORD` - Optional 153 | - **S3** - Required to use file attachments 154 | - Any S3-compatible provider is supported, such as [Cloudflare R2](https://developers.cloudflare.com/r2/) 155 | - Alterantively, you can use a local S3 server like [MinIO](https://github.com/minio/minio#homebrew-recommended) or [LocalStack](https://github.com/localstack/localstack) 156 | - To run LocalStack on macOS: `brew install localstack/tap/localstack-cli && localstack start -d` 157 | - To run MinIO macOS: `brew install minio/stable/minio && minio server /data` 158 | - I recommend using Cloudflare R2, though – it's amazing and should be free for most use cases! 159 | - `S3_BUCKET` - Required 160 | - `S3_REGION` - Optional; defaults to `auto` 161 | - `S3_ENDPOINT` - Required; example: `https://.r2.cloudflarestorage.com` 162 | - `ACCESS_KEY_ID` - Required ([cloudflare R2 docs](https://developers.cloudflare.com/r2/api/s3/tokens/)) 163 | - `SECRET_ACCESS_KEY` - Required ([cloudflare R2 docs](https://developers.cloudflare.com/r2/api/s3/tokens/)) 164 | 165 | ### Services 166 | 167 | The app is composed of two services: a RESTful API **server** and an async task **runner**. Both services are stateless and can be scaled horizontally. 168 | 169 | There are two ways to run these services locally. The quickest way is via `tsx`: 170 | 171 | ```bash 172 | # Start the REST API server in one shell 173 | npx tsx src/server 174 | 175 | # Start an async task queue runner in another shell 176 | npx tsx src/runner 177 | ``` 178 | 179 | Alternatively, you can transpile the source TS to JS first, which is preferred for running in production: 180 | 181 | ```bash 182 | pnpm build 183 | 184 | # Start the REST API server in one shell 185 | npx tsx dist/server 186 | 187 | # Start an async task queue runner in another shell 188 | npx tsx dist/runner 189 | ``` 190 | 191 | ### E2E Examples 192 | 193 | #### Custom Function Example 194 | 195 | [This example](./e2e/index.ts) contains an end-to-end assistant script which uses a custom `get_weather` function. 196 | 197 | You can run it using the official [openai](https://github.com/openai/openai-node) client for Node.js against the default OpenAI API hosted at `https://api.openai.com/v1`. 198 | 199 | ```bash 200 | npx tsx e2e 201 | ``` 202 | 203 | To run the same test suite against your local API, you can run: 204 | 205 | ```bash 206 | OPENAI_API_BASE_URL='http://127.0.0.1:3000' npx tsx e2e 207 | ``` 208 | 209 | It's pretty cool to see both test suites running the exact same Assistants code using the official OpenAI Node.js client – without any noticeable differences between the two versions. Huzzah! 🥳 210 | 211 | #### Retrieval Tool Example 212 | 213 | [This example](./e2e/retrieval.ts) contains an end-to-end assistant script which uses the built-in `retrieval` tool with this `readme.md` file as an attachment. 214 | 215 | You can run it using the official [openai](https://github.com/openai/openai-node) client for Node.js against the default OpenAI API hosted at `https://api.openai.com/v1`. 216 | 217 | ```bash 218 | npx tsx e2e/retrieval.ts 219 | ``` 220 | 221 | To run the same test suite against your local API, you can run: 222 | 223 | ```bash 224 | OPENAI_API_BASE_URL='http://127.0.0.1:3000' npx tsx e2e/retrieval.ts 225 | ``` 226 | 227 | The output will likely differ slightly due to differences in OpenAI's built-in retrieval implementation and [our default, naive retrieval implementation](./src/lib/retrieval.ts). 228 | 229 | Note that the [current `retrieval` implementation](https://github.com/transitive-bullshit/OpenOpenAI/blob/main/src/lib/retrieval.ts) only support text files like `text/plain` and markdown, as no preprocessing or conversions are done at the moment. We also use a very naive retrieval method at the moment which always returns the full file contents as opposed to pre-processing them and only returning the most semantically relevant chunks. See [this issue](https://github.com/transitive-bullshit/OpenOpenAI/issues/2) for more info. 230 | 231 | ### Server routes 232 | 233 | ``` 234 | GET /files 235 | POST /files 236 | DELETE /files/:file_id 237 | GET /files/:file_id 238 | GET /files/:file_id/content 239 | GET /assistants 240 | POST /assistants 241 | GET /assistants/:assistant_id 242 | POST /assistants/:assistant_id 243 | DELETE /assistants/:assistant_id 244 | GET /assistants/:assistant_id/files 245 | GET /assistants/:assistant_id/files 246 | POST /assistants/:assistant_id/files 247 | DELETE /assistants/:assistant_id/files/:file_id 248 | GET /assistants/:assistant_id/files/:file_id 249 | POST /threads 250 | GET /threads/:thread_id 251 | POST /threads/:thread_id 252 | DELETE /threads/:thread_id 253 | GET /threads/:thread_id/messages 254 | POST /threads/:thread_id/messages 255 | GET /threads/:thread_id/messages/:message_id 256 | POST /threads/:thread_id/messages/:message_id 257 | GET /threads/:thread_id/messages/:message_id/files 258 | GET /threads/:thread_id/messages/:message_id/files/:file_id 259 | GET /threads/:thread_id/runs 260 | POST /threads/runs 261 | POST /threads/:thread_id/runs 262 | GET /threads/:thread_id/runs/:run_id 263 | POST /threads/:thread_id/runs/:run_id 264 | POST /threads/:thread_id/runs/:run_id/submit_tool_outputs 265 | POST /threads/:thread_id/runs/:run_id/cancel 266 | GET /threads/:thread_id/runs/:run_id/steps 267 | GET /threads/:thread_id/runs/:run_id/steps/:step_id 268 | GET /openapi 269 | ``` 270 | 271 | You can view the server's auto-generated openapi spec by running the server and then visiting `http://127.0.0.1:3000/openapi` 272 | 273 | ## TODO 274 | 275 | **Status**: All API routes have been tested side-by-side with the official OpenAI API and are working as expected. The only missing features at the moment are support for the built-in `code_interpreter` tool ([issue](https://github.com/transitive-bullshit/OpenOpenAI/issues/3)) and support for non-text files with the built-in `retrieval` tool ([issue](https://github.com/transitive-bullshit/OpenOpenAI/issues/2)). All other functionality should be fully supported and wire-compatible with the official API. 276 | 277 | **TODO**: 278 | 279 | - hosted demo (bring your own OpenAI API key?) 280 | - get hosted redis working 281 | - handle locking thread and messages 282 | - not sure how this works exactly, but according to the [OpenAI Assistants Guide](https://platform.openai.com/docs/assistants/how-it-works/runs-and-run-steps), threads are locked while runs are being processed 283 | - built-in `code_interpreter` tool ([issue](https://github.com/transitive-bullshit/OpenOpenAI/issues/3)) 284 | - support non-text files w/ built-in `retrieval` tool ([issue](https://github.com/transitive-bullshit/OpenOpenAI/issues/2)) 285 | - openai uses prefix IDs for its resources, which would be great, except it's a pain to get working with Prisma ([issue](https://github.com/transitive-bullshit/OpenOpenAI/issues/7)) 286 | - figure out why localhost resolution wasn't working for [#6](https://github.com/transitive-bullshit/OpenOpenAI/pull/6) 287 | - handle context overflows (truncation for now) 288 | 289 | ## License 290 | 291 | MIT © [Travis Fischer](https://transitivebullsh.it) 292 | 293 | If you found this project useful, please consider [sponsoring me](https://github.com/sponsors/transitive-bullshit) or following me on twitter twitter 294 | -------------------------------------------------------------------------------- /src/generated/oai-routes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is auto-generated by OpenOpenAI using OpenAI's OpenAPI spec as a 3 | * source of truth. 4 | * 5 | * DO NOT EDIT THIS FILE MANUALLY if you want your changes to persist. 6 | */ 7 | 8 | import { createRoute } from '@hono/zod-openapi' 9 | 10 | import * as oai from './oai' 11 | 12 | export const listFiles = createRoute({ 13 | method: 'get', 14 | path: '/files', 15 | summary: "Returns a list of files that belong to the user's organization.", 16 | request: { 17 | query: oai.ListFilesParamsQueryClassSchema 18 | }, 19 | responses: { 20 | '200': { 21 | description: 'OK', 22 | content: { 23 | 'application/json': { 24 | schema: oai.ListFilesResponseSchema.openapi('ListFilesResponse') 25 | } 26 | } 27 | } 28 | } 29 | }) 30 | 31 | export const createFile = createRoute({ 32 | method: 'post', 33 | path: '/files', 34 | summary: 35 | 'Upload a file that can be used across various endpoints/features. The size of all the files uploaded by one organization can be up to 100 GB.\n\nThe size of individual files for can be a maximum of 512MB. See the [Assistants Tools guide](/docs/assistants/tools) to learn more about the types of files supported. The Fine-tuning API only supports `.jsonl` files.\n\nPlease [contact us](https://help.openai.com/) if you need to increase these storage limits.\n', 36 | request: { 37 | body: { 38 | required: true, 39 | content: { 40 | 'multipart/form-data': { 41 | schema: oai.CreateFileRequestSchema.openapi('CreateFileRequest') 42 | } 43 | } 44 | } 45 | }, 46 | responses: { 47 | '200': { 48 | description: 'OK', 49 | content: { 50 | 'application/json': { 51 | schema: oai.OpenAIFileClassSchema.openapi('OpenAIFile') 52 | } 53 | } 54 | } 55 | } 56 | }) 57 | 58 | export const deleteFile = createRoute({ 59 | method: 'delete', 60 | path: '/files/{file_id}', 61 | summary: 'Delete a file.', 62 | request: { 63 | params: oai.DeleteFileParamsPathClassSchema 64 | }, 65 | responses: { 66 | '200': { 67 | description: 'OK', 68 | content: { 69 | 'application/json': { 70 | schema: oai.DeleteFileResponseSchema.openapi('DeleteFileResponse') 71 | } 72 | } 73 | } 74 | } 75 | }) 76 | 77 | export const retrieveFile = createRoute({ 78 | method: 'get', 79 | path: '/files/{file_id}', 80 | summary: 'Returns information about a specific file.', 81 | request: { 82 | params: oai.RetrieveFileParamsPathClassSchema 83 | }, 84 | responses: { 85 | '200': { 86 | description: 'OK', 87 | content: { 88 | 'application/json': { 89 | schema: oai.OpenAIFileClassSchema.openapi('OpenAIFile') 90 | } 91 | } 92 | } 93 | } 94 | }) 95 | 96 | export const downloadFile = createRoute({ 97 | method: 'get', 98 | path: '/files/{file_id}/content', 99 | summary: 'Returns the contents of the specified file.', 100 | request: { 101 | params: oai.DownloadFileParamsPathClassSchema 102 | }, 103 | responses: {} 104 | }) 105 | 106 | export const listAssistants = createRoute({ 107 | method: 'get', 108 | path: '/assistants', 109 | summary: 'Returns a list of assistants.', 110 | request: { 111 | query: oai.ListAssistantsParamsQueryClassSchema 112 | }, 113 | responses: { 114 | '200': { 115 | description: 'OK', 116 | content: { 117 | 'application/json': { 118 | schema: oai.ListAssistantsResponseSchema.openapi( 119 | 'ListAssistantsResponse' 120 | ) 121 | } 122 | } 123 | } 124 | } 125 | }) 126 | 127 | export const createAssistant = createRoute({ 128 | method: 'post', 129 | path: '/assistants', 130 | summary: 'Create an assistant with a model and instructions.', 131 | request: { 132 | body: { 133 | required: true, 134 | content: { 135 | 'application/json': { 136 | schema: oai.CreateAssistantRequestSchema.openapi( 137 | 'CreateAssistantRequest' 138 | ) 139 | } 140 | } 141 | } 142 | }, 143 | responses: { 144 | '200': { 145 | description: 'OK', 146 | content: { 147 | 'application/json': { 148 | schema: oai.AssistantObjectSchema.openapi('AssistantObject') 149 | } 150 | } 151 | } 152 | } 153 | }) 154 | 155 | export const getAssistant = createRoute({ 156 | method: 'get', 157 | path: '/assistants/{assistant_id}', 158 | summary: 'Retrieves an assistant.', 159 | request: { 160 | params: oai.GetAssistantParamsPathClassSchema 161 | }, 162 | responses: { 163 | '200': { 164 | description: 'OK', 165 | content: { 166 | 'application/json': { 167 | schema: oai.AssistantObjectSchema.openapi('AssistantObject') 168 | } 169 | } 170 | } 171 | } 172 | }) 173 | 174 | export const modifyAssistant = createRoute({ 175 | method: 'post', 176 | path: '/assistants/{assistant_id}', 177 | summary: 'Modifies an assistant.', 178 | request: { 179 | body: { 180 | required: true, 181 | content: { 182 | 'application/json': { 183 | schema: oai.ModifyAssistantRequestSchema.openapi( 184 | 'ModifyAssistantRequest' 185 | ) 186 | } 187 | } 188 | }, 189 | params: oai.ModifyAssistantParamsPathClassSchema 190 | }, 191 | responses: { 192 | '200': { 193 | description: 'OK', 194 | content: { 195 | 'application/json': { 196 | schema: oai.AssistantObjectSchema.openapi('AssistantObject') 197 | } 198 | } 199 | } 200 | } 201 | }) 202 | 203 | export const deleteAssistant = createRoute({ 204 | method: 'delete', 205 | path: '/assistants/{assistant_id}', 206 | summary: 'Delete an assistant.', 207 | request: { 208 | params: oai.DeleteAssistantParamsPathClassSchema 209 | }, 210 | responses: { 211 | '200': { 212 | description: 'OK', 213 | content: { 214 | 'application/json': { 215 | schema: oai.DeleteAssistantResponseSchema.openapi( 216 | 'DeleteAssistantResponse' 217 | ) 218 | } 219 | } 220 | } 221 | } 222 | }) 223 | 224 | export const createThread = createRoute({ 225 | method: 'post', 226 | path: '/threads', 227 | summary: 'Create a thread.', 228 | request: { 229 | body: { 230 | content: { 231 | 'application/json': { 232 | schema: oai.CreateThreadRequestSchema.openapi('CreateThreadRequest') 233 | } 234 | } 235 | } 236 | }, 237 | responses: { 238 | '200': { 239 | description: 'OK', 240 | content: { 241 | 'application/json': { 242 | schema: oai.ThreadObjectSchema.openapi('ThreadObject') 243 | } 244 | } 245 | } 246 | } 247 | }) 248 | 249 | export const getThread = createRoute({ 250 | method: 'get', 251 | path: '/threads/{thread_id}', 252 | summary: 'Retrieves a thread.', 253 | request: { 254 | params: oai.GetThreadParamsPathClassSchema 255 | }, 256 | responses: { 257 | '200': { 258 | description: 'OK', 259 | content: { 260 | 'application/json': { 261 | schema: oai.ThreadObjectSchema.openapi('ThreadObject') 262 | } 263 | } 264 | } 265 | } 266 | }) 267 | 268 | export const modifyThread = createRoute({ 269 | method: 'post', 270 | path: '/threads/{thread_id}', 271 | summary: 'Modifies a thread.', 272 | request: { 273 | body: { 274 | required: true, 275 | content: { 276 | 'application/json': { 277 | schema: oai.ModifyThreadRequestSchema.openapi('ModifyThreadRequest') 278 | } 279 | } 280 | }, 281 | params: oai.ModifyThreadParamsPathClassSchema 282 | }, 283 | responses: { 284 | '200': { 285 | description: 'OK', 286 | content: { 287 | 'application/json': { 288 | schema: oai.ThreadObjectSchema.openapi('ThreadObject') 289 | } 290 | } 291 | } 292 | } 293 | }) 294 | 295 | export const deleteThread = createRoute({ 296 | method: 'delete', 297 | path: '/threads/{thread_id}', 298 | summary: 'Delete a thread.', 299 | request: { 300 | params: oai.DeleteThreadParamsPathClassSchema 301 | }, 302 | responses: { 303 | '200': { 304 | description: 'OK', 305 | content: { 306 | 'application/json': { 307 | schema: oai.DeleteThreadResponseSchema.openapi('DeleteThreadResponse') 308 | } 309 | } 310 | } 311 | } 312 | }) 313 | 314 | export const listMessages = createRoute({ 315 | method: 'get', 316 | path: '/threads/{thread_id}/messages', 317 | summary: 'Returns a list of messages for a given thread.', 318 | request: { 319 | params: oai.ListMessagesParamsPathClassSchema, 320 | query: oai.ListMessagesParamsQueryClassSchema 321 | }, 322 | responses: { 323 | '200': { 324 | description: 'OK', 325 | content: { 326 | 'application/json': { 327 | schema: oai.ListMessagesResponseClassSchema.openapi( 328 | 'ListMessagesResponse' 329 | ) 330 | } 331 | } 332 | } 333 | } 334 | }) 335 | 336 | export const createMessage = createRoute({ 337 | method: 'post', 338 | path: '/threads/{thread_id}/messages', 339 | summary: 'Create a message.', 340 | request: { 341 | body: { 342 | required: true, 343 | content: { 344 | 'application/json': { 345 | schema: oai.CreateMessageRequestSchema.openapi('CreateMessageRequest') 346 | } 347 | } 348 | }, 349 | params: oai.CreateMessageParamsPathClassSchema 350 | }, 351 | responses: { 352 | '200': { 353 | description: 'OK', 354 | content: { 355 | 'application/json': { 356 | schema: oai.MessageObjectSchema.openapi('MessageObject') 357 | } 358 | } 359 | } 360 | } 361 | }) 362 | 363 | export const getMessage = createRoute({ 364 | method: 'get', 365 | path: '/threads/{thread_id}/messages/{message_id}', 366 | summary: 'Retrieve a message.', 367 | request: { 368 | params: oai.GetMessageParamsPathClassSchema 369 | }, 370 | responses: { 371 | '200': { 372 | description: 'OK', 373 | content: { 374 | 'application/json': { 375 | schema: oai.MessageObjectSchema.openapi('MessageObject') 376 | } 377 | } 378 | } 379 | } 380 | }) 381 | 382 | export const modifyMessage = createRoute({ 383 | method: 'post', 384 | path: '/threads/{thread_id}/messages/{message_id}', 385 | summary: 'Modifies a message.', 386 | request: { 387 | body: { 388 | required: true, 389 | content: { 390 | 'application/json': { 391 | schema: oai.ModifyMessageRequestSchema.openapi('ModifyMessageRequest') 392 | } 393 | } 394 | }, 395 | params: oai.ModifyMessageParamsPathClassSchema 396 | }, 397 | responses: { 398 | '200': { 399 | description: 'OK', 400 | content: { 401 | 'application/json': { 402 | schema: oai.MessageObjectSchema.openapi('MessageObject') 403 | } 404 | } 405 | } 406 | } 407 | }) 408 | 409 | export const createThreadAndRun = createRoute({ 410 | method: 'post', 411 | path: '/threads/runs', 412 | summary: 'Create a thread and run it in one request.', 413 | request: { 414 | body: { 415 | required: true, 416 | content: { 417 | 'application/json': { 418 | schema: oai.CreateThreadAndRunRequestSchema.openapi( 419 | 'CreateThreadAndRunRequest' 420 | ) 421 | } 422 | } 423 | } 424 | }, 425 | responses: { 426 | '200': { 427 | description: 'OK', 428 | content: { 429 | 'application/json': { 430 | schema: oai.RunObjectSchema.openapi('RunObject') 431 | } 432 | } 433 | } 434 | } 435 | }) 436 | 437 | export const listRuns = createRoute({ 438 | method: 'get', 439 | path: '/threads/{thread_id}/runs', 440 | summary: 'Returns a list of runs belonging to a thread.', 441 | request: { 442 | params: oai.ListRunsParamsPathClassSchema, 443 | query: oai.ListRunsParamsQueryClassSchema 444 | }, 445 | responses: { 446 | '200': { 447 | description: 'OK', 448 | content: { 449 | 'application/json': { 450 | schema: oai.ListRunsResponseSchema.openapi('ListRunsResponse') 451 | } 452 | } 453 | } 454 | } 455 | }) 456 | 457 | export const createRun = createRoute({ 458 | method: 'post', 459 | path: '/threads/{thread_id}/runs', 460 | summary: 'Create a run.', 461 | request: { 462 | body: { 463 | required: true, 464 | content: { 465 | 'application/json': { 466 | schema: oai.CreateRunRequestSchema.openapi('CreateRunRequest') 467 | } 468 | } 469 | }, 470 | params: oai.CreateRunParamsPathClassSchema 471 | }, 472 | responses: { 473 | '200': { 474 | description: 'OK', 475 | content: { 476 | 'application/json': { 477 | schema: oai.RunObjectSchema.openapi('RunObject') 478 | } 479 | } 480 | } 481 | } 482 | }) 483 | 484 | export const getRun = createRoute({ 485 | method: 'get', 486 | path: '/threads/{thread_id}/runs/{run_id}', 487 | summary: 'Retrieves a run.', 488 | request: { 489 | params: oai.GetRunParamsPathClassSchema 490 | }, 491 | responses: { 492 | '200': { 493 | description: 'OK', 494 | content: { 495 | 'application/json': { 496 | schema: oai.RunObjectSchema.openapi('RunObject') 497 | } 498 | } 499 | } 500 | } 501 | }) 502 | 503 | export const modifyRun = createRoute({ 504 | method: 'post', 505 | path: '/threads/{thread_id}/runs/{run_id}', 506 | summary: 'Modifies a run.', 507 | request: { 508 | body: { 509 | required: true, 510 | content: { 511 | 'application/json': { 512 | schema: oai.ModifyRunRequestSchema.openapi('ModifyRunRequest') 513 | } 514 | } 515 | }, 516 | params: oai.ModifyRunParamsPathClassSchema 517 | }, 518 | responses: { 519 | '200': { 520 | description: 'OK', 521 | content: { 522 | 'application/json': { 523 | schema: oai.RunObjectSchema.openapi('RunObject') 524 | } 525 | } 526 | } 527 | } 528 | }) 529 | 530 | export const submitToolOuputsToRun = createRoute({ 531 | method: 'post', 532 | path: '/threads/{thread_id}/runs/{run_id}/submit_tool_outputs', 533 | summary: 534 | 'When a run has the `status: "requires_action"` and `required_action.type` is `submit_tool_outputs`, this endpoint can be used to submit the outputs from the tool calls once they\'re all completed. All outputs must be submitted in a single request.\n', 535 | request: { 536 | body: { 537 | required: true, 538 | content: { 539 | 'application/json': { 540 | schema: oai.SubmitToolOutputsRunRequestSchema.openapi( 541 | 'SubmitToolOutputsRunRequest' 542 | ) 543 | } 544 | } 545 | }, 546 | params: oai.SubmitToolOuputsToRunParamsPathClassSchema 547 | }, 548 | responses: { 549 | '200': { 550 | description: 'OK', 551 | content: { 552 | 'application/json': { 553 | schema: oai.RunObjectSchema.openapi('RunObject') 554 | } 555 | } 556 | } 557 | } 558 | }) 559 | 560 | export const cancelRun = createRoute({ 561 | method: 'post', 562 | path: '/threads/{thread_id}/runs/{run_id}/cancel', 563 | summary: 'Cancels a run that is `in_progress`.', 564 | request: { 565 | params: oai.CancelRunParamsPathClassSchema 566 | }, 567 | responses: { 568 | '200': { 569 | description: 'OK', 570 | content: { 571 | 'application/json': { 572 | schema: oai.RunObjectSchema.openapi('RunObject') 573 | } 574 | } 575 | } 576 | } 577 | }) 578 | 579 | export const listRunSteps = createRoute({ 580 | method: 'get', 581 | path: '/threads/{thread_id}/runs/{run_id}/steps', 582 | summary: 'Returns a list of run steps belonging to a run.', 583 | request: { 584 | params: oai.ListRunStepsParamsPathClassSchema, 585 | query: oai.ListRunStepsParamsQueryClassSchema 586 | }, 587 | responses: { 588 | '200': { 589 | description: 'OK', 590 | content: { 591 | 'application/json': { 592 | schema: oai.ListRunStepsResponseClassSchema.openapi( 593 | 'ListRunStepsResponse' 594 | ) 595 | } 596 | } 597 | } 598 | } 599 | }) 600 | 601 | export const getRunStep = createRoute({ 602 | method: 'get', 603 | path: '/threads/{thread_id}/runs/{run_id}/steps/{step_id}', 604 | summary: 'Retrieves a run step.', 605 | request: { 606 | params: oai.GetRunStepParamsPathClassSchema 607 | }, 608 | responses: { 609 | '200': { 610 | description: 'OK', 611 | content: { 612 | 'application/json': { 613 | schema: oai.RunStepObjectSchema.openapi('RunStepObject') 614 | } 615 | } 616 | } 617 | } 618 | }) 619 | 620 | export const listAssistantFiles = createRoute({ 621 | method: 'get', 622 | path: '/assistants/{assistant_id}/files', 623 | summary: 'Returns a list of assistant files.', 624 | request: { 625 | params: oai.ListAssistantFilesParamsPathClassSchema, 626 | query: oai.ListAssistantFilesParamsQueryClassSchema 627 | }, 628 | responses: { 629 | '200': { 630 | description: 'OK', 631 | content: { 632 | 'application/json': { 633 | schema: oai.ListAssistantFilesResponseClassSchema.openapi( 634 | 'ListAssistantFilesResponse' 635 | ) 636 | } 637 | } 638 | } 639 | } 640 | }) 641 | 642 | export const createAssistantFile = createRoute({ 643 | method: 'post', 644 | path: '/assistants/{assistant_id}/files', 645 | summary: 646 | 'Create an assistant file by attaching a [File](/docs/api-reference/files) to an [assistant](/docs/api-reference/assistants).', 647 | request: { 648 | body: { 649 | required: true, 650 | content: { 651 | 'application/json': { 652 | schema: oai.CreateAssistantFileRequestSchema.openapi( 653 | 'CreateAssistantFileRequest' 654 | ) 655 | } 656 | } 657 | }, 658 | params: oai.CreateAssistantFileParamsPathClassSchema 659 | }, 660 | responses: { 661 | '200': { 662 | description: 'OK', 663 | content: { 664 | 'application/json': { 665 | schema: oai.AssistantFileObjectSchema.openapi('AssistantFileObject') 666 | } 667 | } 668 | } 669 | } 670 | }) 671 | 672 | export const getAssistantFile = createRoute({ 673 | method: 'get', 674 | path: '/assistants/{assistant_id}/files/{file_id}', 675 | summary: 'Retrieves an AssistantFile.', 676 | request: { 677 | params: oai.GetAssistantFileParamsPathClassSchema 678 | }, 679 | responses: { 680 | '200': { 681 | description: 'OK', 682 | content: { 683 | 'application/json': { 684 | schema: oai.AssistantFileObjectSchema.openapi('AssistantFileObject') 685 | } 686 | } 687 | } 688 | } 689 | }) 690 | 691 | export const deleteAssistantFile = createRoute({ 692 | method: 'delete', 693 | path: '/assistants/{assistant_id}/files/{file_id}', 694 | summary: 'Delete an assistant file.', 695 | request: { 696 | params: oai.DeleteAssistantFileParamsPathClassSchema 697 | }, 698 | responses: { 699 | '200': { 700 | description: 'OK', 701 | content: { 702 | 'application/json': { 703 | schema: oai.DeleteAssistantFileResponseSchema.openapi( 704 | 'DeleteAssistantFileResponse' 705 | ) 706 | } 707 | } 708 | } 709 | } 710 | }) 711 | 712 | export const listMessageFiles = createRoute({ 713 | method: 'get', 714 | path: '/threads/{thread_id}/messages/{message_id}/files', 715 | summary: 'Returns a list of message files.', 716 | request: { 717 | params: oai.ListMessageFilesParamsPathClassSchema, 718 | query: oai.ListMessageFilesParamsQueryClassSchema 719 | }, 720 | responses: { 721 | '200': { 722 | description: 'OK', 723 | content: { 724 | 'application/json': { 725 | schema: oai.ListMessageFilesResponseClassSchema.openapi( 726 | 'ListMessageFilesResponse' 727 | ) 728 | } 729 | } 730 | } 731 | } 732 | }) 733 | 734 | export const getMessageFile = createRoute({ 735 | method: 'get', 736 | path: '/threads/{thread_id}/messages/{message_id}/files/{file_id}', 737 | summary: 'Retrieves a message file.', 738 | request: { 739 | params: oai.GetMessageFileParamsPathClassSchema 740 | }, 741 | responses: { 742 | '200': { 743 | description: 'OK', 744 | content: { 745 | 'application/json': { 746 | schema: oai.MessageFileObjectSchema.openapi('MessageFileObject') 747 | } 748 | } 749 | } 750 | } 751 | }) 752 | -------------------------------------------------------------------------------- /src/generated/oai.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is auto-generated by OpenOpenAI using OpenAI's OpenAPI spec as a 3 | * source of truth. 4 | * 5 | * DO NOT EDIT THIS FILE MANUALLY if you want your changes to persist. 6 | */ 7 | import { z } from '@hono/zod-openapi' 8 | 9 | export const OrderSchema = z.enum(['asc', 'desc']) 10 | export type Order = z.infer 11 | 12 | // One of `server_error` or `rate_limit_exceeded`. 13 | 14 | export const CodeSchema = z.enum(['rate_limit_exceeded', 'server_error']) 15 | export type Code = z.infer 16 | 17 | // The object type, which is always `thread.run.step``. 18 | 19 | export const PurpleObjectSchema = z.literal('thread.run.step') 20 | export type PurpleObject = z.infer 21 | 22 | // The status of the run step, which can be either `in_progress`, `cancelled`, `failed`, 23 | // `completed`, or `expired`. 24 | 25 | export const PurpleStatusSchema = z.enum([ 26 | 'cancelled', 27 | 'completed', 28 | 'expired', 29 | 'failed', 30 | 'in_progress' 31 | ]) 32 | export type PurpleStatus = z.infer 33 | 34 | // Always `logs`. 35 | // 36 | // Always `image`. 37 | 38 | export const OutputTypeSchema = z.enum(['image', 'logs']) 39 | export type OutputType = z.infer 40 | 41 | // The type of tool call. This is always going to be `code_interpreter` for this type of 42 | // tool call. 43 | // 44 | // The type of tool call. This is always going to be `retrieval` for this type of tool 45 | // call. 46 | // 47 | // The type of tool call. This is always going to be `function` for this type of tool call. 48 | // 49 | // The type of tool being defined: `code_interpreter` 50 | // 51 | // The type of tool being defined: `retrieval` 52 | // 53 | // The type of tool being defined: `function` 54 | 55 | export const ToolTypeSchema = z.enum([ 56 | 'code_interpreter', 57 | 'function', 58 | 'retrieval' 59 | ]) 60 | export type ToolType = z.infer 61 | 62 | // Always `message_creation``. 63 | // 64 | // Always `tool_calls`. 65 | // 66 | // The type of run step, which can be either `message_creation` or `tool_calls`. 67 | 68 | export const StepDetailsTypeSchema = z.enum(['message_creation', 'tool_calls']) 69 | export type StepDetailsType = z.infer 70 | 71 | // The role of the entity that is creating the message. Currently only `user` is supported. 72 | 73 | export const MessageRoleSchema = z.literal('user') 74 | export type MessageRole = z.infer 75 | 76 | // The object type, which is always `thread.run`. 77 | 78 | export const FluffyObjectSchema = z.literal('thread.run') 79 | export type FluffyObject = z.infer 80 | 81 | // The type of tool call the output is required for. For now, this is always `function`. 82 | 83 | export const PurpleTypeSchema = z.literal('function') 84 | export type PurpleType = z.infer 85 | 86 | // For now, this is always `submit_tool_outputs`. 87 | 88 | export const RequiredActionTypeSchema = z.literal('submit_tool_outputs') 89 | export type RequiredActionType = z.infer 90 | 91 | // The status of the run, which can be either `queued`, `in_progress`, `requires_action`, 92 | // `cancelling`, `cancelled`, `failed`, `completed`, or `expired`. 93 | 94 | export const FluffyStatusSchema = z.enum([ 95 | 'cancelled', 96 | 'cancelling', 97 | 'completed', 98 | 'expired', 99 | 'failed', 100 | 'in_progress', 101 | 'queued', 102 | 'requires_action' 103 | ]) 104 | export type FluffyStatus = z.infer 105 | 106 | // The object type, which is always `assistant`. 107 | 108 | export const TentacledObjectSchema = z.literal('assistant') 109 | export type TentacledObject = z.infer 110 | 111 | // Always `file_citation`. 112 | // 113 | // Always `file_path`. 114 | 115 | export const AnnotationTypeSchema = z.enum(['file_citation', 'file_path']) 116 | export type AnnotationType = z.infer 117 | 118 | // Always `image_file`. 119 | // 120 | // Always `text`. 121 | 122 | export const ContentTypeSchema = z.enum(['image_file', 'text']) 123 | export type ContentType = z.infer 124 | 125 | // The object type, which is always `thread.message`. 126 | 127 | export const StickyObjectSchema = z.literal('thread.message') 128 | export type StickyObject = z.infer 129 | 130 | // The entity that produced the message. One of `user` or `assistant`. 131 | 132 | export const DatumRoleSchema = z.enum(['assistant', 'user']) 133 | export type DatumRole = z.infer 134 | 135 | // The object type, which is always `file`. 136 | 137 | export const OpenAIFileObjectSchema = z.literal('file') 138 | export type OpenAIFileObject = z.infer 139 | 140 | // The intended purpose of the file. Supported values are `fine-tune`, `fine-tune-results`, 141 | // `assistants`, and `assistants_output`. 142 | 143 | export const OpenAIFilePurposeSchema = z.enum([ 144 | 'assistants', 145 | 'assistants_output', 146 | 'fine-tune', 147 | 'fine-tune-results' 148 | ]) 149 | export type OpenAIFilePurpose = z.infer 150 | 151 | // Deprecated. The current status of the file, which can be either `uploaded`, `processed`, 152 | // or `error`. 153 | 154 | export const OpenAIFileStatusSchema = z.enum(['error', 'processed', 'uploaded']) 155 | export type OpenAIFileStatus = z.infer 156 | 157 | export const ListFilesResponseObjectSchema = z.literal('list') 158 | export type ListFilesResponseObject = z.infer< 159 | typeof ListFilesResponseObjectSchema 160 | > 161 | 162 | // The object type, which is always `assistant.file`. 163 | 164 | export const IndigoObjectSchema = z.literal('assistant.file') 165 | export type IndigoObject = z.infer 166 | 167 | // The object type, which is always `thread.message.file`. 168 | 169 | export const IndecentObjectSchema = z.literal('thread.message.file') 170 | export type IndecentObject = z.infer 171 | 172 | // The intended purpose of the uploaded file. 173 | // 174 | // Use "fine-tune" for [Fine-tuning](/docs/api-reference/fine-tuning) and "assistants" for 175 | // [Assistants](/docs/api-reference/assistants) and 176 | // [Messages](/docs/api-reference/messages). This allows us to validate the format of the 177 | // uploaded file is correct for fine-tuning. 178 | 179 | export const CreateFileRequestPurposeSchema = z.enum([ 180 | 'assistants', 181 | 'fine-tune' 182 | ]) 183 | export type CreateFileRequestPurpose = z.infer< 184 | typeof CreateFileRequestPurposeSchema 185 | > 186 | 187 | export const DeleteAssistantResponseObjectSchema = 188 | z.literal('assistant.deleted') 189 | export type DeleteAssistantResponseObject = z.infer< 190 | typeof DeleteAssistantResponseObjectSchema 191 | > 192 | 193 | // The object type, which is always `thread`. 194 | 195 | export const ThreadObjectSchema = z.literal('thread') 196 | export type ThreadObject = z.infer 197 | 198 | export const DeleteThreadResponseObjectSchema = z.literal('thread.deleted') 199 | export type DeleteThreadResponseObject = z.infer< 200 | typeof DeleteThreadResponseObjectSchema 201 | > 202 | 203 | export const DeleteAssistantFileResponseObjectSchema = z.literal( 204 | 'assistant.file.deleted' 205 | ) 206 | export type DeleteAssistantFileResponseObject = z.infer< 207 | typeof DeleteAssistantFileResponseObjectSchema 208 | > 209 | 210 | export const DeleteAssistantFileResponseSchema = z.object({ 211 | deleted: z.boolean(), 212 | id: z.string(), 213 | object: DeleteAssistantFileResponseObjectSchema 214 | }) 215 | export type DeleteAssistantFileResponse = z.infer< 216 | typeof DeleteAssistantFileResponseSchema 217 | > 218 | 219 | export const CreateAssistantFileRequestSchema = z.object({ 220 | file_id: z.string() 221 | }) 222 | export type CreateAssistantFileRequest = z.infer< 223 | typeof CreateAssistantFileRequestSchema 224 | > 225 | 226 | export const ToolOutputSchema = z.object({ 227 | output: z.string().optional(), 228 | tool_call_id: z.string().optional() 229 | }) 230 | export type ToolOutput = z.infer 231 | 232 | export const SubmitToolOutputsRunRequestSchema = z.object({ 233 | tool_outputs: z.array(ToolOutputSchema) 234 | }) 235 | export type SubmitToolOutputsRunRequest = z.infer< 236 | typeof SubmitToolOutputsRunRequestSchema 237 | > 238 | 239 | export const ModifyRunRequestSchema = z.object({ 240 | metadata: z.union([z.record(z.string(), z.any()), z.null()]).optional() 241 | }) 242 | export type ModifyRunRequest = z.infer 243 | 244 | export const ModifyMessageRequestSchema = z.object({ 245 | metadata: z.union([z.record(z.string(), z.any()), z.null()]).optional() 246 | }) 247 | export type ModifyMessageRequest = z.infer 248 | 249 | export const DeleteThreadResponseSchema = z.object({ 250 | deleted: z.boolean(), 251 | id: z.string(), 252 | object: DeleteThreadResponseObjectSchema 253 | }) 254 | export type DeleteThreadResponse = z.infer 255 | 256 | export const ModifyThreadRequestSchema = z.object({ 257 | metadata: z.union([z.record(z.string(), z.any()), z.null()]).optional() 258 | }) 259 | export type ModifyThreadRequest = z.infer 260 | 261 | export const ThreadSchema = z.object({ 262 | created_at: z.number(), 263 | id: z.string(), 264 | metadata: z.record(z.string(), z.any()), 265 | object: ThreadObjectSchema 266 | }) 267 | export type Thread = z.infer 268 | 269 | export const DeleteAssistantResponseSchema = z.object({ 270 | deleted: z.boolean(), 271 | id: z.string(), 272 | object: DeleteAssistantResponseObjectSchema 273 | }) 274 | export type DeleteAssistantResponse = z.infer< 275 | typeof DeleteAssistantResponseSchema 276 | > 277 | 278 | export const DeleteFileResponseSchema = z.object({ 279 | deleted: z.boolean(), 280 | id: z.string(), 281 | object: OpenAIFileObjectSchema 282 | }) 283 | export type DeleteFileResponse = z.infer 284 | 285 | export const CreateFileRequestSchema = z.object({ 286 | file: z.any(), 287 | purpose: CreateFileRequestPurposeSchema 288 | }) 289 | export type CreateFileRequest = z.infer 290 | 291 | export const MessageFileObjectSchema = z.object({ 292 | created_at: z.number(), 293 | id: z.string(), 294 | message_id: z.string(), 295 | object: IndecentObjectSchema 296 | }) 297 | export type MessageFileObject = z.infer 298 | 299 | export const ListMessageFilesResponseClassSchema = z.object({ 300 | data: z.array(MessageFileObjectSchema), 301 | first_id: z.string(), 302 | has_more: z.boolean(), 303 | last_id: z.string(), 304 | object: z.string(), 305 | items: z.any() 306 | }) 307 | export type ListMessageFilesResponseClass = z.infer< 308 | typeof ListMessageFilesResponseClassSchema 309 | > 310 | 311 | export const AssistantFileObjectSchema = z.object({ 312 | assistant_id: z.string(), 313 | created_at: z.number(), 314 | id: z.string(), 315 | object: IndigoObjectSchema 316 | }) 317 | export type AssistantFileObject = z.infer 318 | 319 | export const ListAssistantFilesResponseClassSchema = z.object({ 320 | data: z.array(AssistantFileObjectSchema), 321 | first_id: z.string(), 322 | has_more: z.boolean(), 323 | last_id: z.string(), 324 | object: z.string(), 325 | items: z.any() 326 | }) 327 | export type ListAssistantFilesResponseClass = z.infer< 328 | typeof ListAssistantFilesResponseClassSchema 329 | > 330 | 331 | export const OpenAIFileClassSchema = z.object({ 332 | bytes: z.number(), 333 | created_at: z.number(), 334 | filename: z.string(), 335 | id: z.string(), 336 | object: OpenAIFileObjectSchema, 337 | purpose: OpenAIFilePurposeSchema, 338 | status: OpenAIFileStatusSchema, 339 | status_details: z.string().optional() 340 | }) 341 | export type OpenAIFileClass = z.infer 342 | 343 | export const ListFilesResponseSchema = z.object({ 344 | data: z.array( 345 | z.union([ 346 | z.array(z.any()), 347 | z.boolean(), 348 | OpenAIFileClassSchema, 349 | z.number(), 350 | z.number(), 351 | z.null(), 352 | z.string() 353 | ]) 354 | ), 355 | object: ListFilesResponseObjectSchema 356 | }) 357 | export type ListFilesResponse = z.infer 358 | 359 | export const IndecentFunctionObjectSchema = z.object({ 360 | description: z.string().optional(), 361 | name: z.string(), 362 | parameters: z.record(z.string(), z.any()) 363 | }) 364 | export type IndecentFunctionObject = z.infer< 365 | typeof IndecentFunctionObjectSchema 366 | > 367 | 368 | export const CreateRunRequestToolSchema = z.object({ 369 | type: ToolTypeSchema, 370 | function: IndecentFunctionObjectSchema.optional() 371 | }) 372 | export type CreateRunRequestTool = z.infer 373 | 374 | export const CreateRunRequestSchema = z.object({ 375 | assistant_id: z.string(), 376 | instructions: z.string().optional(), 377 | metadata: z.union([z.record(z.string(), z.any()), z.null()]).optional(), 378 | model: z.string().optional(), 379 | tools: z.array(CreateRunRequestToolSchema).optional() 380 | }) 381 | export type CreateRunRequest = z.infer 382 | 383 | export const FilePathSchema = z.object({ 384 | file_id: z.string() 385 | }) 386 | export type FilePath = z.infer 387 | 388 | export const FileCitationSchema = z.object({ 389 | file_id: z.string(), 390 | quote: z.string() 391 | }) 392 | export type FileCitation = z.infer 393 | 394 | export const MessageContentTextAnnotationsFileObjectSchema = z.object({ 395 | end_index: z.number(), 396 | file_citation: FileCitationSchema.optional(), 397 | start_index: z.number(), 398 | text: z.string(), 399 | type: AnnotationTypeSchema, 400 | file_path: FilePathSchema.optional() 401 | }) 402 | export type MessageContentTextAnnotationsFileObject = z.infer< 403 | typeof MessageContentTextAnnotationsFileObjectSchema 404 | > 405 | 406 | export const TextSchema = z.object({ 407 | annotations: z.array(MessageContentTextAnnotationsFileObjectSchema), 408 | value: z.string() 409 | }) 410 | export type Text = z.infer 411 | 412 | export const ImageFileSchema = z.object({ 413 | file_id: z.string() 414 | }) 415 | export type ImageFile = z.infer 416 | 417 | export const MessageContentObjectSchema = z.object({ 418 | image_file: ImageFileSchema.optional(), 419 | type: ContentTypeSchema, 420 | text: TextSchema.optional() 421 | }) 422 | export type MessageContentObject = z.infer 423 | 424 | export const MessageObjectSchema = z.object({ 425 | assistant_id: z.string(), 426 | content: z.array(MessageContentObjectSchema), 427 | created_at: z.number(), 428 | file_ids: z.array(z.string()), 429 | id: z.string(), 430 | metadata: z.record(z.string(), z.any()), 431 | object: StickyObjectSchema, 432 | role: DatumRoleSchema, 433 | run_id: z.string(), 434 | thread_id: z.string() 435 | }) 436 | export type MessageObject = z.infer 437 | 438 | export const ListMessagesResponseClassSchema = z.object({ 439 | data: z.array(MessageObjectSchema), 440 | first_id: z.string(), 441 | has_more: z.boolean(), 442 | last_id: z.string(), 443 | object: z.string() 444 | }) 445 | export type ListMessagesResponseClass = z.infer< 446 | typeof ListMessagesResponseClassSchema 447 | > 448 | 449 | export const IndigoFunctionObjectSchema = z.object({ 450 | description: z.string().optional(), 451 | name: z.string(), 452 | parameters: z.record(z.string(), z.any()) 453 | }) 454 | export type IndigoFunctionObject = z.infer 455 | 456 | export const ModifyAssistantRequestToolSchema = z.object({ 457 | type: ToolTypeSchema, 458 | function: IndigoFunctionObjectSchema.optional() 459 | }) 460 | export type ModifyAssistantRequestTool = z.infer< 461 | typeof ModifyAssistantRequestToolSchema 462 | > 463 | 464 | export const ModifyAssistantRequestSchema = z.object({ 465 | description: z.string().optional(), 466 | file_ids: z.array(z.string()).optional(), 467 | instructions: z.string().optional(), 468 | metadata: z.union([z.record(z.string(), z.any()), z.null()]).optional(), 469 | model: z.string().optional(), 470 | name: z.string().optional(), 471 | tools: z.array(ModifyAssistantRequestToolSchema).optional() 472 | }) 473 | export type ModifyAssistantRequest = z.infer< 474 | typeof ModifyAssistantRequestSchema 475 | > 476 | 477 | export const StickyFunctionObjectSchema = z.object({ 478 | description: z.string().optional(), 479 | name: z.string(), 480 | parameters: z.record(z.string(), z.any()) 481 | }) 482 | export type StickyFunctionObject = z.infer 483 | 484 | export const CreateAssistantRequestToolSchema = z.object({ 485 | type: ToolTypeSchema, 486 | function: StickyFunctionObjectSchema.optional() 487 | }) 488 | export type CreateAssistantRequestTool = z.infer< 489 | typeof CreateAssistantRequestToolSchema 490 | > 491 | 492 | export const CreateAssistantRequestSchema = z.object({ 493 | description: z.string().optional(), 494 | file_ids: z.array(z.string()).optional(), 495 | instructions: z.string().optional(), 496 | metadata: z.union([z.record(z.string(), z.any()), z.null()]).optional(), 497 | model: z.string(), 498 | name: z.string().optional(), 499 | tools: z.array(CreateAssistantRequestToolSchema).optional() 500 | }) 501 | export type CreateAssistantRequest = z.infer< 502 | typeof CreateAssistantRequestSchema 503 | > 504 | 505 | export const TentacledFunctionObjectSchema = z.object({ 506 | description: z.string().optional(), 507 | name: z.string(), 508 | parameters: z.record(z.string(), z.any()) 509 | }) 510 | export type TentacledFunctionObject = z.infer< 511 | typeof TentacledFunctionObjectSchema 512 | > 513 | 514 | export const FluffyAssistantToolsSchema = z.object({ 515 | type: ToolTypeSchema, 516 | function: TentacledFunctionObjectSchema.optional() 517 | }) 518 | export type FluffyAssistantTools = z.infer 519 | 520 | export const AssistantObjectSchema = z.object({ 521 | created_at: z.number(), 522 | description: z.string(), 523 | file_ids: z.array(z.string()), 524 | id: z.string(), 525 | instructions: z.string(), 526 | metadata: z.record(z.string(), z.any()), 527 | model: z.string(), 528 | name: z.string(), 529 | object: TentacledObjectSchema, 530 | tools: z.array(FluffyAssistantToolsSchema) 531 | }) 532 | export type AssistantObject = z.infer 533 | 534 | export const ListAssistantsResponseSchema = z.object({ 535 | data: z.array(AssistantObjectSchema), 536 | first_id: z.string(), 537 | has_more: z.boolean(), 538 | last_id: z.string(), 539 | object: z.string() 540 | }) 541 | export type ListAssistantsResponse = z.infer< 542 | typeof ListAssistantsResponseSchema 543 | > 544 | 545 | export const FluffyFunctionObjectSchema = z.object({ 546 | description: z.string().optional(), 547 | name: z.string(), 548 | parameters: z.record(z.string(), z.any()) 549 | }) 550 | export type FluffyFunctionObject = z.infer 551 | 552 | export const PurpleAssistantToolsSchema = z.object({ 553 | type: ToolTypeSchema, 554 | function: FluffyFunctionObjectSchema.optional() 555 | }) 556 | export type PurpleAssistantTools = z.infer 557 | 558 | export const FluffyFunctionSchema = z.object({ 559 | arguments: z.string(), 560 | name: z.string() 561 | }) 562 | export type FluffyFunction = z.infer 563 | 564 | export const RunToolCallObjectSchema = z.object({ 565 | function: FluffyFunctionSchema, 566 | id: z.string(), 567 | type: PurpleTypeSchema 568 | }) 569 | export type RunToolCallObject = z.infer 570 | 571 | export const SubmitToolOutputsSchema = z.object({ 572 | tool_calls: z.array(RunToolCallObjectSchema) 573 | }) 574 | export type SubmitToolOutputs = z.infer 575 | 576 | export const RequiredActionSchema = z.object({ 577 | submit_tool_outputs: SubmitToolOutputsSchema, 578 | type: RequiredActionTypeSchema 579 | }) 580 | export type RequiredAction = z.infer 581 | 582 | export const FluffyLastErrorSchema = z.object({ 583 | code: CodeSchema, 584 | message: z.string() 585 | }) 586 | export type FluffyLastError = z.infer 587 | 588 | export const RunObjectSchema = z.object({ 589 | assistant_id: z.string(), 590 | cancelled_at: z.number(), 591 | completed_at: z.number(), 592 | created_at: z.number(), 593 | expires_at: z.number(), 594 | failed_at: z.number(), 595 | file_ids: z.array(z.string()), 596 | id: z.string(), 597 | instructions: z.string(), 598 | last_error: FluffyLastErrorSchema, 599 | metadata: z.record(z.string(), z.any()), 600 | model: z.string(), 601 | object: FluffyObjectSchema, 602 | required_action: RequiredActionSchema, 603 | started_at: z.number(), 604 | status: FluffyStatusSchema, 605 | thread_id: z.string(), 606 | tools: z.array(PurpleAssistantToolsSchema) 607 | }) 608 | export type RunObject = z.infer 609 | 610 | export const ListRunsResponseSchema = z.object({ 611 | data: z.array(RunObjectSchema), 612 | first_id: z.string(), 613 | has_more: z.boolean(), 614 | last_id: z.string(), 615 | object: z.string() 616 | }) 617 | export type ListRunsResponse = z.infer 618 | 619 | export const PurpleFunctionObjectSchema = z.object({ 620 | description: z.string().optional(), 621 | name: z.string(), 622 | parameters: z.record(z.string(), z.any()) 623 | }) 624 | export type PurpleFunctionObject = z.infer 625 | 626 | export const CreateThreadAndRunRequestToolSchema = z.object({ 627 | type: ToolTypeSchema, 628 | function: PurpleFunctionObjectSchema.optional() 629 | }) 630 | export type CreateThreadAndRunRequestTool = z.infer< 631 | typeof CreateThreadAndRunRequestToolSchema 632 | > 633 | 634 | export const CreateMessageRequestSchema = z.object({ 635 | content: z.string(), 636 | file_ids: z.array(z.string()).optional(), 637 | metadata: z.union([z.record(z.string(), z.any()), z.null()]).optional(), 638 | role: MessageRoleSchema 639 | }) 640 | export type CreateMessageRequest = z.infer 641 | 642 | export const CreateThreadRequestSchema = z.object({ 643 | messages: z.array(CreateMessageRequestSchema).optional(), 644 | metadata: z.union([z.record(z.string(), z.any()), z.null()]).optional() 645 | }) 646 | export type CreateThreadRequest = z.infer 647 | 648 | export const CreateThreadAndRunRequestSchema = z.object({ 649 | assistant_id: z.string(), 650 | instructions: z.string().optional(), 651 | metadata: z.union([z.record(z.string(), z.any()), z.null()]).optional(), 652 | model: z.string().optional(), 653 | thread: CreateThreadRequestSchema.optional(), 654 | tools: z.array(CreateThreadAndRunRequestToolSchema).optional() 655 | }) 656 | export type CreateThreadAndRunRequest = z.infer< 657 | typeof CreateThreadAndRunRequestSchema 658 | > 659 | 660 | export const PurpleFunctionSchema = z.object({ 661 | arguments: z.string(), 662 | name: z.string(), 663 | output: z.string() 664 | }) 665 | export type PurpleFunction = z.infer 666 | 667 | export const ImageSchema = z.object({ 668 | file_id: z.string() 669 | }) 670 | export type Image = z.infer 671 | 672 | export const RunStepDetailsToolCallsCodeOutputObjectSchema = z.object({ 673 | logs: z.string().optional(), 674 | type: OutputTypeSchema, 675 | image: ImageSchema.optional() 676 | }) 677 | export type RunStepDetailsToolCallsCodeOutputObject = z.infer< 678 | typeof RunStepDetailsToolCallsCodeOutputObjectSchema 679 | > 680 | 681 | export const CodeInterpreterSchema = z.object({ 682 | input: z.string(), 683 | outputs: z.array(RunStepDetailsToolCallsCodeOutputObjectSchema) 684 | }) 685 | export type CodeInterpreter = z.infer 686 | 687 | export const RunStepDetailsToolCallsObjectSchema = z.object({ 688 | code_interpreter: CodeInterpreterSchema.optional(), 689 | id: z.string(), 690 | type: ToolTypeSchema, 691 | retrieval: z.union([z.record(z.string(), z.any()), z.null()]).optional(), 692 | function: PurpleFunctionSchema.optional() 693 | }) 694 | export type RunStepDetailsToolCallsObject = z.infer< 695 | typeof RunStepDetailsToolCallsObjectSchema 696 | > 697 | 698 | export const MessageCreationSchema = z.object({ 699 | message_id: z.string() 700 | }) 701 | export type MessageCreation = z.infer 702 | 703 | export const StepDetailsSchema = z.object({ 704 | message_creation: MessageCreationSchema.optional(), 705 | type: StepDetailsTypeSchema, 706 | tool_calls: z.array(RunStepDetailsToolCallsObjectSchema).optional() 707 | }) 708 | export type StepDetails = z.infer 709 | 710 | export const PurpleLastErrorSchema = z.object({ 711 | code: CodeSchema, 712 | message: z.string() 713 | }) 714 | export type PurpleLastError = z.infer 715 | 716 | export const RunStepObjectSchema = z.object({ 717 | assistant_id: z.string(), 718 | cancelled_at: z.number(), 719 | completed_at: z.number(), 720 | created_at: z.number(), 721 | expired_at: z.number(), 722 | failed_at: z.number(), 723 | id: z.string(), 724 | last_error: PurpleLastErrorSchema, 725 | metadata: z.record(z.string(), z.any()), 726 | object: PurpleObjectSchema, 727 | run_id: z.string(), 728 | status: PurpleStatusSchema, 729 | step_details: StepDetailsSchema, 730 | thread_id: z.string(), 731 | type: StepDetailsTypeSchema 732 | }) 733 | export type RunStepObject = z.infer 734 | 735 | export const ListRunStepsResponseClassSchema = z.object({ 736 | data: z.array(RunStepObjectSchema), 737 | first_id: z.string(), 738 | has_more: z.boolean(), 739 | last_id: z.string(), 740 | object: z.string() 741 | }) 742 | export type ListRunStepsResponseClass = z.infer< 743 | typeof ListRunStepsResponseClassSchema 744 | > 745 | 746 | export const GetMessageFileParamsPathClassSchema = z.object({ 747 | file_id: z.string(), 748 | message_id: z.string(), 749 | thread_id: z.string() 750 | }) 751 | export type GetMessageFileParamsPathClass = z.infer< 752 | typeof GetMessageFileParamsPathClassSchema 753 | > 754 | 755 | export const ListMessageFilesParamsQueryClassSchema = z.object({ 756 | after: z.string().optional(), 757 | before: z.string().optional(), 758 | limit: z.string().optional(), 759 | order: OrderSchema.optional() 760 | }) 761 | export type ListMessageFilesParamsQueryClass = z.infer< 762 | typeof ListMessageFilesParamsQueryClassSchema 763 | > 764 | 765 | export const ListMessageFilesParamsPathClassSchema = z.object({ 766 | message_id: z.string(), 767 | thread_id: z.string() 768 | }) 769 | export type ListMessageFilesParamsPathClass = z.infer< 770 | typeof ListMessageFilesParamsPathClassSchema 771 | > 772 | 773 | export const DeleteAssistantFileParamsPathClassSchema = z.object({ 774 | assistant_id: z.string(), 775 | file_id: z.string() 776 | }) 777 | export type DeleteAssistantFileParamsPathClass = z.infer< 778 | typeof DeleteAssistantFileParamsPathClassSchema 779 | > 780 | 781 | export const GetAssistantFileParamsPathClassSchema = z.object({ 782 | assistant_id: z.string(), 783 | file_id: z.string() 784 | }) 785 | export type GetAssistantFileParamsPathClass = z.infer< 786 | typeof GetAssistantFileParamsPathClassSchema 787 | > 788 | 789 | export const CreateAssistantFileParamsPathClassSchema = z.object({ 790 | assistant_id: z.string() 791 | }) 792 | export type CreateAssistantFileParamsPathClass = z.infer< 793 | typeof CreateAssistantFileParamsPathClassSchema 794 | > 795 | 796 | export const ListAssistantFilesParamsQueryClassSchema = z.object({ 797 | after: z.string().optional(), 798 | before: z.string().optional(), 799 | limit: z.string().optional(), 800 | order: OrderSchema.optional() 801 | }) 802 | export type ListAssistantFilesParamsQueryClass = z.infer< 803 | typeof ListAssistantFilesParamsQueryClassSchema 804 | > 805 | 806 | export const ListAssistantFilesParamsPathClassSchema = z.object({ 807 | assistant_id: z.string() 808 | }) 809 | export type ListAssistantFilesParamsPathClass = z.infer< 810 | typeof ListAssistantFilesParamsPathClassSchema 811 | > 812 | 813 | export const GetRunStepParamsPathClassSchema = z.object({ 814 | run_id: z.string(), 815 | step_id: z.string(), 816 | thread_id: z.string() 817 | }) 818 | export type GetRunStepParamsPathClass = z.infer< 819 | typeof GetRunStepParamsPathClassSchema 820 | > 821 | 822 | export const ListRunStepsParamsQueryClassSchema = z.object({ 823 | after: z.string().optional(), 824 | before: z.string().optional(), 825 | limit: z.string().optional(), 826 | order: OrderSchema.optional() 827 | }) 828 | export type ListRunStepsParamsQueryClass = z.infer< 829 | typeof ListRunStepsParamsQueryClassSchema 830 | > 831 | 832 | export const ListRunStepsParamsPathClassSchema = z.object({ 833 | run_id: z.string(), 834 | thread_id: z.string() 835 | }) 836 | export type ListRunStepsParamsPathClass = z.infer< 837 | typeof ListRunStepsParamsPathClassSchema 838 | > 839 | 840 | export const CancelRunParamsPathClassSchema = z.object({ 841 | run_id: z.string(), 842 | thread_id: z.string() 843 | }) 844 | export type CancelRunParamsPathClass = z.infer< 845 | typeof CancelRunParamsPathClassSchema 846 | > 847 | 848 | export const SubmitToolOuputsToRunParamsPathClassSchema = z.object({ 849 | run_id: z.string(), 850 | thread_id: z.string() 851 | }) 852 | export type SubmitToolOuputsToRunParamsPathClass = z.infer< 853 | typeof SubmitToolOuputsToRunParamsPathClassSchema 854 | > 855 | 856 | export const ModifyRunParamsPathClassSchema = z.object({ 857 | run_id: z.string(), 858 | thread_id: z.string() 859 | }) 860 | export type ModifyRunParamsPathClass = z.infer< 861 | typeof ModifyRunParamsPathClassSchema 862 | > 863 | 864 | export const GetRunParamsPathClassSchema = z.object({ 865 | run_id: z.string(), 866 | thread_id: z.string() 867 | }) 868 | export type GetRunParamsPathClass = z.infer 869 | 870 | export const CreateRunParamsPathClassSchema = z.object({ 871 | thread_id: z.string() 872 | }) 873 | export type CreateRunParamsPathClass = z.infer< 874 | typeof CreateRunParamsPathClassSchema 875 | > 876 | 877 | export const ListRunsParamsQueryClassSchema = z.object({ 878 | after: z.string().optional(), 879 | before: z.string().optional(), 880 | limit: z.string().optional(), 881 | order: OrderSchema.optional() 882 | }) 883 | export type ListRunsParamsQueryClass = z.infer< 884 | typeof ListRunsParamsQueryClassSchema 885 | > 886 | 887 | export const ListRunsParamsPathClassSchema = z.object({ 888 | thread_id: z.string() 889 | }) 890 | export type ListRunsParamsPathClass = z.infer< 891 | typeof ListRunsParamsPathClassSchema 892 | > 893 | 894 | export const ModifyMessageParamsPathClassSchema = z.object({ 895 | message_id: z.string(), 896 | thread_id: z.string() 897 | }) 898 | export type ModifyMessageParamsPathClass = z.infer< 899 | typeof ModifyMessageParamsPathClassSchema 900 | > 901 | 902 | export const GetMessageParamsPathClassSchema = z.object({ 903 | message_id: z.string(), 904 | thread_id: z.string() 905 | }) 906 | export type GetMessageParamsPathClass = z.infer< 907 | typeof GetMessageParamsPathClassSchema 908 | > 909 | 910 | export const CreateMessageParamsPathClassSchema = z.object({ 911 | thread_id: z.string() 912 | }) 913 | export type CreateMessageParamsPathClass = z.infer< 914 | typeof CreateMessageParamsPathClassSchema 915 | > 916 | 917 | export const ListMessagesParamsQueryClassSchema = z.object({ 918 | after: z.string().optional(), 919 | before: z.string().optional(), 920 | limit: z.string().optional(), 921 | order: OrderSchema.optional() 922 | }) 923 | export type ListMessagesParamsQueryClass = z.infer< 924 | typeof ListMessagesParamsQueryClassSchema 925 | > 926 | 927 | export const ListMessagesParamsPathClassSchema = z.object({ 928 | thread_id: z.string() 929 | }) 930 | export type ListMessagesParamsPathClass = z.infer< 931 | typeof ListMessagesParamsPathClassSchema 932 | > 933 | 934 | export const DeleteThreadParamsPathClassSchema = z.object({ 935 | thread_id: z.string() 936 | }) 937 | export type DeleteThreadParamsPathClass = z.infer< 938 | typeof DeleteThreadParamsPathClassSchema 939 | > 940 | 941 | export const ModifyThreadParamsPathClassSchema = z.object({ 942 | thread_id: z.string() 943 | }) 944 | export type ModifyThreadParamsPathClass = z.infer< 945 | typeof ModifyThreadParamsPathClassSchema 946 | > 947 | 948 | export const GetThreadParamsPathClassSchema = z.object({ 949 | thread_id: z.string() 950 | }) 951 | export type GetThreadParamsPathClass = z.infer< 952 | typeof GetThreadParamsPathClassSchema 953 | > 954 | 955 | export const DeleteAssistantParamsPathClassSchema = z.object({ 956 | assistant_id: z.string() 957 | }) 958 | export type DeleteAssistantParamsPathClass = z.infer< 959 | typeof DeleteAssistantParamsPathClassSchema 960 | > 961 | 962 | export const ModifyAssistantParamsPathClassSchema = z.object({ 963 | assistant_id: z.string() 964 | }) 965 | export type ModifyAssistantParamsPathClass = z.infer< 966 | typeof ModifyAssistantParamsPathClassSchema 967 | > 968 | 969 | export const GetAssistantParamsPathClassSchema = z.object({ 970 | assistant_id: z.string() 971 | }) 972 | export type GetAssistantParamsPathClass = z.infer< 973 | typeof GetAssistantParamsPathClassSchema 974 | > 975 | 976 | export const ListAssistantsParamsQueryClassSchema = z.object({ 977 | after: z.string().optional(), 978 | before: z.string().optional(), 979 | limit: z.string().optional(), 980 | order: OrderSchema.optional() 981 | }) 982 | export type ListAssistantsParamsQueryClass = z.infer< 983 | typeof ListAssistantsParamsQueryClassSchema 984 | > 985 | 986 | export const DownloadFileParamsPathClassSchema = z.object({ 987 | file_id: z.string() 988 | }) 989 | export type DownloadFileParamsPathClass = z.infer< 990 | typeof DownloadFileParamsPathClassSchema 991 | > 992 | 993 | export const RetrieveFileParamsPathClassSchema = z.object({ 994 | file_id: z.string() 995 | }) 996 | export type RetrieveFileParamsPathClass = z.infer< 997 | typeof RetrieveFileParamsPathClassSchema 998 | > 999 | 1000 | export const DeleteFileParamsPathClassSchema = z.object({ 1001 | file_id: z.string() 1002 | }) 1003 | export type DeleteFileParamsPathClass = z.infer< 1004 | typeof DeleteFileParamsPathClassSchema 1005 | > 1006 | 1007 | export const ListFilesParamsQueryClassSchema = z.object({ 1008 | purpose: z.string().optional() 1009 | }) 1010 | export type ListFilesParamsQueryClass = z.infer< 1011 | typeof ListFilesParamsQueryClassSchema 1012 | > 1013 | -------------------------------------------------------------------------------- /src/lib/config.ts: -------------------------------------------------------------------------------- 1 | import { type ConnectionOptions, type DefaultJobOptions } from 'bullmq' 2 | import 'dotenv/config' 3 | 4 | export const env = process.env.NODE_ENV || 'development' 5 | export const isDev = env !== 'production' 6 | export const isCI = !!process.env.CI 7 | 8 | export const port = parseInt(process.env.PORT || '3000') 9 | export const processGracefulExitWaitTimeMs = 5000 10 | 11 | export namespace runs { 12 | // 10 minute timeout, including waiting for tool outputs 13 | export const maxRunTime = 10 * 60 * 1000 14 | 15 | // We set a maximum number of run steps to keep the underlying LLM from 16 | // looping indefinitely. With parallel tool calls, we really shouldn't have 17 | // too many steps to resolve a run. 18 | export const maxRunSteps = 4 19 | } 20 | 21 | export namespace queue { 22 | export const name = 'openopenai' 23 | 24 | export const redisConfig: ConnectionOptions = { 25 | host: process.env.REDIS_HOST || 'localhost', 26 | port: parseInt(process.env.REDIS_PORT || '6379'), 27 | password: process.env.REDIS_PASSWORD, 28 | username: process.env.REDIS_USERNAME ?? 'default', 29 | // Fail fast when redis is offline 30 | // @see https://docs.bullmq.io/patterns/failing-fast-when-redis-is-down 31 | enableOfflineQueue: false 32 | } 33 | 34 | export const defaultJobOptions: DefaultJobOptions = { 35 | removeOnComplete: true, 36 | removeOnFail: { 37 | // One day in seconds 38 | age: 24 * 60 * 60, 39 | count: 1000 40 | } 41 | } 42 | 43 | export const concurrency = isDev ? 1 : 16 44 | export const stalledInterval = runs.maxRunTime 45 | export const threadRunJobName = 'thread-run' 46 | 47 | export const startRunner = !!process.env.START_RUNNER 48 | } 49 | 50 | export namespace storage { 51 | export const bucket = process.env.S3_BUCKET! 52 | 53 | if (!bucket && !isCI) { 54 | throw new Error('process.env.S3_BUCKET is required') 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/lib/create-thread.ts: -------------------------------------------------------------------------------- 1 | import type { z } from '@hono/zod-openapi' 2 | import createError from 'http-errors' 3 | import pMap from 'p-map' 4 | 5 | import * as routes from '~/generated/oai-routes' 6 | 7 | import * as utils from './utils' 8 | import { prisma } from './db' 9 | 10 | const CreateThreadParamsSchema = 11 | routes.createThread.request.body.content['application/json'].schema 12 | type CreateThreadParams = z.infer 13 | 14 | export async function createThread(params: CreateThreadParams) { 15 | const { messages: messageInputs, ...data } = utils.convertOAIToPrisma(params) 16 | 17 | // TODO: wrap this all in a transaction? 18 | const thread = await prisma.thread.create({ 19 | data 20 | }) 21 | 22 | const messages = await pMap( 23 | messageInputs, 24 | async (message) => { 25 | const { content, ...data } = utils.convertOAIToPrisma(message) 26 | 27 | if (data.file_ids && data.file_ids.length > 10) { 28 | throw createError(400, 'Too many files') 29 | } 30 | 31 | await prisma.message.create({ 32 | data: { 33 | ...data, 34 | content: [ 35 | { 36 | type: 'text', 37 | text: { 38 | value: content, 39 | annotations: [] 40 | } 41 | } 42 | ], 43 | thread_id: thread.id 44 | } 45 | }) 46 | }, 47 | { 48 | concurrency: 8 49 | } 50 | ) 51 | 52 | return { 53 | thread, 54 | messages 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/lib/db.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Assistant, 3 | type AssistantFile, 4 | type File, 5 | type Message, 6 | type MessageFile, 7 | type Prisma, 8 | PrismaClient, 9 | type Run, 10 | type RunStep, 11 | type Thread 12 | } from '@prisma/client' 13 | 14 | const prisma = new PrismaClient() 15 | 16 | export { prisma } 17 | 18 | export type { Assistant } 19 | export type { AssistantFile } 20 | export type { File } 21 | export type { Message } 22 | export type { MessageFile } 23 | export type { Prisma } 24 | export type { Run } 25 | export type { RunStep } 26 | export type { Thread } 27 | -------------------------------------------------------------------------------- /src/lib/oai.test.ts: -------------------------------------------------------------------------------- 1 | import type * as prisma from '@prisma/client' 2 | import type { z } from '@hono/zod-openapi' 3 | import { describe, expectTypeOf, it } from 'vitest' 4 | 5 | import type * as oai from '~/generated/oai' 6 | 7 | import type { OAITypeToPrismaType, PrismaTypeToOAIType } from './utils' 8 | 9 | describe('Assistant', () => { 10 | it('Assistant OAI to Prisma types', async () => { 11 | const oaiInput = {} as OAITypeToPrismaType< 12 | z.infer 13 | > 14 | const prismaOutput: prisma.Assistant = oaiInput 15 | expectTypeOf().toMatchTypeOf() 16 | 17 | expectTypeOf< 18 | typeof oaiInput 19 | >().toMatchTypeOf() 20 | }) 21 | 22 | it('Assistant Prisma types to OAI types', async () => { 23 | const prismaInput = {} as PrismaTypeToOAIType 24 | const oaiOutput: z.infer = prismaInput 25 | 26 | expectTypeOf().toMatchTypeOf() 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /src/lib/prisma-json-types.d.ts: -------------------------------------------------------------------------------- 1 | import type { JsonifiableObject } from 'type-fest' 2 | 3 | import type * as oai from '~/generated/oai' 4 | 5 | declare global { 6 | /** 7 | * This namespace allows us to customize generated Prisma types, which we use 8 | * for `Json` typing and literal string typing. 9 | * 10 | * @see https://github.com/arthurfiorette/prisma-json-types-generator 11 | */ 12 | namespace PrismaJson { 13 | type Metadata = JsonifiableObject 14 | type Tool = oai.FluffyAssistantTools 15 | type MessageContent = oai.MessageContentObject 16 | type LastError = oai.FluffyLastError 17 | type RequiredAction = oai.RequiredAction 18 | type StepDetails = oai.StepDetails 19 | type RunToolCall = oai.PurpleFunction 20 | 21 | type AssistantObject = 'assistant' 22 | type AssistantFileObject = 'assistant.file' 23 | type FileObject = 'file' 24 | type ThreadObject = 'thread' 25 | type MessageObject = 'thread.message' 26 | type MessageFileObject = 'thread.message.file' 27 | type RunObject = 'thread.run' 28 | type RunStepObject = 'thread.run.step' 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/lib/queue.ts: -------------------------------------------------------------------------------- 1 | import { Queue } from 'bullmq' 2 | 3 | import * as config from './config' 4 | import type { Run, RunStep } from './db' 5 | import type { JobData, JobResult } from './types' 6 | 7 | export const queue = new Queue(config.queue.name, { 8 | connection: config.queue.redisConfig, 9 | defaultJobOptions: config.queue.defaultJobOptions 10 | }) 11 | 12 | export function getJobId(run: Run, runStep?: RunStep) { 13 | return `${run.id}${runStep?.id ? '-' + runStep.id : ''}` 14 | } 15 | 16 | // Useful for debugging queue issues 17 | // const active = (await queue.getActive()).map((job) => job.asJSON()) 18 | // const completed = (await queue.getCompleted()).map((job) => job.asJSON()) 19 | // const failed = (await queue.getFailed()).map((job) => job.asJSON()) 20 | // const delayed = (await queue.getDelayed()).map((job) => job.asJSON()) 21 | // const jobs = (await queue.getJobs()).map((job) => job.asJSON()) 22 | // console.log('active', active) 23 | // console.log('completed', completed) 24 | // console.log('failed', failed) 25 | // console.log('delayed', delayed) 26 | // console.log('jobs', jobs) 27 | -------------------------------------------------------------------------------- /src/lib/retrieval.ts: -------------------------------------------------------------------------------- 1 | import { createAIFunction } from '@dexaai/dexter/prompt' 2 | import pMap from 'p-map' 3 | import { z } from 'zod' 4 | 5 | import type { File } from './db' 6 | import { getObject } from './storage' 7 | import { getNormalizedFileName } from './utils' 8 | 9 | export async function processFilesForAssistant(files: File[]): Promise { 10 | console.log('processFilesForAssistant', files) 11 | await pMap(files, processFileForAssistant, { concurrency: 4 }) 12 | } 13 | 14 | /** 15 | * Preprocess file for knowledge retrieval. 16 | * 17 | * This may include things like chunking and upserting into a vector database. 18 | * 19 | * At retrieval time, you are given an array of `file_ids` to filter by, so 20 | * make sure to store the `file_id` in the database. 21 | */ 22 | export async function processFileForAssistant(file: File): Promise { 23 | console.log('processFileForAssistant', file) 24 | 25 | // TODO: encapsulate the getObject and key in a `files` module 26 | const object = await getObject(file.filename) 27 | // @ts-expect-error TODO 28 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 29 | const _ = await object.Body!.transformToString() 30 | 31 | // TODO: we're not actually pre-processing the file yet 32 | // @see https://github.com/transitive-bullshit/OpenOpenAI/issues/2 33 | 34 | file.status = 'processed' 35 | } 36 | 37 | export const retrievalFunction = createAIFunction( 38 | { 39 | name: 'retrieval', 40 | description: 'Retrieves relevant context from a set of attached files', 41 | argsSchema: z.object({ 42 | query: z.string() 43 | }) 44 | }, 45 | async () => { 46 | // not used 47 | throw new Error('This should never be called') 48 | } 49 | ) 50 | 51 | /** 52 | * Performs knowledge retrieval for a given `query` on a set of `file_ids` for RAG. 53 | * 54 | * This function contains the runtime implementation of the built-in `retrieval` 55 | * tool, which can be enabled on assistants to search arbitrary user files. 56 | * 57 | * TODO: implement actual semantic retrieval 58 | * TODO: perform context compression / truncation of the retrieved context 59 | */ 60 | export async function retrievalTool({ 61 | query: _, 62 | files 63 | }: { 64 | query: string 65 | files: File[] 66 | }): Promise { 67 | const filesWithContent = ( 68 | await pMap(files, getFileContent, { 69 | concurrency: 8 70 | }) 71 | ).filter(Boolean) 72 | 73 | return filesWithContent.map((file) => { 74 | return `Filename: ${getNormalizedFileName(file.file)}\n\n${file.content}` 75 | }) 76 | } 77 | 78 | async function getFileContent( 79 | file: File 80 | ): Promise<{ file: File; content: string } | null> { 81 | // TODO: encapsulate the getObject and key in a `files` module 82 | try { 83 | const object = await getObject(file.filename) 84 | const body = await object.Body!.transformToString() 85 | 86 | // TODO: handle larger files; this is just a naive placeholder 87 | const content = body.slice(0, 10000) 88 | 89 | // TODO: handle non-text/markdown files like pdfs, images, and html 90 | // TODO: actual chunking, compute embeddings, and store in vector db 91 | return { 92 | file, 93 | content 94 | } 95 | } catch (err: any) { 96 | console.error('error fetching file', file.id, err.message) 97 | return null 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/lib/storage.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | 3 | import * as storage from './storage' 4 | 5 | describe('Storage', () => { 6 | it('putObject, getObject, deleteObject', async () => { 7 | if (!process.env.ACCESS_KEY_ID) { 8 | // TODO: ignore on CI 9 | expect(true).toEqual(true) 10 | return 11 | } 12 | 13 | await storage.putObject('test.txt', 'hello world', { 14 | ContentType: 'text/plain' 15 | }) 16 | 17 | const obj = await storage.getObject('test.txt') 18 | expect(obj.ContentType).toEqual('text/plain') 19 | 20 | const body = await obj.Body?.transformToString() 21 | expect(body).toEqual('hello world') 22 | 23 | const res = await storage.deleteObject('test.txt') 24 | expect(res.$metadata.httpStatusCode).toEqual(204) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /src/lib/storage.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DeleteObjectCommand, 3 | type DeleteObjectCommandInput, 4 | GetObjectCommand, 5 | type GetObjectCommandInput, 6 | PutObjectCommand, 7 | type PutObjectCommandInput, 8 | S3Client 9 | } from '@aws-sdk/client-s3' 10 | 11 | import * as config from './config' 12 | 13 | // This storage client is designed to work with any S3-compatible storage provider. 14 | // For Cloudflare R2, see https://developers.cloudflare.com/r2/examples/aws/aws-sdk-js-v3/ 15 | 16 | const bucket = config.storage.bucket 17 | 18 | export const S3 = new S3Client({ 19 | region: process.env.S3_REGION ?? 'auto', 20 | endpoint: process.env.S3_ENDPOINT!, 21 | credentials: { 22 | accessKeyId: process.env.ACCESS_KEY_ID!, 23 | secretAccessKey: process.env.SECRET_ACCESS_KEY! 24 | } 25 | }) 26 | 27 | // This ensures that buckets are created automatically if they don't exist on 28 | // Cloudflare R2. It won't affect other providers. 29 | // @see https://developers.cloudflare.com/r2/examples/aws/custom-header/ 30 | S3.middlewareStack.add( 31 | (next, _) => async (args) => { 32 | const r = args.request as RequestInit 33 | r.headers = { 34 | 'cf-create-bucket-if-missing': 'true', 35 | ...r.headers 36 | } 37 | 38 | return next(args) 39 | }, 40 | { step: 'build', name: 'customHeaders' } 41 | ) 42 | 43 | export async function getObject( 44 | key: string, 45 | opts?: Omit 46 | ) { 47 | return S3.send(new GetObjectCommand({ Bucket: bucket, Key: key, ...opts })) 48 | } 49 | 50 | export async function putObject( 51 | key: string, 52 | value: PutObjectCommandInput['Body'], 53 | opts?: Omit 54 | ) { 55 | return S3.send( 56 | new PutObjectCommand({ Bucket: bucket, Key: key, Body: value, ...opts }) 57 | ) 58 | } 59 | 60 | export async function deleteObject( 61 | key: string, 62 | opts?: Omit 63 | ) { 64 | return S3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key, ...opts })) 65 | } 66 | 67 | export function getS3ObjectUrl(key: string) { 68 | return `${process.env.S3_ENDPOINT}/${bucket}/${key}` 69 | } 70 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import '@total-typescript/ts-reset' 2 | 3 | import type { Run } from './db' 4 | 5 | export type JobData = { 6 | runId: string 7 | } 8 | 9 | export type JobResult = { 10 | runId: string 11 | status: Run['status'] 12 | error?: string 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Model } from '@dexaai/dexter/model' 2 | import { Msg, type Prompt } from '@dexaai/dexter/prompt' 3 | import { deepmerge as deepmergeInit } from '@fastify/deepmerge' 4 | import type { Simplify } from 'type-fest' 5 | import type { IterableElement } from 'type-fest' 6 | 7 | import { 8 | type FluffyAssistantTools, 9 | type RunStepDetailsToolCallsObject 10 | } from '~/generated/oai' 11 | 12 | import * as retrieval from './retrieval' 13 | import type { File } from './db' 14 | import './prisma-json-types.d.ts' 15 | 16 | export type OAITypeToPrismaType> = Simplify< 17 | RequiredNonNullableObject< 18 | Omit< 19 | T, 20 | | 'created_at' 21 | | 'updated_at' 22 | | 'started_at' 23 | | 'cancelled_at' 24 | | 'completed_at' 25 | | 'expires_at' 26 | | 'failed_at' 27 | > & 28 | (T extends { created_at: number } ? { created_at: Date } : unknown) & 29 | (T extends { updated_at: number } ? { updated_at: Date } : unknown) & 30 | (T extends { started_at?: number } ? { started_at?: Date } : unknown) & 31 | (T extends { cancelled_at?: number } 32 | ? { cancelled_at?: Date } 33 | : unknown) & 34 | (T extends { completed_at?: number } 35 | ? { completed_at?: Date } 36 | : unknown) & 37 | (T extends { expires_at: number } ? { expires_at: Date } : unknown) & 38 | (T extends { failed_at: number } ? { failed_at: Date } : unknown) 39 | > 40 | > 41 | 42 | export type PrismaTypeToOAIType> = Simplify< 43 | RequiredNonNullableObject< 44 | Omit< 45 | T, 46 | | 'created_at' 47 | | 'updated_at' 48 | | 'started_at' 49 | | 'cancelled_at' 50 | | 'completed_at' 51 | | 'expires_at' 52 | | 'failed_at' 53 | > & 54 | (T extends { created_at: Date } ? { created_at: number } : unknown) & 55 | (T extends { updated_at: Date } ? { updated_at: number } : unknown) & 56 | (T extends { started_at?: Date } ? { started_at?: number } : unknown) & 57 | (T extends { cancelled_at?: Date } 58 | ? { cancelled_at?: number } 59 | : unknown) & 60 | (T extends { completed_at?: Date } 61 | ? { completed_at?: number } 62 | : unknown) & 63 | (T extends { expires_at: Date } ? { expires_at: number } : unknown) & 64 | (T extends { failed_at: Date } ? { failed_at: number } : unknown) 65 | > 66 | > 67 | 68 | export type RequiredNonNullableObject = { 69 | [P in keyof Required]: NonNullable 70 | } 71 | 72 | const dateKeys = [ 73 | 'created_at', 74 | 'updated_at', 75 | 'started_at', 76 | 'cancelled_at', 77 | 'completed_at', 78 | 'expires_at', 79 | 'failed_at' 80 | ] 81 | 82 | export function convertPrismaToOAI< 83 | T extends Record, 84 | U extends PrismaTypeToOAIType 85 | >(obj: T): U { 86 | obj = removeUndefinedAndNullValues(obj) 87 | 88 | for (const key of dateKeys) { 89 | if (key in obj && obj[key] instanceof Date) { 90 | ;(obj as any)[key] = ((obj[key] as Date).getTime() / 1000) | 0 91 | } 92 | } 93 | 94 | return obj as unknown as U 95 | } 96 | 97 | export function convertOAIToPrisma< 98 | T extends Record, 99 | U extends OAITypeToPrismaType 100 | >(obj: T): U { 101 | obj = removeUndefinedAndNullValues(obj) 102 | 103 | for (const key of dateKeys) { 104 | if (key in obj && obj[key]) { 105 | ;(obj as any)[key] = new Date(obj[key] as number) 106 | } 107 | } 108 | 109 | return obj as unknown as U 110 | } 111 | 112 | export function removeUndefinedAndNullValues>( 113 | obj: T 114 | ): RequiredNonNullableObject { 115 | Object.keys(obj).forEach( 116 | (key) => (obj[key] === undefined || obj[key] === null) && delete obj[key] 117 | ) 118 | return obj as RequiredNonNullableObject 119 | } 120 | 121 | export function getPrismaFindManyParams({ 122 | after, 123 | before, 124 | limit, 125 | order, 126 | defaultLimit = 10 127 | }: { 128 | after?: string 129 | before?: string 130 | limit?: string 131 | order?: string 132 | defaultLimit?: number 133 | } = {}) { 134 | const takeTemp = parseInt(limit ?? '', 10) 135 | const take = isNaN(takeTemp) ? defaultLimit : takeTemp 136 | 137 | const params: any = { 138 | take, 139 | orderBy: { 140 | created_at: order || 'desc' 141 | } 142 | } 143 | 144 | if (after) { 145 | params.cursor = { 146 | id: after 147 | } 148 | params.skip = 1 149 | } 150 | 151 | if (before) { 152 | params.where = { 153 | id: { 154 | lt: before 155 | } 156 | } 157 | } 158 | 159 | return params 160 | } 161 | 162 | export function getPaginatedObject< 163 | T extends Record & { id: string } 164 | >(data: T[], params: any) { 165 | return { 166 | data: data.map(convertPrismaToOAI), 167 | first_id: data[0]?.id, 168 | last_id: data[data.length - 1]?.id, 169 | has_more: data.length >= params.take, 170 | object: 'list' as const 171 | } 172 | } 173 | 174 | type DeepMerge = ReturnType 175 | export const deepMerge: DeepMerge = deepmergeInit() 176 | export const deepMergeArray: DeepMerge = deepmergeInit({ 177 | mergeArray: 178 | (opts) => 179 | (target: any[], source: any[]): any[] => { 180 | return target.map((value, index) => opts.deepmerge(value, source[index])) 181 | } 182 | }) 183 | 184 | export function convertAssistantToolsToChatMessageTools( 185 | tools: FluffyAssistantTools[] 186 | ): Model.Chat.Config['tools'] { 187 | return tools.map(convertAssistantToolToChatMessageTool) 188 | } 189 | 190 | export function convertAssistantToolToChatMessageTool( 191 | tool: FluffyAssistantTools 192 | ): IterableElement { 193 | switch (tool.type) { 194 | case 'function': 195 | return { 196 | type: 'function', 197 | function: tool.function! 198 | } 199 | 200 | case 'retrieval': 201 | return { 202 | type: 'function', 203 | function: retrieval.retrievalFunction.spec 204 | } 205 | 206 | case 'code_interpreter': 207 | return { 208 | type: 'function', 209 | function: { 210 | name: 'code_interpreter', 211 | description: 'TODO', // TODO 212 | parameters: {} // TODO 213 | } 214 | } 215 | 216 | default: 217 | throw new Error(`Invalid tool type: "${tool.type}"`) 218 | } 219 | } 220 | 221 | export function convertAssistantToolCallsToChatMessages( 222 | toolCalls: RunStepDetailsToolCallsObject[] 223 | ): Prompt.Msg[] { 224 | const toolCallMessage = Msg.toolCall( 225 | toolCalls.map((toolCall) => { 226 | switch (toolCall.type) { 227 | case 'function': 228 | return { 229 | id: toolCall.id, 230 | type: 'function', 231 | function: { 232 | name: toolCall.function!.name!, 233 | arguments: toolCall.function!.arguments! 234 | } 235 | } 236 | 237 | case 'retrieval': 238 | return { 239 | id: toolCall.id, 240 | type: 'function', 241 | function: { 242 | name: 'retrieval', 243 | // TODO: no idea if this is correct 244 | arguments: toolCall.retrieval?.input ?? '' 245 | } 246 | } 247 | 248 | case 'code_interpreter': 249 | return { 250 | id: toolCall.id, 251 | type: 'function', 252 | function: { 253 | name: 'code_interpreter', 254 | // TODO: no idea if this is correct 255 | arguments: toolCall.code_interpreter?.input ?? '' 256 | } 257 | } 258 | 259 | default: 260 | throw new Error(`Invalid tool call type: "${toolCall.type}"`) 261 | } 262 | }) 263 | ) 264 | 265 | const toolCallResults = toolCalls.map((toolCall) => { 266 | switch (toolCall.type) { 267 | case 'function': 268 | return Msg.toolResult(toolCall.function!.output, toolCall.id) 269 | 270 | case 'code_interpreter': 271 | // TODO: handle 'image' code_interpreter outputs 272 | return Msg.toolResult( 273 | toolCall 274 | .code_interpreter!.outputs.filter((o) => o.type === 'logs') 275 | .map((o) => o.logs) 276 | .filter(Boolean) 277 | .join('\n\n'), 278 | toolCall.id 279 | ) 280 | 281 | case 'retrieval': 282 | return Msg.toolResult( 283 | // TODO: use stringify helper from dexter 284 | JSON.stringify(toolCall.retrieval!.output, null, 2), 285 | toolCall.id 286 | ) 287 | 288 | default: 289 | throw new Error( 290 | `Invalid tool call type: "${toolCall.type}" for tool call: "${toolCall.id}"` 291 | ) 292 | } 293 | }) 294 | 295 | return [toolCallMessage as Prompt.Msg].concat(toolCallResults) 296 | } 297 | 298 | export function getNormalizedFileName(file: File) { 299 | // File names are prefixed by a content hash for uniqueness when stored to S3, 300 | // but we don't want to include this hash in the file names shown to the user. 301 | return file.filename.split('-').slice(1).join('-') 302 | } 303 | -------------------------------------------------------------------------------- /src/readme.md: -------------------------------------------------------------------------------- 1 | - `server/` - contains an OpenAI-compatible Assistants REST API powered by [Hono](https://hono.dev) 2 | - use `tsx src/server` to start a server instance 3 | - or use `node dist/server` after successfully building the project with `pnpm build` 4 | - `runner/` - contains the [BullMQ async task queue runner](https://docs.bullmq.io/) 5 | - use `tsx src/runner` to start a runner instance 6 | - or use `node dist/runner` after successfully building the project with `pnpm build` 7 | - `generated/` - contains auto-generated [zod schemas](https://zod.dev), types, and [@hono/zod-openapi routes](https://github.com/honojs/middleware/tree/main/packages/zod-openapi) 8 | - all code in this directory is auto-generated by [generate.ts](../bin/generate.ts) using [OpenAI's OpenAPI spec](https://github.com/openai/openai-openapi) as a source of truth 9 | - `lib/` - contains all the server code shared between `server` and the async task queue `runner` 10 | -------------------------------------------------------------------------------- /src/runner/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Msg, 3 | type Prompt, 4 | extractJsonObject, 5 | extractZodObject 6 | } from '@dexaai/dexter/prompt' 7 | import { Worker } from 'bullmq' 8 | import { asyncExitHook } from 'exit-hook' 9 | import { signalsByNumber } from 'human-signals' 10 | import pMap from 'p-map' 11 | import plur from 'plur' 12 | 13 | import * as config from '~/lib/config' 14 | import * as retrieval from '~/lib/retrieval' 15 | import type { RunStepDetailsToolCallsObject } from '~/generated/oai' 16 | import { type File, type Run, type RunStep, prisma } from '~/lib/db' 17 | import type { JobData, JobResult } from '~/lib/types' 18 | import { 19 | convertAssistantToolCallsToChatMessages, 20 | convertAssistantToolsToChatMessageTools, 21 | deepMergeArray, 22 | getNormalizedFileName 23 | } from '~/lib/utils' 24 | 25 | import { chatModel } from './models' 26 | 27 | // TODO: prevent run from infinite looping 28 | 29 | export const worker = new Worker( 30 | config.queue.name, 31 | async (job) => { 32 | if (job.name !== config.queue.threadRunJobName) { 33 | throw new Error(`Unknown job name: ${job.name}`) 34 | } 35 | 36 | const { runId } = job.data 37 | console.log(`Processing ${job.name} job "${job.id}" for run "${runId}"`) 38 | let jobErrorResult: JobResult | undefined 39 | 40 | async function checkRunStatus( 41 | run: Run, 42 | { strict = true }: { strict?: boolean } = {} 43 | ) { 44 | if (!run) { 45 | console.error(`Error job "${job.id}": Invalid run "${runId}"`) 46 | throw new Error(`Invalid run "${runId}"`) 47 | } 48 | 49 | if (run.status === 'cancelling') { 50 | run = await prisma.run.update({ 51 | where: { id: run.id }, 52 | data: { status: 'cancelled' } 53 | }) 54 | 55 | jobErrorResult = { 56 | runId: run.id, 57 | status: run.status 58 | } 59 | 60 | console.warn(`Job "${job.id}": run "${runId}" has been cancelled`) 61 | return jobErrorResult 62 | } 63 | 64 | if (!strict) { 65 | return null 66 | } 67 | 68 | if ( 69 | run.status !== 'queued' && 70 | run.status !== 'in_progress' && 71 | run.status !== 'requires_action' 72 | ) { 73 | jobErrorResult = { 74 | runId: run.id, 75 | status: run.status, 76 | error: `Run status is "${run.status}", cannot process run` 77 | } 78 | 79 | console.error( 80 | `Error job "${job.id}": invalid run "${runId}" status "${run.status}"` 81 | ) 82 | return jobErrorResult 83 | } 84 | 85 | const now = new Date() 86 | if (run.expires_at && run.expires_at < now) { 87 | run = await prisma.run.update({ 88 | where: { id: run.id }, 89 | data: { status: 'expired' } 90 | }) 91 | 92 | jobErrorResult = { 93 | runId: run.id, 94 | status: run.status, 95 | error: 'Run expired' 96 | } 97 | 98 | console.warn(`Job "${job.id}": run "${runId}" expired`) 99 | return jobErrorResult 100 | } 101 | 102 | await job.updateProgress(50) 103 | return null 104 | } 105 | 106 | async function pollRunStatus({ strict = true }: { strict?: boolean } = {}) { 107 | const run = await prisma.run.findUniqueOrThrow({ 108 | where: { id: runId } 109 | }) 110 | 111 | return checkRunStatus(run, { strict }) 112 | } 113 | 114 | let run: Run 115 | let runStep: RunStep 116 | let files: File[] | undefined 117 | 118 | do { 119 | try { 120 | const { 121 | run_steps: runSteps, 122 | assistant, 123 | thread, 124 | ...rest 125 | } = await prisma.run.findUniqueOrThrow({ 126 | where: { id: runId }, 127 | include: { thread: true, assistant: true, run_steps: true } 128 | }) 129 | run = rest 130 | 131 | if (await checkRunStatus(run)) { 132 | return jobErrorResult! 133 | } 134 | 135 | if (!thread) { 136 | console.error( 137 | `Error job "${job.id}": Invalid run "${runId}": thread does not exist` 138 | ) 139 | throw new Error(`Invalid run "${runId}": thread does not exist`) 140 | } 141 | 142 | if (!assistant) { 143 | console.error( 144 | `Error job "${job.id}": Invalid run "${runId}": assistant does not exist` 145 | ) 146 | throw new Error(`Invalid run "${runId}": assistant does not exist`) 147 | } 148 | 149 | if (!files) { 150 | files = await prisma.file.findMany({ 151 | where: { 152 | id: { 153 | in: run.file_ids 154 | } 155 | } 156 | }) 157 | } 158 | 159 | const startedAt = new Date() 160 | 161 | if (run.status !== 'in_progress') { 162 | run = await prisma.run.update({ 163 | where: { id: runId }, 164 | data: { 165 | status: 'in_progress', 166 | started_at: run.started_at ? undefined : startedAt 167 | } 168 | }) 169 | } 170 | 171 | const messages = await prisma.message.findMany({ 172 | where: { 173 | thread_id: thread.id 174 | }, 175 | orderBy: { 176 | created_at: 'asc' 177 | } 178 | }) 179 | 180 | // TODO: handle image_file attachments and annotations 181 | let chatMessages: Prompt.Msg[] = messages 182 | .map((msg) => { 183 | switch (msg.role) { 184 | case 'system': { 185 | const content = msg.content.find((c) => c.type === 'text')?.text 186 | ?.value 187 | if (!content) return null 188 | 189 | return Msg.system(content, { cleanContent: false }) 190 | } 191 | 192 | case 'assistant': { 193 | const content = msg.content.find((c) => c.type === 'text')?.text 194 | ?.value 195 | if (!content) return null 196 | 197 | return Msg.assistant(content, { cleanContent: false }) 198 | } 199 | 200 | case 'user': { 201 | const content = msg.content.find((c) => c.type === 'text')?.text 202 | ?.value 203 | if (!content) return null 204 | 205 | return Msg.user(content, { cleanContent: false }) 206 | } 207 | 208 | case 'function': 209 | throw new Error( 210 | 'Invalid message role "function" should be handled internally' 211 | ) 212 | 213 | case 'tool': 214 | throw new Error( 215 | 'Invalid message role "tool" should be handled internally' 216 | ) 217 | 218 | default: 219 | throw new Error(`Invalid message role "${msg.role}"`) 220 | } 221 | }) 222 | .filter(Boolean) 223 | 224 | // const isCodeInterpreterEnabled = run.tools?.some((tool) => tool.type === 'code_interpreter') 225 | const isRetrievalEnabled = 226 | run.tools?.some((tool) => tool.type === 'retrieval') && files.length 227 | 228 | // TODO: custom code interpreter instructions 229 | const assistantSystemtMessage: Prompt.Msg = Msg.system( 230 | `${run.instructions ? `${run.instructions}\n\n` : ''}${ 231 | isRetrievalEnabled 232 | ? `You can use the "retrieval" tool to retrieve relevant context from the following attached files:\n${files 233 | .map((file) => '- ' + getNormalizedFileName(file)) 234 | .join( 235 | '\n' 236 | )}\nMake sure to be extremely concise when using attached files.` 237 | : '' 238 | }` 239 | ) 240 | 241 | if (assistantSystemtMessage.content) { 242 | chatMessages = [assistantSystemtMessage].concat(chatMessages) 243 | } 244 | 245 | for (const runStep of runSteps) { 246 | if (runStep.type === 'tool_calls' && runStep.status === 'completed') { 247 | chatMessages = chatMessages.concat( 248 | convertAssistantToolCallsToChatMessages( 249 | runStep.step_details!.tool_calls! 250 | ) 251 | ) 252 | } 253 | } 254 | 255 | const chatCompletionParams: Parameters[0] = { 256 | messages: chatMessages, 257 | model: assistant.model, 258 | tools: convertAssistantToolsToChatMessageTools(assistant.tools), 259 | tool_choice: 260 | runSteps.length >= config.runs.maxRunSteps ? 'none' : 'auto' 261 | } 262 | 263 | console.log( 264 | `Job "${job.id}" run "${run.id}": >>> chat completion call`, 265 | chatCompletionParams 266 | ) 267 | 268 | // Invoke the chat model with the thread context, asssistant config, 269 | // any tool outputs from previous run steps, and available tools 270 | const res = await chatModel.run(chatCompletionParams) 271 | const { message } = res 272 | 273 | console.log( 274 | `Job "${job.id}" run "${run.id}": <<< chat completion call`, 275 | res 276 | ) 277 | 278 | // Check for run cancellation or expiration 279 | if (await pollRunStatus()) { 280 | return jobErrorResult! 281 | } 282 | 283 | if (message.role !== 'assistant') { 284 | throw new Error( 285 | `Unexpected error during run "${runId}": last message should be an "assistant" message` 286 | ) 287 | } 288 | 289 | if (Msg.isFuncCall(message)) { 290 | // this should never happen since we're using tools, not functions 291 | throw new Error( 292 | `Unexpected error during run "${runId}": received a function call, which should be a tools call` 293 | ) 294 | } else if (Msg.isToolCall(message)) { 295 | let status = 'in_progress' as Run['status'] 296 | 297 | const toolCalls = 298 | message.tool_calls.map( 299 | (toolCall) => { 300 | if (toolCall.type !== 'function') { 301 | throw new Error( 302 | `Unsupported tool call type "${toolCall.type}"` 303 | ) 304 | } 305 | 306 | if (toolCall.function.name === 'retrieval') { 307 | return { 308 | id: toolCall.id, 309 | type: 'retrieval', 310 | retrieval: extractJsonObject(toolCall.function.arguments) 311 | } 312 | } else if (toolCall.function.name === 'code_interpreter') { 313 | return { 314 | id: toolCall.id, 315 | type: 'code_interpreter', 316 | code_interpreter: { 317 | input: toolCall.function.arguments, 318 | // TODO: this shouldn't be required here typing-wise, because it doesn't have an output yet 319 | outputs: [] 320 | } 321 | } 322 | } else { 323 | status = 'requires_action' 324 | 325 | return { 326 | id: toolCall.id, 327 | type: 'function', 328 | function: { 329 | // TODO: this shouldn't be required here typing-wise, because it doesn't have an output yet 330 | output: '{}', 331 | ...toolCall.function 332 | } 333 | } 334 | } 335 | } 336 | ) 337 | 338 | const builtInToolCalls = toolCalls.filter( 339 | (toolCall) => toolCall.type !== 'function' 340 | ) 341 | const externalToolCalls = toolCalls.filter( 342 | (toolCall) => toolCall.type === 'function' 343 | ) 344 | 345 | runStep = await prisma.runStep.create({ 346 | data: { 347 | type: 'tool_calls', 348 | status: 'in_progress', 349 | assistant_id: assistant.id, 350 | thread_id: thread.id, 351 | run_id: run.id, 352 | step_details: { 353 | type: 'tool_calls', 354 | tool_calls: toolCalls 355 | } 356 | } 357 | }) 358 | 359 | if (status !== run.status) { 360 | // TODO: check for run cancellation or expiration 361 | run = await prisma.run.update({ 362 | where: { id: run.id }, 363 | data: { 364 | status, 365 | required_action: 366 | status !== 'requires_action' 367 | ? undefined 368 | : { 369 | type: 'submit_tool_outputs', 370 | submit_tool_outputs: { 371 | // TODO: this cast shouldn't be necessary 372 | tool_calls: toolCalls.filter( 373 | (toolCall) => toolCall.type === 'function' 374 | ) as any 375 | } 376 | } 377 | } 378 | }) 379 | 380 | console.log( 381 | `Job "${job.id}" run "${run.id}" status "${ 382 | run.status 383 | }" submit_tool_outputs waiting for ${ 384 | externalToolCalls.length 385 | } tool ${plur('call', externalToolCalls.length)}`, 386 | run 387 | ) 388 | } 389 | 390 | if (builtInToolCalls.length > 0) { 391 | console.log( 392 | `Job "${job.id}" run "${run.id}": invoking ${ 393 | builtInToolCalls.length 394 | } tool ${plur('call', builtInToolCalls.length)}` 395 | ) 396 | } 397 | 398 | // Handle retrieval and code_interpreter tool calls 399 | const toolResults: Record = {} 400 | 401 | await pMap( 402 | message.tool_calls, 403 | // eslint-disable-next-line no-loop-func 404 | async (toolCall) => { 405 | if (toolCall.function.name === 'retrieval') { 406 | const args = extractZodObject({ 407 | schema: retrieval.retrievalFunction.argsSchema, 408 | json: toolCall.function.arguments 409 | }) 410 | 411 | console.log( 412 | `Job "${job.id}" run "${run.id}": <<< invoking "retrieval" tool`, 413 | { 414 | ...args, 415 | files 416 | } 417 | ) 418 | 419 | const outputs = await retrieval.retrievalTool({ 420 | ...args, 421 | files: files! 422 | }) 423 | 424 | console.log( 425 | `Job "${job.id}" run "${run.id}": >>> "retrieval" tool`, 426 | { 427 | ...args, 428 | files 429 | }, 430 | outputs 431 | ) 432 | 433 | toolResults[toolCall.id] = outputs 434 | } else if (toolCall.function.name === 'code_interpreter') { 435 | // TODO: code_interpreter implementation 436 | console.error('TODO: code_interpreter implementation') 437 | toolResults[toolCall.id] = [ 438 | { 439 | type: 'logs', 440 | logs: 'Error: code_interpreter is not yet implemented' 441 | } 442 | ] 443 | } else { 444 | // `function` implementation is handled by the third-party developer 445 | // via `submit_tool_outputs` 446 | return 447 | } 448 | }, 449 | { 450 | concurrency: 8 451 | } 452 | ) 453 | 454 | // Check for run cancellation or expiration 455 | if (await pollRunStatus()) { 456 | return jobErrorResult! 457 | } 458 | 459 | if (Object.keys(toolResults).length > 0) { 460 | for (const toolCallId of Object.keys(toolResults)) { 461 | const toolResult = toolResults[toolCallId] 462 | const toolCall = toolCalls.find( 463 | (toolCall) => toolCall.id === toolCallId 464 | ) 465 | if (!toolCall) { 466 | throw new Error( 467 | `Invalid tool call id "${toolCallId}" in toolResults` 468 | ) 469 | } 470 | 471 | switch (toolCall.type) { 472 | case 'function': 473 | throw new Error( 474 | 'Invalid tool call type "function" should be resolved by "submit_tool_outputs"' 475 | ) 476 | 477 | case 'retrieval': 478 | toolCall.retrieval!.output = toolResult 479 | break 480 | 481 | case 'code_interpreter': 482 | toolCall.code_interpreter!.outputs = toolResult 483 | break 484 | 485 | default: 486 | throw new Error(`Invalid tool call type "${toolCall.type}"`) 487 | } 488 | } 489 | 490 | const completedAt = new Date() 491 | const isRunStepCompleted = status !== 'requires_action' 492 | 493 | // TODO: In-between steps, if isRunStepCompleted is `false`, we may 494 | // have received `submit_tool_outputs` for some tools, so we need to 495 | // include any possible external tool call updates in our update 496 | runStep = await prisma.runStep.findUniqueOrThrow({ 497 | where: { id: runStep.id } 498 | }) 499 | 500 | const mergedToolCalls = deepMergeArray( 501 | toolCalls, 502 | runStep.step_details!.tool_calls 503 | ) 504 | 505 | runStep = await prisma.runStep.update({ 506 | where: { id: runStep.id }, 507 | data: { 508 | status: isRunStepCompleted ? 'completed' : undefined, 509 | completed_at: isRunStepCompleted ? completedAt : undefined, 510 | step_details: { 511 | type: 'tool_calls', 512 | tool_calls: mergedToolCalls 513 | } 514 | } 515 | }) 516 | 517 | // If `isRunStepCompleted`, we will now loop because run.status 518 | // should be 'in_progress', else this job will finish with the run 519 | // having 'requires_action' status 520 | if (isRunStepCompleted) { 521 | continue 522 | } 523 | } else { 524 | // The job will finish with the run having 'requires_action' status 525 | } 526 | } else { 527 | const completedAt = new Date() 528 | 529 | // TODO: handle annotations 530 | const newMessage = await prisma.message.create({ 531 | data: { 532 | content: [ 533 | { 534 | type: 'text', 535 | text: { 536 | value: message.content!, 537 | annotations: [] 538 | } 539 | } 540 | ], 541 | role: message.role, 542 | assistant_id: assistant.id, 543 | thread_id: thread.id, 544 | run_id: run.id 545 | } 546 | }) 547 | 548 | await prisma.runStep.create({ 549 | data: { 550 | type: 'message_creation', 551 | status: 'completed', 552 | completed_at: completedAt, 553 | assistant_id: assistant.id, 554 | thread_id: thread.id, 555 | run_id: run.id, 556 | step_details: { 557 | type: 'message_creation', 558 | message_creation: { 559 | message_id: newMessage.id 560 | } 561 | } 562 | } 563 | }) 564 | 565 | run = await prisma.run.update({ 566 | where: { id: run.id }, 567 | data: { status: 'completed', completed_at: completedAt } 568 | }) 569 | } 570 | 571 | if (await pollRunStatus({ strict: false })) { 572 | return jobErrorResult! 573 | } 574 | 575 | console.log( 576 | `Job "${job.id}" run "${runId}" job done with run status "${run.status}"` 577 | ) 578 | 579 | return { 580 | runId, 581 | status: run.status 582 | } 583 | } catch (err: any) { 584 | console.error(`Error job "${job.id}" run "${runId}":`, err) 585 | 586 | await prisma.run.update({ 587 | where: { id: runId }, 588 | data: { 589 | status: 'failed', 590 | failed_at: new Date(), 591 | last_error: err.message 592 | } 593 | }) 594 | 595 | throw err 596 | } 597 | } while (true) 598 | }, 599 | { 600 | connection: config.queue.redisConfig, 601 | // TODO: for development, set this to 1 602 | concurrency: config.queue.concurrency, 603 | stalledInterval: config.queue.stalledInterval 604 | } 605 | ) 606 | 607 | console.log( 608 | `Runner started for queue "${config.queue.name}" listening for "${config.queue.threadRunJobName}" jobs` 609 | ) 610 | 611 | asyncExitHook( 612 | async (signal: number) => { 613 | console.log( 614 | `Received ${ 615 | signalsByNumber[signal - 128]?.name ?? signal - 128 616 | }; closing runner...` 617 | ) 618 | 619 | // NOTE: the order of these calls is important for edge cases 620 | await worker.close() 621 | await prisma.$disconnect() 622 | }, 623 | { 624 | wait: config.processGracefulExitWaitTimeMs 625 | } 626 | ) 627 | -------------------------------------------------------------------------------- /src/runner/models.ts: -------------------------------------------------------------------------------- 1 | import { ChatModel, createOpenAIClient } from '@dexaai/dexter/model' 2 | 3 | import * as config from '~/lib/config' 4 | 5 | // TODO: support non-OpenAI models 6 | export const chatModel = new ChatModel({ 7 | client: createOpenAIClient(), 8 | debug: config.isDev 9 | }) 10 | -------------------------------------------------------------------------------- /src/server/assistant-files.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIHono } from '@hono/zod-openapi' 2 | import createHttpError from 'http-errors' 3 | 4 | import * as routes from '~/generated/oai-routes' 5 | import * as utils from '~/lib/utils' 6 | import { prisma } from '~/lib/db' 7 | import { processFileForAssistant } from '~/lib/retrieval' 8 | 9 | const app: OpenAPIHono = new OpenAPIHono() 10 | 11 | app.openapi(routes.listAssistantFiles, async (c) => { 12 | const { assistant_id } = c.req.valid('param') 13 | const query = c.req.valid('query') 14 | console.log('listAssistantFiles', { assistant_id, query }) 15 | 16 | const params = utils.getPrismaFindManyParams(query) 17 | const res = await prisma.assistantFile.findMany({ 18 | ...params, 19 | where: { 20 | ...params?.where, 21 | assistant_id 22 | } 23 | }) 24 | 25 | return c.jsonT(utils.getPaginatedObject(res, params)) 26 | }) 27 | 28 | app.openapi(routes.createAssistantFile, async (c) => { 29 | const { assistant_id } = c.req.valid('param') 30 | const body = c.req.valid('json') 31 | console.log('createAssistantFile', { assistant_id, body }) 32 | const { file_id } = body 33 | 34 | // Ensure assistant exists 35 | const assistant = await prisma.assistant.findUniqueOrThrow({ 36 | where: { id: assistant_id } 37 | }) 38 | if (assistant.file_ids.includes(file_id)) { 39 | throw createHttpError(409, 'File is already attached to assistant') 40 | } 41 | 42 | const file = await prisma.file.findUniqueOrThrow({ 43 | where: { id: file_id } 44 | }) 45 | 46 | const assistantFile = await prisma.assistantFile.create({ 47 | data: { 48 | id: file_id, 49 | assistant_id 50 | } 51 | }) 52 | 53 | // Process file for assistant (knowledge retrieval pre-processing) 54 | await processFileForAssistant(file) 55 | await prisma.assistant.update({ 56 | where: { id: assistant_id }, 57 | data: { 58 | file_ids: { 59 | push: file_id 60 | } 61 | } 62 | }) 63 | 64 | return c.jsonT(utils.convertPrismaToOAI(assistantFile)) 65 | }) 66 | 67 | app.openapi(routes.deleteAssistantFile, async (c) => { 68 | const { assistant_id, file_id } = c.req.valid('param') 69 | console.log('deleteAssistantFile', { assistant_id, file_id }) 70 | 71 | const assistantFile = await prisma.assistantFile.delete({ 72 | where: { 73 | id: file_id, 74 | assistant_id 75 | } 76 | }) 77 | if (!assistantFile) return c.notFound() as any 78 | 79 | return c.jsonT({ 80 | deleted: true, 81 | id: assistantFile.id, 82 | object: 'assistant.file.deleted' as const 83 | }) 84 | }) 85 | 86 | app.openapi(routes.getAssistantFile, async (c) => { 87 | const { assistant_id, file_id } = c.req.valid('param') 88 | console.log('getAssistantFile', { assistant_id, file_id }) 89 | 90 | const assistantFile = await prisma.assistantFile.findUniqueOrThrow({ 91 | where: { 92 | id: file_id, 93 | assistant_id 94 | } 95 | }) 96 | if (!assistantFile) return c.notFound() as any 97 | 98 | return c.jsonT(utils.convertPrismaToOAI(assistantFile)) 99 | }) 100 | 101 | export default app 102 | -------------------------------------------------------------------------------- /src/server/assistants.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIHono } from '@hono/zod-openapi' 2 | import createHttpError from 'http-errors' 3 | 4 | import * as routes from '~/generated/oai-routes' 5 | import * as utils from '~/lib/utils' 6 | import { prisma } from '~/lib/db' 7 | 8 | const app: OpenAPIHono = new OpenAPIHono() 9 | 10 | app.openapi(routes.listAssistants, async (c) => { 11 | const query = c.req.valid('query') 12 | console.log('listAssistantFiles', { query }) 13 | 14 | const params = utils.getPrismaFindManyParams(query) 15 | const res = await prisma.assistant.findMany(params) 16 | 17 | return c.jsonT(utils.getPaginatedObject(res, params)) 18 | }) 19 | 20 | app.openapi(routes.createAssistant, async (c) => { 21 | const body = c.req.valid('json') 22 | console.log('createAssistant', { body }) 23 | 24 | if (body.file_ids?.length) { 25 | const hasRetrieval = body.tools?.some((tool) => tool.type === 'retrieval') 26 | const hasCodeInterpreter = body.tools?.some( 27 | (tool) => tool.type === 'code_interpreter' 28 | ) 29 | 30 | if (!hasRetrieval && !hasCodeInterpreter) { 31 | throw createHttpError( 32 | 400, 33 | 'file_ids are only supported if retrieval or code_interpreter tools are enabled.' 34 | ) 35 | } 36 | 37 | // TODO: check file_ids exist 38 | // TODO: check file_ids have purpose `assistants` or have an attached `AssistantFile`? 39 | // NOTE: These checks are probably overkill, and for our use case, it's likely 40 | // better to err on the side of being more permissive. 41 | } 42 | 43 | const assistant = await prisma.assistant.create({ 44 | data: utils.convertOAIToPrisma(body) 45 | }) 46 | 47 | return c.jsonT(utils.convertPrismaToOAI(assistant)) 48 | }) 49 | 50 | app.openapi(routes.getAssistant, async (c) => { 51 | const { assistant_id } = c.req.valid('param') 52 | console.log('getAssistant', { assistant_id }) 53 | 54 | const assistant = await prisma.assistant.findUniqueOrThrow({ 55 | where: { 56 | id: assistant_id 57 | } 58 | }) 59 | if (!assistant) return c.notFound() as any 60 | 61 | return c.jsonT(utils.convertPrismaToOAI(assistant)) 62 | }) 63 | 64 | app.openapi(routes.modifyAssistant, async (c) => { 65 | const { assistant_id } = c.req.valid('param') 66 | const body = c.req.valid('json') 67 | console.log('modifyAssistant', { assistant_id, body }) 68 | 69 | const assistant = await prisma.assistant.update({ 70 | where: { 71 | id: assistant_id 72 | }, 73 | data: utils.convertOAIToPrisma(body) 74 | }) 75 | if (!assistant) return c.notFound() as any 76 | 77 | return c.jsonT(utils.convertPrismaToOAI(assistant)) 78 | }) 79 | 80 | app.openapi(routes.deleteAssistant, async (c) => { 81 | const { assistant_id } = c.req.valid('param') 82 | console.log('deleteAssistant', { assistant_id }) 83 | 84 | const assistant = await prisma.assistant.delete({ 85 | where: { 86 | id: assistant_id 87 | } 88 | }) 89 | if (!assistant) return c.notFound() as any 90 | 91 | return c.jsonT({ 92 | deleted: true, 93 | id: assistant.id, 94 | object: 'assistant.deleted' as const 95 | }) 96 | }) 97 | 98 | export default app 99 | -------------------------------------------------------------------------------- /src/server/files.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIHono } from '@hono/zod-openapi' 2 | import { sha256 } from 'crypto-hash' 3 | import { fileTypeFromBlob } from 'file-type' 4 | import createHttpError from 'http-errors' 5 | 6 | import * as routes from '~/generated/oai-routes' 7 | import * as storage from '~/lib/storage' 8 | import * as utils from '~/lib/utils' 9 | import { prisma } from '~/lib/db' 10 | import { processFileForAssistant } from '~/lib/retrieval' 11 | 12 | const app: OpenAPIHono = new OpenAPIHono() 13 | 14 | app.openapi(routes.listFiles, async (c) => { 15 | const { purpose } = c.req.valid('query') 16 | console.log('listFiles', { purpose }) 17 | 18 | const res = await prisma.file.findMany({ 19 | where: { 20 | purpose 21 | } 22 | }) 23 | 24 | return c.jsonT({ 25 | // TODO: this cast shouldn't be necessary 26 | data: res.map(utils.convertPrismaToOAI) as any, 27 | object: 'list' as const 28 | }) 29 | }) 30 | 31 | app.openapi(routes.createFile, async (c) => { 32 | const body = c.req.valid('form') 33 | console.log('createFile', { body }) 34 | 35 | const { file: data, purpose } = body 36 | const dataAsFile = data as File 37 | const dataAsArrayBuffer = await dataAsFile.arrayBuffer() 38 | const dataAsUint8Array = new Uint8Array(dataAsArrayBuffer) 39 | 40 | const fileType = await fileTypeFromBlob(dataAsFile) 41 | const fileHash = await sha256(dataAsArrayBuffer) 42 | const fileName = `${fileHash}${ 43 | dataAsFile.name 44 | ? `-${dataAsFile.name}` 45 | : fileType?.ext 46 | ? `.${fileType.ext}` 47 | : '' 48 | }` 49 | const contentType = fileType?.mime || 'application/octet-stream' 50 | 51 | const res = await storage.putObject(fileName, dataAsUint8Array, { 52 | ContentType: contentType 53 | }) 54 | console.log('uploaded file', fileName, res) 55 | 56 | let file = await prisma.file.create({ 57 | data: { 58 | filename: fileName, 59 | status: 'uploaded', 60 | bytes: dataAsArrayBuffer.byteLength, 61 | purpose 62 | } 63 | }) 64 | if (!file) return c.notFound() as any 65 | 66 | if (purpose === 'assistants') { 67 | // Process file for assistant (knowledge retrieval pre-processing) 68 | await processFileForAssistant(file) 69 | 70 | if (file.status !== 'uploaded') { 71 | file = await prisma.file.update({ 72 | where: { id: file.id }, 73 | data: file 74 | }) 75 | } 76 | } 77 | 78 | return c.jsonT(utils.convertPrismaToOAI(file)) 79 | }) 80 | 81 | app.openapi(routes.deleteFile, async (c) => { 82 | const { file_id } = c.req.valid('param') 83 | console.log('deleteFile', { file_id }) 84 | 85 | const file = await prisma.file.delete({ 86 | where: { 87 | id: file_id 88 | } 89 | }) 90 | if (!file) return c.notFound() as any 91 | 92 | return c.jsonT({ 93 | deleted: true, 94 | id: file.id, 95 | object: 'file' as const 96 | }) 97 | }) 98 | 99 | app.openapi(routes.retrieveFile, async (c) => { 100 | const { file_id } = c.req.valid('param') 101 | console.log('retrieveFile', { file_id }) 102 | 103 | const file = await prisma.file.findUniqueOrThrow({ 104 | where: { 105 | id: file_id 106 | } 107 | }) 108 | if (!file) return c.notFound() as any 109 | 110 | return c.jsonT(utils.convertPrismaToOAI(file)) 111 | }) 112 | 113 | app.openapi(routes.downloadFile, async (c) => { 114 | const { file_id } = c.req.valid('param') 115 | console.log('downloadFile', { file_id }) 116 | 117 | const file = await prisma.file.findUniqueOrThrow({ 118 | where: { 119 | id: file_id 120 | } 121 | }) 122 | if (!file) return c.notFound() as any 123 | 124 | const object = await storage.getObject(file.filename) 125 | // TODO: what encoding should we use here? it's not specified by the spec 126 | const body = await object.Body?.transformToString() 127 | if (!body) { 128 | throw createHttpError(500, 'Failed to retrieve file') 129 | } 130 | 131 | return c.json(body) 132 | }) 133 | 134 | export default app 135 | -------------------------------------------------------------------------------- /src/server/index.ts: -------------------------------------------------------------------------------- 1 | import { serve } from '@hono/node-server' 2 | import { OpenAPIHono } from '@hono/zod-openapi' 3 | import 'dotenv/config' 4 | import { asyncExitHook } from 'exit-hook' 5 | import { signalsByNumber } from 'human-signals' 6 | 7 | import * as config from '~/lib/config' 8 | import { prisma } from '~/lib/db' 9 | import { queue } from '~/lib/queue' 10 | 11 | import assistantFiles from './assistant-files' 12 | import assistants from './assistants' 13 | import files from './files' 14 | import messageFiles from './message-files' 15 | import messages from './messages' 16 | import runSteps from './run-steps' 17 | import runs from './runs' 18 | import threads from './threads' 19 | 20 | const app = new OpenAPIHono() 21 | 22 | app.use('*', async function errorHandler(c, next) { 23 | try { 24 | // Useful for debugging 25 | // const body = await c.req.formData() 26 | // console.log(body.get('file')) 27 | 28 | await next() 29 | } catch (err: any) { 30 | console.error('ERROR', err.message) 31 | const statusCode = err.statusCode || err.status 32 | 33 | // handle https://github.com/jshttp/http-errors 34 | if (statusCode) { 35 | c.status(statusCode) 36 | if (err.message) { 37 | c.text(err.message) 38 | } 39 | return 40 | } 41 | 42 | // handle prisma errors 43 | if (err.code === 'P2025') { 44 | return c.notFound() as any 45 | } 46 | 47 | if (err.$metadata?.httpStatusCode === 404) { 48 | return c.notFound() as any 49 | } 50 | 51 | throw err 52 | } 53 | }) 54 | 55 | app.route('', files) 56 | app.route('', assistants) 57 | app.route('', assistantFiles) 58 | app.route('', threads) 59 | app.route('', messages) 60 | app.route('', messageFiles) 61 | app.route('', runs) 62 | app.route('', runSteps) 63 | 64 | // TODO: these values should be taken from the source openapi spec 65 | app.doc('/openapi', { 66 | openapi: '3.0.0', 67 | info: { 68 | version: '2.0.0', 69 | title: 'OpenAPI' 70 | } 71 | }) 72 | 73 | const server = serve({ 74 | fetch: app.fetch, 75 | port: config.port 76 | }) 77 | 78 | console.log(`Server listening on port ${config.port}`) 79 | 80 | if (config.queue.startRunner) { 81 | await import('~/runner/index') 82 | } 83 | 84 | asyncExitHook( 85 | async (signal: number) => { 86 | console.log( 87 | `Received signal ${ 88 | signalsByNumber[signal - 128]?.name ?? signal - 128 89 | }; closing server...` 90 | ) 91 | 92 | // NOTE: the order of these calls is important for edge cases 93 | // TODO: awaiting server.close seems to cause errors 94 | // await promisify(server.close)() 95 | server.close() 96 | await queue.close() 97 | await prisma.$disconnect() 98 | }, 99 | { 100 | wait: config.processGracefulExitWaitTimeMs 101 | } 102 | ) 103 | -------------------------------------------------------------------------------- /src/server/message-files.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIHono } from '@hono/zod-openapi' 2 | 3 | import * as routes from '~/generated/oai-routes' 4 | import * as utils from '~/lib/utils' 5 | import { prisma } from '~/lib/db' 6 | 7 | const app: OpenAPIHono = new OpenAPIHono() 8 | 9 | app.openapi(routes.listMessageFiles, async (c) => { 10 | const { thread_id, message_id } = c.req.valid('param') 11 | const query = c.req.valid('query') 12 | console.log('listMessageFiles', { thread_id, message_id, query }) 13 | 14 | const params = utils.getPrismaFindManyParams(query) 15 | const res = await prisma.messageFile.findMany({ 16 | ...params, 17 | where: { 18 | ...params?.where, 19 | thread_id, 20 | message_id 21 | } 22 | }) 23 | 24 | return c.jsonT(utils.getPaginatedObject(res, params)) 25 | }) 26 | 27 | app.openapi(routes.getMessageFile, async (c) => { 28 | const { thread_id, message_id, file_id } = c.req.valid('param') 29 | console.log('getMessageFile', { thread_id, message_id, file_id }) 30 | 31 | const message = await prisma.message.findUniqueOrThrow({ 32 | where: { 33 | id: message_id 34 | } 35 | }) 36 | if (message.thread_id !== thread_id) return c.notFound() as any 37 | 38 | const messageFile = await prisma.messageFile.findUniqueOrThrow({ 39 | where: { 40 | id: file_id 41 | } 42 | }) 43 | if (messageFile.message_id !== message_id) return c.notFound() as any 44 | 45 | return c.jsonT(utils.convertPrismaToOAI(messageFile)) 46 | }) 47 | 48 | export default app 49 | -------------------------------------------------------------------------------- /src/server/messages.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIHono } from '@hono/zod-openapi' 2 | import createError from 'http-errors' 3 | 4 | import * as routes from '~/generated/oai-routes' 5 | import * as utils from '~/lib/utils' 6 | import { prisma } from '~/lib/db' 7 | 8 | const app: OpenAPIHono = new OpenAPIHono() 9 | 10 | app.openapi(routes.listMessages, async (c) => { 11 | const { thread_id } = c.req.valid('param') 12 | const query = c.req.valid('query') 13 | console.log('listAssistantFiles', { thread_id, query }) 14 | 15 | const params = utils.getPrismaFindManyParams(query) 16 | const res = await prisma.message.findMany({ 17 | orderBy: { 18 | created_at: 'desc' 19 | }, 20 | ...params, 21 | where: { 22 | ...params?.where, 23 | thread_id 24 | } 25 | }) 26 | 27 | // TODO: assistant_id and run_id may not exist here, but the output 28 | // types are too strict 29 | return c.jsonT(utils.getPaginatedObject(res, params) as any) 30 | }) 31 | 32 | app.openapi(routes.createMessage, async (c) => { 33 | const { thread_id } = c.req.valid('param') 34 | const body = c.req.valid('json') 35 | console.log('createMessage', { thread_id, body }) 36 | 37 | if (body.file_ids && body.file_ids.length > 10) { 38 | throw createError(400, 'Too many files') 39 | } 40 | 41 | if (body.role !== 'user') { 42 | throw new Error('createMessage only accepts "user" messages') 43 | } 44 | 45 | const { content, ...data } = utils.convertOAIToPrisma(body) 46 | 47 | const res = await prisma.message.create({ 48 | data: { 49 | ...data, 50 | content: [ 51 | { 52 | type: 'text', 53 | text: { 54 | value: content, 55 | annotations: [] 56 | } 57 | } 58 | ], 59 | thread_id 60 | } 61 | }) 62 | if (!res) return c.notFound() as any 63 | 64 | return c.jsonT(utils.convertPrismaToOAI(res)) 65 | }) 66 | 67 | app.openapi(routes.getMessage, async (c) => { 68 | const { thread_id, message_id } = c.req.valid('param') 69 | console.log('getMessage', { thread_id, message_id }) 70 | 71 | const res = await prisma.message.findUniqueOrThrow({ 72 | where: { 73 | id: message_id, 74 | thread_id 75 | } 76 | }) 77 | if (!res) return c.notFound() as any 78 | 79 | return c.jsonT(utils.convertPrismaToOAI(res)) 80 | }) 81 | 82 | app.openapi(routes.modifyMessage, async (c) => { 83 | const { thread_id, message_id } = c.req.valid('param') 84 | const body = c.req.valid('json') 85 | console.log('modifyMessage', { thread_id, message_id, body }) 86 | 87 | const res = await prisma.message.update({ 88 | where: { 89 | id: message_id, 90 | thread_id 91 | }, 92 | data: utils.convertOAIToPrisma(body) 93 | }) 94 | if (!res) return c.notFound() as any 95 | 96 | return c.jsonT(utils.convertPrismaToOAI(res)) 97 | }) 98 | 99 | export default app 100 | -------------------------------------------------------------------------------- /src/server/run-steps.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIHono } from '@hono/zod-openapi' 2 | 3 | import * as routes from '~/generated/oai-routes' 4 | import * as utils from '~/lib/utils' 5 | import { prisma } from '~/lib/db' 6 | 7 | const app: OpenAPIHono = new OpenAPIHono() 8 | 9 | app.openapi(routes.listRunSteps, async (c) => { 10 | const { thread_id, run_id } = c.req.valid('param') 11 | const query = c.req.valid('query') 12 | console.log('listRunSteps', { thread_id, run_id, query }) 13 | 14 | const params = utils.getPrismaFindManyParams(query) 15 | const res = await prisma.runStep.findMany({ 16 | ...params, 17 | where: { 18 | ...params?.where, 19 | thread_id, 20 | run_id 21 | } 22 | }) 23 | 24 | // TODO: figure out why the types aren't working here 25 | return c.jsonT(utils.getPaginatedObject(res, params) as any) 26 | }) 27 | 28 | app.openapi(routes.getRunStep, async (c) => { 29 | const { thread_id, run_id, step_id } = c.req.valid('param') 30 | console.log('getRunStep', { thread_id, run_id, step_id }) 31 | 32 | const res = await prisma.runStep.findUniqueOrThrow({ 33 | where: { 34 | id: step_id, 35 | thread_id, 36 | run_id 37 | } 38 | }) 39 | if (!res) return c.notFound() as any 40 | 41 | return c.jsonT(utils.convertPrismaToOAI(res)) 42 | }) 43 | 44 | export default app 45 | -------------------------------------------------------------------------------- /src/server/runs.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIHono } from '@hono/zod-openapi' 2 | import { Prisma } from '@prisma/client' 3 | import createHttpError from 'http-errors' 4 | 5 | import * as routes from '~/generated/oai-routes' 6 | import * as config from '~/lib/config' 7 | import * as utils from '~/lib/utils' 8 | import { createThread } from '~/lib/create-thread' 9 | import { prisma } from '~/lib/db' 10 | import { getJobId, queue } from '~/lib/queue' 11 | 12 | const app: OpenAPIHono = new OpenAPIHono() 13 | 14 | app.openapi(routes.listRuns, async (c) => { 15 | const { thread_id } = c.req.valid('param') 16 | const query = c.req.valid('query') 17 | console.log('listRuns', { thread_id, query }) 18 | 19 | const params = utils.getPrismaFindManyParams(query) 20 | const res = await prisma.run.findMany({ 21 | ...params, 22 | where: { 23 | ...params?.where, 24 | thread_id 25 | } 26 | }) 27 | 28 | // TODO: figure out why the types aren't working here 29 | return c.jsonT(utils.getPaginatedObject(res, params) as any) 30 | }) 31 | 32 | app.openapi(routes.createThreadAndRun, async (c) => { 33 | const body = c.req.valid('json') 34 | console.log('createThreadAndRun', { body }) 35 | 36 | const assistant = await prisma.assistant.findUniqueOrThrow({ 37 | where: { 38 | id: body.assistant_id 39 | } 40 | }) 41 | 42 | const { thread: threadData, ...data } = utils.convertOAIToPrisma(body) 43 | const { thread } = await createThread(threadData) 44 | 45 | const now = new Date().getTime() 46 | const run = await prisma.run.create({ 47 | data: { 48 | // @ts-ignore tools may be overridden by params 49 | tools: assistant.tools, 50 | file_ids: assistant.file_ids, 51 | ...utils.convertOAIToPrisma(data), 52 | thread_id: thread.id, 53 | status: 'queued' as const, 54 | expires_at: new Date(now + config.runs.maxRunTime) 55 | } 56 | }) 57 | 58 | // Kick off async task 59 | const job = await queue.add( 60 | config.queue.threadRunJobName, 61 | { runId: run.id }, 62 | { 63 | jobId: getJobId(run) 64 | } 65 | ) 66 | console.log('job', job.asJSON()) 67 | 68 | return c.jsonT(utils.convertPrismaToOAI(run)) 69 | }) 70 | 71 | app.openapi(routes.createRun, async (c) => { 72 | const { thread_id } = c.req.valid('param') 73 | const body = c.req.valid('json') 74 | console.log('createRun', { thread_id, body }) 75 | 76 | // Ensure the assistant exists 77 | const assistant = await prisma.assistant.findUniqueOrThrow({ 78 | where: { id: body.assistant_id } 79 | }) 80 | 81 | // Ensure the thread exists 82 | await prisma.thread.findUniqueOrThrow({ 83 | where: { id: thread_id } 84 | }) 85 | 86 | const now = new Date().getTime() 87 | const run = await prisma.run.create({ 88 | data: { 89 | // @ts-ignore tools may be overridden by params 90 | tools: assistant.tools, 91 | file_ids: assistant.file_ids, 92 | ...utils.convertOAIToPrisma(body), 93 | thread_id, 94 | status: 'queued' as const, 95 | expires_at: new Date(now + config.runs.maxRunTime) 96 | } 97 | }) 98 | 99 | // Kick off async task 100 | const job = await queue.add( 101 | config.queue.threadRunJobName, 102 | { runId: run.id }, 103 | { 104 | jobId: getJobId(run) 105 | } 106 | ) 107 | console.log('job', job.asJSON()) 108 | 109 | return c.jsonT(utils.convertPrismaToOAI(run)) 110 | }) 111 | 112 | app.openapi(routes.getRun, async (c) => { 113 | const { thread_id, run_id } = c.req.valid('param') 114 | console.log('getRun', { thread_id, run_id }) 115 | 116 | const run = await prisma.run.findUniqueOrThrow({ 117 | where: { 118 | id: run_id, 119 | thread_id 120 | } 121 | }) 122 | if (!run) return c.notFound() as any 123 | 124 | return c.jsonT(utils.convertPrismaToOAI(run)) 125 | }) 126 | 127 | app.openapi(routes.modifyRun, async (c) => { 128 | const { thread_id, run_id } = c.req.valid('param') 129 | const body = c.req.valid('json') 130 | console.log('modifyRun', { thread_id, run_id, body }) 131 | 132 | const run = await prisma.run.update({ 133 | where: { 134 | id: run_id, 135 | thread_id 136 | }, 137 | data: utils.convertOAIToPrisma(body) 138 | }) 139 | if (!run) return c.notFound() as any 140 | 141 | return c.jsonT(utils.convertPrismaToOAI(run)) 142 | }) 143 | 144 | app.openapi(routes.submitToolOuputsToRun, async (c) => { 145 | const { thread_id, run_id } = c.req.valid('param') 146 | const body = c.req.valid('json') 147 | console.log('submitToolOuputsToRun', { thread_id, run_id, body }) 148 | 149 | let run = await prisma.run.findUniqueOrThrow({ 150 | where: { 151 | id: run_id, 152 | thread_id 153 | } 154 | }) 155 | if (!run) return c.notFound() as any 156 | 157 | let runStep = await prisma.runStep.findFirstOrThrow({ 158 | where: { 159 | run_id, 160 | type: 'tool_calls' as const 161 | }, 162 | orderBy: { 163 | created_at: 'desc' 164 | } 165 | }) 166 | if (!runStep) return c.notFound() as any 167 | 168 | // TODO: validate body.tool_outputs against run.tools 169 | 170 | switch (run.status) { 171 | case 'cancelled': 172 | throw createHttpError( 173 | 400, 174 | `Run status is "${run.status}", cannot submit tool outputs` 175 | ) 176 | 177 | case 'cancelling': 178 | throw createHttpError( 179 | 400, 180 | `Run status is "${run.status}", cannot submit tool outputs` 181 | ) 182 | 183 | case 'completed': 184 | throw createHttpError( 185 | 400, 186 | `Run status is "${run.status}", cannot submit tool outputs` 187 | ) 188 | 189 | case 'expired': 190 | throw createHttpError( 191 | 400, 192 | `Run status is "${run.status}", cannot submit tool outputs` 193 | ) 194 | 195 | case 'failed': 196 | throw createHttpError( 197 | 400, 198 | `Run status is "${run.status}", cannot submit tool outputs` 199 | ) 200 | 201 | case 'in_progress': 202 | throw createHttpError( 203 | 400, 204 | `Run status is "${run.status}", cannot submit tool outputs` 205 | ) 206 | 207 | case 'queued': 208 | throw createHttpError( 209 | 400, 210 | `Run status is "${run.status}", cannot submit tool outputs` 211 | ) 212 | 213 | case 'requires_action': { 214 | const requiredAction = run.required_action 215 | if (!requiredAction) { 216 | throw createHttpError( 217 | 500, 218 | `Run status is "${run.status}", but missing "run.required_action"` 219 | ) 220 | } 221 | 222 | if (requiredAction.type !== 'submit_tool_outputs') { 223 | throw createHttpError( 224 | 500, 225 | `Run status is "${run.status}", but "run.required_action.type" is not "submit_tool_outputs"` 226 | ) 227 | } 228 | 229 | // TODO: validate requiredAction.submit_tool_outputs 230 | 231 | const toolCalls = runStep.step_details?.tool_calls 232 | if (!toolCalls) throw createHttpError(500, 'Invalid tool call') 233 | 234 | for (const toolOutput of body.tool_outputs) { 235 | const toolCall = toolCalls.find( 236 | (toolCall) => toolCall.id === toolOutput.tool_call_id! 237 | ) 238 | if (!toolCall) throw createHttpError(400, 'Invalid tool call') 239 | 240 | switch (toolCall.type) { 241 | case 'code_interpreter': 242 | // TODO 243 | // toolCall.code_interpreter?.outputs 244 | throw createHttpError( 245 | 400, 246 | 'Error third-party code_interpreter tool calls are not supported at this time' 247 | ) 248 | 249 | case 'function': 250 | toolCall.function!.output = toolOutput.output! 251 | break 252 | 253 | case 'retrieval': 254 | // TODO 255 | throw createHttpError( 256 | 400, 257 | 'Error third-party retrieval tool calls are not supported at this time' 258 | ) 259 | 260 | default: 261 | throw createHttpError(500, 'Invalid tool call type') 262 | } 263 | } 264 | 265 | // TODO: update corresponding ToolCall? 266 | runStep.status = 'completed' 267 | 268 | const { id, object, created_at, ...runStepUpdate } = runStep as any 269 | runStep = await prisma.runStep.update({ 270 | where: { id: runStep.id }, 271 | data: runStepUpdate 272 | }) 273 | 274 | run = await prisma.run.update({ 275 | where: { id: run.id }, 276 | data: { status: 'queued', required_action: Prisma.JsonNull } 277 | }) 278 | 279 | // Resume async task 280 | const job = await queue.add( 281 | config.queue.threadRunJobName, 282 | { runId: run.id }, 283 | { 284 | jobId: getJobId(run, runStep) 285 | } 286 | ) 287 | console.log('job', job.asJSON()) 288 | break 289 | } 290 | 291 | default: 292 | throw createHttpError(500, 'Invalid tool call type') 293 | } 294 | 295 | return c.jsonT(utils.convertPrismaToOAI(run)) 296 | }) 297 | 298 | app.openapi(routes.cancelRun, async (c) => { 299 | const { thread_id, run_id } = c.req.valid('param') 300 | console.log('cancelRun', { thread_id, run_id }) 301 | 302 | let run = await prisma.run.update({ 303 | where: { 304 | id: run_id, 305 | thread_id 306 | }, 307 | data: { 308 | status: 'cancelling', 309 | cancelled_at: new Date() 310 | }, 311 | include: { run_steps: true } 312 | }) 313 | if (!run) return c.notFound() as any 314 | 315 | try { 316 | // Attempt to remove the cancelled run from the async task queue 317 | const res = await Promise.all([ 318 | queue.remove(getJobId(run)), 319 | run.run_steps.length 320 | ? queue.remove(getJobId(run, run.run_steps[run.run_steps.length - 1])) 321 | : Promise.resolve(0) 322 | ]) 323 | 324 | if (res[0] === 1 || res[1] === 1) { 325 | run = await prisma.run.update({ 326 | where: { 327 | id: run_id, 328 | thread_id 329 | }, 330 | data: { 331 | status: 'cancelled' 332 | }, 333 | include: { run_steps: true } 334 | }) 335 | if (!run) return c.notFound() as any 336 | } 337 | } catch (err) { 338 | console.warn( 339 | `Error removing cancelled run "${run_id}" from async task queue`, 340 | err 341 | ) 342 | } 343 | 344 | return c.jsonT(utils.convertPrismaToOAI(run)) 345 | }) 346 | 347 | export default app 348 | -------------------------------------------------------------------------------- /src/server/threads.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIHono } from '@hono/zod-openapi' 2 | 3 | import * as routes from '~/generated/oai-routes' 4 | import * as utils from '~/lib/utils' 5 | import { createThread } from '~/lib/create-thread' 6 | import { prisma } from '~/lib/db' 7 | 8 | const app: OpenAPIHono = new OpenAPIHono() 9 | 10 | app.openapi(routes.createThread, async (c) => { 11 | const body = c.req.valid('json') 12 | console.log('createThread', { body }) 13 | 14 | const { thread } = await createThread(body) 15 | if (!thread) return c.notFound() as any 16 | 17 | return c.jsonT(utils.convertPrismaToOAI(thread)) 18 | }) 19 | 20 | app.openapi(routes.getThread, async (c) => { 21 | const { thread_id } = c.req.valid('param') 22 | console.log('getThread', { thread_id }) 23 | 24 | const res = await prisma.thread.findUniqueOrThrow({ 25 | where: { 26 | id: thread_id 27 | } 28 | }) 29 | if (!res) return c.notFound() as any 30 | 31 | return c.jsonT(utils.convertPrismaToOAI(res)) 32 | }) 33 | 34 | app.openapi(routes.modifyThread, async (c) => { 35 | const { thread_id } = c.req.valid('param') 36 | const body = c.req.valid('json') 37 | console.log('modifyThread', { thread_id, body }) 38 | 39 | const res = await prisma.message.update({ 40 | where: { 41 | id: thread_id 42 | }, 43 | data: utils.convertOAIToPrisma(body) 44 | }) 45 | if (!res) return c.notFound() as any 46 | 47 | return c.jsonT(utils.convertPrismaToOAI(res)) 48 | }) 49 | 50 | app.openapi(routes.deleteThread, async (c) => { 51 | const { thread_id } = c.req.valid('param') 52 | console.log('deleteThread', { thread_id }) 53 | 54 | const res = await prisma.thread.delete({ 55 | where: { 56 | id: thread_id 57 | } 58 | }) 59 | 60 | return c.jsonT({ 61 | deleted: true, 62 | id: res.id, 63 | object: 'thread.deleted' as const 64 | }) 65 | }) 66 | 67 | export default app 68 | -------------------------------------------------------------------------------- /tsconfig.dist.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "bin", "e2e"], 3 | "exclude": ["node_modules", "dist", "openai-openapi"], 4 | "compilerOptions": { 5 | "target": "es2020", 6 | "lib": ["ESNext", "DOM"], 7 | "module": "esnext", 8 | "moduleResolution": "Bundler", 9 | "jsx": "preserve", 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "strict": true, 13 | "noImplicitAny": false, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "forceConsistentCasingInFileNames": true, 19 | "importHelpers": true, 20 | "esModuleInterop": true, 21 | "resolveJsonModule": true, 22 | "allowSyntheticDefaultImports": true, 23 | "isolatedModules": true, 24 | "declaration": false, 25 | "declarationMap": false, 26 | "sourceMap": true, 27 | "noEmit": false, 28 | "outDir": "dist", 29 | "baseUrl": "src/", 30 | "paths": { 31 | "~/*": ["./*"] 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | 3 | export default defineConfig(() => { 4 | return { 5 | mode: 'test', 6 | 7 | test: { 8 | environment: 'node', 9 | globals: true, 10 | watch: false 11 | } 12 | } 13 | }) 14 | --------------------------------------------------------------------------------