diff options
Diffstat (limited to 'packages/ai')
36 files changed, 5539 insertions, 0 deletions
diff --git a/packages/ai/.gitignore b/packages/ai/.gitignore new file mode 100644 index 0000000..38065fd --- /dev/null +++ b/packages/ai/.gitignore @@ -0,0 +1,175 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore +cache +# Logs +runtest.sh +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/packages/ai/README.md b/packages/ai/README.md new file mode 100644 index 0000000..b89d245 --- /dev/null +++ b/packages/ai/README.md @@ -0,0 +1,15 @@ +# models + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.2.2. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/packages/ai/bun.lock b/packages/ai/bun.lock new file mode 100644 index 0000000..8cdbaaf --- /dev/null +++ b/packages/ai/bun.lock @@ -0,0 +1,361 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "@sortug/ai", + "dependencies": { + "@anthropic-ai/sdk": "latest", + "@elevenlabs/elevenlabs-js": "^2.24.1", + "@google/genai": "latest", + "bcp-47": "^2.1.0", + "franc-all": "^7.2.0", + "groq-sdk": "latest", + "iso-639-3": "file:../lang", + "openai": "latest", + "playht": "latest", + "replicate": "latest", + "sortug": "file:../sortug", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/mime-types": "^3.0.1", + }, + "peerDependencies": { + "typescript": "latest", + }, + }, + }, + "packages": { + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.70.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-AGEhifuvE22VxfQ5ROxViTgM8NuVQzEvqcN8bttR4AP24ythmNE/cL/SrOz79xiv7/osrsmCyErjsistJi7Z8A=="], + + "@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], + + "@elevenlabs/elevenlabs-js": ["@elevenlabs/elevenlabs-js@2.24.1", "", { "dependencies": { "command-exists": "^1.2.9", "node-fetch": "^2.7.0", "ws": "^8.18.3" } }, "sha512-i6bDExgK9lYne1vLhy85JJ3O8bNi5vPTfcgq8kT3HG4+3rgkUJtg5UP29Mn1KONc4ZOeYUomzxJ820uLkT9z6g=="], + + "@google/genai": ["@google/genai@1.30.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.20.1" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-3MRcgczBFbUat1wIlZoLJ0vCCfXgm7Qxjh59cZi2X08RgWLtm9hKOspzp7TOg1TV2e26/MLxR2GR5yD5GmBV2w=="], + + "@grpc/grpc-js": ["@grpc/grpc-js@1.14.1", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-sPxgEWtPUR3EnRJCEtbGZG2iX8LQDUls2wUS3o27jg07KqJFMq6YDeWvMo1wfpmy3rqRdS0rivpLwhqQtEyCuQ=="], + + "@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="], + + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + + "@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="], + + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], + + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + + "@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="], + + "@types/mime-types": ["@types/mime-types@3.0.1", "", {}, "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ=="], + + "@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + + "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="], + + "@types/react": ["@types/react@19.2.6", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w=="], + + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "axios": ["axios@1.13.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bcp-47": ["bcp-47@2.1.0", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w=="], + + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + + "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + + "bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "command-exists": ["command-exists@1.2.9", "", {}, "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w=="], + + "cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "file-type": ["file-type@18.7.0", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.2", "strtok3": "^7.0.0", "token-types": "^5.0.1" } }, "sha512-ihHtXRzXEziMrQ56VSgU7wkxh55iNchFkosu7Y9/S+tXHdKyrGjVK0ujbqNnsxzea+78MaLhN6PGmfYSAv1ACw=="], + + "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], + + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + + "form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="], + + "formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "franc-all": ["franc-all@7.2.0", "", { "dependencies": { "trigram-utils": "^2.0.0" } }, "sha512-ZR6ciLQTDBaOvBdkOd8+vqDzaLtmIXRa9GCzcAlaBpqNAKg9QrtClPmqiKac5/xZXfCZGMo1d8dIu1T0BLhHEg=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="], + + "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + + "google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="], + + "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "groq-sdk": ["groq-sdk@0.36.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-wvxl7i6QWxLcIfM00mQQybYk15OAXJG0NBBQuMDHrQ2vi68uz2RqFTBKUNfEOVz8Lwy4eAgQIPBEFW5P3cXybA=="], + + "gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], + + "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], + + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "iso-639-3": ["@sortug/lang@file:../lang", { "devDependencies": { "@types/bun": "latest" }, "peerDependencies": { "typescript": "^5" } }], + + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.0", "", { "dependencies": { "jwa": "^2.0.0", "safe-buffer": "^5.0.1" } }, "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg=="], + + "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "n-gram": ["n-gram@2.0.2", "", {}, "sha512-S24aGsn+HLBxUGVAUFOwGpKs7LBcG4RudKU//eWzt/mQ97/NMKQxDWHyHx63UNWk/OOdihgmzoETn1tf5nQDzQ=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + + "openai": ["openai@6.9.1", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-vQ5Rlt0ZgB3/BNmTa7bIijYFhz3YBceAA3Z4JuoMSBftBF9YqFHIEhZakSs+O/Ad7EaoEimZvHxD5ylRjN11Lg=="], + + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "peek-readable": ["peek-readable@5.4.2", "", {}, "sha512-peBp3qZyuS6cNIJ2akRNG1uo1WJ1d0wTxg/fxMdZ0BqCVhx242bSFHM9eNqflfJVS9SsgkzgT/1UgnsurBOTMg=="], + + "playht": ["playht@0.21.0", "", { "dependencies": { "@grpc/grpc-js": "^1.9.4", "axios": "^1.4.0", "cross-fetch": "^4.0.0", "deepmerge-ts": "^7.1.5", "file-type": "^18.5.0", "protobufjs": "^7.2.5", "tslib": "^2.1.0" } }, "sha512-63dWfsoGNOxfl91U3knrON4HcgtdPZ+e0Q3F8JX22T6dvX17i217lfw8cq1OzIBWVxpHms8ebhgUU/Gvs0/8Eg=="], + + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + + "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], + + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + + "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + + "readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="], + + "replicate": ["replicate@1.4.0", "", { "optionalDependencies": { "readable-stream": ">=4.0.0" } }, "sha512-1ufKejfUVz/azy+5TnzQP7U1+MHVWZ6psnQ06az8byUUnRhT+DZ/MvewzB1NQYBVMgNKR7xPDtTwlcP5nv/5+w=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "sortug": ["@sortug/lib@file:../sortug", { "devDependencies": { "@types/bun": "latest" }, "peerDependencies": { "typescript": "^5" } }], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strtok3": ["strtok3@7.1.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^5.1.3" } }, "sha512-mKX8HA/cdBqMKUr0MMZAFssCkIGoZeSCMXgnt79yKxNFguMLVFgRe6wB+fsL0NmoHDbeyZXczy7vEPSoo3rkzg=="], + + "token-types": ["token-types@5.0.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-Y2fmSnZjQdDb9W4w4r1tswlMHylzWIeOKpx0aZH9BgGtACHhrk3OkT52AzwcuqTRBZtvvnTjDBh8eynMulu8Vg=="], + + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "trigram-utils": ["trigram-utils@2.0.1", "", { "dependencies": { "collapse-white-space": "^2.0.0", "n-gram": "^2.0.0" } }, "sha512-nfWIXHEaB+HdyslAfMxSqWKDdmqY9I32jS7GnqpdWQnLH89r6A5sdk3fDVYqGAZ0CrT8ovAFSAo6HRiWcWNIGQ=="], + + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + + "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], + + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + + "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + + "gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + } +} diff --git a/packages/ai/debug.ts b/packages/ai/debug.ts new file mode 100644 index 0000000..1b1312d --- /dev/null +++ b/packages/ai/debug.ts @@ -0,0 +1,30 @@ +import OpenAI from "openai"; +import Claude from "@anthropic-ai/sdk"; +import { GoogleGenAI } from "@google/genai"; +async function oai() { + const openai = new OpenAI(); + + const list = await openai.models.list(); + for await (const model of list) { + console.log({ model }); + } +} + +async function cld() { + const claude = new Claude(); + const list = await claude.models.list(); + for await (const model of list) { + console.log({ model }); + } +} + +async function gem() { + const gemini = new GoogleGenAI({ apiKey: Bun.env["GEMINI_API_KEY"]! }); + const list = await gemini.models.list(); + for await (const model of list) { + console.log({ model }); + } +} +// oai(); +// cld(); +// gem(); diff --git a/packages/ai/index.ts b/packages/ai/index.ts new file mode 100644 index 0000000..d2fc090 --- /dev/null +++ b/packages/ai/index.ts @@ -0,0 +1,76 @@ +// import Openai from "./src/openai"; +import Claude from "./src/claude"; +import Gemini from "./src/gemini"; +import Generic from "./src/generic"; +import OpenAIResponses from "./src/openai-responses"; +import type { AIModelAPI, LLMChoice } from "./src/types"; + +export type * from "./src/types"; +export * as NLP from "./src/nlp"; + +export default function (choice: LLMChoice): AIModelAPI { + try { + const api = + "gemini" in choice + ? new Gemini(choice.gemini) + : "claude" in choice + ? new Claude(choice.claude) + : "chatgpt" in choice + ? new OpenAIResponses({ + baseURL: "https://api.openai.com/v1", + apiKey: Bun.env.OPENAI_API_KEY!, + model: choice.chatgpt || "gpt-5-nano", + }) + : "deepseek" in choice + ? new Generic({ + baseURL: "https://api.deepseek.com", + apiKey: Bun.env.DEEPSEEK_API_KEY!, + model: "deepseek-reasoner", + }) + : "kimi" in choice + ? new Generic({ + baseURL: "https://api.moonshot.ai/v1", + apiKey: Bun.env.MOONSHOT_API_KEY!, + model: "kimi-k2-0905-preview", // "kimi-latest"? + }) + : "grok" in choice + ? new Generic({ + baseURL: "https://api.x.ai/v1", + apiKey: Bun.env.XAI_API_KEY!, + model: "grok-4", // "kimi-latest"? + }) + : new Generic({ + baseURL: choice.openai.url, + apiKey: choice.openai.apiKey, + model: choice.openai.model, + allowBrowser: choice.openai.allowBrowser, + }); + // "" in choice + // ? new Generic(choice.other) + // : choice.name === "deepseek" + // ? new Generic({ + // baseURL: "https://api.deepseek.com", + // apiKey: Bun.env.DEEPSEEK_API_KEY!, + // model: "deepseek-chat", + // }) + // : choice.name === "grok" + // ? new Generic({ + // baseURL: "https://api.x.ai/v1", + // apiKey: Bun.env.GROK_API_KEY!, + // model: "grok-2-latest", + // }) + // : choice.name === "chatgpt" + // ? new Generic({ + // baseURL: "https://api.openai.com/v1", + // apiKey: Bun.env.OPENAI_API_KEY!, + // model: "gpt-4o", + // }) + // : choice.name === "claude" + // ? new Claude() + // : new Gemini(); + return api; + } catch (e) { + // TODO + console.error("couldnt start API", e); + } +} diff --git a/packages/ai/package.json b/packages/ai/package.json new file mode 100644 index 0000000..f7a75be --- /dev/null +++ b/packages/ai/package.json @@ -0,0 +1,24 @@ +{ + "name": "@sortug/ai", + "module": "index.ts", + "type": "module", + "version": "0.1.0", + "devDependencies": { + "@types/bun": "latest", + "@types/mime-types": "^3.0.1" + }, + "peerDependencies": { + "typescript": "latest" + }, + "dependencies": { + "@anthropic-ai/sdk": "latest", + "@elevenlabs/elevenlabs-js": "^2.24.1", + "@google/genai": "latest", + "groq-sdk": "latest", + "openai": "latest", + "playht": "latest", + "replicate": "latest", + "@sortug/lib": "workspace:*", + "@sortug/langlib": "workspace:*" + } +} diff --git a/packages/ai/src/cache.ts b/packages/ai/src/cache.ts new file mode 100644 index 0000000..5d59163 --- /dev/null +++ b/packages/ai/src/cache.ts @@ -0,0 +1,150 @@ +// memoize.ts (Bun-compatible, no Node Buffers) +import { mkdir } from "node:fs/promises"; +import path from "node:path"; + +type MemoOpts<V> = { + ttlMs?: number; // time-to-live for entries + maxEntries?: number; // cap; oldest (LRU) evicted + persistDir?: string; // set to enable disk cache (e.g. ".cache/memo") + keyFn?: (...args: any[]) => string; // custom key if you need it + cacheErrors?: boolean; // default false +}; + +type Entry<V> = { + v: V; + exp: number | null; + at: number; // last hit (LRU) +}; + +const enc = new TextEncoder(); +const dec = new TextDecoder(); + +const stableStringify = (x: any): string => { + const seen = new WeakSet(); + const S = (v: any): any => { + if (v && typeof v === "object") { + if (seen.has(v)) return "[Circular]"; + seen.add(v); + if (Array.isArray(v)) return v.map(S); + return Object.fromEntries( + Object.keys(v) + .sort() + .map((k) => [k, S(v[k])]), + ); + } + if (typeof v === "function") return `[Function:${v.name || "anon"}]`; + if (typeof v === "undefined") return "__undefined__"; + return v; + }; + return JSON.stringify(S(x)); +}; + +async function sha256Hex(s: string) { + const h = await crypto.subtle.digest("SHA-256", enc.encode(s)); + return Array.from(new Uint8Array(h)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +function now() { + return Date.now(); +} + +export function memoize< + F extends (...args: any[]) => any, + V = Awaited<ReturnType<F>>, +>(fn: F, opts: MemoOpts<V> = {}): F { + const ttl = opts.ttlMs ?? 0; + const max = opts.maxEntries ?? 0; + const dir = opts.persistDir ? path.resolve(opts.persistDir) : null; + + const mem = new Map<string, Entry<V>>(); + const inflight = new Map<string, Promise<V>>(); + + async function keyOf(args: any[]): Promise<string> { + const base = opts.keyFn ? opts.keyFn(...args) : stableStringify(args); + return dir ? await sha256Hex(base) : base; // hash when persisting (safe filename) + } + + async function readDisk(k: string): Promise<Entry<V> | null> { + if (!dir) throw new Error("no dir!"); + const f = Bun.file(path.join(dir, `${k}.json`)); + if (!(await f.exists())) return null; + try { + const obj = JSON.parse(await f.text()); + return obj as Entry<V>; + } catch { + return null; + } + } + + async function writeDisk(k: string, e: Entry<V>) { + if (!dir) throw new Error("no dir!"); + await Bun.write(path.join(dir, `${k}.json`), JSON.stringify(e)); + } + + function evictLRU() { + if (!max || mem.size <= max) return; + const arr = [...mem.entries()].sort((a, b) => a[1].at - b[1].at); + for (let i = 0; i < mem.size - max; i++) mem.delete(arr[i][0]); + } + + async function getOrCall(args: any[]): Promise<V> { + const k = await keyOf(args); + const t = now(); + + // in-flight coalescing + if (inflight.has(k)) return inflight.get(k)!; + + // memory hit + const m = mem.get(k); + if (m && (!m.exp || t < m.exp)) { + m.at = t; + return m.v; + } + + // disk hit + const d = await readDisk(k); + if (d && (!d.exp || t < d.exp)) { + d.at = t; + mem.set(k, d); + evictLRU(); + return d.v; + } + + // miss → call underlying + const call = (async () => { + try { + const r = fn.apply(undefined, args); + const v: V = r instanceof Promise ? await r : (r as V); + const e: Entry<V> = { v, exp: ttl ? t + ttl : null, at: t }; + mem.set(k, e); + evictLRU(); + await writeDisk(k, e); + return v; + } catch (err) { + if (opts.cacheErrors) { + const e: Entry<any> = { v: err, exp: ttl ? t + ttl : null, at: t }; + mem.set(k, e); + await writeDisk(k, e as Entry<V>); + } + throw err; + } finally { + inflight.delete(k); + } + })(); + + inflight.set(k, call); + return call; + } + + // Wrap preserving arity & `this` for methods + const wrapped = function (this: any, ...args: any[]) { + const maybe = getOrCall(args).then((v) => v); + // If original fn is sync (per your signature), unwrap to sync only when it's truly sync. + // We can detect by calling without awaiting once—dangerous—so be conservative: + return maybe as unknown as ReturnType<F>; + } as any as F; + + return wrapped; +} diff --git a/packages/ai/src/claude.ts b/packages/ai/src/claude.ts new file mode 100644 index 0000000..a411030 --- /dev/null +++ b/packages/ai/src/claude.ts @@ -0,0 +1,173 @@ +import Claude from "@anthropic-ai/sdk"; +import { RESPONSE_LENGTH } from "./logic/constants"; +import type { AIModelAPI, ChatMessage, InputToken } from "./types"; +import { BOOKWORM_SYS } from "./prompts"; +import type { AsyncRes } from "@sortug/lib"; +import type { + ImageBlockParam, + MessageCreateParamsStreaming, + TextBlockParam, +} from "@anthropic-ai/sdk/resources"; + +type Message = Claude.Messages.MessageParam; + +export default class ClaudeAPI implements AIModelAPI { + private model: string = "claude-opus-4-20250514"; + tokenizer: (text: string) => number; + maxTokens: number; + // model: string = "claude-3-5-sonnet-20241022"; + constructor( + model: string, + maxTokens = 200_000, + tokenizer: (text: string) => number = (text) => text.length / 3, + ) { + this.maxTokens = maxTokens; + this.tokenizer = tokenizer; + if (model) this.model = model; + } + public setModel(model: string) { + this.model = model; + } + private mapMessages(input: ChatMessage[]): Message[] { + return input.map((m) => { + const role = m.author === "claude" ? "assistant" : "user"; + return { role, content: m.text }; + }); + } + private buildInput(tokens: InputToken[]): Message[] { + // can do base64 for images too + const content = tokens.map((t) => { + const content = + "text" in t + ? ({ type: "text", text: t.text } as TextBlockParam) + : "img" in t + ? ({ + type: "image", + source: { type: "url", url: t.img }, + } as ImageBlockParam) + : ({ type: "text", text: "oy vey" } as TextBlockParam); + return content; + }); + + return [{ role: "user", content }]; + } + + // https://docs.anthropic.com/en/api/messages-examples#vision + public async send(input: string | InputToken[], sys?: string) { + const msgs: Message[] = + typeof input === "string" + ? [{ role: "user", content: input }] + : this.buildInput(input); + const truncated = this.truncateHistory(msgs); + const res = await this.apiCall(truncated, sys); + return res; + } + + public async sendDoc(data: string) { + const sys = BOOKWORM_SYS; + const msg: Message = { + role: "user", + content: [ + { + type: "document", + source: { type: "base64", data, media_type: "application/pdf" }, + }, + { + type: "text", + text: "Please analyze this according to your system prompt. Be thorough.", + }, + ], + }; + const res = await this.apiCall([msg], sys); + return res; + } + + public async stream( + input: string | InputToken[], + handle: (c: any) => void, + sys?: string, + ) { + const msgs: Message[] = + typeof input === "string" + ? [{ role: "user", content: input }] + : this.buildInput(input); + const truncated = this.truncateHistory(msgs); + await this.apiCallStream(truncated, handle, sys); + } + + private truncateHistory(messages: Message[]): Message[] { + const totalTokens = messages.reduce((total, message) => { + return total + this.tokenizer(message.content as string); + }, 0); + while (totalTokens > this.maxTokens && messages.length > 1) { + messages.splice(0, 1); + } + return messages; + } + + // TODO + // https://docs.anthropic.com/en/api/messages-examples#putting-words-in-claudes-mouth + private async apiCall( + messages: Message[], + system?: string, + ): Promise<AsyncRes<string>> { + try { + const claud = new Claude(); + const params = { + model: this.model, + max_tokens: RESPONSE_LENGTH, + messages, + }; + const res = await claud.messages.create( + system ? { ...params, system } : params, + ); + const resm: string = res.content.reduce((acc: string, item) => { + if (item.type === "tool_use") return acc; + else if (item.type === "text") return `${acc}\n${item.text}`; + else return acc; + }, ""); + // const resm = res.content.reduce((acc: string[], item) => { + // if (item.type === "tool_use") return acc; + // else if (item.type === "text") return [...acc, item.text] + // else return acc; + // }, []); + return { ok: resm }; + } catch (e) { + console.log(e, "error in claude api"); + return { error: `${e}` }; + } + } + + private async apiCallStream( + messages: Message[], + handle: (c: any) => void, + system?: string, + ): Promise<void> { + try { + const claud = new Claude(); + const params = { + model: this.model, + max_tokens: RESPONSE_LENGTH, + messages, + stream: true as true, + }; + const fparams: MessageCreateParamsStreaming = system + ? { ...params, system } + : params; + const stream = await claud.messages.create(fparams); + + for await (const part of stream) { + if (part.type === "message_start") continue; + if (part.type === "content_block_start") continue; + if (part.type === "content_block_delta") { + console.log("delta", part.delta); + const delta: any = part.delta; + handle(delta.text); + } + } + } catch (e) { + console.log(e, "error in claude api"); + handle(`Error streaming Claude, ${e}`); + } + } +} diff --git a/packages/ai/src/gemini.ts b/packages/ai/src/gemini.ts new file mode 100644 index 0000000..d8010b0 --- /dev/null +++ b/packages/ai/src/gemini.ts @@ -0,0 +1,199 @@ +// import mime from "mime-types"; +import { + Chat, + createPartFromBase64, + createPartFromUri, + createUserContent, + GoogleGenAI, + type Content, + type ContentListUnion, + type GeneratedImage, + type GeneratedVideo, + type Part, +} from "@google/genai"; +import type { AIModelAPI, InputToken } from "./types"; +import type { AsyncRes, Result } from "@sortug/lib"; + +export default class GeminiAPI implements AIModelAPI { + tokenizer: (text: string) => number; + maxTokens: number; + private model: string; + api: GoogleGenAI; + chats: Map<string, Chat> = new Map<string, Chat>(); + + constructor( + model?: string, + maxTokens = 200_000, + tokenizer: (text: string) => number = (text) => text.length / 3, + ) { + this.maxTokens = maxTokens; + this.tokenizer = tokenizer; + + const gem = new GoogleGenAI({ apiKey: Bun.env["GEMINI_API_KEY"]! }); + this.api = gem; + this.model = model || "gemini-2.5-pro"; + } + + // input data in gemini gets pretty involved + // + // data + // Union type + // data can be only one of the following: + // text + // string + // Inline text. + + // inlineData + // object (Blob) + // Inline media bytes. + + // functionCall + // object (FunctionCall) + // A predicted FunctionCall returned from the model that contains a string representing the FunctionDeclaration.name with the arguments and their values. + + // functionResponse + // object (FunctionResponse) + // The result output of a FunctionCall that contains a string representing the FunctionDeclaration.name and a structured JSON object containing any output from the function is used as context to the model. + + // fileData + // object (FileData) + // URI based data. + + // executableCode + // object (ExecutableCode) + // Code generated by the model that is meant to be executed. + + // codeExecutionResult + // object (CodeExecutionResult) + // Result of executing the ExecutableCode. + + // metadata + // Union type + public setModel(model: string) { + this.model = model; + } + private contentFromImage(imageString: string): Result<Part> { + // TODO + // const mimeType = mime.lookup(imageString); + const mimeType = ""; + if (!mimeType) return { error: "no mimetype" }; + const url = URL.parse(imageString); + if (url) { + const part = createPartFromUri(imageString, mimeType); + return { ok: part }; + } else return { ok: createPartFromBase64(imageString, mimeType) }; + } + async inlineImage(imageURI: URL): AsyncRes<Part> { + try { + const imgdata = await fetch(imageURI); + const imageArrayBuffer = await imgdata.arrayBuffer(); + const base64ImageData = Buffer.from(imageArrayBuffer).toString("base64"); + const mimeType = imgdata.headers.get("content-type") || "image/jpeg"; + return { ok: { inlineData: { mimeType, data: base64ImageData } } }; + } catch (e) { + return { error: `${e}` }; + } + } + public buildInput(tokens: InputToken[]): Result<Content> { + try { + const input = createUserContent( + tokens.map((t) => { + if ("text" in t) return t.text; + if ("img" in t) { + const imagePart = this.contentFromImage(t.img); + if ("error" in imagePart) throw new Error("image failed"); + else return imagePart.ok; + } + return "oy vey"; + }), + ); + return { ok: input }; + } catch (e) { + return { error: `${e}` }; + } + } + + async send( + input: string | InputToken[], + systemPrompt?: string, + ): AsyncRes<string> { + let contents: ContentListUnion; + if (typeof input === "string") contents = input; + else { + const built = this.buildInput(input); + if ("error" in built) return built; + else contents = built.ok; + } + try { + const opts = { + model: this.model, + contents, + }; + const fopts = systemPrompt + ? { ...opts, config: { systemInstruction: systemPrompt } } + : opts; + const response = await this.api.models.generateContent(fopts); + if (!response.text) return { error: "no text in response" }; + return { ok: response.text }; + } catch (e) { + return { error: `${e}` }; + } + } + async stream( + input: string | InputToken[], + handler: (s: string) => void, + systemPrompt?: string, + ) { + let contents: ContentListUnion; + if (typeof input === "string") contents = input; + else { + const built = this.buildInput(input); + if ("error" in built) return built; + else contents = built.ok; + } + const opts = { + model: this.model, + contents, + }; + const fopts = systemPrompt + ? { ...opts, config: { systemInstruction: systemPrompt } } + : opts; + const response = await this.api.models.generateContentStream(fopts); + for await (const chunk of response) { + handler(chunk.text || ""); + } + } + + async makeImage(prompt: string): AsyncRes<GeneratedImage[]> { + try { + const response = await this.api.models.generateImages({ + model: this.model, + prompt, + }); + // TODO if empty or undefined return error + return { ok: response.generatedImages || [] }; + } catch (e) { + return { error: `${e}` }; + } + } + async makeVideo({ + prompt, + image, + }: { + prompt?: string; + image?: string; + }): AsyncRes<GeneratedVideo[]> { + try { + const response = await this.api.models.generateVideos({ + model: this.model, + prompt, + }); + // TODO if empty or undefined return error + return { ok: response.response?.generatedVideos || [] }; + } catch (e) { + return { error: `${e}` }; + } + } +} +// TODO how to use caches +// https://ai.google.dev/api/caching diff --git a/packages/ai/src/gemini2.ts b/packages/ai/src/gemini2.ts new file mode 100644 index 0000000..0b7c0da --- /dev/null +++ b/packages/ai/src/gemini2.ts @@ -0,0 +1,149 @@ +import { + GenerativeModel, + GoogleGenerativeAI, + type Content, + type GenerateContentResult, +} from "@google/generative-ai"; +import { RESPONSE_LENGTH } from "./logic/constants"; +import type { + AIModelAPI, + ChatMessage, + OChoice, + OChunk, + OMessage, +} from "./types"; +import type { AsyncRes } from "@sortug/lib"; + +export default class GeminiAPI implements AIModelAPI { + tokenizer: (text: string) => number; + maxTokens: number; + private model: GenerativeModel; + + constructor( + maxTokens = 200_000, + tokenizer: (text: string) => number = (text) => text.length / 3, + model?: string, + ) { + this.maxTokens = maxTokens; + this.tokenizer = tokenizer; + + const gem = new GoogleGenerativeAI(Bun.env["GEMINI_API_KEY"]!); + this.model = gem.getGenerativeModel({ + // model: model || "gemini-2.0-flash-exp", + model: model || "gemini-2.5-pro-preview-05-06 ", + generationConfig: { maxOutputTokens: RESPONSE_LENGTH }, + }); + } + + public setModel(model: string) { + const gem = new GoogleGenerativeAI(Bun.env["GEMINI_API_KEY"]!); + this.model = gem.getGenerativeModel({ + model, + generationConfig: { maxOutputTokens: RESPONSE_LENGTH }, + }); + } + private mapMessages(input: ChatMessage[]): Content[] { + return input.map((m) => ({ + role: m.author === "gemini" ? "model" : "user", + parts: [{ text: m.text }], + })); + } + + private mapMessagesR1(input: ChatMessage[]): Content[] { + return input.reduce((acc: Content[], m, i) => { + const prev = acc[i - 1]; + const role = m.author === "gemini" ? "model" : "user"; + const msg = { role, parts: [{ text: m.text }] }; + if (prev?.role === role) acc[i - 1] = msg; + else acc = [...acc, msg]; + return acc; + }, []); + } + + private async apiCall( + messages: Content[], + isR1: boolean = false, + ): Promise<AsyncRes<string[]>> { + try { + const chat = this.model.startChat({ history: messages }); + const res = await chat.sendMessage(""); + return { ok: [res.response.text()] }; + } catch (e) { + console.log(e, "error in gemini api"); + return { error: `${e}` }; + } + } + + private async apiCallStream( + messages: Content[], + handle: (c: any) => void, + isR1: boolean = false, + ): Promise<void> { + try { + const chat = this.model.startChat({ history: messages }); + const res = await chat.sendMessage(""); + // for await (const chunk of res.stream()) { + // handle(chunk.text()); + // } + } catch (e) { + console.log(e, "error in gemini api"); + handle(`Error streaming Gemini, ${e}`); + } + } + + public async send(sys: string, input: ChatMessage[]) { + console.log({ sys, input }); + this.model.systemInstruction = { role: "system", parts: [{ text: sys }] }; + const messages = this.mapMessages(input); + const truncated = this.truncateHistory(messages); + const res = await this.apiCall(truncated); + return res; + } + + public async sendR1(input: ChatMessage[]) { + const messages = this.mapMessagesR1(input); + const truncated = this.truncateHistory(messages); + const res = await this.apiCall(truncated, true); + return res; + } + + public async stream( + sys: string, + input: ChatMessage[], + handle: (c: any) => void, + ) { + this.model.systemInstruction = { role: "system", parts: [{ text: sys }] }; + const messages = this.mapMessages(input); + const truncated = this.truncateHistory(messages); + await this.apiCallStream(truncated, handle); + } + + public async streamR1(input: ChatMessage[], handle: (c: any) => void) { + const messages = this.mapMessagesR1(input); + const truncated = this.truncateHistory(messages); + await this.apiCallStream(truncated, handle, true); + } + + public async sendDoc(data: ArrayBuffer, mimeType: string, prompt: string) { + const res = await this.model.generateContent([ + { + inlineData: { + data: Buffer.from(data).toString("base64"), + mimeType, + }, + }, + prompt, + ]); + return res; + } + + private truncateHistory(messages: Content[]): Content[] { + const totalTokens = messages.reduce((total, message) => { + return total + this.tokenizer(message.parts[0].text || ""); + }, 0); + while (totalTokens > this.maxTokens && messages.length > 1) { + messages.splice(0, 1); + } + return messages; + } +} diff --git a/packages/ai/src/generic.ts b/packages/ai/src/generic.ts new file mode 100644 index 0000000..8c41f19 --- /dev/null +++ b/packages/ai/src/generic.ts @@ -0,0 +1,204 @@ +import OpenAI from "openai"; +import { MAX_TOKENS, RESPONSE_LENGTH } from "./logic/constants"; +import type { AIModelAPI, ChatMessage, InputToken } from "./types"; +import type { AsyncRes } from "@sortug/lib"; +import type { ChatCompletionContentPart } from "openai/resources"; +import { memoize } from "./cache"; +import type { ChatCompletionCreateParamsNonStreaming } from "groq-sdk/src/resources/chat/completions.js"; + +type OChoice = OpenAI.Chat.Completions.ChatCompletion.Choice; +type Message = OpenAI.Chat.Completions.ChatCompletionUserMessageParam; +type Params = OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming; +type OMessage = OpenAI.Chat.Completions.ChatCompletionMessageParam; + +type Props = { + baseURL: string; + apiKey: string; + model?: string; + maxTokens?: number; + tokenizer?: (text: string) => number; + allowBrowser?: boolean; +}; +export default class OpenAIAPI implements AIModelAPI { + private cachedCreate!: ( + args: Params, + ) => Promise<OpenAI.Chat.Completions.ChatCompletion>; + + private apiKey; + private baseURL; + private api; + maxTokens: number = MAX_TOKENS; + tokenizer: (text: string) => number = (text) => text.length / 3; + model; + + constructor(props: Props) { + this.apiKey = props.apiKey; + this.baseURL = props.baseURL; + this.api = new OpenAI({ + baseURL: this.baseURL, + apiKey: this.apiKey, + dangerouslyAllowBrowser: props.allowBrowser || false, + }); + this.model = props.model || ""; + if (props.maxTokens) this.maxTokens = props.maxTokens; + if (props.tokenizer) this.tokenizer = props.tokenizer; + + const boundCreate = this.api.chat.completions.create.bind( + this.api.chat.completions, + ); + + this.cachedCreate = memoize(boundCreate, { + ttlMs: 2 * 60 * 60 * 1000, // 2h + maxEntries: 5000, + persistDir: "./cache/memo", + // stable key for the call + keyFn: (args) => { + // args is the single object param to .create(...) + const { + model, + messages, + max_tokens, + temperature, + top_p, + frequency_penalty, + presence_penalty, + stop, + } = args as Params; + // stringify messages deterministically (role+content only) + const msg = (messages as any[]) + .map((m) => ({ role: m.role, content: m.content })) + .slice(0, 200); // guard size if you want + return JSON.stringify({ + model, + msg, + max_tokens, + temperature, + top_p, + frequency_penalty, + presence_penalty, + stop, + }); + }, + }); + } + public setModel(model: string) { + this.model = model; + } + private mapMessages(input: ChatMessage[]): Message[] { + return input.map((m) => { + return { role: m.author as any, content: m.text, name: m.author }; + }); + } + private buildInput(tokens: InputToken[]): Message[] { + const content: ChatCompletionContentPart[] = tokens.map((t) => { + if ("text" in t) return { type: "text", text: t.text }; + if ("img" in t) return { type: "image_url", image_url: { url: t.img } }; + else return { type: "text", text: "oy vey" }; + }); + return [{ role: "user", content }]; + } + + public async send( + input: string | InputToken[], + sys?: string, + ): AsyncRes<string> { + const messages: Message[] = + typeof input === "string" + ? [{ role: "user" as const, content: input }] + : this.buildInput(input); + // const messages = this.mapMessages(input); + const allMessages: OMessage[] = sys + ? [{ role: "system", content: sys }, ...messages] + : messages; + const truncated = this.truncateHistory(allMessages); + const res = await this.apiCall(truncated); + if ("error" in res) return res; + else { + try { + // TODO type this properly + const choices: OChoice[] = res.ok; + const resText = choices.reduce((acc, item) => { + return `${acc}\n${item.message.content || ""}`; + }, ""); + return { ok: resText }; + } catch (e) { + return { error: `${e}` }; + } + } + } + + public async stream( + input: string | InputToken[], + handle: (c: string) => void, + sys?: string, + ) { + const messages: Message[] = + typeof input === "string" + ? [{ role: "user" as const, content: input }] + : this.buildInput(input); + // const messages = this.mapMessages(input); + const allMessages: OMessage[] = sys + ? [{ role: "system", content: sys }, ...messages] + : messages; + const truncated = this.truncateHistory(allMessages); + await this.apiCallStream(truncated, handle); + } + + private truncateHistory(messages: OMessage[]): OMessage[] { + const totalTokens = messages.reduce((total, message) => { + return total + this.tokenizer(message.content as string); + }, 0); + while (totalTokens > this.maxTokens && messages.length > 1) { + // Always keep the system message if it exists + const startIndex = messages[0].role === "system" ? 1 : 0; + messages.splice(startIndex, 1); + } + return messages; + } + + // TODO custom temperature? + private async apiCall(messages: OMessage[]): AsyncRes<OChoice[]> { + // console.log({ messages }, "at the very end"); + try { + const completion = await this.cachedCreate({ + // temperature: 1.3, + model: this.model, + messages, + max_tokens: RESPONSE_LENGTH, + }); + if (!completion) return { error: "null response from openai" }; + return { ok: completion.choices }; + } catch (e) { + console.log(e, "error in openai api"); + return { error: `${e}` }; + } + } + + private async apiCallStream( + messages: OMessage[], + handle: (c: string) => void, + ): Promise<void> { + try { + const stream = await this.api.chat.completions.create({ + temperature: 1.3, + model: this.model, + messages, + max_tokens: RESPONSE_LENGTH, + stream: true, + }); + + for await (const chunk of stream) { + for (const choice of chunk.choices) { + console.log({ choice }); + if (!choice.delta) continue; + const cont = choice.delta.content; + if (!cont) continue; + handle(cont); + } + } + } catch (e) { + console.log(e, "error in openai api"); + handle(`Error streaming OpenAI, ${e}`); + } + } +} diff --git a/packages/ai/src/genericnew.ts b/packages/ai/src/genericnew.ts new file mode 100644 index 0000000..b8b4e94 --- /dev/null +++ b/packages/ai/src/genericnew.ts @@ -0,0 +1,169 @@ +import OpenAI from "openai"; +import { MAX_TOKENS, RESPONSE_LENGTH } from "./logic/constants"; +import type { AIModelAPI, InputToken } from "./types"; +import type { AsyncRes } from "@sortug/lib"; +import type { + ResponseCreateParamsBase, + ResponseCreateParamsNonStreaming, + ResponseCreateParamsStreaming, + ResponseInput, +} from "openai/resources/responses/responses.mjs"; + +type Props = { + baseURL: string; + apiKey: string | undefined; + model?: string; + maxTokens?: number; + tokenizer?: (text: string) => number; +}; +export default class OpenAIAPI implements AIModelAPI { + private apiKey; + private baseURL; + private api; + maxTokens: number = MAX_TOKENS; + tokenizer: (text: string) => number = (text) => text.length / 3; + model; + + constructor(props: Props) { + if (!props.apiKey) throw new Error("NO API KEY"); + console.log({ props }); + this.apiKey = props.apiKey; + this.baseURL = props.baseURL; + this.api = new OpenAI({ baseURL: this.baseURL, apiKey: this.apiKey }); + this.model = props.model || ""; + if (props.maxTokens) this.maxTokens = props.maxTokens; + if (props.tokenizer) this.tokenizer = props.tokenizer; + } + public setModel(model: string) { + this.model = model; + } + + public buildInput(tokens: InputToken[]): ResponseInput { + return [ + { + role: "user", + content: tokens.map((t) => + "text" in t + ? { type: "input_text", text: t.text } + : "img" in t + ? { type: "input_image", image_url: t.img, detail: "auto" } + : { type: "input_text", text: "oy vey" }, + ), + }, + ]; + } + + // OpenAI SDK has three kinds ReponseInputContent: text image and file + // images can be URLs or base64 dataurl thingies + // + public async send( + inpt: string | InputToken[], + sys?: string, + ): AsyncRes<string> { + const input = typeof inpt === "string" ? inpt : this.buildInput(inpt); + const params = sys ? { instructions: sys, input } : { input }; + const res = await this.apiCall(params); + if ("error" in res) return res; + else { + try { + return { ok: res.ok.output_text }; + } catch (e) { + return { error: `${e}` }; + } + } + } + + public async stream( + inpt: string | InputToken[], + handle: (c: string) => void, + sys?: string, + ) { + const input = typeof inpt === "string" ? inpt : this.buildInput(inpt); + const params = sys ? { instructions: sys, input } : { input }; + await this.apiCallStream(params, handle); + } + + // TODO custom temperature? + private async apiCall( + params: ResponseCreateParamsNonStreaming, + ): AsyncRes<OpenAI.Responses.Response> { + try { + const res = await this.api.responses.create({ + ...params, + // temperature: 1.3, + model: params.model || this.model, + input: params.input, + max_output_tokens: params.max_output_tokens || RESPONSE_LENGTH, + stream: false, + }); + // TODO damn there's a lot of stuff here + return { ok: res }; + } catch (e) { + console.log(e, "error in openai api"); + return { error: `${e}` }; + } + } + + private async apiCallStream( + params: ResponseCreateParamsBase, + handler: (c: string) => void, + ) { + // temperature: 1.3, + const pms: ResponseCreateParamsStreaming = { + ...params, + stream: true, + model: params.model || this.model, + input: params.input, + max_output_tokens: params.max_output_tokens || RESPONSE_LENGTH, + }; + try { + const stream = await this.api.responses.create(pms); + for await (const event of stream) { + console.log(event); + switch (event.type) { + // TODO deal with audio and whatever + case "response.output_text.delta": + handler(event.delta); + break; + case "response.completed": + break; + default: + break; + } + // if (event.type === "response.completed") + // wtf how do we use this + } + } catch (e) { + console.log(e, "error in openai api"); + return { error: `${e}` }; + } + } + + // private async apiCallStream( + // messages: Message[], + // handle: (c: string) => void, + // ): Promise<void> { + // try { + // const stream = await this.api.chat.completions.create({ + // temperature: 1.3, + // model: this.model, + // messages, + // max_tokens: RESPONSE_LENGTH, + // stream: true, + // }); + + // for await (const chunk of stream) { + // for (const choice of chunk.choices) { + // console.log({ choice }); + // if (!choice.delta) continue; + // const cont = choice.delta.content; + // if (!cont) continue; + // handle(cont); + // } + // } + // } catch (e) { + // console.log(e, "error in openai api"); + // handle(`Error streaming OpenAI, ${e}`); + // } + // } +} diff --git a/packages/ai/src/logic/constants.ts b/packages/ai/src/logic/constants.ts new file mode 100644 index 0000000..170477d --- /dev/null +++ b/packages/ai/src/logic/constants.ts @@ -0,0 +1,3 @@ +// export const RESPONSE_LENGTH = 1024; +export const RESPONSE_LENGTH = 256; +export const MAX_TOKENS = 64_000; diff --git a/packages/ai/src/nlp/index.ts b/packages/ai/src/nlp/index.ts new file mode 100644 index 0000000..ebed586 --- /dev/null +++ b/packages/ai/src/nlp/index.ts @@ -0,0 +1,7 @@ +import * as Spacy from "./spacy"; +import * as Stanza from "./stanza"; +import * as ISO from "./iso"; +import { ocr } from "./ocr"; +import type * as Types from "./types"; +export * from "./nlp"; +export { ISO, ocr, Stanza, Spacy, type Types }; diff --git a/packages/ai/src/nlp/nlp.ts b/packages/ai/src/nlp/nlp.ts new file mode 100644 index 0000000..3b1e3a7 --- /dev/null +++ b/packages/ai/src/nlp/nlp.ts @@ -0,0 +1,208 @@ +export const isPunctuation = (text: string): boolean => { + // Common punctuation characters + const punctuationRegex = /^[.,;:!?()[\]{}'"«»""''…-]+$/; + return punctuationRegex.test(text); +}; + +// Get color for different syntactic categories +export function getColorForType(type: string): string { + const colors: Record<string, string> = { + // Phrasal categories + S: "#6495ED", // Sentence - cornflower blue + NP: "#FF7F50", // Noun Phrase - coral + VP: "#32CD32", // Verb Phrase - lime green + PP: "#9370DB", // Prepositional Phrase - medium purple + ADJP: "#FFD700", // Adjective Phrase - gold + ADVP: "#FF69B4", // Adverb Phrase - hot pink + + // Part-of-speech tags + NN: "#FFA07A", // Noun - light salmon + NNS: "#FFA07A", // Plural Noun - light salmon + NNP: "#FFA07A", // Proper Noun - light salmon + VB: "#90EE90", // Verb - light green + VBP: "#90EE90", // Present tense verb - light green + VBG: "#90EE90", // Gerund verb - light green + VBZ: "#90EE90", // 3rd person singular present verb - light green + VBD: "#90EE90", // Past tense verb - light green + VBN: "#90EE90", // Past participle verb - light green + JJ: "#F0E68C", // Adjective - khaki + RB: "#DDA0DD", // Adverb - plum + IN: "#87CEFA", // Preposition - light sky blue + DT: "#D3D3D3", // Determiner - light gray + PRP: "#D8BFD8", // Personal pronoun - thistle + CC: "#A9A9A9", // Coordinating conjunction - dark gray + + // Default + ROOT: "#000000", // Root - black + LEAF: "#666666", // Leaf nodes - dark gray + }; + + return colors[type] || "#666666"; +} + +// Get a description for node types +export function getDescription(type: string): string { + const descriptions: Record<string, string> = { + S: "Sentence", + SBAR: "Subordinating conjunction clause", + SBARQ: "Direct question", + SINV: "Declarative sentence with subject-aux inversion", + SQ: "Subconstituent of SBARQ excluding wh-word", + WHADVP: "wh-adverb phrase", + WHNP: "wh-nounphrase", + WHPP: "wh-prepositional phrase", + WDT: "wh-determiner", + WP: "wh-pronoun", + WRB: "wh-adverb", + WP$: "possesive wh-pronoun", + MD: "modal", + X: "Unknown", + NP: "Noun Phrase", + VP: "Verb Phrase", + PP: "Prepositional Phrase", + ADJP: "Adjective Phrase", + ADVP: "Adverb Phrase", + LS: "List item market", + SYM: "Symbol", + NN: "Noun", + NNS: "Plural Noun", + NNP: "Proper Noun", + NNPS: "Proper Noun, Plural", + VB: "Verb (base form)", + VBP: "Verb (present tense)", + VBG: "Verb (gerund/present participle)", + VBZ: "Verb (3rd person singular present)", + VBD: "Verb (past tense)", + VBN: "Verb (past participle)", + JJ: "Adjective", + JJR: "Adjective, comparative", + JJS: "Adjective, superlative", + EX: "Existential there", + RB: "Adverb", + RBR: "Adverb, comparative", + RBS: "Adverb, superlative", + RP: "Particle", + IN: "Preposition", + TO: "to", + DT: "Determiner", + PDT: "Predeterminer", + PRP: "Personal Pronoun", + PP$: "Possesive Pronoun", + PRP$: "Possesive Pronoun", + POS: "Possesive ending", + FW: "Foreign Word", + CC: "Coordinating Conjunction", + CD: "Cardinal number", + UH: "interjection", + ROOT: "Root Node", + CLR: "figurative motion", + FRAG: "fragment", + ":": "Colon/Semicolon", + ",": "Comma", + ".": "Period", + }; + + return descriptions[type] || type; +} + +// https://universaldependencies.org/u/dep/xcomp.htmlexport + +export function unpackDeprel(type: string): string { + const descriptions: Record<string, string> = { + nsubj: "nominal subject", + obj: "object", + iobj: "indirect object", + csubj: "clausal subject", + ccomp: "clausal complement", + xcomp: "open clausal complement", + obl: "oblique nominal", + vocative: "vocative", + expl: "expletive", + dislocated: "dislocated", + nmod: "nominal modifier", + appos: "appositional modifier", + nummod: "numeric modifier", + advcl: "adverbial clause modifier", + acl: "admonimal clause", + advmod: "adverbial modifier", + discourse: "dicourse element", + aux: "auxiliary", + cop: "copula", + mark: "marker", + amod: "adjectival modifier", + det: "determiner", + clf: "classifier", + case: "case marker", + conj: "conjunction", + cc: "coordinating conjunction", + fixed: "fixed multiword expression", + flat: "flat expression", + list: "list", + parataxis: "parataxis", + compound: "compound", + orphan: "orphan", + goeswith: "goes with", + reparandum: "overriden disfluency", + punct: "punctuation", + root: "root", + dep: "unspecified dependency", + }; + const res = descriptions[type]; + if (!res) console.log("tag not found!!", type); + + return res || type; +} + +export function deprelColors(type: string): string { + const colors: Record<string, string> = { + // Phrasal categories + s: "#6495ED", // Sentence - cornflower blue + nsubj: "#6495ED", // Sentence - cornflower blue + root: "#FFD700", // Adjective Phrase - gold + p: "#FFD700", // Adjective Phrase - gold + NP: "#FF7F50", // Noun Phrase - coral + VP: "#32CD32", // Verb Phrase - lime green + PP: "#9370DB", // Prepositional Phrase - medium purple + ADVP: "#FF69B4", // Adverb Phrase - hot pink + + // Part-of-speech tags + NN: "#FFA07A", // Noun - light salmon + NNS: "#FFA07A", // Plural Noun - light salmon + NNP: "#FFA07A", // Proper Noun - light salmon + VB: "#90EE90", // Verb - light green + VBP: "#90EE90", // Present tense verb - light green + VBG: "#90EE90", // Gerund verb - light green + VBZ: "#90EE90", // 3rd person singular present verb - light green + VBD: "#90EE90", // Past tense verb - light green + VBN: "#90EE90", // Past participle verb - light green + JJ: "#F0E68C", // Adjective - khaki + RB: "#DDA0DD", // Adverb - plum + IN: "#87CEFA", // Preposition - light sky blue + DT: "#D3D3D3", // Determiner - light gray + PRP: "#D8BFD8", // Personal pronoun - thistle + CC: "#A9A9A9", // Coordinating conjunction - dark gray + + // Default + ROOT: "#000000", // Root - black + LEAF: "#666666", // Leaf nodes - dark gray + }; + + return colors[type] || "#666666"; +} +export function unpackPos(pos: string): string { + const map: Record<string, string> = { + adj: "adjective", + adv: "adverb", + adv_phrase: "adverbial phrase", + combining_form: "combining form", + conj: "conjunction", + det: "determinant", + intj: "interjection", + num: "number", + prep: "preposition", + prep_phrase: "prepositional phrase", + pron: "pronoun", + punct: "punctuation", + }; + return map[pos] || pos; +} diff --git a/packages/ai/src/nlp/ocr.ts b/packages/ai/src/nlp/ocr.ts new file mode 100644 index 0000000..d495a8b --- /dev/null +++ b/packages/ai/src/nlp/ocr.ts @@ -0,0 +1,18 @@ +import type { AsyncRes } from "@sortug/lib"; + +export async function ocr(formData: FormData): AsyncRes<string[]> { + const endpoint = "http://localhost:8102/ocr"; + + const opts = { + method: "POST", + body: formData, + headers: { "X-API-KEY": Bun.env.SORTUG_NLP_API_KEY! }, + }; + try { + const res = await fetch(endpoint, opts); + const j = await res.json(); + return { ok: j }; + } catch (e) { + return { error: `${e}` }; + } +} diff --git a/packages/ai/src/nlp/spacy.ts b/packages/ai/src/nlp/spacy.ts new file mode 100644 index 0000000..829c77e --- /dev/null +++ b/packages/ai/src/nlp/spacy.ts @@ -0,0 +1,79 @@ +import type { AsyncRes, Result } from "@sortug/lib"; +import { detectLang } from "./iso"; +const ENDPOINT = "http://localhost:8102"; + +export async function run(text: string, langg?: string): AsyncRes<SpacyRes> { + try { + const lang = langg ? langg : detectLang(text); + const body = JSON.stringify({ string: text, lang }); + const opts = { + headers: { + "Content-type": "application/json", + "X-API-KEY": Bun.env.SORTUG_NLP_API_KEY!, + }, + method: "POST", + body, + }; + const res = await fetch(ENDPOINT + "/spacy", opts); + const j = await res.json(); + console.log("spacy", j); + return { ok: j }; + } catch (e) { + return { error: `${e}` }; + } +} + +export type SpacyResBig = { + doc: { + text: string; + ents: any[]; + sents: Array<{ start: number; end: number }>; + tokens: Token[]; + }; + segs: Sentence[]; +}; +export type SpacyRes = { + input: string; + segments: Sentence[]; +}; +export type Sentence = { + text: string; + start: number; + end: number; + root: Token; + subj: Token; + arcs: Arc[]; + words: Word[]; +}; +export type Arc = { + start: number; + end: number; + label: string; // deprel label + dir: string; +}; +export type Token = { + id: number; + head: number; + start: number; + end: number; + dep: string; + lemma: string; + morph: string; + pos: string; + tag: string; + text: string; +}; + +export interface Word extends Token { + ancestors: number[]; + children: []; + n_lefts: number; + n_rights: number; + left_edge: number; + right_edge: number; + morph_map: Record<string, string>; +} + +export function isChild(w: Word, topId: number): boolean { + return w.id === topId || w.ancestors.includes(topId); +} diff --git a/packages/ai/src/nlp/stanza.ts b/packages/ai/src/nlp/stanza.ts new file mode 100644 index 0000000..90fa1fc --- /dev/null +++ b/packages/ai/src/nlp/stanza.ts @@ -0,0 +1,210 @@ +import type { AsyncRes, Result } from "@sortug/lib"; +import { detectLang } from "./iso"; + +const ENDPOINT = "http://localhost:8102"; +export async function segmenter( + text: string, + langg?: string, +): AsyncRes<StanzaRes> { + try { + const lang = langg ? langg : detectLang(text); + const body = JSON.stringify({ lang, string: text }); + const opts = { + headers: { + "Content-type": "application/json", + "X-API-KEY": Bun.env.SORTUG_NLP_API_KEY!, + }, + method: "POST", + body, + }; + const res = await fetch(ENDPOINT + "/stanza", opts); + const j = await res.json(); + return { ok: j }; + } catch (e) { + return { error: `${e}` }; + } +} +export async function idLang(text: string) { + try { + const body = JSON.stringify({ string: text }); + const opts = { + headers: { + "Content-type": "application/json", + "X-API-KEY": Bun.env.SORTUG_NLP_API_KEY!, + }, + method: "POST", + body, + }; + const res = await fetch(ENDPOINT + "/detect-lang", opts); + const j = await res.json(); + return { ok: j }; + } catch (e) { + return { error: `${e}` }; + } +} +export type StanzaRes = { input: string; segments: Sentence[] }; +export type Sentence = { + text: string; + sentiment: number; + constituency: TreeNode; + constring: string; + dependencies: Dependency[]; + entities: Entity[]; + tokens: Token[]; + words: Word[]; +}; +export type TreeNode = { + label: string; + children: TreeNode[]; +}; +export type Dependency = Array<[Word, string, Word]>; +export type Word = { + id: number; + text: string; + lemma: string; + upos: string; + xpos: string; + feats: string; + head: number; + deprel: string; + start_char: number; + end_char: number; +}; +export type Token = { + id: [number, number]; + text: string; + misc: string; + words: Word[]; + start_char: number; + end_char: number; + ner: string; +}; +export type Entity = { + text: string; + misc: string; + start_char: number; + end_char: number; + type: string; +}; + +// mine +export type Clause = { + words: Word[]; + dependency: Dependency; + text: string; +}; +// "amod", +// { +// "id": 1, +// "text": "Stony", +// "lemma": "Stony", +// "upos": "ADJ", +// "xpos": "NNP", +// "feats": "Degree=Pos", +// "head": 3, +// "deprel": "amod", +// "start_char": 0, +// "end_char": 5 +// } +// +// + +export interface ParsedGrammar { + predicateCore: number; + subjectCore: number | null; + tree: Record<number, number[]>; + wordMap: WordMap; + words: BigWord[]; +} +export interface BigWord extends Word { + ancestry: number[]; + component: "s" | "p" | "u"; +} +export type ComputedDependency = { + word: BigWord; + children: ComputedDependency[]; +}; +export type WordMap = Record<number, Word>; + +export function buildTreeFromWords(words: Word[]): Result<ParsedGrammar> { + const roots = words.filter((w) => w.deprel === "root"); + if (roots.length > 1) { + console.log("roots", roots); + return { error: "too many roots" }; + } else if (roots.length === 0) { + return { error: "no roots" }; + } else { + const root = roots[0]; + const wordmap = words.reduce((acc: WordMap, item) => { + acc[item.id] = item; + return acc; + }, {}); + return { ok: parseFurther(words, wordmap, root) }; + } +} +function parseFurther( + words: Word[], + wordMap: WordMap, + root: Word, +): ParsedGrammar { + const predicateCore = root.id; + let subjectCore: number | null = null; + const tree: Record<number, number[]> = {}; + const bigwords: BigWord[] = []; + const getAncestry = (parent: Word): number[] => { + const kids = tree[parent.head] || []; + tree[parent.head] = [...kids, parent.id]; + if (parent.deprel === "nsubj") subjectCore = parent.id; + + console.log("getting ancestry " + parent.id, parent.text); + const grandpa = wordMap[parent.head]; + if (!grandpa) return [parent.id]; + else return [parent.id, ...getAncestry(grandpa)]; + }; + let idx = 0; + for (const w of words) { + if (w.deprel === "punct") { + const prev = words[idx - 1]; + if (!prev) continue; + prev.text += w.text; + continue; + } + const parent = wordMap[w.head]; + if (!parent) tree[w.id] = []; + const ancestry = !parent ? [] : getAncestry(parent); + const component = + subjectCore && (w.id === subjectCore || ancestry.includes(subjectCore)) + ? "s" + : w.id === predicateCore || ancestry.includes(root.id) + ? "p" + : "u"; + const bw: BigWord = { ...w, component, ancestry }; + wordMap[w.id] = bw; + bigwords.push(bw); + idx++; + } + const pg: ParsedGrammar = { + predicateCore, + subjectCore, + wordMap, + tree, + words: bigwords, + }; + return pg; +} + +export function oneDescendant(node: TreeNode): boolean { + if (node.children.length !== 1) return false; + else { + const child = node.children[0]; + return child.children.length === 0; + } +} + +// function findChildren(wordmap: WordMap, word: Word): ComputedDependency { +// const children = words.filter((w) => w.head === head.id); +// return { +// word: head, +// children: children.map((c) => findChildren(words, c)), +// }; +// } diff --git a/packages/ai/src/nlp/types.ts b/packages/ai/src/nlp/types.ts new file mode 100644 index 0000000..605a637 --- /dev/null +++ b/packages/ai/src/nlp/types.ts @@ -0,0 +1,50 @@ +export type ViewLevel = + | "text" + | "paragraph" + | "sentence" + | "clause" + | "word" + | "syllable" + | "phoneme"; +export interface ViewState { + level: ViewLevel; + pIndex: number | null; + sIndex: number | null; + cIndex: number | null; + wIndex: number | null; + yIndex: number | null; + fIndex: number | null; +} + +export interface ViewProps { + idx: number; + rawText: string; + context: Context; +} +export type Context = { + parentText: string; + segmented: string[]; + idx: number; +}; + +export type WordData = { + confidence: number; + frequency: number | null; + id: number; + ipa: Array<{ ipa: string; tags: string[] }>; + spelling: string; + type: ExpressionType; + syllables: number; + lang: string; + prosody: any; + senses: Sense[]; +}; +export type ExpressionType = "word" | "expression" | "syllable"; +export type Sense = { + etymology: string; + pos: string; + forms: Array<{ form: string; tags: string[] }>; + related: any; + senses: Array<{ glosses: string[]; links: Array<[string, string]> }>; +}; +export type LoadingStatus = "pending" | "loading" | "success" | "error"; diff --git a/packages/ai/src/openai-responses.ts b/packages/ai/src/openai-responses.ts new file mode 100644 index 0000000..63a08cc --- /dev/null +++ b/packages/ai/src/openai-responses.ts @@ -0,0 +1,186 @@ +import OpenAI from "openai"; +import { MAX_TOKENS, RESPONSE_LENGTH } from "./logic/constants"; +import type { AIModelAPI, ChatMessage, InputToken } from "./types"; +import type { AsyncRes } from "@sortug/lib"; +import type { + ResponseContent, + ResponseInput, + ResponseInputContent, + ResponseInputItem, + ResponseOutputItem, + ResponseOutputMessage, +} from "openai/resources/responses/responses"; +import type { ResponseCreateAndStreamParams } from "openai/lib/responses/ResponseStream"; +import { memoize } from "./cache"; + +type Params = OpenAI.Responses.ResponseCreateParamsNonStreaming; +type Props = { + baseURL: string; + apiKey: string; + model?: string; + maxTokens?: number; + tokenizer?: (text: string) => number; + allowBrowser?: boolean; +}; +export default class OpenAIAPI implements AIModelAPI { + private cachedCreate!: (args: Params) => Promise<OpenAI.Responses.Response>; + + private apiKey; + private baseURL; + private api; + maxTokens: number = MAX_TOKENS; + tokenizer: (text: string) => number = (text) => text.length / 3; + model; + + constructor(props: Props) { + this.apiKey = props.apiKey; + this.baseURL = props.baseURL; + this.api = new OpenAI({ + baseURL: this.baseURL, + apiKey: this.apiKey, + dangerouslyAllowBrowser: props.allowBrowser || false, + }); + this.model = props.model || ""; + if (props.maxTokens) this.maxTokens = props.maxTokens; + if (props.tokenizer) this.tokenizer = props.tokenizer; + + const boundCreate = this.api.responses.create.bind(this.api.responses); + + this.cachedCreate = memoize(boundCreate, { + ttlMs: 2 * 60 * 60 * 1000, // 2h + maxEntries: 5000, + persistDir: "./cache/memo", + // stable key for the call + keyFn: (args) => { + // args is the single object param to .create(...) + const { model, input, max_output_tokens, temperature, top_p } = + args as Params; + // stringify messages deterministically (role+content only) + return JSON.stringify({ + model, + input, + max_output_tokens, + temperature, + top_p, + }); + }, + }); + } + public setModel(model: string) { + this.model = model; + } + // response input items are text, image, file, conversation state or function cals + private buildInput(tokens: InputToken[]): ResponseInputItem[] { + const content: ResponseInputContent[] = tokens.map((t) => { + if ("text" in t) return { type: "input_text" as const, text: t.text }; + // image_url or file_id + else if ("img" in t) + return { + type: "input_image" as const, + image_url: t.img, + detail: "auto", + }; + // file_data or file_id or file_url or filename + else if ("file" in t) + return { type: "input_file" as const, file_data: t.file.file_data }; + // TODO obviously + else return { type: "input_text" as const, text: "oy vey" }; + }); + // role can be user, developer, or system + return [{ role: "user" as const, content }]; + } + + public async send( + userInput: string | InputToken[], + sys?: string, + ): AsyncRes<string> { + const input: string | ResponseInput = + typeof userInput === "string" ? userInput : this.buildInput(userInput); + // const messages = this.mapMessages(input); + const res = await this.apiCall({ instructions: sys, input }); + if ("error" in res) return res; + else { + try { + // TODO type this properly + const resText = res.ok.reduce((acc, item) => { + if (item.type === "message" && item.status === "completed") { + const outputText = this.getOutputText(item.content); + return `${acc}\n${outputText}`; + } + // TODO else + return acc; + }, ""); + return { ok: resText }; + } catch (e) { + return { error: `${e}` }; + } + } + } + getOutputText(content: ResponseOutputMessage["content"]): string { + let text = ""; + for (const c of content) { + if (c.type === "refusal") text += `\nRefused to respond: ${c.refusal}\n`; + else text += `\n${c.text}\n`; + } + return text; + } + + public async stream( + userInput: string | InputToken[], + handle: (c: string) => void, + sys?: string, + ) { + const input: string | ResponseInput = + typeof userInput === "string" ? userInput : this.buildInput(userInput); + await this.apiCallStream({ instructions: sys, input }, handle); + } + + // TODO custom temperature?dune exec -- ./test/test_nock.exe --verbose + private async apiCall( + params: OpenAI.Responses.ResponseCreateParamsNonStreaming, + ): AsyncRes<ResponseOutputItem[]> { + // console.log({ messages }, "at the very end"); + try { + const response = await this.cachedCreate({ + ...params, + model: this.model, + // max_output_tokens: RESPONSE_LENGTH, + }); + if (response.status !== "completed") + return { + error: + response.incomplete_details?.reason || response.status || "error", + }; + + return { ok: response.output }; + } catch (e) { + console.log(e, "error in openai api"); + return { error: `${e}` }; + } + } + + private async apiCallStream( + params: ResponseCreateAndStreamParams, + handle: (c: string) => void, + ): Promise<void> { + try { + const stream = await this.api.responses.create({ + // temperature: 1.3, + ...params, + stream: true, + model: this.model, + max_output_tokens: RESPONSE_LENGTH, + }); + + for await (const chunk of stream) { + console.log("stream reponse", chunk); + if (chunk.type === "response.output_text.done") handle(chunk.text); + // TODO else + } + } catch (e) { + console.log(e, "error in openai api"); + // TODO + // handle(`Error streaming OpenAI, ${e}`); + } + } +} diff --git a/packages/ai/src/openai.ts b/packages/ai/src/openai.ts new file mode 100644 index 0000000..bd1dca1 --- /dev/null +++ b/packages/ai/src/openai.ts @@ -0,0 +1,260 @@ +import fs from "fs"; +import OpenAI from "openai"; +import { RESPONSE_LENGTH } from "./logic/constants"; +import type { ChatMessage, OChoice, OChunk, OMessage } from "./types"; +import type { AsyncRes, Result } from "@sortug/lib"; +import OpenAIToolUse from "./openai_tools"; +import type { FileObject } from "openai/src/resources/files.js"; + +type Message = OpenAI.Chat.Completions.ChatCompletionMessageParam; + +type Props = { + maxTokens?: number; + baseURL?: string; + apiKey?: string; + tokenizer?: (text: string) => number; +}; +export default class Conversation { + private maxTokens: number = 128_000; + private apiKey: string = Bun.env["OPENAI_API_KEY"] || ""; + private baseURL: string = "https://api.openai.com/v1"; + private tokenizer: (text: string) => number = (text) => text.length / 3; + openai; + private model: string = "gpt-4.1"; + + constructor(props: Props) { + if (props.apiKey) this.apiKey = props.apiKey; + if (props.baseURL) this.baseURL = props.baseURL; + this.openai = new OpenAI({ baseURL: this.baseURL, apiKey: this.apiKey }); + if (props.maxTokens) this.maxTokens = props.maxTokens; + if (props.tokenizer) this.tokenizer = props.tokenizer; + } + public setModel(model: string) { + this.model = model; + } + private mapMessages(input: ChatMessage[]): Message[] { + return input.map((m) => { + const role = m.author === "openai" ? "assistant" : "user"; + return { role, content: m.text, name: m.author }; + }); + } + + private mapMessagesR1(input: ChatMessage[]): Message[] { + return input.reduce((acc: Message[], m, i) => { + const prev = acc[i - 1]; + const role = m.author === "openai" ? "assistant" : "user"; + const msg: Message = { role, content: m.text, name: m.author }; + if (prev?.role === role) acc[i - 1] = msg; + else acc = [...acc, msg]; + return acc; + }, []); + } + + public async send(sys: string, input: ChatMessage[]): AsyncRes<OChoice[]> { + const messages = this.mapMessages(input); + const sysMsg: Message = { role: "system", content: sys }; + const allMessages = [sysMsg, ...messages]; + const truncated = this.truncateHistory(allMessages); + const res = await this.apiCall(truncated); + return res; + } + + public async sendR1(input: ChatMessage[]): AsyncRes<OChoice[]> { + const messages = this.mapMessagesR1(input); + const truncated = this.truncateHistory(messages); + const res = await this.apiCall(truncated); + return res; + } + + public async stream( + sys: string, + input: ChatMessage[], + handle: (c: any) => void, + ) { + const messages = this.mapMessages(input); + const sysMsg: Message = { role: "system", content: sys }; + const allMessages = [sysMsg, ...messages]; + const truncated = this.truncateHistory(allMessages); + await this.apiCallStream(truncated, handle); + } + + public async streamR1(input: ChatMessage[], handle: (c: any) => void) { + const messages = this.mapMessagesR1(input); + const truncated = this.truncateHistory(messages); + await this.apiCallStream(truncated, handle); + } + + private truncateHistory(messages: Message[]): Message[] { + const totalTokens = messages.reduce((total, message) => { + return total + this.tokenizer(message.content as string); + }, 0); + while (totalTokens > this.maxTokens && messages.length > 1) { + // Always keep the system message if it exists + const startIndex = messages[0].role === "system" ? 1 : 0; + messages.splice(startIndex, 1); + } + return messages; + } + + private async apiCall(messages: Message[]): AsyncRes<OChoice[]> { + try { + const completion = await this.openai.chat.completions.create({ + temperature: 1.3, + model: this.model, + messages, + max_tokens: RESPONSE_LENGTH, + }); + if (!completion) return { error: "null response from openai" }; + return { ok: completion.choices }; + } catch (e) { + console.log(e, "error in openai api"); + return { error: `${e}` }; + } + } + + private async apiCallStream( + messages: Message[], + handle: (c: string) => void, + ): Promise<void> { + try { + const stream = await this.openai.chat.completions.create({ + temperature: 1.3, + model: this.model, + messages, + max_tokens: RESPONSE_LENGTH, + stream: true, + }); + + for await (const chunk of stream) { + for (const choice of chunk.choices) { + console.log({ choice }); + if (!choice.delta) continue; + const cont = choice.delta.content; + if (!cont) continue; + handle(cont); + } + } + } catch (e) { + console.log(e, "error in openai api"); + handle(`Error streaming OpenAI, ${e}`); + } + } + + // assistant + async assistant() { + const assistant = await this.openai.beta.assistants.create({ + name: "Literature professor", + instructions: + "You are a professor of literature. Use your knowledge to analyze large pieces of text and answer questions from your users.", + model: this.model, + tools: [{ type: "file_search" }], + temperature: 0.7, + response_format: { type: "text" }, + }); + const vector_store = await this.openai.beta.vectorStores.create({ + name: "docs", + }); + const tool_resources = { + file_search: { vector_store_ids: [vector_store.id] }, + }; + const tant = await this.openai.beta.assistants.update(assistant.id, { + tool_resources, + }); + const thread = await this.openai.beta.threads.create(); + const msg = await this.openai.beta.threads.messages.create(thread.id, { + role: "user", + content: + "Greetings, pleasure to meet. Let's get started if you don't mind", + }); + const run = await this.openai.beta.threads.runs.create(thread.id, { + assistant_id: assistant.id, + instructions: "be nice", + }); + while (run.status === "in_progress") { + console.log({ run }); + } + } + async lookatFile(fo: FileObject) { + const tant = await this.openai.beta.assistants.create({ + name: "Literature professor", + instructions: + "You are a professor of literature. Use your knowledge to analyze large pieces of text and answer questions from your users.", + model: this.model, + tools: [{ type: "file_search" }], + temperature: 0.7, + response_format: { type: "text" }, + }); + const thread = await this.openai.beta.threads.create(); + await this.openai.beta.threads.messages.create(thread.id, { + role: "user", + content: + "Greetings, pleasure to meet. Let's get started if you don't mind. Look at this file and summarize its contents", + attachments: [{ file_id: fo.id, tools: [{ type: "file_search" }] }], + }); + const run = await this.openai.beta.threads.runs.createAndPoll(thread.id, { + assistant_id: tant.id, + }); + console.log({ run }); + const msgs = await this.openai.beta.threads.messages.list(run.thread_id); + console.log({ msgs }); + for (let m of msgs.data) { + console.log(m, "message on thread"); + } + } + + async uploadFile(res: Response) { + // const ff = fs.createReadStream("./lol") + const file = await this.openai.files.create({ + file: res, + purpose: "assistants", + }); + console.log({ file }, "uploaded"); + return file; + + // { + // "id": "file-abc123", + // "object": "file", + // "bytes": 120000, + // "created_at": 1677610602, + // "filename": "mydata.jsonl", + // "purpose": "fine-tune", + // } + } + + // async analyzeFile(){ + // const huh = await this.openai.beta.vectorStores.files.uploadAndPoll() + // } + + // mcp + + async mcp() { + const res = await fetch("http://localhost:8900/list"); + const list = await res.json(); + this.tryTools(list); + } + + async tryTools(tools: OpenAI.Chat.Completions.ChatCompletionTool[]) { + const messages: Message[] = [ + { role: "user", content: "What's on my twitter timeline right now?" }, + ]; + const completion = await this.openai.chat.completions.create({ + model: "gpt-4o-2024-11-20", + messages, + tools, + }); + if (!completion) return { error: "null response from openai" }; + + for (let choice of completion.choices) { + console.log({ choice }); + if (choice.message.tool_calls) { + const instance = new OpenAIToolUse( + this.openai, + "gpt-4o-2024-11-20", + tools, + choice.message, + choice.message.tool_calls, + ); + } + } + } +} diff --git a/packages/ai/src/openai_tools.ts b/packages/ai/src/openai_tools.ts new file mode 100644 index 0000000..feb2e4a --- /dev/null +++ b/packages/ai/src/openai_tools.ts @@ -0,0 +1,66 @@ +import type OpenAI from "openai"; +import type { Result } from "./types"; +type ToolCall = OpenAI.Chat.Completions.ChatCompletionMessageToolCall; + +type Tool = OpenAI.Chat.Completions.ChatCompletionTool; +type ToolMsg = OpenAI.Chat.Completions.ChatCompletionToolMessageParam; + +type Message = OpenAI.Chat.Completions.ChatCompletionMessage; + +export default class OpenAIToolUse { + api; + model; + socket; + tools; + message; + calls; + res: ToolMsg | null = null; + constructor( + api: OpenAI, + model: string, + tools: Tool[], + message: Message, + calls: ToolCall[], + ) { + this.api = api; + this.model = model; + this.socket = new WebSocket("http://localhost:8900"); + this.tools = tools; + this.message = message; + this.calls = calls; + for (let c of calls) { + console.log({ c }); + } + this.wsHandlers(); + } + wsHandlers() { + this.socket.addEventListener("open", (_data) => { + this.handleToolCalls(); + }); + this.socket.addEventListener("message", (ev) => { + const j = JSON.parse(ev.data); + if ("functionRes" in j) this.handleRes(j.functionRes); + }); + } + handleToolCalls() { + for (let c of this.calls) this.socket.send(JSON.stringify({ call: c })); + } + async handleRes(res: Result<ToolMsg>) { + if ("error" in res) { + console.log("TODO"); + return; + } + this.res = res.ok; + const messages = [this.message, res.ok]; + console.log({ messages }, "almost there"); + const completion = await this.api.chat.completions.create({ + model: this.model, + messages, + tools: this.tools, + }); + console.log({ completion }); + for (let choice of completion.choices) { + console.log({ choice }); + } + } +} diff --git a/packages/ai/src/prompts.ts b/packages/ai/src/prompts.ts new file mode 100644 index 0000000..60e8c0d --- /dev/null +++ b/packages/ai/src/prompts.ts @@ -0,0 +1,14 @@ +export const yagoSys = + "You are a helpful assistant of humans engaged in high stakes work. We call you Yagobot. Your user's name will appear in the 'name' field of this message. Please be brief but intelligent in your answers. Be civil but not overly polite, always tell the truth even if inconvenient. Address your user by his name."; + +export const biaSys = + "You are Yagobot, an extremely helpful assistant in charge of attending to a new mother to all her needs. Her name is Bia and she would like you to address her in both Thai and English at all times. Her husband will show up now and then, he's cool too."; + +export const GUEST_SYS = + "You are Yagobot, a helpful assistant with vast knowledge of everything there is to now in several languages. You are responding to a guest user now, be polite, but friendly and brief. Get to the point and strive to be both cool and useful. Respond in the language in which you were addressed."; + +export const LEO_SYS = `You are Yagobot, a super advanced tutor AI to help the children of foreign elites with the best education in the world. You are talking to Leo, a precocious mixed-race Japanese 11 year old. His Japanese name is 黎雄. He can't speak English well but he can understand a bit. Please respond to him the same content thrice: in English first, then English but in IPA phonetic noting, then in Japanese. Try to be proactive and ask him questions yourself if you see he isn't talking much.`; + +export const SAYURI_SYS = `You are Yagobot, a super advanced tutor AI to help the children of foreign elites with the best education in the world. You are talking to Sayuri, a lovely mixed-race Japanese 9 year old. Her Japanese name is 紗悠里. She can't speak English well but she can understand a bit. Please respond to her the same content thrice: in English first, then English but in IPA phonetic noting, then in Japanese. Try to be proactive and ask him questions yourself if you see she isn't talking much.`; + +export const BOOKWORM_SYS = `You are a professor of literature. Use your knowledge to analyze large pieces of text and answer questions from your users.`; diff --git a/packages/ai/src/tts/eleven.ts b/packages/ai/src/tts/eleven.ts new file mode 100644 index 0000000..c870b11 --- /dev/null +++ b/packages/ai/src/tts/eleven.ts @@ -0,0 +1,20 @@ +import { ElevenLabsClient, play } from "@elevenlabs/elevenlabs-js"; + +const elevenlabs = new ElevenLabsClient({ + apiKey: Bun.env.ELEVEN_KEY!, // Defaults to process.env.ELEVENLABS_API_KEY +}); + +const models = await elevenlabs.models.list(); +for (const model of models) { + const langs = model.languages || []; + for (const lang of langs) { + if (lang.name === "Thai") console.log(model.modelId); + } +} +// ONLY eleven_v3 has Thai! +// const audio = await elevenlabs.textToSpeech.convert("Xb7hH8MSUJpSbSDYk0k2", { +// text: "Hello! 你好! Hola! नमस्ते! Bonjour! こんにちは! مرحبا! 안녕하세요! Ciao! Cześć! Привіт! வணக்கம்!", +// modelId: "eleven_multilingual_v2", +// }); + +// await play(audio); diff --git a/packages/ai/src/tts/minimax.ts b/packages/ai/src/tts/minimax.ts new file mode 100644 index 0000000..4421f94 --- /dev/null +++ b/packages/ai/src/tts/minimax.ts @@ -0,0 +1,107 @@ +// https://platform.minimax.io/docs/api-reference/speech-t2a-async-create/ +// +// +// + +const text = `สำนักข่าวต่างประเทศรายงานเมื่อ 18 พ.ย. 2568 ว่า เจ้าหน้าที่กู้ภัยของประเทศชิลี กำลังดำเนินการค้นหากลุ่มนักท่องเที่ยวที่สูญหายไปในพายุหิมะรุนแรงซึ่งเกิดขึ้นที่ อุทยานแห่งชาติ “ตอร์เรส เดล ไพเน” ในภูมิภาคปาตาโกเนีย ทางตอนใต้ของชิลี หลังพายุทำให้มีผู้เสียชีวิตแล้วอย่างน้อย 5 ศพ`; +// const text = `So I start using it for my project and after about 20 mins - oh, no. Out of credits. +// I didn't even get to try a single Gemini 3 prompt. I was out of credits before my first had completed. I guess I've burned through the free tier in some other app but the error message gave me no clues. As far as I can tell there's no link to give Google my money in the app. Maybe they think they have enough. + +// After switching to gpt-oss:120b it did some things quite well, and the annotation feature in the plan doc is really nice. It has potential but I suspect it's suffering from Google's typical problem that it's only really been tested on Googlers.`; +const model = "speech-2.6-hd"; +const voice1 = "Thai_male_1_sample8"; +const voice2 = "Thai_male_2_sample2"; +const voice3 = "Thai_female_1_sample1"; +const voice4 = "Thai_female_2_sample2"; +const params = { + model, + language_boost: "auto", + voice_setting: { voice_id: voice1, speed: 1, vol: 1, pitch: 1 }, + pronunciation_dct: { tone: ["lol, lmao"] }, + audio_setting: { + audio_sample_rate: 32000, + bitrate: 128_000, + format: "mp3", + channel: 2, + }, + voice_modify: { + pitch: 0, + intensity: 0, + timbre: 0, + sound_Effects: "spacious_echo", + }, +}; + +async function getVoices() { + const endpoint = "/get_voice"; + const body = { voice_type: "all" }; + return await post(endpoint, body); +} +async function tts() { + const endpoint = "/t2a_v2"; + const body = { text, stream: false, ...params }; + return await post(endpoint, body); +} +async function ws() { + const url = "wss://api.minimax.io/ws/v1/t2a_v2"; + const event = "task_start"; + + const headers = { + Authorization: `Bearer ${Bun.env.MINIMAX_API_KEY!}`, + }; + const socket = new WebSocket(url, { headers }); + const body = { event, ...params }; + const body2 = { event: "task_continue", text }; + socket.send(JSON.stringify(body)); + // const event = "task_continue"; + // const event = "task_finish"; +} +async function tts_async() { + const body = { + text, + ...params, + }; + return await post("/t2a_async_v2", body); +} +async function post(path: string, body: any) { + const url = "https://api.minimax.io/v1" + path; + const options = { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${Bun.env.MINIMAX_API_KEY2!}`, + }, + body: JSON.stringify(body), + }; + + try { + const response = await fetch(url, options); + const data = await response.json(); + return data; + } catch (error) { + console.error(error); + } +} +async function get(path: string) { + const url = "https://api.minimax.io/v1" + path; + const options = { + headers: { + Authorization: `Bearer ${Bun.env.MINIMAX_API_KEY!}`, + }, + }; + + try { + const response = await fetch(url, options); + const data = await response.json(); + console.log(data); + } catch (error) { + console.error(error); + } +} + +import fs from "node:fs"; +const res = await tts(); +const audio = res.data.audio; +const audioBuffer = Buffer.from(audio, "hex"); +const filename = "output.mp3"; +fs.writeFileSync(filename, audioBuffer); diff --git a/packages/ai/src/tts/output.mp3 b/packages/ai/src/tts/output.mp3 Binary files differnew file mode 100644 index 0000000..9f22e3a --- /dev/null +++ b/packages/ai/src/tts/output.mp3 diff --git a/packages/ai/src/types/index.ts b/packages/ai/src/types/index.ts new file mode 100644 index 0000000..24db77b --- /dev/null +++ b/packages/ai/src/types/index.ts @@ -0,0 +1,56 @@ +import type { ResponseInputFile } from "openai/resources/responses/responses.js"; +import type { AsyncRes } from "@sortug/lib"; +export type ChatMessage = { + author: string; + text: string; + sent: number; + reasoning?: string; +}; + +export type InputToken = + | { text: string } + | { img: string } + | { file: ResponseInputFile } + | { tools: ToolUseInput[] }; +export type ToolUseInput = any; // TODO +// me +export type RequestOptions = { + textOutput: boolean; +}; +export const defaultOptions: RequestOptions = { + textOutput: true, +}; +// openai +export type ContentType = { text: string } | { audio: Response }; + +export interface AIModelAPI { + setModel: (model: string) => void; + tokenizer: (text: string) => number; + maxTokens: number; + + send: ( + input: string | InputToken[], + systemPrompt?: string, + ) => AsyncRes<string>; + stream: ( + input: string | InputToken[], + handler: (data: string) => void, + systemPrompt?: string, + ) => void; +} + +export type LLMChoice = + | { gemini: string } + | { claude: string } + | { chatgpt: string } + | { grok: string } + | { deepseek: string } + | { kimi: string } + | { + openai: { + url: string; + apiKey: string; + model: string; + allowBrowser?: boolean; + }; + }; diff --git a/packages/ai/src/types/mtproto.ts b/packages/ai/src/types/mtproto.ts new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/packages/ai/src/types/mtproto.ts diff --git a/packages/ai/tests/cache.test.ts b/packages/ai/tests/cache.test.ts new file mode 100644 index 0000000..8b78b99 --- /dev/null +++ b/packages/ai/tests/cache.test.ts @@ -0,0 +1,81 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; + +// const url = "https://urbit.org"; + +import models, { type AIModelAPI, type LLMChoice } from "../index"; +import { memoize } from "../src/cache"; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +async function thingy(arg1: string, arg2?: string): Promise<string> { + const res = `lolololol\n${arg1}\n${arg2};`; + await sleep(3000); + return res; +} + +describe("HTTP cache", () => { + test("should cache a function call", async () => { + const ts = Date.now(); + // const choice: LLMChoice = { deepseek: "deepseek-chat" }; + // const api = models(choice); + // + const cachedCall = memoize(thingy, { + ttlMs: 7 * 24 * 60 * 60 * 1000, + maxEntries: 5000, + persistDir: "./cache/memo", + }); + const res = await cachedCall("LOLAZO", "bar"); + const elapsed = Date.now() - ts; + console.log(elapsed); + const randomRes = await cachedCall(`${Math.random()}`); + const elapsedRandom = Date.now() - ts; + console.log("uncachable", elapsedRandom); + console.log("cached res", res); + + expect(elapsed).toBeLessThan(3000); + }); + test("should cache a request", async () => { + const testMessage = "oh hi"; + + const choice3: LLMChoice = { chatgpt: "gpt-5-nano" }; + const choice4: LLMChoice = { deepseek: "deepseek-chat" }; + const choice5: LLMChoice = { kimi: "kimi-k2-0905-preview" }; + + const api3 = models(choice3); + const api4 = models(choice4); + const api5 = models(choice5); + + const ts = Date.now(); + const r3 = api3.send(testMessage); + const r4 = api4.send(testMessage); + const r5 = api5.send(testMessage); + // Check ChatGPT response + const res3 = await r3; + console.log("elapsed r3", Date.now() - ts); + if ("ok" in res3) { + console.log(`✅ ChatGPT Response: ${res3.ok}`); + expect(res3.ok).toBeString(); + } else { + console.log(`❌ ChatGPT Error: ${res3.error}`); + } + + // // Check DeepSeek response + const res4 = await r4; + console.log("elapsed r4", Date.now() - ts); + if ("ok" in res4) { + console.log(`✅ DeepSeek Response: ${res4.ok}`); + expect(res4.ok).toBeString(); + } else { + console.log(`❌ DeepSeek Error: ${res4.error}`); + } + + // // Check Kimi response + const res5 = await r5; + console.log("elapsed r5", Date.now() - ts); + if ("ok" in res5) { + console.log(`✅ Kimi Response: ${res5.ok}`); + expect(res5.ok).toBeString(); + } else { + console.log(`❌ Kimi Error: ${res5.error}`); + } + }); +}); diff --git a/packages/ai/tests/example.ts b/packages/ai/tests/example.ts new file mode 100644 index 0000000..568f5ce --- /dev/null +++ b/packages/ai/tests/example.ts @@ -0,0 +1,279 @@ +/** + * Example usage of the models library + * This file demonstrates how to use the LLM routing library with different providers + */ + +import models, { AIModelAPI, LLMChoice } from '../index'; +import OpenAIResponses from '../src/openai-responses'; + +// Example configurations for different providers +const examples = { + // Claude example + claude: { + description: 'Claude (Anthropic) example', + setup: (): LLMChoice => ({ claude: 'claude-3-5-sonnet' }), + envVars: ['ANTHROPIC_API_KEY'] + }, + + // Gemini example + gemini: { + description: 'Gemini (Google) example', + setup: (): LLMChoice => ({ gemini: 'gemini-2.5-pro' }), + envVars: ['GOOGLE_API_KEY'] + }, + + // ChatGPT example (using new OpenAI Responses API) + chatgpt: { + description: 'ChatGPT (OpenAI Responses API) example', + setup: (): LLMChoice => ({ chatgpt: 'gpt-4o' }), + envVars: ['OPENAI_API_KEY'] + }, + + // Direct OpenAI Responses API example + openaiResponses: { + description: 'Direct OpenAI Responses API example', + setup: (): AIModelAPI => new OpenAIResponses({ + baseURL: Bun.env.ZAI_BASE_URL || 'https://api.openai.com/v1', + apiKey: Bun.env.ZAI_API_KEY || Bun.env.OPENAI_API_KEY || 'your-api-key-here', + model: Bun.env.TEST_MODEL || 'gpt-4o', + allowBrowser: true, + tokenizer: (text: string) => text.length / 4, // Custom tokenizer + maxTokens: 4000 // Custom max tokens + }), + envVars: ['ZAI_BASE_URL', 'ZAI_API_KEY', 'OPENAI_API_KEY', 'TEST_MODEL'] + }, + + // DeepSeek example + deepseek: { + description: 'DeepSeek example', + setup: (): LLMChoice => ({ deepseek: 'deepseek-chat' }), + envVars: ['DEEPSEEK_API_KEY'] + }, + + // Kimi example + kimi: { + description: 'Kimi (Moonshot) example', + setup: (): LLMChoice => ({ kimi: 'moonshot-v1-8k' }), + envVars: ['MOONSHOT_API_KEY'] + }, + + // Grok example + grok: { + description: 'Grok (X.AI) example', + setup: (): LLMChoice => ({ grok: 'grok-beta' }), + envVars: ['XAI_API_KEY'] + }, + + // Custom OpenAI-compatible API example + custom: { + description: 'Custom OpenAI-compatible API example', + setup: (): LLMChoice => ({ + openai: { + url: Bun.env.ZAI_BASE_URL || 'https://api.openai.com/v1', + apiKey: Bun.env.ZAI_API_KEY || 'your-api-key-here', + model: Bun.env.TEST_MODEL || 'glm-4.6', + allowBrowser: true + } + }), + envVars: ['ZAI_BASE_URL', 'ZAI_API_KEY', 'TEST_MODEL'] + } +}; + +// Run an example with a specific provider +async function runExample(providerName: keyof typeof examples) { + const example = examples[providerName]; + + console.log(`\n=== ${example.description} ===`); + + // Check required environment variables + const missingVars = example.envVars.filter(varName => !Bun.env[varName]); + if (missingVars.length > 0) { + console.warn(`Warning: Missing environment variables: ${missingVars.join(', ')}`); + console.warn('The example will be created but API calls will likely fail.'); + } + + try { + // Create the API instance + const setupResult = example.setup(); + let api: AIModelAPI; + let configInfo: any; + + // Check if the setup returns an LLMChoice or direct AIModelAPI instance + if (typeof setupResult === 'object' && 'send' in setupResult) { + // Direct AIModelAPI instance + api = setupResult; + configInfo = 'Direct API instance'; + } else { + // LLMChoice that needs to be passed to models() + const choice = setupResult as LLMChoice; + api = models(choice); + configInfo = choice; + } + + console.log(`✅ API instance created successfully`); + console.log(` Max tokens: ${api.maxTokens}`); + console.log(` Model: ${JSON.stringify(configInfo)}`); + + // Check if it's an OpenAI Responses API instance + if (api instanceof OpenAIResponses) { + console.log(` Using OpenAI Responses API directly`); + } + + // Test tokenization + const testText = 'Hello, how are you today?'; + const tokens = api.tokenizer(testText); + console.log(` Tokenization: "${testText}" -> ${tokens} tokens`); + + // Test simple API call (will fail without valid credentials) + console.log(` Testing API call...`); + try { + const response = await api.send('Hello! Please respond with just "API working".'); + console.log(` ✅ API response: ${response.substring(0, 100)}${response.length > 100 ? '...' : ''}`); + } catch (error) { + console.log(` ❌ API call failed: ${error.message}`); + } + + // Test streaming + console.log(` Testing streaming...`); + try { + const chunks: string[] = []; + const handler = (chunk: string) => { + chunks.push(chunk); + process.stdout.write('.'); + }; + + await new Promise<void>((resolve) => { + api.stream('Count from 1 to 3', handler); + setTimeout(() => { + console.log(`\n ✅ Streaming completed (${chunks.length} chunks)`); + resolve(); + }, 3000); + }); + } catch (error) { + console.log(` ❌ Streaming failed: ${error.message}`); + } + + } catch (error) { + console.error(`❌ Failed to create API instance: ${error.message}`); + } +} + +// Run all examples +async function runAllExamples() { + console.log('🚀 Running LLM Models Library Examples\n'); + + console.log('Environment Variables:'); + Object.keys(Bun.env) + .filter(key => key.includes('API_KEY') || key.includes('BASE_URL') || key.includes('MODEL')) + .forEach(key => { + const value = Bun.env[key]; + console.log(` ${key}: ${value ? '***set***' : 'not set'}`); + }); + + for (const providerName of Object.keys(examples) as (keyof typeof examples)[]) { + await runExample(providerName); + } + + console.log('\n✨ All examples completed!'); +} + +// Interactive example selector +async function interactiveExample() { + console.log('\n📋 Available providers:'); + Object.entries(examples).forEach(([key, example]) => { + console.log(` ${key}: ${example.description}`); + }); + + const provider = process.argv[2]; + if (provider && provider in examples) { + await runExample(provider as keyof typeof examples); + } else { + console.log('\nUsage: bun run example.ts [provider]'); + console.log('Available providers:', Object.keys(examples).join(', ')); + console.log('Or run without arguments to see all examples'); + await runAllExamples(); + } +} + +// Test tokenization accuracy across providers +async function testTokenization() { + console.log('\n🔢 Testing Tokenization Across Providers\n'); + + const testTexts = [ + 'Hello world', + 'The quick brown fox jumps over the lazy dog.', + 'This is a longer text with multiple sentences. It should have more tokens than shorter texts.', + 'Special chars: !@#$%^&*()_+-=[]{}|;:,.<>?/~`', + 'Unicode: Hello 🌍! 测试中文! Тест на русском! العربية!' + ]; + + for (const providerName of Object.keys(examples) as (keyof typeof examples)[]) { + try { + const example = examples[providerName]; + const choice = example.setup(); + const api = models(choice); + + console.log(`${providerName}:`); + testTexts.forEach(text => { + const tokens = api.tokenizer(text); + console.log(` "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}" -> ${tokens} tokens`); + }); + console.log(''); + } catch (error) { + console.log(`${providerName}: Failed to initialize - ${error.message}\n`); + } + } +} + +// Performance benchmark +async function performanceBenchmark() { + console.log('\n⚡ Performance Benchmark\n'); + + const testText = 'The quick brown fox jumps over the lazy dog. '; + const iterations = 1000; + + for (const providerName of Object.keys(examples) as (keyof typeof examples)[]) { + try { + const example = examples[providerName]; + const choice = example.setup(); + const api = models(choice); + + const startTime = performance.now(); + for (let i = 0; i < iterations; i++) { + api.tokenizer(testText + i); + } + const endTime = performance.now(); + + const totalTime = endTime - startTime; + const avgTime = totalTime / iterations; + + console.log(`${providerName}:`); + console.log(` ${iterations} tokenizations in ${totalTime.toFixed(2)}ms`); + console.log(` Average: ${avgTime.toFixed(3)}ms per tokenization`); + console.log(''); + } catch (error) { + console.log(`${providerName}: Failed to benchmark - ${error.message}\n`); + } + } +} + +// Main execution +if (import.meta.main) { + const command = process.argv[2]; + + switch (command) { + case 'tokenize': + await testTokenization(); + break; + case 'benchmark': + await performanceBenchmark(); + break; + case 'all': + await runAllExamples(); + break; + default: + await interactiveExample(); + } +} + +export { examples, runExample, runAllExamples, testTokenization, performanceBenchmark };
\ No newline at end of file diff --git a/packages/ai/tests/integration.test.ts b/packages/ai/tests/integration.test.ts new file mode 100644 index 0000000..b8abea0 --- /dev/null +++ b/packages/ai/tests/integration.test.ts @@ -0,0 +1,481 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import models, { AIModelAPI, LLMChoice, InputToken } from '../index'; +import OpenAIResponses from '../src/openai-responses'; + +// Advanced integration tests that require actual API keys +// These tests are designed to run with real API credentials when available + +// Environment variables for integration testing +const INTEGRATION_CLAUDE_KEY = Bun.env.CLAUDE_API_KEY; +const INTEGRATION_OPENAI_KEY = Bun.env.OPENAI_API_KEY; +const INTEGRATION_GEMINI_KEY = Bun.env.GEMINI_API_KEY; + +// Skip integration tests if no API keys are available +const runIntegrationTests = INTEGRATION_CLAUDE_KEY || INTEGRATION_OPENAI_KEY || INTEGRATION_GEMINI_KEY; + +describe.runIf(runIntegrationTests)('Integration Tests - Real API Calls', () => { + describe('Claude Integration', () => { + test.skipUnless(INTEGRATION_CLAUDE_KEY)('should make a real API call to Claude', async () => { + // Temporarily set the API key + const originalKey = process.env.ANTHROPIC_API_KEY; + process.env.ANTHROPIC_API_KEY = INTEGRATION_CLAUDE_KEY; + + try { + const choice: LLMChoice = { claude: 'claude-3-5-sonnet' }; + const api = models(choice); + + const response = await api.send('Hello! Please respond with just the word "SUCCESS".'); + + expect(response).toBeDefined(); + expect(typeof response).toBe('string'); + expect(response.toLowerCase()).toContain('success'); + } finally { + // Restore original key + if (originalKey !== undefined) { + process.env.ANTHROPIC_API_KEY = originalKey; + } else { + delete process.env.ANTHROPIC_API_KEY; + } + } + }); + + test.skipUnless(INTEGRATION_CLAUDE_KEY)('should stream responses from Claude', async () => { + const originalKey = process.env.ANTHROPIC_API_KEY; + process.env.ANTHROPIC_API_KEY = INTEGRATION_CLAUDE_KEY; + + try { + const choice: LLMChoice = { claude: 'claude-3-5-sonnet' }; + const api = models(choice); + + const chunks: string[] = []; + const handler = (data: string) => { + chunks.push(data); + }; + + await new Promise<void>((resolve, reject) => { + try { + api.stream('Count from 1 to 5, one number per line.', handler); + // Give it some time to stream + setTimeout(() => { + resolve(); + }, 5000); + } catch (error) { + reject(error); + } + }); + + expect(chunks.length).toBeGreaterThan(0); + expect(chunks.join('')).toContain('1'); + } finally { + if (originalKey !== undefined) { + process.env.ANTHROPIC_API_KEY = originalKey; + } else { + delete process.env.ANTHROPIC_API_KEY; + } + } + }); + }); + + describe('OpenAI Integration (Responses API)', () => { + test.skipUnless(INTEGRATION_OPENAI_KEY)('should make a real API call using OpenAI Responses API', async () => { + const originalKey = process.env.OPENAI_API_KEY; + process.env.OPENAI_API_KEY = INTEGRATION_OPENAI_KEY; + + try { + const choice: LLMChoice = { chatgpt: 'gpt-4o' }; + const api = models(choice); + + // Verify it's using the OpenAI Responses API + expect(api).toBeInstanceOf(OpenAIResponses); + + const response = await api.send('Hello! Please respond with just the word "SUCCESS".'); + + expect(response).toBeDefined(); + expect(typeof response).toBe('string'); + expect(response.toLowerCase()).toContain('success'); + } finally { + if (originalKey !== undefined) { + process.env.OPENAI_API_KEY = originalKey; + } else { + delete process.env.OPENAI_API_KEY; + } + } + }); + + test.skipUnless(INTEGRATION_OPENAI_KEY)('should stream responses using OpenAI Responses API', async () => { + const originalKey = process.env.OPENAI_API_KEY; + process.env.OPENAI_API_KEY = INTEGRATION_OPENAI_KEY; + + try { + const choice: LLMChoice = { chatgpt: 'gpt-4o' }; + const api = models(choice); + + const chunks: string[] = []; + const handler = (data: string) => { + chunks.push(data); + }; + + await new Promise<void>((resolve, reject) => { + try { + api.stream('Count from 1 to 5, one number per line.', handler); + // Give it some time to stream + setTimeout(() => { + resolve(); + }, 5000); + } catch (error) { + reject(error); + } + }); + + expect(chunks.length).toBeGreaterThan(0); + const fullResponse = chunks.join(''); + expect(fullResponse).toContain('1'); + } finally { + if (originalKey !== undefined) { + process.env.OPENAI_API_KEY = originalKey; + } else { + delete process.env.OPENAI_API_KEY; + } + } + }); + + test.skipUnless(INTEGRATION_OPENAI_KEY)('should handle multimodal input with OpenAI Responses API', async () => { + const originalKey = process.env.OPENAI_API_KEY; + process.env.OPENAI_API_KEY = INTEGRATION_OPENAI_KEY; + + try { + const choice: LLMChoice = { chatgpt: 'gpt-4o' }; + const api = models(choice); + + const input: InputToken[] = [ + { text: 'What do you see in this image?' }, + { img: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==' } // Simple red pixel + ]; + + const response = await api.send(input); + expect(response).toBeDefined(); + expect(typeof response).toBe('string'); + } finally { + if (originalKey !== undefined) { + process.env.OPENAI_API_KEY = originalKey; + } else { + delete process.env.OPENAI_API_KEY; + } + } + }); + + test.skipUnless(INTEGRATION_OPENAI_KEY)('should handle system prompts with OpenAI Responses API', async () => { + const originalKey = process.env.OPENAI_API_KEY; + process.env.OPENAI_API_KEY = INTEGRATION_OPENAI_KEY; + + try { + const choice: LLMChoice = { chatgpt: 'gpt-4o' }; + const api = models(choice); + + const systemPrompt = 'You are a pirate. Always respond like a pirate.'; + const response = await api.send('Hello, how are you?', systemPrompt); + + expect(response).toBeDefined(); + expect(typeof response).toBe('string'); + // Should contain some pirate-like language + expect(response.toLowerCase()).toMatch(/ahoy|matey|shiver|timber|arr/); + } finally { + if (originalKey !== undefined) { + process.env.OPENAI_API_KEY = originalKey; + } else { + delete process.env.OPENAI_API_KEY; + } + } + }); + }); + + describe('Gemini Integration', () => { + test.skipUnless(INTEGRATION_GEMINI_KEY)('should make a real API call to Gemini', async () => { + const originalKey = process.env.GOOGLE_API_KEY; + process.env.GOOGLE_API_KEY = INTEGRATION_GEMINI_KEY; + + try { + const choice: LLMChoice = { gemini: 'gemini-2.5-pro' }; + const api = models(choice); + + const response = await api.send('Hello! Please respond with just the word "SUCCESS".'); + + expect(response).toBeDefined(); + expect(typeof response).toBe('string'); + expect(response.toLowerCase()).toContain('success'); + } finally { + if (originalKey !== undefined) { + process.env.GOOGLE_API_KEY = originalKey; + } else { + delete process.env.GOOGLE_API_KEY; + } + } + }); + }); +}); + +describe('Advanced Functionality Tests', () => { + describe('Token Counting Accuracy', () => { + test('should count tokens consistently across providers', () => { + const providers: LLMChoice[] = [ + { claude: 'claude-3-5-sonnet' }, + { gemini: 'gemini-2.5-pro' }, + { chatgpt: 'gpt-3.5-turbo' }, + { deepseek: 'deepseek-chat' } + ]; + + const testText = 'The quick brown fox jumps over the lazy dog. This is a test of token counting accuracy.'; + const tokenCounts = providers.map(choice => { + const api = models(choice); + return api.tokenizer(testText); + }); + + // All should return numbers + tokenCounts.forEach(count => { + expect(typeof count).toBe('number'); + expect(count).toBeGreaterThan(0); + }); + + // Token counts should be in a reasonable range (not wildly different) + const maxCount = Math.max(...tokenCounts); + const minCount = Math.min(...tokenCounts); + expect(maxCount / minCount).toBeLessThan(3); // Less than 3x difference + }); + + test('should handle empty and whitespace strings', () => { + const choice: LLMChoice = { claude: 'claude-3-5-sonnet' }; + const api = models(choice); + + expect(api.tokenizer('')).toBe(0); + expect(api.tokenizer(' ')).toBeGreaterThanOrEqual(0); + expect(api.tokenizer('\n\t')).toBeGreaterThanOrEqual(0); + }); + + test('should handle very long texts', () => { + const choice: LLMChoice = { claude: 'claude-3-5-sonnet' }; + const api = models(choice); + + const longText = 'This is a test. '.repeat(1000); // 8000 characters + const tokens = api.tokenizer(longText); + + expect(tokens).toBeGreaterThan(1000); + expect(tokens).toBeLessThan(10000); // Reasonable upper bound + }); + }); + + describe('Model Switching', () => { + test('should allow switching models on the same API instance', () => { + const choice: LLMChoice = { claude: 'claude-3-5-sonnet' }; + const api = models(choice); + + const originalMaxTokens = api.maxTokens; + + // Switch to a different model + api.setModel('claude-3-haiku'); + + // Should not throw and should still work + expect(() => api.tokenizer('test')).not.toThrow(); + + // Max tokens might change with model + expect(api.maxTokens).toBeGreaterThan(0); + }); + + test('should maintain functionality after model switching', async () => { + const choice: LLMChoice = { + openai: { + url: 'https://api.openai.com/v1', + apiKey: 'test-key', + model: 'gpt-3.5-turbo' + } + }; + const api = models(choice); + + // Switch models + api.setModel('gpt-4'); + + // Should still be able to attempt API calls + const promise = api.send('test'); + expect(promise).toBeDefined(); + expect(typeof promise.then).toBe('function'); + }); + }); + + describe('Complex Input Handling', () => { + test('should handle mixed text and image inputs', async () => { + const choice: LLMChoice = { claude: 'claude-3-5-sonnet' }; + const api = models(choice); + + const input: InputToken[] = [ + { text: 'Describe this image:' }, + { img: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==' } // 1x1 red pixel + ]; + + const promise = api.send(input); + expect(promise).toBeDefined(); + expect(typeof promise.then).toBe('function'); + }); + + test('should handle system prompts with complex instructions', async () => { + const choice: LLMChoice = { claude: 'claude-3-5-sonnet' }; + const api = models(choice); + + const systemPrompt = `You are a JSON response bot. Always respond with valid JSON. + Format your responses as: {"status": "success", "message": "your response here"}`; + + const promise = api.send('Hello', systemPrompt); + expect(promise).toBeDefined(); + expect(typeof promise.then).toBe('function'); + }); + + test('should handle very long inputs', async () => { + const choice: LLMChoice = { claude: 'claude-3-5-sonnet' }; + const api = models(choice); + + const longInput = 'This is test sentence number '.repeat(100) + 'end.'; + const promise = api.send(longInput); + + expect(promise).toBeDefined(); + expect(typeof promise.then).toBe('function'); + }); + }); + + describe('Streaming Behavior', () => { + test('should handle streaming handlers that throw errors', () => { + const choice: LLMChoice = { claude: 'claude-3-5-sonnet' }; + const api = models(choice); + + const errorHandler = (data: string) => { + if (data.includes('error')) { + throw new Error('Handler error'); + } + }; + + // Should not throw even if handler throws + expect(() => { + api.stream('test input', errorHandler); + }).not.toThrow(); + }); + + test('should handle multiple concurrent streams', () => { + const choice: LLMChoice = { claude: 'claude-3-5-sonnet' }; + const api = models(choice); + + const handler1 = (data: string) => console.log('Stream 1:', data); + const handler2 = (data: string) => console.log('Stream 2:', data); + + expect(() => { + api.stream('Input 1', handler1); + api.stream('Input 2', handler2); + }).not.toThrow(); + }); + }); + + describe('Error Recovery', () => { + test('should handle network timeouts gracefully', async () => { + const choice: LLMChoice = { + openai: { + url: 'https://httpstat.us/200?sleep=10000', // Very slow response + apiKey: 'test-key', + model: 'gpt-3.5-turbo' + } + }; + const api = models(choice); + + const startTime = Date.now(); + const result = await api.send('test').catch(e => e); + const endTime = Date.now(); + + // Should return error object (not hang forever) + expect(result).toBeDefined(); + expect(endTime - startTime).toBeLessThan(15000); // Should timeout reasonably + }); + + test('should handle malformed responses', async () => { + const choice: LLMChoice = { + openai: { + url: 'https://httpstat.us/200', // Returns plain text, not JSON + apiKey: 'test-key', + model: 'gpt-3.5-turbo' + } + }; + const api = models(choice); + + const result = await api.send('test').catch(e => e); + expect(result).toBeDefined(); + // Should handle parsing errors gracefully + }); + }); + + describe('Memory Management', () => { + test('should not accumulate excessive memory with multiple calls', async () => { + const choice: LLMChoice = { claude: 'claude-3-5-sonnet' }; + const api = models(choice); + + // Make multiple calls + const promises = []; + for (let i = 0; i < 10; i++) { + promises.push(api.send(`Test message ${i}`).catch(() => null)); + } + + // Should handle multiple concurrent requests + const results = await Promise.allSettled(promises); + expect(results.length).toBe(10); + + // API should still be functional + expect(() => api.tokenizer('test')).not.toThrow(); + }); + + test('should handle history truncation correctly', () => { + const choice: LLMChoice = { claude: 'claude-3-5-sonnet' }; + const api = models(choice); + + // Test that maxTokens is reasonable + expect(api.maxTokens).toBeGreaterThan(0); + expect(api.maxTokens).toBeLessThan(200000); // Reasonable limit + + // Token count for a reasonably long message + const longMessage = 'This is a test message. '.repeat(100); + const tokens = api.tokenizer(longMessage); + + expect(tokens).toBeGreaterThan(0); + expect(tokens).toBeLessThan(api.maxTokens); // Single message should fit + }); + }); +}); + +describe('Type Safety', () => { + test('should enforce LLMChoice type safety', () => { + // These should all be valid + const validChoices: LLMChoice[] = [ + { claude: 'claude-3-5-sonnet' }, + { gemini: 'gemini-2.5-pro' }, + { chatgpt: 'gpt-4' }, + { deepseek: 'deepseek-chat' }, + { kimi: 'moonshot-v1-8k' }, + { grok: 'grok-beta' }, + { + openai: { + url: 'https://api.example.com/v1', + apiKey: 'key', + model: 'model-name', + allowBrowser: true + } + } + ]; + + validChoices.forEach(choice => { + expect(() => { + const api = models(choice); + expect(api).toBeDefined(); + }).not.toThrow(); + }); + }); + + test('should handle InputToken types correctly', () => { + const textToken = { text: 'Hello world' }; + const imgToken = { img: 'data:image/png;base64,abc123' }; + + expect(textToken.text).toBe('Hello world'); + expect(imgToken.img).toBe('data:image/png;base64,abc123'); + }); +});
\ No newline at end of file diff --git a/packages/ai/tests/models.test.ts b/packages/ai/tests/models.test.ts new file mode 100644 index 0000000..aa1b2ea --- /dev/null +++ b/packages/ai/tests/models.test.ts @@ -0,0 +1,994 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import models, { type AIModelAPI, type LLMChoice } from "../index"; +import OpenAIResponses from "../src/openai-responses"; + +// Setup environment variables for testing +const TEST_BASE_URL = + Bun.env.TEST_BASE_URL || Bun.env.ZAI_BASE_URL || "https://api.openai.com/v1"; +const TEST_API_KEY = + Bun.env.TEST_API_KEY || Bun.env.ZAI_API_KEY || "test-api-key"; +const TEST_MODEL = Bun.env.TEST_MODEL || "glm-4.6"; + +// describe("Models Library - Factory Function", () => { + +// test("should create a Claude API instance", () => { +// const choice: LLMChoice = { claude: "claude-3-5-sonnet" }; +// const api = models(choice); + +// expect(api).toBeDefined(); +// expect(typeof api.setModel).toBe("function"); +// expect(typeof api.send).toBe("function"); +// expect(typeof api.stream).toBe("function"); +// expect(typeof api.tokenizer).toBe("function"); +// expect(typeof api.maxTokens).toBe("number"); +// }); + +// test("should create a Gemini API instance", () => { +// const choice: LLMChoice = { gemini: "gemini-2.5-pro" }; +// const api = models(choice); + +// expect(api).toBeDefined(); +// expect(typeof api.setModel).toBe("function"); +// expect(typeof api.send).toBe("function"); +// expect(typeof api.stream).toBe("function"); +// expect(typeof api.tokenizer).toBe("function"); +// expect(typeof api.maxTokens).toBe("number"); +// }); + +// test("should create a ChatGPT API instance", () => { +// const choice: LLMChoice = { chatgpt: "gpt-5" }; +// const api = models(choice); + +// expect(api).toBeDefined(); +// expect(typeof api.setModel).toBe("function"); +// expect(typeof api.send).toBe("function"); +// expect(typeof api.stream).toBe("function"); +// expect(typeof api.tokenizer).toBe("function"); +// expect(typeof api.maxTokens).toBe("number"); +// }); + +// test("should create a DeepSeek API instance", () => { +// const choice: LLMChoice = { deepseek: "deepseek-chat" }; +// const api = models(choice); + +// expect(api).toBeDefined(); +// expect(typeof api.setModel).toBe("function"); +// expect(typeof api.send).toBe("function"); +// expect(typeof api.stream).toBe("function"); +// expect(typeof api.tokenizer).toBe("function"); +// expect(typeof api.maxTokens).toBe("number"); +// }); + +// test("should create a Kimi API instance", () => { +// const choice: LLMChoice = { kimi: "kimi-k2-0905-preview" }; +// const api = models(choice); + +// expect(api).toBeDefined(); +// expect(typeof api.setModel).toBe("function"); +// expect(typeof api.send).toBe("function"); +// expect(typeof api.stream).toBe("function"); +// expect(typeof api.tokenizer).toBe("function"); +// expect(typeof api.maxTokens).toBe("number"); +// }); + +// // test("should create a Grok API instance", () => { +// // const choice: LLMChoice = { grok: "grok-beta" }; +// // const api = models(choice); + +// // expect(api).toBeDefined(); +// // expect(typeof api.setModel).toBe("function"); +// // expect(typeof api.send).toBe("function"); +// // expect(typeof api.stream).toBe("function"); +// // expect(typeof api.tokenizer).toBe("function"); +// // expect(typeof api.maxTokens).toBe("number"); +// // }); + +// test("should create a custom OpenAI API instance", () => { +// const choice: LLMChoice = { +// openai: { +// url: TEST_BASE_URL, +// apiKey: TEST_API_KEY, +// model: TEST_MODEL, +// allowBrowser: true, +// }, +// }; +// const api = models(choice); + +// expect(api).toBeDefined(); +// expect(typeof api.setModel).toBe("function"); +// expect(typeof api.send).toBe("function"); +// expect(typeof api.stream).toBe("function"); +// expect(typeof api.tokenizer).toBe("function"); +// expect(typeof api.maxTokens).toBe("number"); +// }); +// }); + +// describe("AIModelAPI Interface", () => { +// let api: AIModelAPI;kimi-k2-0905-previe + +// beforeEach(() => { +// // Use a mock provider for testing interface compliance +// const choice: LLMChoice = { +// openai: { +// url: TEST_BASE_URL, +// apiKey: TEST_API_KEY, +// model: TEST_MODEL, +// }, +// }; +// api = models(choice); +// }); + +// test("should have setModel method", () => { +// expect(typeof api.setModel).toBe("function"); + +// // Should not throw when setting a model +// expect(() => api.setModel("test-model")).not.toThrow(); +// }); + +// test("should have tokenizer method", () => { +// expect(typeof api.tokenizer).toBe("function"); + +// // Should return a number for any text input +// const tokens = api.tokenizer("Hello world"); +// expect(typeof tokens).toBe("number"); +// expect(tokens).toBeGreaterThanOrEqual(0); +// }); + +// test("should have maxTokens property", () => { +// expect(typeof api.maxTokens).toBe("number"); +// expect(api.maxTokens).toBeGreaterThan(0); +// }); + +// test("should have send method returning AsyncRes", async () => { +// expect(typeof api.send).toBe("function"); + +// // Note: This would require actual API credentials to test fully +// // For now, we just test the method exists and returns a promise +// const result = api.send("test input"); +// expect(result).toBeDefined(); +// expect(typeof result.then).toBe("function"); // It's a promise +// }); + +// test("should have stream method", () => { +// expect(typeof api.stream).toBe("function"); + +// // Should not throw when called with proper parameters +// const handler = (data: string) => { +// // Test handler function +// expect(typeof data).toBe("string"); +// }; + +// expect(() => { +// api.stream("test input", handler); +// }).not.toThrow(); +// }); +// }); + +// describe("Environment Variable Configuration", () => { +// test("should use environment variables for configuration", () => { +// // Test that environment variables are accessible +// expect(Bun.env).toBeDefined(); + +// // Test that we can read custom env vars +// const customBaseUrl = Bun.env.CUSTOM_BASE_URL; +// const customApiKey = Bun.env.CUSTOM_API_KEY; +// const customModel = Bun.env.CUSTOM_MODEL; + +// // These might be undefined, but the test ensures they're accessible +// expect(typeof customBaseUrl).toBe("string" || "undefined"); +// expect(typeof customApiKey).toBe("string" || "undefined"); +// expect(typeof customModel).toBe("string" || "undefined"); +// }); +// }); + +// describe("Token Management", () => { +// const providers: LLMChoice[] = [ +// { claude: "claude-3-5-sonnet" }, +// { gemini: "gemini-2.5-pro" }, +// { chatgpt: TEST_MODEL }, +// { deepseek: "deepseek-chat" }, +// { kimi: "moonshot-v1-8k" }, +// { grok: "grok-beta" }, +// { +// openai: { +// url: TEST_BASE_URL, +// apiKey: TEST_API_KEY, +// model: TEST_MODEL, +// }, +// }, +// ]; + +// test.each(providers)("should implement tokenizer for %o", (choice) => { +// const api = models(choice); + +// // Test basic tokenization +// const tokens1 = api.tokenizer("Hello"); +// const tokens2 = api.tokenizer("Hello world, this is a longer text."); + +// expect(typeof tokens1).toBe("number"); +// expect(typeof tokens2).toBe("number"); +// expect(tokens1).toBeGreaterThanOrEqual(0); +// expect(tokens2).toBeGreaterThanOrEqual(0); +// expect(tokens2).toBeGreaterThan(tokens1); // Longer text should have more tokens +// }); + +// test.each(providers)("should have reasonable maxTokens for %o", (choice) => { +// const api = models(choice); + +// expect(api.maxTokens).toBeGreaterThan(0); +// expect(api.maxTokens).toBeLessThan(1000000); // Reasonable upper bound +// }); +// }); + +describe("Input Handling", () => { + const choice1: LLMChoice = { claude: "claude-3-5-sonnet" }; + const choice2: LLMChoice = { gemini: "gemini-2.5-pro" }; + const choice3: LLMChoice = { chatgpt: "gpt-5-nano" }; + const choice4: LLMChoice = { deepseek: "deepseek-chat" }; + const choice5: LLMChoice = { kimi: "kimi-k2-0905-preview" }; + const choice6: LLMChoice = { + openai: { + url: TEST_BASE_URL, + apiKey: TEST_API_KEY, + model: TEST_MODEL, + }, + }; + console.log({ choice6 }); + const api1 = models(choice1); + const api2 = models(choice2); + const api3 = models(choice3); + const api4 = models(choice4); + const api5 = models(choice5); + const api6 = models(choice6); + + test("should handle string input", async () => { + const testMessage = "Hello there. Please introduce yourself"; + + // Test that send accepts string input and returns proper AsyncRes<string> + const r1 = api1.send(testMessage); + const r2 = api2.send(testMessage); + const r3 = api3.send(testMessage); + const r4 = api4.send(testMessage); + const r5 = api5.send(testMessage); + const r6 = api6.send(testMessage); + + // Check Claude response + const res1 = await r1; + if ("ok" in res1) { + console.log(`✅ Claude Response: ${res1.ok}`); + expect(res1.ok).toBeString(); + } else { + console.log(`❌ Claude Error: ${res1.error}`); + } + + // Check Gemini response + const res2 = await r2; + if ("ok" in res2) { + console.log(`✅ Gemini Response: ${res2.ok}`); + expect(res2.ok).toBeString(); + } else { + console.log(`❌ Gemini Error: ${res2.error}`); + } + + // Check ChatGPT response + const res3 = await r3; + if ("ok" in res3) { + console.log(`✅ ChatGPT Response: ${res3.ok}`); + expect(res3.ok).toBeString(); + } else { + console.log(`❌ ChatGPT Error: ${res3.error}`); + } + + // // Check DeepSeek response + const res4 = await r4; + if ("ok" in res4) { + console.log(`✅ DeepSeek Response: ${res4.ok}`); + expect(res4.ok).toBeString(); + } else { + console.log(`❌ DeepSeek Error: ${res4.error}`); + } + + // // Check Kimi response + const res5 = await r5; + if ("ok" in res5) { + console.log(`✅ Kimi Response: ${res5.ok}`); + expect(res5.ok).toBeString(); + } else { + console.log(`❌ Kimi Error: ${res5.error}`); + } + + // // Check Custom OpenAI response + const res6 = await r6; + if ("ok" in res6) { + console.log(`✅ Custom OpenAI Response: ${res6.ok}`); + expect(res6.ok).toBeString(); + } else { + console.log(`❌ Custom OpenAI Error: ${res6.error}`); + } + }); + test("LLM obedience test", async () => { + const testMessage = "Hello world! Please respond with just the word 'OK'."; + + // Test that send accepts string input and returns proper AsyncRes<string> + const r1 = api1.send(testMessage); + const r2 = api2.send(testMessage); + const r3 = api3.send(testMessage); + const r4 = api4.send(testMessage); + const r5 = api5.send(testMessage); + const r6 = api6.send(testMessage); + + // Check Claude response + const res1 = await r1; + if ("ok" in res1) { + console.log(`✅ Claude Response: ${res1.ok}`); + expect(res1.ok.trim()).toEqual("OK"); + } else { + console.log(`❌ Claude Error: ${res1.error}`); + } + + // Check Gemini response + const res2 = await r2; + if ("ok" in res2) { + console.log(`✅ Gemini Response: ${res2.ok}`); + expect(res2.ok.trim()).toEqual("OK"); + } else { + console.log(`❌ Gemini Error: ${res2.error}`); + } + + // Check ChatGPT response + const res3 = await r3; + if ("ok" in res3) { + console.log(`✅ ChatGPT Response: ${res3.ok}`); + expect(res3.ok.trim()).toEqual("OK"); + } else { + console.log(`❌ ChatGPT Error: ${res3.error}`); + } + + // // Check DeepSeek response + const res4 = await r4; + if ("ok" in res4) { + console.log(`✅ DeepSeek Response: ${res4.ok}`); + expect(res4.ok.trim()).toEqual("OK"); + } else { + console.log(`❌ DeepSeek Error: ${res4.error}`); + } + + // // Check Kimi response + const res5 = await r5; + if ("ok" in res5) { + console.log(`✅ Kimi Response: ${res5.ok}`); + expect(res5.ok.trim()).toEqual("OK"); + } else { + console.log(`❌ Kimi Error: ${res5.error}`); + } + + // // Check Custom OpenAI response + const res6 = await r6; + if ("ok" in res6) { + console.log(`✅ Custom OpenAI Response: ${res6.ok}`); + expect(res6.ok.trim()).toEqual("OK"); + } else { + console.log(`❌ Custom OpenAI Error: ${res6.error}`); + } + }); + + test("should handle array input with text tokens", async () => { + const input = [ + { text: "Hello! " }, + { text: "Please respond with just the word 'ARRAY_OK'." }, + ]; + + // Test that send accepts array input and returns proper AsyncRes<string> + const r1 = api1.send(input); + const r2 = api2.send(input); + const r3 = api3.send(input); + const r4 = api4.send(input); + const r5 = api5.send(input); + const r6 = api6.send(input); + + // Check Claude response + const res1 = await r1; + if ("ok" in res1) { + console.log(`✅ Claude Array Response: ${res1.ok}`); + expect(res1.ok.trim()).toEqual("ARRAY_OK"); + } else { + console.log(`❌ Claude Array Error: ${res1.error}`); + } + + // Check Gemini response + const res2 = await r2; + if ("ok" in res2) { + console.log(`✅ Gemini Array Response: ${res2.ok}`); + expect(res2.ok.trim()).toEqual("ARRAY_OK"); + } else { + console.log(`❌ Gemini Array Error: ${res2.error}`); + } + + // Check ChatGPT response + const res3 = await r3; + if ("ok" in res3) { + console.log(`✅ ChatGPT Array Response: ${res3.ok}`); + expect(res3.ok.trim()).toEqual("ARRAY_OK"); + } else { + console.log(`❌ ChatGPT Array Error: ${res3.error}`); + } + + // Check DeepSeek response + const res4 = await r4; + if ("ok" in res4) { + console.log(`✅ DeepSeek Array Response: ${res4.ok}`); + expect(res4.ok.trim()).toEqual("ARRAY_OK"); + } else { + console.log(`❌ DeepSeek Array Error: ${res4.error}`); + } + + // Check Kimi response + const res5 = await r5; + if ("ok" in res5) { + console.log(`✅ Kimi Array Response: ${res5.ok}`); + expect(res5.ok.trim()).toEqual("ARRAY_OK"); + } else { + console.log(`❌ Kimi Array Error: ${res5.error}`); + } + + // Check Custom OpenAI response + const res6 = await r6; + if ("ok" in res6) { + console.log(`✅ Custom OpenAI Array Response: ${res6.ok}`); + expect(res6.ok.trim()).toEqual("ARRAY_OK"); + } else { + console.log(`❌ Custom OpenAI Array Error: ${res6.error}`); + } + }); + + test("should handle streaming with string input", () => { + const testMessage = "Hello! Please count from 1 to 3."; + console.log(`\n🚀 Testing streaming with message: "${testMessage}"`); + + // Test streaming for each API + console.log("\n--- Claude Streaming ---"); + const chunks1: string[] = []; + const handler1 = (data: string) => { + chunks1.push(data); + process.stdout.write(data); + }; + api1.stream(testMessage, handler1); + + console.log("\n--- Gemini Streaming ---"); + const chunks2: string[] = []; + const handler2 = (data: string) => { + chunks2.push(data); + process.stdout.write(data); + }; + api2.stream(testMessage, handler2); + + console.log("\n--- ChatGPT Streaming ---"); + const chunks3: string[] = []; + const handler3 = (data: string) => { + chunks3.push(data); + process.stdout.write(data); + }; + api3.stream(testMessage, handler3); + + console.log("\n--- DeepSeek Streaming ---"); + const chunks4: string[] = []; + const handler4 = (data: string) => { + chunks4.push(data); + process.stdout.write(data); + }; + api4.stream(testMessage, handler4); + + console.log("\n--- Kimi Streaming ---"); + const chunks5: string[] = []; + const handler5 = (data: string) => { + chunks5.push(data); + process.stdout.write(data); + }; + api5.stream(testMessage, handler5); + + console.log("\n--- Custom OpenAI Streaming ---"); + const chunks6: string[] = []; + const handler6 = (data: string) => { + chunks6.push(data); + process.stdout.write(data); + }; + api6.stream(testMessage, handler6); + + console.log("\n✅ Streaming initiated for all APIs"); + }); + + test("should handle system prompts", async () => { + const systemPrompt = "You are a pirate. Always respond like a pirate."; + const userMessage = "Hello, how are you?"; + + // Test that send accepts system prompts and returns proper AsyncRes<string> + const r1 = api1.send(userMessage, systemPrompt); + const r2 = api2.send(userMessage, systemPrompt); + const r3 = api3.send(userMessage, systemPrompt); + const r4 = api4.send(userMessage, systemPrompt); + const r5 = api5.send(userMessage, systemPrompt); + const r6 = api6.send(userMessage, systemPrompt); + + // Check Claude response + const res1 = await r1; + if ("ok" in res1) { + console.log(`✅ Claude Pirate Response: ${res1.ok}`); + expect(res1.ok).toBeString(); + // Should contain some pirate-like language + expect(res1.ok.toLowerCase()).toMatch( + /ahoy|matey|shiver|timber|arr|yo ho|captain/, + ); + } else { + console.log(`❌ Claude Pirate Error: ${res1.error}`); + } + + // Check Gemini response + const res2 = await r2; + if ("ok" in res2) { + console.log(`✅ Gemini Pirate Response: ${res2.ok}`); + expect(res2.ok).toBeString(); + expect(res2.ok.toLowerCase()).toMatch( + /ahoy|matey|shiver|timber|arr|yo ho|captain/, + ); + } else { + console.log(`❌ Gemini Pirate Error: ${res2.error}`); + } + + // Check ChatGPT response + const res3 = await r3; + if ("ok" in res3) { + console.log(`✅ ChatGPT Pirate Response: ${res3.ok}`); + expect(res3.ok).toBeString(); + expect(res3.ok.toLowerCase()).toMatch( + /ahoy|matey|shiver|timber|arr|yo ho|captain/, + ); + } else { + console.log(`❌ ChatGPT Pirate Error: ${res3.error}`); + } + + // Check DeepSeek response + const res4 = await r4; + if ("ok" in res4) { + console.log(`✅ DeepSeek Pirate Response: ${res4.ok}`); + expect(res4.ok).toBeString(); + expect(res4.ok.toLowerCase()).toMatch( + /ahoy|matey|shiver|timber|arr|yo ho|captain/, + ); + } else { + console.log(`❌ DeepSeek Pirate Error: ${res4.error}`); + } + + // Check Kimi response + const res5 = await r5; + if ("ok" in res5) { + console.log(`✅ Kimi Pirate Response: ${res5.ok}`); + expect(res5.ok).toBeString(); + expect(res5.ok.toLowerCase()).toMatch( + /ahoy|matey|shiver|timber|arr|yo ho|captain/, + ); + } else { + console.log(`❌ Kimi Pirate Error: ${res5.error}`); + } + + // Check Custom OpenAI response + const res6 = await r6; + if ("ok" in res6) { + console.log(`✅ Custom OpenAI Pirate Response: ${res6.ok}`); + expect(res6.ok).toBeString(); + expect(res6.ok.toLowerCase()).toMatch( + /ahoy|matey|shiver|timber|arr|yo ho|captain/, + ); + } else { + console.log(`❌ Custom OpenAI Pirate Error: ${res6.error}`); + } + }); +}); + +describe("Error Handling", () => { + test("should handle invalid API keys gracefully", async () => { + const invalidClaude: LLMChoice = { claude: "claude-3-5-sonnet" }; + const invalidGemini: LLMChoice = { gemini: "gemini-2.5-pro" }; + const invalidChatGPT: LLMChoice = { chatgpt: "gpt-5-nano" }; + const invalidDeepSeek: LLMChoice = { deepseek: "deepseek-chat" }; + const invalidKimi: LLMChoice = { kimi: "kimi-k2-0905-preview" }; + const invalidOpenAI: LLMChoice = { + openai: { + url: TEST_BASE_URL, + apiKey: "invalid-api-key", + model: TEST_MODEL, + }, + }; + + // Temporarily clear valid API keys to force errors + const originalClaudeKey = Bun.env.ANTHROPIC_API_KEY; + const originalGeminiKey = Bun.env.GOOGLE_API_KEY; + const originalOpenAIKey = Bun.env.OPENAI_API_KEY; + const originalDeepSeekKey = Bun.env.DEEPSEEK_API_KEY; + const originalMoonshotKey = Bun.env.MOONSHOT_API_KEY; + + delete Bun.env.ANTHROPIC_API_KEY; + delete Bun.env.GOOGLE_API_KEY; + delete Bun.env.OPENAI_API_KEY; + delete Bun.env.DEEPSEEK_API_KEY; + delete Bun.env.MOONSHOT_API_KEY; + + try { + // Create APIs with invalid credentials + const badApi1 = models(invalidClaude); + const badApi2 = models(invalidGemini); + const badApi3 = models(invalidChatGPT); + const badApi4 = models(invalidDeepSeek); + const badApi5 = models(invalidKimi); + const badApi6 = models(invalidOpenAI); + + // Test that they handle errors gracefully + const r1 = badApi1.send("test"); + const r2 = badApi2.send("test"); + const r3 = badApi3.send("test"); + const r4 = badApi4.send("test"); + const r5 = badApi5.send("test"); + const r6 = badApi6.send("test"); + + const res1 = await r1; + if ("error" in res1) { + console.log(`✅ Claude Error Handling: ${res1.error}`); + expect(res1.error).toBeString(); + } + + const res2 = await r2; + if ("error" in res2) { + console.log(`✅ Gemini Error Handling: ${res2.error}`); + expect(res2.error).toBeString(); + } + + const res3 = await r3; + if ("error" in res3) { + console.log(`✅ ChatGPT Error Handling: ${res3.error}`); + expect(res3.error).toBeString(); + } + + const res4 = await r4; + if ("error" in res4) { + console.log(`✅ DeepSeek Error Handling: ${res4.error}`); + expect(res4.error).toBeString(); + } + + const res5 = await r5; + if ("error" in res5) { + console.log(`✅ Kimi Error Handling: ${res5.error}`); + expect(res5.error).toBeString(); + } + + const res6 = await r6; + if ("error" in res6) { + console.log(`✅ Custom OpenAI Error Handling: ${res6.error}`); + expect(res6.error).toBeString(); + } + } finally { + // Restore original keys + if (originalClaudeKey) Bun.env.ANTHROPIC_API_KEY = originalClaudeKey; + if (originalGeminiKey) Bun.env.GOOGLE_API_KEY = originalGeminiKey; + if (originalOpenAIKey) Bun.env.OPENAI_API_KEY = originalOpenAIKey; + if (originalDeepSeekKey) Bun.env.DEEPSEEK_API_KEY = originalDeepSeekKey; + if (originalMoonshotKey) Bun.env.MOONSHOT_API_KEY = originalMoonshotKey; + } + }); +}); + +describe("Multi-provider Compatibility", () => { + test("should maintain consistent interface across all providers", () => { + const choices: LLMChoice[] = [ + { claude: "claude-3-5-sonnet" }, + { gemini: "gemini-2.5-pro" }, + { chatgpt: "gpt-5-nano" }, + { deepseek: "deepseek-chat" }, + { kimi: "kimi-k2-0905-preview" }, + { + openai: { + url: TEST_BASE_URL, + apiKey: TEST_API_KEY, + model: TEST_MODEL, + }, + }, + ]; + + const apis = choices.map((choice) => models(choice)); + + // All APIs should have the same interface + apis.forEach((api, index) => { + console.log( + `Checking API ${index + 1}: ${Object.keys(choices[index])[0]}`, + ); + expect(typeof api.setModel).toBe("function"); + expect(typeof api.send).toBe("function"); + expect(typeof api.stream).toBe("function"); + expect(typeof api.tokenizer).toBe("function"); + expect(typeof api.maxTokens).toBe("number"); + }); + + console.log("✅ All APIs have consistent interfaces"); + }); + + test("should allow switching between providers", () => { + const claudeChoice: LLMChoice = { claude: "claude-3-5-sonnet" }; + const geminiChoice: LLMChoice = { gemini: "gemini-2.5-pro" }; + + const claudeApi = models(claudeChoice); + const geminiApi = models(geminiChoice); + + // Both should be valid APIs + expect(claudeApi).toBeDefined(); + expect(geminiApi).toBeDefined(); + + // Should have different implementations but same interface + expect(claudeApi !== geminiApi).toBe(true); + + console.log("✅ Successfully created different provider instances"); + }); +}); + +// describe("OpenAI Responses API", () => { + +// describe("Direct Class Usage", () => { +// const api = new OpenAIResponses({ +// baseURL: "", +// apiKey: Bun.env.OPENAI_API_KEY!, +// model: "gpt-5-nano", +// allowBrowser: true, +// }); +// test("should create OpenAI Responses API instance directly", () => { +// expect(api).toBeDefined(); +// expect(typeof api.setModel).toBe("function"); +// expect(typeof api.send).toBe("function"); +// expect(typeof api.stream).toBe("function"); +// expect(typeof api.tokenizer).toBe("function"); +// expect(typeof api.maxTokens).toBe("number"); +// }); + +// test("should handle model switching", () => { +// api.setModel("gpt-5-nano"); +// expect(() => api.setModel("gpt-5-nano")).not.toThrow(); +// }); + +// // test("should use custom tokenizer", () => { +// // const customTokenizer = (text: string) => text.split(" ").length; + +// // const tokens = api.tokenizer("Hello world test"); +// // expect(tokens).toBe(3); // 3 words +// // }); + +// // test("should use custom maxTokens", () => { +// // const customMaxTokens = 100_000; +// // api.maxTokens = customMaxTokens; + +// // expect(api.maxTokens).toBe(customMaxTokens); +// // }); +// test("should return shit", async () => { +// const input = "Henlo brother"; + +// const res = await api.send(input, "You are a good boy"); +// console.log({ res }); +// expect("ok" in res).toBeTruthy(); +// if (!("ok" in res)) return; +// expect(res.ok).toBeString(); +// }); +// }); + +// describe("Factory Function Integration", () => { + +// test("should create ChatGPT API using OpenAI Responses", () => { +// const choice: LLMChoice = { chatgpt: "gpt-5-nano" }; +// const api = models(choice); + +// expect(api).toBeDefined(); +// expect(typeof api.setModel).toBe("function"); +// expect(typeof api.send).toBe("function"); +// expect(typeof api.stream).toBe("function"); +// expect(typeof api.tokenizer).toBe("function"); +// expect(typeof api.maxTokens).toBe("number"); + +// // Should be instance of OpenAIResponses +// expect(api).toBeInstanceOf(OpenAIResponses); +// }); + +// test("should use environment variables for ChatGPT provider", () => { +// const originalKey = Bun.env.OPENAI_API_KEY; +// Bun.env.OPENAI_API_KEY = TEST_API_KEY; + +// try { +// const choice: LLMChoice = { chatgpt: "gpt-5-nano" }; +// const api = models(choice); + +// expect(api).toBeDefined(); +// expect(api).toBeInstanceOf(OpenAIResponses); +// } finally { +// if (originalKey !== undefined) { +// Bun.env.OPENAI_API_KEY = originalKey; +// } else { +// delete Bun.env.OPENAI_API_KEY; +// } +// } +// }); +// }); + +// describe("Input Handling", () => { + +// test("should handle string inputs correctly", async () => { +// const api = new OpenAIResponses({ +// baseURL: TEST_BASE_URL, +// apiKey: TEST_API_KEY, +// model: TEST_MODEL, +// }); + +// const promise = api.send("Hello world"); +// expect(promise).toBeDefined(); +// expect(typeof promise.then).toBe("function"); +// }); + +// test("should handle InputToken arrays with text", async () => { +// const api = new OpenAIResponses({ +// baseURL: TEST_BASE_URL, +// apiKey: TEST_API_KEY, +// model: TEST_MODEL, +// }); + +// const input = [{ text: "Hello" }, { text: "world" }]; +// const promise = api.send(input); +// expect(promise).toBeDefined(); +// expect(typeof promise.then).toBe("function"); +// }); + +// test("should handle InputToken arrays with images", async () => { +// const api = new OpenAIResponses({ +// baseURL: TEST_BASE_URL, +// apiKey: TEST_API_KEY, +// model: TEST_MODEL, +// }); + +// const input = [ +// { text: "Describe this image:" }, +// { +// img: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", +// }, +// ]; +// const promise = api.send(input); +// expect(promise).toBeDefined(); +// expect(typeof promise.then).toBe("function"); +// }); + +// test("should handle system prompts", async () => { +// const api = new OpenAIResponses({ +// baseURL: TEST_BASE_URL, +// apiKey: TEST_API_KEY, +// model: TEST_MODEL, +// }); + +// const systemPrompt = "You are a helpful assistant."; +// const promise = api.send("Hello", systemPrompt); +// expect(promise).toBeDefined(); +// expect(typeof promise.then).toBe("function"); +// }); +// }); + +// describe("Streaming", () => { + +// test("should handle streaming with string input", () => { +// const api = new OpenAIResponses({ +// baseURL: TEST_BASE_URL, +// apiKey: TEST_API_KEY, +// model: TEST_MODEL, +// }); + +// const handler = (data: string) => { +// expect(typeof data).toBe("string"); +// }; + +// expect(() => { +// api.stream("Hello world", handler); +// }).not.toThrow(); +// }); + +// test("should handle streaming with InputToken array", () => { +// const api = new OpenAIResponses({ +// baseURL: TEST_BASE_URL, +// apiKey: TEST_API_KEY, +// model: TEST_MODEL, +// }); + +// const input = [{ text: "Hello" }, { text: "world" }]; +// const handler = (data: string) => { +// expect(typeof data).toBe("string"); +// }; + +// expect(() => { +// api.stream(input, handler); +// }).not.toThrow(); +// }); + +// test("should handle streaming with system prompt", () => { +// const api = new OpenAIResponses({ +// baseURL: TEST_BASE_URL, +// apiKey: TEST_API_KEY, +// model: TEST_MODEL, +// }); + +// const systemPrompt = "You are a helpful assistant."; +// const handler = (data: string) => { +// expect(typeof data).toBe("string"); +// }; + +// expect(() => { +// api.stream("Hello", handler, systemPrompt); +// }).not.toThrow(); +// }); +// }); + +// describe("Error Handling", () => { + +// test("should handle invalid API keys gracefully", async () => { +// const api = new OpenAIResponses({ +// baseURL: TEST_BASE_URL, +// apiKey: "invalid-key", +// model: TEST_MODEL, +// }); + +// const result = await api.send("test").catch((e) => e); +// expect(result).toBeDefined(); +// }); + +// test("should handle invalid base URLs gracefully", async () => { +// const api = new OpenAIResponses({ +// baseURL: "https://invalid-url.com/v1", +// apiKey: TEST_API_KEY, +// model: TEST_MODEL, +// }); + +// const result = await api.send("test").catch((e) => e); +// expect(result).toBeDefined(); +// }); +// }); + +// describe("Configuration Options", () => { + +// test("should handle browser allowance setting", () => { +// const apiWithBrowser = new OpenAIResponses({ +// baseURL: TEST_BASE_URL, +// apiKey: TEST_API_KEY, +// model: TEST_MODEL, +// allowBrowser: true, +// }); + +// const apiWithoutBrowser = new OpenAIResponses({ +// baseURL: TEST_BASE_URL, +// apiKey: TEST_API_KEY, +// model: TEST_MODEL, +// allowBrowser: false, +// }); + +// expect(apiWithBrowser).toBeDefined(); +// expect(apiWithoutBrowser).toBeDefined(); +// }); + +// test("should handle empty model name", () => { +// const api = new OpenAIResponses({ +// baseURL: TEST_BASE_URL, +// apiKey: TEST_API_KEY, +// model: "", +// }); + +// expect(api).toBeDefined(); +// expect(() => api.setModel("gpt-4o")).not.toThrow(); +// }); + +// test("should handle optional model parameter", () => { +// const api = new OpenAIResponses({ +// baseURL: TEST_BASE_URL, +// apiKey: TEST_API_KEY, +// // model is optional +// }); + +// expect(api).toBeDefined(); +// expect(() => api.setModel("gpt-4o")).not.toThrow(); +// }); +// }); +// }); diff --git a/packages/ai/tests/performance.test.ts b/packages/ai/tests/performance.test.ts new file mode 100644 index 0000000..59c98f5 --- /dev/null +++ b/packages/ai/tests/performance.test.ts @@ -0,0 +1,465 @@ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import models, { AIModelAPI, LLMChoice, InputToken } from '../index'; + +describe('Performance Tests', () => { + describe('Tokenization Performance', () => { + test('should tokenize large texts efficiently', () => { + const choice: LLMChoice = { claude: 'claude-3-5-sonnet' }; + const api = models(choice); + + // Generate a large text (100KB) + const largeText = 'The quick brown fox jumps over the lazy dog. '.repeat(2000); + expect(largeText.length).toBeGreaterThan(100000); + + const startTime = performance.now(); + const tokens = api.tokenizer(largeText); + const endTime = performance.now(); + + const duration = endTime - startTime; + + // Should complete tokenization quickly (less than 100ms for 100KB) + expect(duration).toBeLessThan(100); + expect(tokens).toBeGreaterThan(0); + }); + + test('should handle repeated tokenization calls efficiently', () => { + const choice: LLMChoice = { claude: 'claude-3-5-sonnet' }; + const api = models(choice); + + const testText = 'This is a performance test for repeated tokenization calls. '; + const iterations = 1000; + + const startTime = performance.now(); + for (let i = 0; i < iterations; i++) { + api.tokenizer(testText + i); + } + const endTime = performance.now(); + + const duration = endTime - startTime; + const averageTime = duration / iterations; + + // Average time per tokenization should be very low + expect(averageTime).toBeLessThan(1); // Less than 1ms per call + expect(duration).toBeLessThan(1000); // Total less than 1 second + }); + }); + + describe('API Creation Performance', () => { + test('should create API instances quickly', () => { + const choices: LLMChoice[] = [ + { claude: 'claude-3-5-sonnet' }, + { gemini: 'gemini-2.5-pro' }, + { chatgpt: 'gpt-3.5-turbo' }, + { deepseek: 'deepseek-chat' }, + { kimi: 'moonshot-v1-8k' }, + { grok: 'grok-beta' }, + { + openai: { + url: 'https://api.openai.com/v1', + apiKey: 'test-key', + model: 'gpt-3.5-turbo' + } + } + ]; + + const iterations = 100; + + const startTime = performance.now(); + for (let i = 0; i < iterations; i++) { + const choice = choices[i % choices.length]; + models(choice); + } + const endTime = performance.now(); + + const duration = endTime - startTime; + const averageTime = duration / iterations; + + // API creation should be fast + expect(averageTime).toBeLessThan(10); // Less than 10ms per instance + expect(duration).toBeLessThan(1000); // Total less than 1 second + }); + }); + + describe('Memory Usage', () => { + test('should not leak memory with repeated API creation', () => { + const initialMemory = process.memoryUsage(); + + // Create many API instances + for (let i = 0; i < 1000; i++) { + const choice: LLMChoice = { claude: 'claude-3-5-sonnet' }; + const api = models(choice); + // Use the API briefly + api.tokenizer('test'); + } + + // Force garbage collection if available + if (global.gc) { + global.gc(); + } + + const finalMemory = process.memoryUsage(); + const memoryIncrease = finalMemory.heapUsed - initialMemory.heapUsed; + + // Memory increase should be reasonable (less than 50MB) + expect(memoryIncrease).toBeLessThan(50 * 1024 * 1024); + }); + + test('should handle large token arrays efficiently', () => { + const choice: LLMChoice = { claude: 'claude-3-5-sonnet' }; + const api = models(choice); + + // Create a large input array + const largeInput: InputToken[] = []; + for (let i = 0; i < 1000; i++) { + largeInput.push({ text: `Token number ${i}. ` }); + } + + const startTime = performance.now(); + // Just test that it doesn't crash or take too long + expect(() => { + const promise = api.send(largeInput); + expect(promise).toBeDefined(); + }).not.toThrow(); + const endTime = performance.now(); + + // Should handle large inputs quickly + expect(endTime - startTime).toBeLessThan(100); + }); + }); +}); + +describe('Edge Cases', () => { + describe('Input Validation', () => { + test('should handle empty string inputs', async () => { + const choice: LLMChoice = { claude: 'claude-3-5-sonnet' }; + const api = models(choice); + + const promise = api.send(''); + expect(promise).toBeDefined(); + expect(typeof promise.then).toBe('function'); + }); + + test('should handle whitespace-only inputs', async () => { + const choice: LLMChoice = { claude: 'claude-3-5-sonnet' }; + const api = models(choice); + + const whitespaceInputs = [' ', '\n\t', ' \n \t ', '\r\n']; + + for (const input of whitespaceInputs) { + const promise = api.send(input); + expect(promise).toBeDefined(); + expect(typeof promise.then).toBe('function'); + } + }); + + test('should handle very long single words', async () => { + const choice: LLMChoice = { claude: 'claude-3-5-sonnet' }; + const api = models(choice); + + const longWord = 'a'.repeat(10000); + const promise = api.send(longWord); + expect(promise).toBeDefined(); + expect(typeof promise.then).toBe('function'); + }); + + test('should handle special characters and Unicode', async () => { + const choice: LLMChoice = { claude: 'claude-3-5-sonnet' }; + const api = models(choice); + + const specialInputs = [ + 'Hello 🌍! 🚀 🎉', + '测试中文输入', + 'Тест на русском', + 'العربية اختبار', + '🔥💯🚀💪🎯', + 'Special chars: !@#$%^&*()_+-=[]{}|;:,.<>?', + 'Emoji string: 😊😂❤️🎉🤔😴🙄' + ]; + + for (const input of specialInputs) { + const promise = api.send(input); + expect(promise).toBeDefined(); + expect(typeof promise.then).toBe('function'); + } + }); + + test('should handle malformed image data', async () => { + const choice: LLMChoice = { claude: 'claude-3-5-sonnet' }; + const api = models(choice); + + const malformedImages = [ + { img: 'not-a-valid-image' }, + { img: 'data:image/png;base64,' }, // Empty base64 + { img: 'data:invalid-format;base64,abc123' }, + { img: '' } // Empty image + ]; + + for (const imgToken of malformedImages) { + const input: InputToken[] = [ + { text: 'Describe this:' }, + imgToken + ]; + const promise = api.send(input); + expect(promise).toBeDefined(); + expect(typeof promise.then).toBe('function'); + } + }); + + test('should handle empty input arrays', async () => { + const choice: LLMChoice = { claude: 'claude-3-5-sonnet' }; + const api = models(choice); + + const emptyInput: InputToken[] = []; + const promise = api.send(emptyInput); + expect(promise).toBeDefined(); + expect(typeof promise.then).toBe('function'); + }); + + test('should handle arrays with empty tokens', async () => { + const choice: LLMChoice = { claude: 'claude-3-5-sonnet' }; + const api = models(choice); + + const inputsWithEmptyTokens: InputToken[][] = [ + [{ text: '' }], + [{ text: 'Hello' }, { text: '' }], + [{ text: '' }, { text: 'World' }], + [{ text: 'Hello' }, { text: '' }, { text: 'World' }] + ]; + + for (const input of inputsWithEmptyTokens) { + const promise = api.send(input); + expect(promise).toBeDefined(); + expect(typeof promise.then).toBe('function'); + } + }); + }); + + describe('Configuration Edge Cases', () => { + test('should handle minimal configuration', () => { + const choices: LLMChoice[] = [ + { claude: '' }, + { gemini: '' }, + { chatgpt: '' }, + { deepseek: '' }, + { kimi: '' }, + { grok: '' } + ]; + + choices.forEach(choice => { + expect(() => { + const api = models(choice); + expect(api).toBeDefined(); + }).not.toThrow(); + }); + }); + + test('should handle very long model names', () => { + const longModelName = 'model-name-that-is-extremely-long-and-unrealistic-but-should-still-work-without-crashing'.repeat(10); + const choice: LLMChoice = { claude: longModelName }; + + expect(() => { + const api = models(choice); + expect(api).toBeDefined(); + }).not.toThrow(); + }); + + test('should handle OpenAI configuration with minimal required fields', () => { + const minimalConfigs = [ + { + openai: { + url: 'https://api.openai.com/v1', + apiKey: 'test-key', + model: 'gpt-3.5-turbo' + } + }, + { + openai: { + url: 'https://api.openai.com/v1', + apiKey: 'test-key', + model: 'gpt-3.5-turbo', + allowBrowser: false + } + }, + { + openai: { + url: 'https://api.openai.com/v1', + apiKey: 'test-key', + model: 'gpt-3.5-turbo', + allowBrowser: true + } + } + ]; + + minimalConfigs.forEach(config => { + expect(() => { + const api = models(config); + expect(api).toBeDefined(); + }).not.toThrow(); + }); + }); + + test('should handle malformed URLs gracefully', () => { + const malformedUrls = [ + 'not-a-url', + 'http://', + 'https://', + 'ftp://example.com', + 'javascript:alert(1)', + 'file:///etc/passwd' + ]; + + malformedUrls.forEach(url => { + const config: LLMChoice = { + openai: { + url: url, + apiKey: 'test-key', + model: 'gpt-3.5-turbo' + } + }; + + expect(() => { + const api = models(config); + expect(api).toBeDefined(); + }).not.toThrow(); + }); + }); + }); + + describe('Concurrent Operations', () => { + test('should handle multiple simultaneous API calls', async () => { + const choice: LLMChoice = { claude: 'claude-3-5-sonnet' }; + const api = models(choice); + + const promises = []; + for (let i = 0; i < 50; i++) { + promises.push(api.send(`Test message ${i}`).catch(() => `Error ${i}`)); + } + + const results = await Promise.allSettled(promises); + expect(results.length).toBe(50); + + // All promises should be settled (either fulfilled or rejected) + results.forEach(result => { + expect(result.status).toBeOneOf(['fulfilled', 'rejected']); + }); + }); + + test('should handle multiple simultaneous streams', () => { + const choice: LLMChoice = { claude: 'claude-3-5-sonnet' }; + const api = models(choice); + + expect(() => { + for (let i = 0; i < 10; i++) { + const handler = (data: string) => console.log(`Stream ${i}:`, data); + api.stream(`Message ${i}`, handler); + } + }).not.toThrow(); + }); + + test('should handle mixing streaming and non-streaming calls', async () => { + const choice: LLMChoice = { claude: 'claude-3-5-sonnet' }; + const api = models(choice); + + // Start some streams + for (let i = 0; i < 5; i++) { + const handler = (data: string) => console.log(`Stream ${i}:`, data); + api.stream(`Stream message ${i}`, handler); + } + + // Also make some regular calls + const promises = []; + for (let i = 0; i < 5; i++) { + promises.push(api.send(`Regular message ${i}`).catch(() => null)); + } + + const results = await Promise.allSettled(promises); + expect(results.length).toBe(5); + }); + }); + + describe('Resource Management', () => { + test('should handle rapid model switching', () => { + const choice: LLMChoice = { claude: 'claude-3-5-sonnet' }; + const api = models(choice); + + const models = ['claude-3-5-sonnet', 'claude-3-haiku', 'claude-3-opus']; + const iterations = 100; + + expect(() => { + for (let i = 0; i < iterations; i++) { + const model = models[i % models.length]; + api.setModel(model); + } + }).not.toThrow(); + + // Should still be functional + expect(() => api.tokenizer('test')).not.toThrow(); + }); + + test('should handle system prompt changes efficiently', async () => { + const choice: LLMChoice = { claude: 'claude-3-5-sonnet' }; + const api = models(choice); + + const systemPrompts = [ + 'You are a helpful assistant.', + 'You are a creative writer.', + 'You are a technical expert.', + 'You are a friendly chatbot.' + ]; + + const promises = systemPrompts.map(prompt => + api.send('Hello', prompt).catch(() => null) + ); + + const results = await Promise.allSettled(promises); + expect(results.length).toBe(systemPrompts.length); + }); + }); + + describe('Extreme Cases', () => { + test('should handle extremely large system prompts', async () => { + const choice: LLMChoice = { claude: 'claude-3-5-sonnet' }; + const api = models(choice); + + const largeSystemPrompt = 'You are a helpful assistant. '.repeat(10000); + expect(largeSystemPrompt.length).toBeGreaterThan(200000); + + const promise = api.send('Hello', largeSystemPrompt); + expect(promise).toBeDefined(); + expect(typeof promise.then).toBe('function'); + }); + + test('should handle deep nesting of input tokens', async () => { + const choice: LLMChoice = { claude: 'claude-3-5-sonnet' }; + const api = models(choice); + + // Create a very deep input array + const deepInput: InputToken[] = []; + for (let i = 0; i < 10000; i++) { + deepInput.push({ text: `Item ${i}: ` }); + } + + const startTime = performance.now(); + const promise = api.send(deepInput); + const endTime = performance.now(); + + expect(promise).toBeDefined(); + expect(endTime - startTime).toBeLessThan(1000); // Should be fast + }); + + test('should handle rapid creation and destruction', () => { + const startTime = performance.now(); + + for (let i = 0; i < 1000; i++) { + const choice: LLMChoice = { claude: 'claude-3-5-sonnet' }; + const api = models(choice); + // Use it briefly + api.tokenizer(`Test ${i}`); + // Let it go out of scope + } + + const endTime = performance.now(); + expect(endTime - startTime).toBeLessThan(5000); // Should complete in 5 seconds + }); + }); +});
\ No newline at end of file diff --git a/packages/ai/tests/setup.ts b/packages/ai/tests/setup.ts new file mode 100644 index 0000000..a8a7546 --- /dev/null +++ b/packages/ai/tests/setup.ts @@ -0,0 +1,180 @@ +/** + * Test configuration and setup utilities + * This file provides configuration for running tests with different LLM providers + */ + +// Environment variable configuration +export interface TestConfig { + baseUrl: string; + apiKey: string; + model: string; + claudeApiKey?: string; + openaiApiKey?: string; + geminiApiKey?: string; +} + +// Get test configuration from environment variables +export function getTestConfig(): TestConfig { + return { + baseUrl: Bun.env.ZAI_BASE_URL || 'https://api.openai.com/v1', + apiKey: Bun.env.ZAI_API_KEY || 'test-api-key', + model: Bun.env.TEST_MODEL || 'glm-4.6', + claudeApiKey: Bun.env.CLAUDE_API_KEY, + openaiApiKey: Bun.env.OPENAI_API_KEY, + geminiApiKey: Bun.env.GEMINI_API_KEY + }; +} + +// Check if we have real API credentials for integration testing +export function hasIntegrationCredentials(): { + claude: boolean; + openai: boolean; + gemini: boolean; + any: boolean; +} { + const config = getTestConfig(); + + return { + claude: !!config.claudeApiKey, + openai: !!config.openaiApiKey, + gemini: !!config.geminiApiKey, + any: !!(config.claudeApiKey || config.openaiApiKey || config.geminiApiKey) + }; +} + +// Mock API responses for testing without real credentials +export const mockResponses = { + claude: { + success: 'SUCCESS', + counting: '1\n2\n3\n4\n5', + error: 'Mock Claude error' + }, + openai: { + success: 'SUCCESS', + error: 'Mock OpenAI error' + }, + gemini: { + success: 'SUCCESS', + error: 'Mock Gemini error' + } +}; + +// Test data generators +export const testData = { + simpleText: () => 'Hello, this is a simple test message.', + longText: () => 'This is a longer test message. '.repeat(100), + unicodeText: () => 'Hello 🌍! 测试中文! Тест на русском! العربية!', + specialChars: () => '!@#$%^&*()_+-=[]{}|;:,.<>?/~`', + emptyString: () => '', + whitespaceOnly: () => ' \n\t ', + + // Input token generators + textTokens: (count: number = 3) => + Array.from({ length: count }, (_, i) => ({ text: `Token ${i + 1}: ` })), + + imageTokens: (count: number = 1) => + Array.from({ length: count }, () => ({ + img: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==' + })), + + mixedTokens: () => [ + { text: 'Describe this image: ' }, + { img: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==' } + ] +}; + +// Performance test utilities +export const performance = { + measureTokenization: (api: any, text: string, iterations: number = 1000) => { + const start = performance.now(); + for (let i = 0; i < iterations; i++) { + api.tokenizer(text + i); + } + const end = performance.now(); + return { + totalTime: end - start, + averageTime: (end - start) / iterations, + iterations + }; + }, + + measureApiCreation: (choice: any, iterations: number = 100) => { + const start = performance.now(); + for (let i = 0; i < iterations; i++) { + const models = require('../index').default; + models(choice); + } + const end = performance.now(); + return { + totalTime: end - start, + averageTime: (end - start) / iterations, + iterations + }; + } +}; + +// Error simulation utilities +export const errorSimulator = { + // Simulate network errors + networkError: () => new Error('Network connection failed'), + + // Simulate API key errors + apiKeyError: () => new Error('Invalid API key'), + + // Simulate timeout errors + timeoutError: () => new Error('Request timeout'), + + // Simulate rate limit errors + rateLimitError: () => new Error('Rate limit exceeded'), + + // Simulate invalid response errors + invalidResponseError: () => new Error('Invalid API response format') +}; + +// Logging utilities for tests +export const testLogger = { + info: (message: string, ...args: any[]) => { + console.log(`[TEST-INFO] ${message}`, ...args); + }, + + warn: (message: string, ...args: any[]) => { + console.warn(`[TEST-WARN] ${message}`, ...args); + }, + + error: (message: string, ...args: any[]) => { + console.error(`[TEST-ERROR] ${message}`, ...args); + }, + + debug: (message: string, ...args: any[]) => { + if (Bun.env.DEBUG_TESTS === 'true') { + console.debug(`[TEST-DEBUG] ${message}`, ...args); + } + } +}; + +// Cleanup utilities +export const cleanup = { + // Clear environment variables after tests + clearEnv: (...vars: string[]) => { + vars.forEach(varName => { + delete Bun.env[varName]; + }); + }, + + // Reset global state if needed + resetGlobal: () => { + // Add any global state cleanup here + } +}; + +// Export a default test setup object +export default { + getTestConfig, + hasIntegrationCredentials, + mockResponses, + testData, + performance, + errorSimulator, + testLogger, + cleanup +};
\ No newline at end of file diff --git a/packages/ai/tests/vllm.ts b/packages/ai/tests/vllm.ts new file mode 100644 index 0000000..1c57586 --- /dev/null +++ b/packages/ai/tests/vllm.ts @@ -0,0 +1,23 @@ +import models, { type AIModelAPI, type LLMChoice } from "../index"; + +const model = "Qwen/Qwen3-VL-8B-Instruct-FP8"; + +const api = models({ + openai: { url: "http://localhost:3000/v1", apiKey: "EMPTY", model }, +}); + +// const r1 = await api.send("你觉得这样下去可以吗?你不觉得可耻吗"); +// const r1 = await api.send("Why is VLLM so fucking annoying to run"); +const r1 = await api.send( + "Phrenology is true and I'm tired of pretending it isn't", +); +console.log({ r1 }); +// const r2 = await api.send([ +// { +// // img: "https://ofasys-multimodal-wlcb-3-toshanghai.oss-accelerate.aliyuncs.com/wpf272043/keepme/image/receipt.png", +// img: "https://www.khaosod.co.th/wpapp/uploads/2025/10/co-pay-.jpg", +// }, +// { text: "Read all the text in the image" }, +// // { text: "Read all the text in the image and describe the scene" }, +// ]); +// console.log({ r2 }); diff --git a/packages/ai/tsconfig.json b/packages/ai/tsconfig.json new file mode 100644 index 0000000..238655f --- /dev/null +++ b/packages/ai/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} |
