mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 06:04:40 +00:00
feat(ai): Create unified AI package with OpenAI, Anthropic, and Gemini support
- Set up @mariozechner/ai package structure following monorepo patterns - Install OpenAI, Anthropic, and Google Gemini SDK dependencies - Document comprehensive API investigation for all three providers - Design minimal unified API with streaming-first architecture - Add models.dev integration for pricing and capabilities - Implement automatic caching strategy for all providers - Update project documentation with package creation guide
This commit is contained in:
parent
2c03724862
commit
f064ea0e14
14 changed files with 7437 additions and 21 deletions
390
package-lock.json
generated
390
package-lock.json
generated
|
|
@ -19,6 +19,15 @@
|
|||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.60.0.tgz",
|
||||
"integrity": "sha512-9zu/TXaUy8BZhXedDtt1wT3H4LOlpKDO1/ftiFpeR3N1PCr3KJFKkxxlQWWt1NNp08xSwUNJ3JNY8yhl8av6eQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"anthropic-ai-sdk": "bin/cli"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/biome": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.1.4.tgz",
|
||||
|
|
@ -624,6 +633,31 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@google/genai": {
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.14.0.tgz",
|
||||
"integrity": "sha512-jirYprAAJU1svjwSDVCzyVq+FrJpJd5CSxR/g2Ga/gZ0ZYZpcWjMS75KJl9y71K1mDN+tcx6s21CzCbB2R840g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"google-auth-library": "^9.14.2",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.11.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@modelcontextprotocol/sdk": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@mariozechner/ai": {
|
||||
"resolved": "packages/ai",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@mariozechner/pi": {
|
||||
"resolved": "packages/pods",
|
||||
"link": true
|
||||
|
|
@ -659,6 +693,15 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
|
||||
|
|
@ -683,6 +726,41 @@
|
|||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bignumber.js": {
|
||||
"version": "9.3.1",
|
||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
||||
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.5.0.tgz",
|
||||
|
|
@ -695,6 +773,32 @@
|
|||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz",
|
||||
|
|
@ -737,6 +841,12 @@
|
|||
"@esbuild/win32-x64": "0.25.8"
|
||||
}
|
||||
},
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
|
|
@ -752,6 +862,36 @@
|
|||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/gaxios": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz",
|
||||
"integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"extend": "^3.0.2",
|
||||
"https-proxy-agent": "^7.0.1",
|
||||
"is-stream": "^2.0.0",
|
||||
"node-fetch": "^2.6.9",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/gcp-metadata": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz",
|
||||
"integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"gaxios": "^6.1.1",
|
||||
"google-logging-utils": "^0.0.2",
|
||||
"json-bigint": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.10.1",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz",
|
||||
|
|
@ -765,6 +905,58 @@
|
|||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/google-auth-library": {
|
||||
"version": "9.15.1",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz",
|
||||
"integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"ecdsa-sig-formatter": "^1.0.11",
|
||||
"gaxios": "^6.1.1",
|
||||
"gcp-metadata": "^6.1.0",
|
||||
"gtoken": "^7.0.0",
|
||||
"jws": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/google-logging-utils": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz",
|
||||
"integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/gtoken": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz",
|
||||
"integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"gaxios": "^6.0.0",
|
||||
"jws": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/husky": {
|
||||
"version": "9.1.7",
|
||||
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
|
||||
|
|
@ -781,6 +973,95 @@
|
|||
"url": "https://github.com/sponsors/typicode"
|
||||
}
|
||||
},
|
||||
"node_modules/is-stream": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/json-bigint": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
|
||||
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bignumber.js": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
|
||||
"integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.0",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/openai": {
|
||||
"version": "5.12.2",
|
||||
"resolved": "https://registry.npmjs.org/openai/-/openai-5.12.2.tgz",
|
||||
"integrity": "sha512-xqzHHQch5Tws5PcKR2xsZGX9xtch+JQFz5zb14dGqlshmmDAFBFEWmeIpf7wVqWV+w7Emj7jRgkNJakyKE0tYQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"openai": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ws": {
|
||||
"optional": true
|
||||
},
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
|
|
@ -791,6 +1072,26 @@
|
|||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
|
|
@ -818,6 +1119,12 @@
|
|||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.20.3",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz",
|
||||
|
|
@ -858,6 +1165,56 @@
|
|||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/agent": {
|
||||
"name": "@mariozechner/pi-agent",
|
||||
"version": "0.5.8",
|
||||
|
|
@ -1041,25 +1398,6 @@
|
|||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"packages/agent/node_modules/openai": {
|
||||
"version": "5.12.2",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"openai": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ws": {
|
||||
"optional": true
|
||||
},
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/agent/node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"license": "BlueOak-1.0.0"
|
||||
|
|
@ -1259,6 +1597,20 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@mariozechner/ai",
|
||||
"version": "0.5.8",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.60.0",
|
||||
"@google/genai": "1.14.0",
|
||||
"openai": "5.12.2"
|
||||
},
|
||||
"devDependencies": {},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"packages/pods": {
|
||||
"name": "@mariozechner/pi",
|
||||
"version": "0.5.8",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
],
|
||||
"scripts": {
|
||||
"clean": "npm run clean --workspaces",
|
||||
"build": "npm run build -w @mariozechner/pi-tui && npm run build -w @mariozechner/pi-agent && npm run build -w @mariozechner/pi",
|
||||
"build": "npm run build -w @mariozechner/pi-tui && npm run build -w @mariozechner/ai && npm run build -w @mariozechner/pi-agent && npm run build -w @mariozechner/pi",
|
||||
"check": "biome check --write . && npm run check --workspaces && tsc --noEmit",
|
||||
"test": "npm run test --workspaces --if-present",
|
||||
"version:patch": "npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js",
|
||||
|
|
|
|||
62
packages/ai/README.md
Normal file
62
packages/ai/README.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# @mariozechner/ai
|
||||
|
||||
Unified API for OpenAI, Anthropic, and Google Gemini LLM providers. This package provides a common interface for working with multiple LLM providers, handling their differences transparently while exposing a consistent, minimal API.
|
||||
|
||||
## Features (Planned)
|
||||
|
||||
- **Unified Interface**: Single API for OpenAI, Anthropic, and Google Gemini
|
||||
- **Streaming Support**: Real-time response streaming with delta events
|
||||
- **Tool Calling**: Consistent tool/function calling across providers
|
||||
- **Reasoning/Thinking**: Support for reasoning tokens where available
|
||||
- **Session Management**: Serializable conversation state across providers
|
||||
- **Token Tracking**: Unified token counting (input, output, cached, reasoning)
|
||||
- **Interrupt Handling**: Graceful cancellation of requests
|
||||
- **Provider Detection**: Automatic configuration based on endpoint
|
||||
- **Caching Support**: Provider-specific caching strategies
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @mariozechner/ai
|
||||
```
|
||||
|
||||
## Quick Start (Coming Soon)
|
||||
|
||||
```typescript
|
||||
import { createClient } from '@mariozechner/ai';
|
||||
|
||||
// Automatically detects provider from configuration
|
||||
const client = createClient({
|
||||
provider: 'openai',
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
model: 'gpt-4'
|
||||
});
|
||||
|
||||
// Same API works for all providers
|
||||
const response = await client.complete({
|
||||
messages: [
|
||||
{ role: 'user', content: 'Hello!' }
|
||||
],
|
||||
stream: true
|
||||
});
|
||||
|
||||
for await (const event of response) {
|
||||
if (event.type === 'content') {
|
||||
process.stdout.write(event.text);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Supported Providers
|
||||
|
||||
- **OpenAI**: GPT-3.5, GPT-4, o1, o3 models
|
||||
- **Anthropic**: Claude models via native SDK
|
||||
- **Google Gemini**: Gemini models with thinking support
|
||||
|
||||
## Development
|
||||
|
||||
This package is part of the pi monorepo. See the main README for development instructions.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
1706
packages/ai/anthropic-api.md
Normal file
1706
packages/ai/anthropic-api.md
Normal file
File diff suppressed because it is too large
Load diff
1233
packages/ai/gemini-api.md
Normal file
1233
packages/ai/gemini-api.md
Normal file
File diff suppressed because it is too large
Load diff
2320
packages/ai/openai-api.md
Normal file
2320
packages/ai/openai-api.md
Normal file
File diff suppressed because it is too large
Load diff
32
packages/ai/package.json
Normal file
32
packages/ai/package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"name": "@mariozechner/ai",
|
||||
"version": "0.5.8",
|
||||
"description": "Unified API for OpenAI, Anthropic, and Google Gemini LLM providers",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": ["dist", "README.md"],
|
||||
"scripts": {
|
||||
"clean": "rm -rf dist",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"check": "biome check --write .",
|
||||
"prepublishOnly": "npm run clean && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"openai": "5.12.2",
|
||||
"@anthropic-ai/sdk": "0.60.0",
|
||||
"@google/genai": "1.14.0"
|
||||
},
|
||||
"devDependencies": {},
|
||||
"keywords": ["ai", "llm", "openai", "anthropic", "gemini", "unified", "api"],
|
||||
"author": "Mario Zechner",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/badlogic/pi-mono.git",
|
||||
"directory": "packages/ai"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
950
packages/ai/plan.md
Normal file
950
packages/ai/plan.md
Normal file
|
|
@ -0,0 +1,950 @@
|
|||
# Unified AI API Design Plan
|
||||
|
||||
Based on comprehensive investigation of OpenAI, Anthropic, and Gemini SDKs with actual implementation examples.
|
||||
|
||||
## Key API Differences Summary
|
||||
|
||||
### OpenAI
|
||||
- **Dual APIs**: Chat Completions (broad support) vs Responses API (o1/o3 thinking content)
|
||||
- **Thinking**: Only Responses API gives actual content, Chat Completions only gives counts
|
||||
- **Roles**: `system`, `user`, `assistant`, `tool` (o1/o3 use `developer` instead of `system`)
|
||||
- **Streaming**: Deltas in chunks with `stream_options.include_usage` for token usage
|
||||
|
||||
### Anthropic
|
||||
- **Single API**: Messages API with comprehensive streaming
|
||||
- **Content Blocks**: Always arrays, even for simple text
|
||||
- **System**: Separate parameter, not in messages array
|
||||
- **Tool Use**: Content blocks, not separate message role
|
||||
- **Thinking**: Explicit budget allocation, appears as content blocks
|
||||
- **Caching**: Per-block cache control with TTL options
|
||||
|
||||
### Gemini
|
||||
- **Parts System**: All content split into typed parts
|
||||
- **System**: Separate `systemInstruction` parameter
|
||||
- **Roles**: Uses `model` instead of `assistant`
|
||||
- **Thinking**: `part.thought: true` flag identifies reasoning
|
||||
- **Streaming**: Returns complete responses, not deltas
|
||||
- **Function Calls**: Embedded in parts array
|
||||
|
||||
## Unified API Design
|
||||
|
||||
### Core Client
|
||||
|
||||
```typescript
|
||||
interface AIConfig {
|
||||
provider: 'openai' | 'anthropic' | 'gemini';
|
||||
apiKey: string;
|
||||
model: string;
|
||||
baseURL?: string; // For OpenAI-compatible endpoints
|
||||
}
|
||||
|
||||
interface ModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
capabilities: {
|
||||
reasoning: boolean;
|
||||
toolCall: boolean;
|
||||
vision: boolean;
|
||||
audio?: boolean;
|
||||
};
|
||||
cost: {
|
||||
input: number; // per million tokens
|
||||
output: number; // per million tokens
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
};
|
||||
limits: {
|
||||
context: number;
|
||||
output: number;
|
||||
};
|
||||
knowledge?: string; // Knowledge cutoff date
|
||||
}
|
||||
|
||||
class AI {
|
||||
constructor(config: AIConfig);
|
||||
|
||||
// Main streaming interface - everything else builds on this
|
||||
async *stream(request: Request): AsyncGenerator<Event>;
|
||||
|
||||
// Convenience method for non-streaming
|
||||
async complete(request: Request): Promise<Response>;
|
||||
|
||||
// Get model information
|
||||
getModelInfo(): ModelInfo;
|
||||
|
||||
// Abort current request
|
||||
abort(): void;
|
||||
}
|
||||
```
|
||||
|
||||
### Message Format
|
||||
|
||||
```typescript
|
||||
type Message =
|
||||
| {
|
||||
role: 'user';
|
||||
content: string | Content[];
|
||||
}
|
||||
| {
|
||||
role: 'assistant';
|
||||
content: string | Content[];
|
||||
model: string;
|
||||
usage: TokenUsage;
|
||||
toolCalls?: {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, any>;
|
||||
}[];
|
||||
}
|
||||
| {
|
||||
role: 'tool';
|
||||
content: string | Content[];
|
||||
toolCallId: string;
|
||||
};
|
||||
|
||||
interface Content {
|
||||
type: 'text' | 'image';
|
||||
text?: string;
|
||||
image?: {
|
||||
data: string; // base64
|
||||
mimeType: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Request Format
|
||||
|
||||
```typescript
|
||||
interface Request {
|
||||
messages: Message[];
|
||||
|
||||
// System prompt (separated for Anthropic/Gemini compatibility)
|
||||
systemPrompt?: string;
|
||||
|
||||
// Common parameters
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
stopSequences?: string[];
|
||||
|
||||
// Tools
|
||||
tools?: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, any>; // JSON Schema
|
||||
}[];
|
||||
toolChoice?: 'auto' | 'none' | 'required' | { name: string };
|
||||
|
||||
// Thinking/reasoning
|
||||
reasoning?: {
|
||||
enabled: boolean;
|
||||
effort?: 'low' | 'medium' | 'high'; // OpenAI reasoning_effort
|
||||
maxTokens?: number; // Anthropic thinking budget
|
||||
};
|
||||
|
||||
// Abort signal
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
```
|
||||
|
||||
### Event Stream
|
||||
|
||||
```typescript
|
||||
type Event =
|
||||
| { type: 'start'; model: string; provider: string }
|
||||
| { type: 'text'; content: string; delta: string }
|
||||
| { type: 'thinking'; content: string; delta: string }
|
||||
| { type: 'toolCall'; toolCall: ToolCall }
|
||||
| { type: 'usage'; usage: TokenUsage }
|
||||
| { type: 'done'; reason: StopReason; message: Message } // message includes model and usage
|
||||
| { type: 'error'; error: Error };
|
||||
|
||||
interface TokenUsage {
|
||||
input: number;
|
||||
output: number;
|
||||
total: number;
|
||||
thinking?: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
cost?: {
|
||||
input: number;
|
||||
output: number;
|
||||
cache?: number;
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
type StopReason = 'stop' | 'length' | 'toolUse' | 'safety' | 'error';
|
||||
```
|
||||
|
||||
## Caching Strategy
|
||||
|
||||
Caching is handled automatically by each provider adapter:
|
||||
|
||||
- **OpenAI**: Automatic prompt caching (no configuration needed)
|
||||
- **Gemini**: Automatic context caching (no configuration needed)
|
||||
- **Anthropic**: We automatically add cache_control to the system prompt and older messages
|
||||
|
||||
```typescript
|
||||
class AnthropicAdapter {
|
||||
private addCaching(messages: Message[]): any[] {
|
||||
const anthropicMessages = [];
|
||||
|
||||
// Automatically cache older messages (assuming incremental context)
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
const isOld = i < messages.length - 2; // Cache all but last 2 messages
|
||||
|
||||
// Convert to Anthropic format with automatic caching
|
||||
const blocks = this.toContentBlocks(msg);
|
||||
if (isOld && blocks.length > 0) {
|
||||
blocks[0].cache_control = { type: 'ephemeral' };
|
||||
}
|
||||
|
||||
anthropicMessages.push({
|
||||
role: msg.role === 'assistant' ? 'assistant' : 'user',
|
||||
content: blocks
|
||||
});
|
||||
}
|
||||
|
||||
return anthropicMessages;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Provider Adapter Implementation
|
||||
|
||||
### OpenAI Adapter
|
||||
|
||||
```typescript
|
||||
class OpenAIAdapter {
|
||||
private client: OpenAI;
|
||||
private useResponsesAPI: boolean = false;
|
||||
|
||||
async *stream(request: Request): AsyncGenerator<Event> {
|
||||
// Determine which API to use
|
||||
if (request.reasoning?.enabled && this.isReasoningModel()) {
|
||||
yield* this.streamResponsesAPI(request);
|
||||
} else {
|
||||
yield* this.streamChatCompletions(request);
|
||||
}
|
||||
}
|
||||
|
||||
private async *streamChatCompletions(request: Request) {
|
||||
const stream = await this.client.chat.completions.create({
|
||||
model: this.model,
|
||||
messages: this.toOpenAIMessages(request),
|
||||
tools: this.toOpenAITools(request.tools),
|
||||
reasoning_effort: request.reasoning?.effort,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true }
|
||||
});
|
||||
|
||||
let content = '';
|
||||
let toolCalls: any[] = [];
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (chunk.choices[0]?.delta?.content) {
|
||||
const delta = chunk.choices[0].delta.content;
|
||||
content += delta;
|
||||
yield { type: 'text', content, delta };
|
||||
}
|
||||
|
||||
if (chunk.choices[0]?.delta?.tool_calls) {
|
||||
// Accumulate tool calls
|
||||
this.mergeToolCalls(toolCalls, chunk.choices[0].delta.tool_calls);
|
||||
for (const tc of toolCalls) {
|
||||
yield { type: 'toolCall', toolCall: tc, partial: true };
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
yield {
|
||||
type: 'usage',
|
||||
usage: {
|
||||
input: chunk.usage.prompt_tokens,
|
||||
output: chunk.usage.completion_tokens,
|
||||
total: chunk.usage.total_tokens,
|
||||
thinking: chunk.usage.completion_tokens_details?.reasoning_tokens
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async *streamResponsesAPI(request: Request) {
|
||||
// Use Responses API for actual thinking content
|
||||
const response = await this.client.responses.create({
|
||||
model: this.model,
|
||||
input: this.toResponsesInput(request),
|
||||
tools: this.toResponsesTools(request.tools),
|
||||
stream: true
|
||||
});
|
||||
|
||||
for await (const event of response) {
|
||||
if (event.type === 'response.reasoning_text.delta') {
|
||||
yield {
|
||||
type: 'thinking',
|
||||
content: event.text,
|
||||
delta: event.delta
|
||||
};
|
||||
}
|
||||
// Handle other event types...
|
||||
}
|
||||
}
|
||||
|
||||
private toOpenAIMessages(request: Request): any[] {
|
||||
const messages: any[] = [];
|
||||
|
||||
// Handle system prompt
|
||||
if (request.systemPrompt) {
|
||||
const role = this.isReasoningModel() ? 'developer' : 'system';
|
||||
messages.push({ role, content: request.systemPrompt });
|
||||
}
|
||||
|
||||
// Convert unified messages
|
||||
for (const msg of request.messages) {
|
||||
if (msg.role === 'tool') {
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: msg.content,
|
||||
tool_call_id: msg.toolCallId
|
||||
});
|
||||
} else {
|
||||
messages.push({
|
||||
role: msg.role,
|
||||
content: this.contentToString(msg.content),
|
||||
tool_calls: msg.toolCalls
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Anthropic Adapter
|
||||
|
||||
```typescript
|
||||
class AnthropicAdapter {
|
||||
private client: Anthropic;
|
||||
|
||||
async *stream(request: Request): AsyncGenerator<Event> {
|
||||
const stream = this.client.messages.stream({
|
||||
model: this.model,
|
||||
max_tokens: request.maxTokens || 1024,
|
||||
messages: this.addCaching(request.messages),
|
||||
system: request.systemPrompt,
|
||||
tools: this.toAnthropicTools(request.tools),
|
||||
thinking: request.reasoning?.enabled ? {
|
||||
type: 'enabled',
|
||||
budget_tokens: request.reasoning.maxTokens || 2000
|
||||
} : undefined
|
||||
});
|
||||
|
||||
let content = '';
|
||||
let thinking = '';
|
||||
|
||||
stream.on('text', (delta, snapshot) => {
|
||||
content = snapshot;
|
||||
// Note: Can't yield from callback, need different approach
|
||||
});
|
||||
|
||||
stream.on('thinking', (delta, snapshot) => {
|
||||
thinking = snapshot;
|
||||
});
|
||||
|
||||
// Use raw streaming instead for proper async generator
|
||||
const rawStream = await this.client.messages.create({
|
||||
...params,
|
||||
stream: true
|
||||
});
|
||||
|
||||
for await (const chunk of rawStream) {
|
||||
switch (chunk.type) {
|
||||
case 'content_block_delta':
|
||||
if (chunk.delta.type === 'text_delta') {
|
||||
content += chunk.delta.text;
|
||||
yield {
|
||||
type: 'text',
|
||||
content,
|
||||
delta: chunk.delta.text
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
case 'message_delta':
|
||||
if (chunk.usage) {
|
||||
yield {
|
||||
type: 'usage',
|
||||
usage: {
|
||||
input: chunk.usage.input_tokens,
|
||||
output: chunk.usage.output_tokens,
|
||||
total: chunk.usage.input_tokens + chunk.usage.output_tokens,
|
||||
cacheRead: chunk.usage.cache_read_input_tokens,
|
||||
cacheWrite: chunk.usage.cache_creation_input_tokens
|
||||
}
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private toAnthropicMessages(request: Request): any[] {
|
||||
return request.messages.map(msg => {
|
||||
if (msg.role === 'tool') {
|
||||
// Tool results go as user messages with tool_result blocks
|
||||
return {
|
||||
role: 'user',
|
||||
content: [{
|
||||
type: 'tool_result',
|
||||
tool_use_id: msg.toolCallId,
|
||||
content: msg.content
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
// Always use content blocks
|
||||
const blocks: any[] = [];
|
||||
|
||||
if (typeof msg.content === 'string') {
|
||||
blocks.push({
|
||||
type: 'text',
|
||||
text: msg.content,
|
||||
cache_control: msg.cacheControl
|
||||
});
|
||||
} else {
|
||||
// Convert unified content to blocks
|
||||
for (const part of msg.content) {
|
||||
if (part.type === 'text') {
|
||||
blocks.push({ type: 'text', text: part.text });
|
||||
} else if (part.type === 'image') {
|
||||
blocks.push({
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: part.image.mimeType,
|
||||
data: part.image.data
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add tool calls as blocks
|
||||
if (msg.toolCalls) {
|
||||
for (const tc of msg.toolCalls) {
|
||||
blocks.push({
|
||||
type: 'tool_use',
|
||||
id: tc.id,
|
||||
name: tc.name,
|
||||
input: tc.arguments
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
role: msg.role === 'assistant' ? 'assistant' : 'user',
|
||||
content: blocks
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Gemini Adapter
|
||||
|
||||
```typescript
|
||||
class GeminiAdapter {
|
||||
private client: GoogleGenAI;
|
||||
|
||||
async *stream(request: Request): AsyncGenerator<Event> {
|
||||
const stream = await this.client.models.generateContentStream({
|
||||
model: this.model,
|
||||
systemInstruction: request.systemPrompt ? {
|
||||
parts: [{ text: request.systemPrompt }]
|
||||
} : undefined,
|
||||
contents: this.toGeminiContents(request),
|
||||
tools: this.toGeminiTools(request.tools),
|
||||
abortSignal: request.signal
|
||||
});
|
||||
|
||||
let content = '';
|
||||
let thinking = '';
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const candidate = chunk.candidates?.[0];
|
||||
if (!candidate?.content?.parts) continue;
|
||||
|
||||
for (const part of candidate.content.parts) {
|
||||
if (part.text && !part.thought) {
|
||||
content += part.text;
|
||||
yield {
|
||||
type: 'text',
|
||||
content,
|
||||
delta: part.text
|
||||
};
|
||||
} else if (part.text && part.thought) {
|
||||
thinking += part.text;
|
||||
yield {
|
||||
type: 'thinking',
|
||||
content: thinking,
|
||||
delta: part.text
|
||||
};
|
||||
} else if (part.functionCall) {
|
||||
yield {
|
||||
type: 'toolCall',
|
||||
toolCall: {
|
||||
id: part.functionCall.id || crypto.randomUUID(),
|
||||
name: part.functionCall.name,
|
||||
arguments: part.functionCall.args
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.usageMetadata) {
|
||||
yield {
|
||||
type: 'usage',
|
||||
usage: {
|
||||
input: chunk.usageMetadata.promptTokenCount || 0,
|
||||
output: chunk.usageMetadata.candidatesTokenCount || 0,
|
||||
total: chunk.usageMetadata.totalTokenCount || 0,
|
||||
thinking: chunk.usageMetadata.thoughtsTokenCount,
|
||||
cacheRead: chunk.usageMetadata.cachedContentTokenCount
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private toGeminiContents(request: Request): any[] {
|
||||
return request.messages.map(msg => {
|
||||
const parts: any[] = [];
|
||||
|
||||
if (typeof msg.content === 'string') {
|
||||
parts.push({ text: msg.content });
|
||||
} else {
|
||||
for (const part of msg.content) {
|
||||
if (part.type === 'text') {
|
||||
parts.push({ text: part.text });
|
||||
} else if (part.type === 'image') {
|
||||
parts.push({
|
||||
inlineData: {
|
||||
mimeType: part.image.mimeType,
|
||||
data: part.image.data
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add function calls as parts
|
||||
if (msg.toolCalls) {
|
||||
for (const tc of msg.toolCalls) {
|
||||
parts.push({
|
||||
functionCall: {
|
||||
name: tc.name,
|
||||
args: tc.arguments
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add tool results as function responses
|
||||
if (msg.role === 'tool') {
|
||||
parts.push({
|
||||
functionResponse: {
|
||||
name: msg.toolCallId,
|
||||
response: { result: msg.content }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
role: msg.role === 'assistant' ? 'model' : msg.role === 'tool' ? 'user' : msg.role,
|
||||
parts
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Streaming
|
||||
|
||||
```typescript
|
||||
const ai = new AI({
|
||||
provider: 'openai',
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
model: 'gpt-4'
|
||||
});
|
||||
|
||||
const stream = ai.stream({
|
||||
messages: [
|
||||
{ role: 'user', content: 'Write a haiku about coding' }
|
||||
],
|
||||
systemPrompt: 'You are a poetic programmer'
|
||||
});
|
||||
|
||||
for await (const event of stream) {
|
||||
switch (event.type) {
|
||||
case 'text':
|
||||
process.stdout.write(event.delta);
|
||||
break;
|
||||
case 'usage':
|
||||
console.log(`\nTokens: ${event.usage.total}`);
|
||||
break;
|
||||
case 'done':
|
||||
console.log(`\nFinished: ${event.reason}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cross-Provider Tool Calling
|
||||
|
||||
```typescript
|
||||
async function callWithTools(provider: 'openai' | 'anthropic' | 'gemini') {
|
||||
const ai = new AI({
|
||||
provider,
|
||||
apiKey: process.env[`${provider.toUpperCase()}_API_KEY`],
|
||||
model: getDefaultModel(provider)
|
||||
});
|
||||
|
||||
const messages: Message[] = [{
|
||||
role: 'user',
|
||||
content: 'What is the weather in Paris and calculate 15 * 23?'
|
||||
}];
|
||||
|
||||
const stream = ai.stream({
|
||||
messages,
|
||||
tools: [
|
||||
{
|
||||
name: 'weather',
|
||||
description: 'Get weather for a location',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
location: { type: 'string' }
|
||||
},
|
||||
required: ['location']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'calculator',
|
||||
description: 'Calculate math expressions',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
expression: { type: 'string' }
|
||||
},
|
||||
required: ['expression']
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const toolCalls: any[] = [];
|
||||
|
||||
for await (const event of stream) {
|
||||
if (event.type === 'toolCall') {
|
||||
toolCalls.push(event.toolCall);
|
||||
|
||||
// Execute tool
|
||||
const result = await executeToolCall(event.toolCall);
|
||||
|
||||
// Add tool result to conversation
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
toolCalls: [event.toolCall]
|
||||
});
|
||||
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: JSON.stringify(result),
|
||||
toolCallId: event.toolCall.id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Continue conversation with tool results
|
||||
if (toolCalls.length > 0) {
|
||||
const finalStream = ai.stream({ messages });
|
||||
|
||||
for await (const event of finalStream) {
|
||||
if (event.type === 'text') {
|
||||
process.stdout.write(event.delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Thinking/Reasoning
|
||||
|
||||
```typescript
|
||||
async function withThinking() {
|
||||
// OpenAI o1
|
||||
const openai = new AI({
|
||||
provider: 'openai',
|
||||
model: 'o1-preview'
|
||||
});
|
||||
|
||||
// Anthropic Claude
|
||||
const anthropic = new AI({
|
||||
provider: 'anthropic',
|
||||
model: 'claude-3-opus-20240229'
|
||||
});
|
||||
|
||||
// Gemini thinking model
|
||||
const gemini = new AI({
|
||||
provider: 'gemini',
|
||||
model: 'gemini-2.0-flash-thinking-exp-1219'
|
||||
});
|
||||
|
||||
for (const ai of [openai, anthropic, gemini]) {
|
||||
const stream = ai.stream({
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: 'Solve this step by step: If a tree falls in a forest...'
|
||||
}],
|
||||
reasoning: {
|
||||
enabled: true,
|
||||
effort: 'high', // OpenAI reasoning_effort
|
||||
maxTokens: 2000 // Anthropic budget
|
||||
}
|
||||
});
|
||||
|
||||
for await (const event of stream) {
|
||||
if (event.type === 'thinking') {
|
||||
console.log('[THINKING]', event.delta);
|
||||
} else if (event.type === 'text') {
|
||||
console.log('[RESPONSE]', event.delta);
|
||||
} else if (event.type === 'done') {
|
||||
// Final message includes model and usage with cost
|
||||
console.log('Model:', event.message.model);
|
||||
console.log('Tokens:', event.message.usage?.total);
|
||||
console.log('Cost: $', event.message.usage?.cost?.total);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Critical Decisions
|
||||
|
||||
1. **Streaming First**: All providers support streaming, non-streaming is just collected events
|
||||
2. **Unified Events**: Same event types across all providers for consistent handling
|
||||
3. **Separate System Prompt**: Required for Anthropic/Gemini compatibility
|
||||
4. **Tool Role**: Unified way to handle tool responses across providers
|
||||
5. **Content Arrays**: Support both string and structured content
|
||||
6. **Thinking Extraction**: Normalize reasoning across different provider formats
|
||||
|
||||
### Provider-Specific Handling
|
||||
|
||||
**OpenAI**:
|
||||
- Choose between Chat Completions and Responses API based on model and thinking needs
|
||||
- Map `developer` role for o1/o3 models
|
||||
- Handle streaming tool call deltas
|
||||
|
||||
**Anthropic**:
|
||||
- Convert to content blocks (always arrays)
|
||||
- Tool results as user messages with tool_result blocks
|
||||
- Handle MessageStream events or raw streaming
|
||||
|
||||
**Gemini**:
|
||||
- Convert to parts system
|
||||
- Extract thinking from `part.thought` flag
|
||||
- Map `assistant` to `model` role
|
||||
- Handle function calls/responses in parts
|
||||
|
||||
### Error Handling
|
||||
|
||||
```typescript
|
||||
class AIError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public provider: string,
|
||||
public retryable: boolean,
|
||||
public statusCode?: number
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
// In adapters
|
||||
try {
|
||||
// API call
|
||||
} catch (error) {
|
||||
if (error instanceof RateLimitError) {
|
||||
throw new AIError(
|
||||
'Rate limit exceeded',
|
||||
'rate_limit',
|
||||
this.provider,
|
||||
true,
|
||||
429
|
||||
);
|
||||
}
|
||||
// Map other errors...
|
||||
}
|
||||
```
|
||||
|
||||
## Model Information & Cost Tracking
|
||||
|
||||
### Models Database
|
||||
|
||||
We cache the models.dev API data at build time for fast, offline access:
|
||||
|
||||
```typescript
|
||||
// scripts/update-models.ts - Run during build or manually
|
||||
async function updateModels() {
|
||||
const response = await fetch('https://models.dev/api.json');
|
||||
const data = await response.json();
|
||||
|
||||
// Transform to our format
|
||||
const models: ModelsDatabase = transformModelsData(data);
|
||||
|
||||
// Generate TypeScript file
|
||||
const content = `// Auto-generated from models.dev API
|
||||
// Last updated: ${new Date().toISOString()}
|
||||
// Run 'npm run update-models' to refresh
|
||||
|
||||
export const MODELS_DATABASE: ModelsDatabase = ${JSON.stringify(models, null, 2)};
|
||||
`;
|
||||
|
||||
await fs.writeFile('src/models-data.ts', content);
|
||||
}
|
||||
|
||||
// src/models.ts - Runtime model lookup
|
||||
import { MODELS_DATABASE } from './models-data.js';
|
||||
|
||||
// Simple lookup with fallback
|
||||
export function getModelInfo(provider: string, model: string): ModelInfo {
|
||||
const info = MODELS_DATABASE.providers[provider]?.models[model];
|
||||
|
||||
if (!info) {
|
||||
// Fallback for unknown models
|
||||
return {
|
||||
id: model,
|
||||
name: model,
|
||||
provider,
|
||||
capabilities: {
|
||||
reasoning: false,
|
||||
toolCall: true,
|
||||
vision: false
|
||||
},
|
||||
cost: { input: 0, output: 0 },
|
||||
limits: { context: 128000, output: 4096 }
|
||||
};
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
// Optional: Runtime override for testing new models
|
||||
const runtimeOverrides = new Map<string, ModelInfo>();
|
||||
|
||||
export function registerModel(provider: string, model: string, info: ModelInfo) {
|
||||
runtimeOverrides.set(`${provider}:${model}`, info);
|
||||
}
|
||||
```
|
||||
|
||||
### Cost Calculation
|
||||
|
||||
```typescript
|
||||
class CostTracker {
|
||||
private usage: TokenUsage = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
total: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0
|
||||
};
|
||||
|
||||
private modelInfo: ModelInfo;
|
||||
|
||||
constructor(modelInfo: ModelInfo) {
|
||||
this.modelInfo = modelInfo;
|
||||
}
|
||||
|
||||
addUsage(tokens: Partial<TokenUsage>): TokenUsage {
|
||||
this.usage.input += tokens.input || 0;
|
||||
this.usage.output += tokens.output || 0;
|
||||
this.usage.thinking += tokens.thinking || 0;
|
||||
this.usage.cacheRead += tokens.cacheRead || 0;
|
||||
this.usage.cacheWrite += tokens.cacheWrite || 0;
|
||||
this.usage.total = this.usage.input + this.usage.output + (this.usage.thinking || 0);
|
||||
|
||||
// Calculate costs (per million tokens)
|
||||
const cost = this.modelInfo.cost;
|
||||
this.usage.cost = {
|
||||
input: (this.usage.input / 1_000_000) * cost.input,
|
||||
output: (this.usage.output / 1_000_000) * cost.output,
|
||||
cache:
|
||||
((this.usage.cacheRead || 0) / 1_000_000) * (cost.cacheRead || 0) +
|
||||
((this.usage.cacheWrite || 0) / 1_000_000) * (cost.cacheWrite || 0),
|
||||
total: 0
|
||||
};
|
||||
|
||||
this.usage.cost.total =
|
||||
this.usage.cost.input +
|
||||
this.usage.cost.output +
|
||||
this.usage.cost.cache;
|
||||
|
||||
return { ...this.usage };
|
||||
}
|
||||
|
||||
getTotalCost(): number {
|
||||
return this.usage.cost?.total || 0;
|
||||
}
|
||||
|
||||
getUsageSummary(): string {
|
||||
return `Tokens: ${this.usage.total} (${this.usage.input}→${this.usage.output}) | Cost: $${this.getTotalCost().toFixed(4)}`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Integration in Adapters
|
||||
|
||||
```typescript
|
||||
class OpenAIAdapter {
|
||||
private costTracker: CostTracker;
|
||||
|
||||
constructor(config: AIConfig) {
|
||||
const modelInfo = getModelInfo('openai', config.model);
|
||||
this.costTracker = new CostTracker(modelInfo);
|
||||
}
|
||||
|
||||
async *stream(request: Request): AsyncGenerator<Event> {
|
||||
// ... streaming logic ...
|
||||
|
||||
if (chunk.usage) {
|
||||
const usage = this.costTracker.addUsage({
|
||||
input: chunk.usage.prompt_tokens,
|
||||
output: chunk.usage.completion_tokens,
|
||||
thinking: chunk.usage.completion_tokens_details?.reasoning_tokens,
|
||||
cacheRead: chunk.usage.prompt_tokens_details?.cached_tokens
|
||||
});
|
||||
|
||||
yield { type: 'usage', usage };
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Create models.ts with models.dev integration
|
||||
2. Implement base `AI` class with adapter pattern
|
||||
3. Create three provider adapters with full streaming support
|
||||
4. Add comprehensive error mapping
|
||||
5. Implement token counting and cost tracking
|
||||
6. Add test suite for each provider
|
||||
7. Create migration guide from native SDKs
|
||||
5
packages/ai/src/index.ts
Normal file
5
packages/ai/src/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// @mariozechner/ai - Unified API for OpenAI, Anthropic, and Google Gemini
|
||||
// This package provides a common interface for working with multiple LLM providers
|
||||
|
||||
// TODO: Export types and implementations once defined
|
||||
export const version = "0.5.8";
|
||||
9
packages/ai/tsconfig.build.json
Normal file
9
packages/ai/tsconfig.build.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
606
todos/done/20250817-183528-ai-unified-api-package-analysis.md
Normal file
606
todos/done/20250817-183528-ai-unified-api-package-analysis.md
Normal file
|
|
@ -0,0 +1,606 @@
|
|||
# Analysis: Creating Unified AI Package
|
||||
|
||||
## Package Structure Analysis for Pi Monorepo
|
||||
|
||||
Based on my examination of the existing packages (`tui`, `agent`, and `pods`), here are the comprehensive patterns and conventions used in this monorepo:
|
||||
|
||||
### 1. Package Naming Conventions
|
||||
|
||||
**Scoped NPM packages with consistent naming:**
|
||||
- All packages use the `@mariozechner/` scope
|
||||
- Package names follow the pattern: `@mariozechner/pi-<package-name>`
|
||||
- Special case: the main CLI package is simply `@mariozechner/pi` (not `pi-pods`)
|
||||
|
||||
**Directory structure:**
|
||||
- Packages are located in `/packages/<package-name>/`
|
||||
- Directory names match the suffix of the npm package name (e.g., `tui`, `agent`, `pods`)
|
||||
|
||||
### 2. Package.json Structure Patterns
|
||||
|
||||
**Common fields across all packages:**
|
||||
```json
|
||||
{
|
||||
"name": "@mariozechner/pi-<name>",
|
||||
"version": "0.5.8", // Lockstep versioning - all packages share same version
|
||||
"description": "...",
|
||||
"type": "module", // All packages use ES modules
|
||||
"author": "Mario Zechner",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/badlogic/pi-mono.git",
|
||||
"directory": "packages/<name>"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0" // Consistent Node.js requirement
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Binary packages (agent, pods):**
|
||||
- Include `"bin"` field with CLI command mapping
|
||||
- Examples: `"pi-agent": "dist/cli.js"` and `"pi": "dist/cli.js"`
|
||||
|
||||
**Library packages (tui):**
|
||||
- Include `"main"` field pointing to built entry point
|
||||
- Include `"types"` field for TypeScript definitions
|
||||
|
||||
### 3. Scripts Configuration
|
||||
|
||||
**Universal scripts across all packages:**
|
||||
- `"clean": "rm -rf dist"` - Removes build artifacts
|
||||
- `"build": "tsc -p tsconfig.build.json"` - Builds with dedicated build config
|
||||
- `"check": "biome check --write ."` - Linting and formatting
|
||||
- `"prepublishOnly": "npm run clean && npm run build"` - Pre-publish cleanup
|
||||
|
||||
**CLI-specific build scripts:**
|
||||
- Add `&& chmod +x dist/cli.js` for executable permissions
|
||||
- Copy additional assets (e.g., `&& cp src/models.json dist/` for pods package)
|
||||
|
||||
### 4. Dependencies Structure
|
||||
|
||||
**Dependency hierarchy follows a clear pattern:**
|
||||
```
|
||||
pi-tui (foundation) -> pi-agent (uses tui) -> pi (uses agent)
|
||||
```
|
||||
|
||||
**Internal dependencies:**
|
||||
- Use exact version matching for internal packages (e.g., `"^0.5.8"`)
|
||||
- Agent depends on TUI: `"@mariozechner/pi-tui": "^0.5.8"`
|
||||
- Pods depends on Agent: `"@mariozechner/pi-agent": "^0.5.8"`
|
||||
|
||||
**External dependencies:**
|
||||
- Common dependencies like `chalk` are used across multiple packages
|
||||
- Specialized dependencies are package-specific (e.g., `marked` for tui, `openai` for agent)
|
||||
|
||||
### 5. TypeScript Configuration
|
||||
|
||||
**Dual TypeScript configuration approach:**
|
||||
|
||||
**`tsconfig.build.json` (for production builds):**
|
||||
```json
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
```
|
||||
|
||||
**Root `tsconfig.json` (for development and type checking):**
|
||||
- Contains path mappings for cross-package imports during development
|
||||
- Includes all source and test files
|
||||
- Uses `"noEmit": true` for type checking without building
|
||||
|
||||
### 6. Source Directory Structure
|
||||
|
||||
**Standard structure across all packages:**
|
||||
```
|
||||
src/
|
||||
├── index.ts # Main export file
|
||||
├── cli.ts # CLI entry point (if applicable)
|
||||
├── <core-files>.ts # Core functionality
|
||||
├── components/ # Components (for tui)
|
||||
├── tools/ # Tool implementations (for agent)
|
||||
├── commands/ # Command implementations (for pods)
|
||||
└── renderers/ # Output renderers (for agent)
|
||||
```
|
||||
|
||||
### 7. Export Patterns (index.ts)
|
||||
|
||||
**Comprehensive type and function exports:**
|
||||
- Export both types and implementation classes
|
||||
- Use `export type` for type-only exports
|
||||
- Group exports logically with comments
|
||||
- Example from tui: exports components, interfaces, and utilities
|
||||
- Example from agent: exports core classes, types, and utilities
|
||||
|
||||
### 8. Files Configuration
|
||||
|
||||
**Files included in NPM packages:**
|
||||
- `"files": ["dist"]` or `"files": ["dist/**/*", "README.md"]`
|
||||
- All packages include built `dist/` directory
|
||||
- Some include additional files like README.md or scripts
|
||||
|
||||
### 9. README.md Structure
|
||||
|
||||
**Comprehensive documentation pattern:**
|
||||
- Feature overview with key capabilities
|
||||
- Quick start section with code examples
|
||||
- Detailed API documentation
|
||||
- Installation instructions
|
||||
- Development setup
|
||||
- Testing information (especially for tui)
|
||||
- Examples and usage patterns
|
||||
|
||||
### 10. Testing Structure (TUI package)
|
||||
|
||||
**Dedicated test directory:**
|
||||
- `test/` directory with `.test.ts` files for unit tests
|
||||
- Example applications (e.g., `chat-app.ts`, `file-browser.ts`)
|
||||
- Custom testing infrastructure (e.g., `virtual-terminal.ts`)
|
||||
- Test script: `"test": "node --test --import tsx test/*.test.ts"`
|
||||
|
||||
### 11. Version Management
|
||||
|
||||
**Lockstep versioning:**
|
||||
- All packages share the same version number
|
||||
- Root package.json scripts handle version bumping across all packages
|
||||
- Version sync script ensures internal dependency versions match
|
||||
|
||||
### 12. Build Order
|
||||
|
||||
**Dependency-aware build order:**
|
||||
- Root build script builds packages in dependency order
|
||||
- `"build": "npm run build -w @mariozechner/pi-tui && npm run build -w @mariozechner/pi-agent && npm run build -w @mariozechner/pi"`
|
||||
|
||||
### 13. Common Configuration Files
|
||||
|
||||
**Shared across monorepo:**
|
||||
- `biome.json` - Unified linting and formatting configuration
|
||||
- `tsconfig.base.json` - Base TypeScript configuration
|
||||
- `.gitignore` - Ignores `dist/`, `node_modules/`, and other build artifacts
|
||||
- Husky pre-commit hooks for formatting and type checking
|
||||
|
||||
### 14. Keywords and Metadata
|
||||
|
||||
**Descriptive keywords for NPM discovery:**
|
||||
- Each package includes relevant keywords (e.g., "tui", "terminal", "agent", "ai", "llm")
|
||||
- Keywords help with package discoverability
|
||||
|
||||
This analysis shows a well-structured monorepo with consistent patterns that would make adding new packages straightforward by following these established conventions.
|
||||
|
||||
## Monorepo Configuration Analysis
|
||||
|
||||
Based on my analysis of the pi-mono monorepo configuration, here's a comprehensive guide on how to properly integrate a new package:
|
||||
|
||||
### 1. Root Package.json Configuration
|
||||
|
||||
**Workspace Configuration:**
|
||||
- Uses npm workspaces with `"workspaces": ["packages/*"]`
|
||||
- All packages are located under `/packages/` directory
|
||||
- Private monorepo (`"private": true`) with ESM modules (`"type": "module"`)
|
||||
|
||||
**Build System:**
|
||||
- **Sequential Build Order**: The build script explicitly defines dependency order:
|
||||
```json
|
||||
"build": "npm run build -w @mariozechner/pi-tui && npm run build -w @mariozechner/pi-agent && npm run build -w @mariozechner/pi"
|
||||
```
|
||||
- **Dependency Chain**: `pi-tui` → `pi-agent` → `pi` (pods)
|
||||
- **Important**: New packages must be inserted in the correct dependency order in the build script
|
||||
|
||||
**Scripts Available:**
|
||||
- `clean`: Cleans all package dist folders
|
||||
- `build`: Sequential build respecting dependencies
|
||||
- `check`: Runs Biome formatting, package checks, and TypeScript checking
|
||||
- `test`: Runs tests across all packages
|
||||
- Version management scripts (lockstep versioning)
|
||||
- Publishing scripts with dry-run capability
|
||||
|
||||
### 2. Root TypeScript Configuration
|
||||
|
||||
**Dual Configuration System:**
|
||||
- **`tsconfig.base.json`**: Base TypeScript settings for all packages
|
||||
- **`tsconfig.json`**: Development configuration with path mappings for cross-package imports
|
||||
- **Package `tsconfig.build.json`**: Clean build configs per package
|
||||
|
||||
**Path Mappings** (in `/Users/badlogic/workspaces/pi-mono/tsconfig.json`):
|
||||
```json
|
||||
"paths": {
|
||||
"@mariozechner/pi-tui": ["./packages/tui/src/index.ts"],
|
||||
"@mariozechner/pi-agent": ["./packages/agent/src/index.ts"],
|
||||
"@mariozechner/pi": ["./packages/pods/src/index.ts"]
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Package Dependencies and Structure
|
||||
|
||||
**Dependency Structure:**
|
||||
- `pi-tui` (base library) - no internal dependencies
|
||||
- `pi-agent` depends on `pi-tui`
|
||||
- `pi` (pods) depends on `pi-agent`
|
||||
|
||||
**Standard Package Structure:**
|
||||
```
|
||||
packages/new-package/
|
||||
├── src/
|
||||
│ ├── index.ts # Main export file
|
||||
│ └── ... # Implementation files
|
||||
├── package.json # Package configuration
|
||||
├── tsconfig.build.json # Build-specific TypeScript config
|
||||
├── README.md # Package documentation
|
||||
└── dist/ # Build output (gitignored)
|
||||
```
|
||||
|
||||
### 4. Version Management
|
||||
|
||||
**Lockstep Versioning:**
|
||||
- All packages share the same version number (currently 0.5.8)
|
||||
- Automated version sync script: `/Users/badlogic/workspaces/pi-mono/scripts/sync-versions.js`
|
||||
- Inter-package dependencies are automatically updated to match current versions
|
||||
|
||||
**Version Scripts:**
|
||||
- `npm run version:patch/minor/major` - Updates all package versions and syncs dependencies
|
||||
- Automatic dependency version synchronization
|
||||
|
||||
### 5. GitIgnore Patterns
|
||||
|
||||
**Package-Level Ignores:**
|
||||
```
|
||||
packages/*/node_modules/
|
||||
packages/*/dist/
|
||||
```
|
||||
Plus standard ignores for logs, IDE files, environment files, etc.
|
||||
|
||||
## How to Integrate a New Package
|
||||
|
||||
### Step 1: Create Package Structure
|
||||
```bash
|
||||
mkdir packages/your-new-package
|
||||
cd packages/your-new-package
|
||||
```
|
||||
|
||||
### Step 2: Create package.json
|
||||
```json
|
||||
{
|
||||
"name": "@mariozechner/your-new-package",
|
||||
"version": "0.5.8",
|
||||
"description": "Your package description",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"clean": "rm -rf dist",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"check": "biome check --write .",
|
||||
"prepublishOnly": "npm run clean && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
// Add dependencies on other packages in the monorepo if needed
|
||||
// "@mariozechner/pi-tui": "^0.5.8"
|
||||
},
|
||||
"devDependencies": {},
|
||||
"keywords": ["relevant", "keywords"],
|
||||
"author": "Mario Zechner",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/badlogic/pi-mono.git",
|
||||
"directory": "packages/your-new-package"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Create tsconfig.build.json
|
||||
```json
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Create src/index.ts
|
||||
```typescript
|
||||
// Main exports for your package
|
||||
export * from './your-main-module.js';
|
||||
```
|
||||
|
||||
### Step 5: Update Root Configuration
|
||||
|
||||
**Add to `/Users/badlogic/workspaces/pi-mono/tsconfig.json` paths:**
|
||||
```json
|
||||
"paths": {
|
||||
"@mariozechner/pi-tui": ["./packages/tui/src/index.ts"],
|
||||
"@mariozechner/pi-agent": ["./packages/agent/src/index.ts"],
|
||||
"@mariozechner/pi": ["./packages/pods/src/index.ts"],
|
||||
"@mariozechner/your-new-package": ["./packages/your-new-package/src/index.ts"]
|
||||
}
|
||||
```
|
||||
|
||||
**Update build script in root `/Users/badlogic/workspaces/pi-mono/package.json`:**
|
||||
```json
|
||||
"build": "npm run build -w @mariozechner/pi-tui && npm run build -w @mariozechner/pi-agent && npm run build -w @mariozechner/your-new-package && npm run build -w @mariozechner/pi"
|
||||
```
|
||||
(Insert in correct dependency order)
|
||||
|
||||
### Step 6: Update sync-versions.js
|
||||
If your package depends on other monorepo packages, add synchronization logic to `/Users/badlogic/workspaces/pi-mono/scripts/sync-versions.js`.
|
||||
|
||||
### Step 7: Install and Test
|
||||
```bash
|
||||
# From monorepo root
|
||||
npm install
|
||||
npm run build
|
||||
npm run check
|
||||
```
|
||||
|
||||
## Key Requirements for New Packages
|
||||
|
||||
1. **Must use ESM modules** (`"type": "module"`)
|
||||
2. **Must follow lockstep versioning** (same version as other packages)
|
||||
3. **Must be placed in correct build order** based on dependencies
|
||||
4. **Must use tab indentation** (Biome config: `"indentStyle": "tab"`)
|
||||
5. **Must avoid `any` types** unless absolutely necessary (project instruction)
|
||||
6. **Must include proper TypeScript declarations** (`"declaration": true`)
|
||||
7. **Must use Node.js >= 20.0.0** (engine requirement)
|
||||
8. **Must follow the standard package structure** with src/, dist/, proper exports
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **Development**: Use `tsx` to run source files directly (no build needed)
|
||||
2. **Type Checking**: `npm run check` works across all packages
|
||||
3. **Building**: Sequential builds respect dependency order
|
||||
4. **Publishing**: Automatic version sync and cross-package dependency updates
|
||||
5. **Testing**: Each package can have its own test suite
|
||||
|
||||
This monorepo is well-structured for maintaining multiple related packages with clean dependency management and automated version synchronization.
|
||||
|
||||
## Detailed Findings: Unified AI API Requirements Based on Current pi-agent Usage
|
||||
|
||||
After thoroughly analyzing the existing agent package (`/Users/badlogic/workspaces/pi-mono/packages/agent`), here are the comprehensive requirements for a unified AI API based on current usage patterns:
|
||||
|
||||
### **1. Core API Structure & Event System**
|
||||
|
||||
**Current Pattern:**
|
||||
- Event-driven architecture using `AgentEvent` types
|
||||
- Single `AgentEventReceiver` interface for all output handling
|
||||
- Support for both single-shot and interactive modes
|
||||
|
||||
**Required API Features:**
|
||||
```typescript
|
||||
type AgentEvent =
|
||||
| { type: "session_start"; sessionId: string; model: string; api: string; baseURL: string; systemPrompt: string }
|
||||
| { type: "assistant_start" }
|
||||
| { type: "reasoning"; text: string }
|
||||
| { type: "tool_call"; toolCallId: string; name: string; args: string }
|
||||
| { type: "tool_result"; toolCallId: string; result: string; isError: boolean }
|
||||
| { type: "assistant_message"; text: string }
|
||||
| { type: "error"; message: string }
|
||||
| { type: "user_message"; text: string }
|
||||
| { type: "interrupted" }
|
||||
| { type: "token_usage"; inputTokens: number; outputTokens: number; totalTokens: number; cacheReadTokens: number; cacheWriteTokens: number; reasoningTokens: number }
|
||||
```
|
||||
|
||||
### **2. OpenAI API Integration Patterns**
|
||||
|
||||
**Current Implementation:**
|
||||
- Uses OpenAI SDK v5.12.2 (`import OpenAI from "openai"`)
|
||||
- Supports both Chat Completions (`/v1/chat/completions`) and Responses API (`/v1/responses`)
|
||||
- Provider detection based on base URL patterns
|
||||
|
||||
**Provider Support Required:**
|
||||
```typescript
|
||||
// Detected providers based on baseURL patterns
|
||||
type Provider = "openai" | "gemini" | "groq" | "anthropic" | "openrouter" | "other"
|
||||
|
||||
// Provider-specific configurations
|
||||
interface ProviderConfig {
|
||||
openai: { reasoning_effort: "minimal" | "low" | "medium" | "high" }
|
||||
gemini: { extra_body: { google: { thinking_config: { thinking_budget: number, include_thoughts: boolean } } } }
|
||||
groq: { reasoning_format: "parsed", reasoning_effort: string }
|
||||
openrouter: { reasoning: { effort: "low" | "medium" | "high" } }
|
||||
}
|
||||
```
|
||||
|
||||
### **3. Streaming vs Non-Streaming**
|
||||
|
||||
**Current Status:**
|
||||
- **No streaming currently implemented** - uses standard request/response
|
||||
- All API calls are non-streaming: `await client.chat.completions.create()` and `await client.responses.create()`
|
||||
- Events are emitted synchronously after full response
|
||||
|
||||
**Streaming Requirements for Unified API:**
|
||||
- Support for streaming responses with partial content updates
|
||||
- Event-driven streaming with `assistant_message_delta` events
|
||||
- Proper handling of tool call streaming
|
||||
- Reasoning token streaming for supported models
|
||||
|
||||
### **4. Tool Calling Architecture**
|
||||
|
||||
**Current Implementation:**
|
||||
```typescript
|
||||
// Tool definitions for both APIs
|
||||
toolsForResponses: Array<{type: "function", name: string, description: string, parameters: object}>
|
||||
toolsForChat: ChatCompletionTool[]
|
||||
|
||||
// Tool execution with abort support
|
||||
async function executeTool(name: string, args: string, signal?: AbortSignal): Promise<string>
|
||||
|
||||
// Built-in tools: read, list, bash, glob, rg (ripgrep)
|
||||
```
|
||||
|
||||
**Unified API Requirements:**
|
||||
- Automatic tool format conversion between Chat Completions and Responses API
|
||||
- Built-in tools with filesystem and shell access
|
||||
- Custom tool registration capability
|
||||
- Tool execution with proper abort/interrupt handling
|
||||
- Tool result streaming for long-running operations
|
||||
|
||||
### **5. Message Structure Handling**
|
||||
|
||||
**Current Pattern:**
|
||||
- Dual message format support based on API type
|
||||
- Automatic conversion between formats in `setEvents()` method
|
||||
|
||||
**Chat Completions Format:**
|
||||
```typescript
|
||||
{ role: "system" | "user" | "assistant" | "tool", content: string, tool_calls?: any[] }
|
||||
```
|
||||
|
||||
**Responses API Format:**
|
||||
```typescript
|
||||
{ type: "message" | "function_call" | "function_call_output", content: any[] }
|
||||
```
|
||||
|
||||
### **6. Session Persistence System**
|
||||
|
||||
**Current Implementation:**
|
||||
```typescript
|
||||
interface SessionData {
|
||||
config: AgentConfig
|
||||
events: SessionEvent[]
|
||||
totalUsage: TokenUsage
|
||||
}
|
||||
|
||||
// File-based persistence in ~/.pi/sessions/
|
||||
// JSONL format with session headers and event entries
|
||||
// Automatic session continuation support
|
||||
```
|
||||
|
||||
**Requirements:**
|
||||
- Directory-based session organization
|
||||
- Event replay capability for session restoration
|
||||
- Cumulative token usage tracking
|
||||
- Session metadata (config, timestamps, working directory)
|
||||
|
||||
### **7. Token Counting & Usage Tracking**
|
||||
|
||||
**Current Implementation:**
|
||||
```typescript
|
||||
interface TokenUsage {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
totalTokens: number
|
||||
cacheReadTokens: number
|
||||
cacheWriteTokens: number
|
||||
reasoningTokens: number // For o1/o3 and reasoning models
|
||||
}
|
||||
```
|
||||
|
||||
**Provider-Specific Token Mapping:**
|
||||
- OpenAI: `prompt_tokens`, `completion_tokens`, `cached_tokens`, `reasoning_tokens`
|
||||
- Responses API: `input_tokens`, `output_tokens`, `cached_tokens`, `reasoning_tokens`
|
||||
- Cumulative tracking across conversations
|
||||
|
||||
### **8. Abort/Interrupt Handling**
|
||||
|
||||
**Current Pattern:**
|
||||
```typescript
|
||||
class Agent {
|
||||
private abortController: AbortController | null = null
|
||||
|
||||
async ask(message: string) {
|
||||
this.abortController = new AbortController()
|
||||
// Pass signal to all API calls and tool executions
|
||||
}
|
||||
|
||||
interrupt(): void {
|
||||
this.abortController?.abort()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Requirements:**
|
||||
- AbortController integration for all async operations
|
||||
- Graceful interruption of API calls, tool execution, and streaming
|
||||
- Proper cleanup and "interrupted" event emission
|
||||
- Signal propagation to nested operations
|
||||
|
||||
### **9. Reasoning/Thinking Support**
|
||||
|
||||
**Current Implementation:**
|
||||
```typescript
|
||||
// Provider-specific reasoning extraction
|
||||
function parseReasoningFromMessage(message: any, baseURL?: string): {
|
||||
cleanContent: string
|
||||
reasoningTexts: string[]
|
||||
}
|
||||
|
||||
// Automatic reasoning support detection
|
||||
async function checkReasoningSupport(client, model, api, baseURL, signal): Promise<boolean>
|
||||
```
|
||||
|
||||
**Provider Support:**
|
||||
- **OpenAI o1/o3**: Full thinking content via Responses API
|
||||
- **Groq GPT-OSS**: Reasoning via `reasoning_format: "parsed"`
|
||||
- **Gemini 2.5**: Thinking content via `<thought>` tags
|
||||
- **OpenRouter**: Model-dependent reasoning support
|
||||
|
||||
### **10. Error Handling Patterns**
|
||||
|
||||
**Current Approach:**
|
||||
- Try/catch blocks around all API calls
|
||||
- Error events emitted through event system
|
||||
- Specific error handling for reasoning model failures
|
||||
- Provider-specific error interpretation
|
||||
|
||||
### **11. Configuration Management**
|
||||
|
||||
**Current Structure:**
|
||||
```typescript
|
||||
interface AgentConfig {
|
||||
apiKey: string
|
||||
baseURL: string
|
||||
model: string
|
||||
api: "completions" | "responses"
|
||||
systemPrompt: string
|
||||
}
|
||||
```
|
||||
|
||||
**Provider Detection:**
|
||||
```typescript
|
||||
function detectProvider(baseURL?: string): Provider {
|
||||
// URL pattern matching for automatic provider configuration
|
||||
}
|
||||
```
|
||||
|
||||
### **12. Output Rendering System**
|
||||
|
||||
**Current Renderers:**
|
||||
- **ConsoleRenderer**: Terminal output with animations, token display
|
||||
- **TuiRenderer**: Full interactive TUI with pi-tui integration
|
||||
- **JsonRenderer**: JSONL event stream output
|
||||
|
||||
**Requirements:**
|
||||
- Event-based rendering architecture
|
||||
- Real-time token usage display
|
||||
- Loading animations for async operations
|
||||
- Markdown rendering support
|
||||
- Tool execution progress indication
|
||||
|
||||
### **Summary: Key Unified API Requirements**
|
||||
|
||||
1. **Event-driven architecture** with standardized event types
|
||||
2. **Dual API support** (Chat Completions + Responses API) with automatic format conversion
|
||||
3. **Provider abstraction** with automatic detection and configuration
|
||||
4. **Comprehensive tool system** with abort support and built-in tools
|
||||
5. **Session persistence** with event replay and token tracking
|
||||
6. **Reasoning/thinking support** across multiple providers
|
||||
7. **Interrupt handling** with AbortController integration
|
||||
8. **Token usage tracking** with provider-specific mapping
|
||||
9. **Flexible rendering** through event receiver pattern
|
||||
10. **Configuration management** with provider-specific settings
|
||||
|
||||
The unified API should maintain this event-driven, provider-agnostic approach while adding streaming capabilities and enhanced tool execution features that the current implementation lacks.
|
||||
46
todos/done/20250817-183528-ai-unified-api-package.md
Normal file
46
todos/done/20250817-183528-ai-unified-api-package.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Create AI Package with Unified API
|
||||
|
||||
**Status:** Done
|
||||
**Agent PID:** 10965
|
||||
|
||||
## Original Todo
|
||||
ai: create a new package ai (package name @mariozechner/ai) which implements a common api for the openai, anthropic, and google gemini apis
|
||||
- look at the other packages and how they are set up, mirror that setup for ai
|
||||
- install the latest version of each dependency via npm in the ai package
|
||||
- openai@5.12.2
|
||||
- @anthropic-ai/sdk@0.60.0
|
||||
- @google/genai@1.14.0
|
||||
- investigate the APIs in their respective node_modules folder so you understand how to use them. specifically, we need to understand how to:
|
||||
- stream responses, including reasoning/thinking tokens and tool calls
|
||||
- abort requests
|
||||
- handle errors
|
||||
- handle stop reasons
|
||||
- maintain the context (message history) such that it can be serialized in a uniform format to disk, and deserialized again later and used with the other api
|
||||
- count tokens (input, output, cached read, cached write)
|
||||
- enable caching
|
||||
- Create a plan.md in the ai package that details how the unified API on top of all three could look like. we want the most minimal api possible, which allows serialization/deserialization, turning on/off reasoning/thinking, and handle system prompt and tool specifications
|
||||
|
||||
## Description
|
||||
Create the initial package scaffold for @mariozechner/ai following the established monorepo patterns, install the required dependencies (openai, anthropic, google genai SDKs), and create a plan.md file that details the unified API design for all three providers.
|
||||
|
||||
*Read [analysis.md](./analysis.md) in full for detailed codebase research and context*
|
||||
|
||||
## Implementation Plan
|
||||
- [x] Create package directory structure at packages/ai/
|
||||
- [x] Create package.json with proper configuration following monorepo patterns
|
||||
- [x] Create tsconfig.build.json for build configuration
|
||||
- [x] Create initial src/index.ts file
|
||||
- [x] Add package to root tsconfig.json path mappings
|
||||
- [x] Update root package.json build script to include ai package
|
||||
- [x] Install dependencies: openai@5.12.2, @anthropic-ai/sdk@0.60.0, @google/genai@1.14.0
|
||||
- [x] Create README.md with package description
|
||||
- [x] Create plan.md detailing the unified API design
|
||||
- [x] Investigate OpenAI, Anthropic, and Gemini APIs in detail
|
||||
- [x] Document implementation details for each API
|
||||
- [x] Update todos/project-description.md with "How to Create a New Package" section
|
||||
- [x] Update todos/project-description.md Testing section to reflect that tui has Node.js built-in tests
|
||||
- [x] Run npm install from root to link everything
|
||||
- [x] Verify package builds correctly with npm run build
|
||||
|
||||
## Notes
|
||||
[Implementation notes]
|
||||
|
|
@ -39,4 +39,98 @@ A comprehensive toolkit for managing Large Language Model (LLM) deployments and
|
|||
- Publish: `npm run publish`
|
||||
|
||||
## Testing
|
||||
Currently no formal testing framework is configured. Test infrastructure exists but no actual test files or framework dependencies are present.
|
||||
The TUI package includes comprehensive tests using Node.js built-in test framework:
|
||||
- Unit tests in `packages/tui/test/*.test.ts`
|
||||
- Test runner: `node --test --import tsx test/*.test.ts`
|
||||
- Virtual terminal for TUI testing via `@xterm/headless`
|
||||
- Example applications for manual testing
|
||||
|
||||
## How to Create a New Package
|
||||
|
||||
Follow these steps to add a new package to the monorepo:
|
||||
|
||||
1. **Create package directory structure:**
|
||||
```bash
|
||||
mkdir -p packages/your-package/src
|
||||
```
|
||||
|
||||
2. **Create package.json:**
|
||||
```json
|
||||
{
|
||||
"name": "@mariozechner/your-package",
|
||||
"version": "0.5.8",
|
||||
"description": "Package description",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": ["dist", "README.md"],
|
||||
"scripts": {
|
||||
"clean": "rm -rf dist",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"check": "biome check --write .",
|
||||
"prepublishOnly": "npm run clean && npm run build"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {},
|
||||
"keywords": ["relevant", "keywords"],
|
||||
"author": "Mario Zechner",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/badlogic/pi-mono.git",
|
||||
"directory": "packages/your-package"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Create tsconfig.build.json:**
|
||||
```json
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
```
|
||||
|
||||
4. **Create src/index.ts:**
|
||||
```typescript
|
||||
// Main exports for your package
|
||||
export const version = "0.5.8";
|
||||
```
|
||||
|
||||
5. **Update root tsconfig.json paths:**
|
||||
Add your package to the `paths` mapping in the correct dependency order:
|
||||
```json
|
||||
"paths": {
|
||||
"@mariozechner/pi-tui": ["./packages/tui/src/index.ts"],
|
||||
"@mariozechner/your-package": ["./packages/your-package/src/index.ts"],
|
||||
// ... other packages
|
||||
}
|
||||
```
|
||||
|
||||
6. **Update root package.json build script:**
|
||||
Insert your package in the correct dependency order:
|
||||
```json
|
||||
"build": "npm run build -w @mariozechner/pi-tui && npm run build -w @mariozechner/your-package && ..."
|
||||
```
|
||||
|
||||
7. **Install and verify:**
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
npm run check
|
||||
```
|
||||
|
||||
**Important Notes:**
|
||||
- All packages use lockstep versioning (same version number)
|
||||
- Follow dependency order: foundational packages build first
|
||||
- Use ESM modules (`"type": "module"`)
|
||||
- No `any` types unless absolutely necessary
|
||||
- Include README.md with package documentation
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
"noEmit": true,
|
||||
"paths": {
|
||||
"@mariozechner/pi-tui": ["./packages/tui/src/index.ts"],
|
||||
"@mariozechner/pi-ai": ["./packages/ai/src/index.ts"],
|
||||
"@mariozechner/pi-agent": ["./packages/agent/src/index.ts"],
|
||||
"@mariozechner/pi": ["./packages/pods/src/index.ts"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue