commit 4e854ba8c2b2294906e4a7d4412fc494459f567c Author: Benjamin Hackl Date: Tue Nov 3 20:01:28 2026 +0100 UniGPT provider for pi Native /login with a verified API key, automatic model discovery from UniGPT's /models endpoint, per-deployment deployment-id headers, and model metadata from pi's bundled catalog. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..86dfb1b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node: [22, 24] + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 11 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run ci diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ded2c40 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.env +.env.* +!.env.example +*.tsbuildinfo +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4d936ae --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Benjamin Hackl + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8abbd9d --- /dev/null +++ b/README.md @@ -0,0 +1,126 @@ +# pi-unigpt + +Use [UniGPT](https://unigpt.uni-graz.at), the University of Graz's MyGPT +platform, as a model provider in [pi](https://pi.dev). + +- Log in with **`/login unigpt`**; your key is checked before pi saves it. +- Models are discovered automatically. No model list to maintain. +- Context size, output limit, and image support come from pi's own model + catalog, so they stay current as pi updates. +- Tool calling, streaming, and image input work through pi's built-in + OpenAI Responses support. + +Requires pi **1.5.x** and Node.js **22.19+**. + +## Install + +```sh +pi install git:github.com/behackl/pi-unigpt +``` + +Then start pi (or run `/reload` in a running session) and log in: + +```text +/login unigpt +``` + +Paste your UniGPT API key into the masked dialog, **not into the chat**. Pi +confirms how many models your key can use. Pick one with `/model`; UniGPT +models are listed under `unigpt`. + +To try the extension without installing it: + +```sh +pi -e git:github.com/behackl/pi-unigpt +``` + +## Commands + +| Command | What it does | +| --- | --- | +| `/login unigpt` | Save and verify your API key | +| `/logout` | Remove the saved key from pi (it stays valid at UniGPT) | +| `/model` | Pick a model; also reloads the model list | +| `/unigpt-refresh` | Reload the UniGPT model list now and show how many models you have | + +## How models are described + +UniGPT's API only lists deployment IDs such as `gpt-5.4`. Those IDs are +OpenAI model names, so the extension looks each one up in pi's bundled model +catalog and uses its name, context window, output limit, and image support. +Where OpenAI and Azure list different limits, the smaller one is used. + +IDs that pi does not know get pi's standard defaults for custom models: +128k context, 16k output, text only. Update pi to pick up newer models. + +Two things are deliberately not taken from the catalog: + +- **Thinking levels are off for every model.** UniGPT ignores the requested + reasoning effort and uses a fixed one per deployment, so pi's thinking + levels would have no effect. Reasoning models still reason. +- **Costs are shown as zero.** That means "not tracked", not "free". + +You can override any value in `~/.pi/agent/models.json`: + +```json +{ + "providers": { + "unigpt": { + "modelOverrides": { + "gpt-5.4": { "contextWindow": 200000 } + } + } + } +} +``` + +## Your API key + +Pi stores the key under `unigpt` in `~/.pi/agent/auth.json`. That file is +private to your user account but **not encrypted**. To keep the key out of +it, replace the stored value with an environment variable or a password +manager command; pi resolves either when it needs the key: + +```json +{ + "unigpt": { "type": "api_key", "key": "${UNIGPT_API_KEY}" } +} +``` + +```json +{ + "unigpt": { "type": "api_key", "key": "!op read op://Private/UniGPT/credential" } +} +``` + +## Upgrading from a models.json setup + +If you configured UniGPT by hand before, delete the `unigpt` entry under +`providers` in `~/.pi/agent/models.json` (overrides as shown above are +fine). Otherwise your old model list and key take precedence over the +extension. Then run `/reload` and `/login unigpt`. + +## Troubleshooting + +- **No UniGPT models in `/model`:** run `/unigpt-refresh`; it reports the + exact problem (rejected key, unreachable server, empty model list). +- **`pi -p` says a UniGPT model was not found:** print mode only reads the + saved model list and never fetches it. Start pi interactively once after + logging in; later print runs reuse that list. +- **A specific model fails with HTTP 500:** some deployments are + misconfigured on the server side; try another model and tell the UniGPT + team. + +## Development + +```sh +pnpm install +pnpm run ci # type check + tests +``` + +Tests use fake keys and a local HTTP server; they never contact UniGPT or +read your pi configuration. + +## License + +MIT diff --git a/package.json b/package.json new file mode 100644 index 0000000..ccaf491 --- /dev/null +++ b/package.json @@ -0,0 +1,53 @@ +{ + "name": "pi-unigpt", + "version": "0.1.0", + "description": "UniGPT (University of Graz) provider for pi: /login with an API key, automatic model discovery.", + "license": "MIT", + "author": "Benjamin Hackl", + "repository": { + "type": "git", + "url": "git+https://github.com/behackl/pi-unigpt.git" + }, + "homepage": "https://github.com/behackl/pi-unigpt#readme", + "bugs": { + "url": "https://github.com/behackl/pi-unigpt/issues" + }, + "type": "module", + "keywords": [ + "pi-package", + "pi", + "pi-extension", + "unigpt", + "mygpt", + "uni-graz" + ], + "files": [ + "src", + "README.md", + "LICENSE" + ], + "engines": { + "node": ">=22.19.0" + }, + "pi": { + "extensions": [ + "./src/index.ts" + ] + }, + "scripts": { + "check": "tsc --noEmit", + "test": "node --import tsx --test test/*.test.ts", + "ci": "pnpm run check && pnpm test" + }, + "peerDependencies": { + "@earendil-works/pi-ai": ">=1.5.0 <2", + "@earendil-works/pi-coding-agent": ">=1.5.0 <2" + }, + "devDependencies": { + "@earendil-works/pi-ai": "1.5.0", + "@earendil-works/pi-coding-agent": "1.5.0", + "@types/node": "^22.19.0", + "tsx": "^4.20.0", + "typescript": "^5.9.0" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..d88355d --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1445 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@earendil-works/pi-ai': + specifier: 1.5.0 + version: 1.5.0(@aws-sdk/credential-provider-node@3.972.92)(@smithy/signature-v4@5.7.5)(undici@8.12.3)(ws@8.22.1) + '@earendil-works/pi-coding-agent': + specifier: 1.5.0 + version: 1.5.0(@aws-sdk/credential-provider-node@3.972.92)(@smithy/signature-v4@5.7.5)(ws@8.22.1) + '@types/node': + specifier: ^22.19.0 + version: 22.20.5 + tsx: + specifier: ^4.20.0 + version: 4.23.15 + typescript: + specifier: ^5.9.0 + version: 5.9.3 + +packages: + + '@anthropic-ai/sdk@0.129.0': + resolution: {integrity: sha512-MH7LB20kNpGLUpPTe2OG5XvL/KhX8HUO9XyBaFjgFk6TLS4Xjt0VPIuMKywe7POKKPslX+QxSlVok9e+8kG5Nw==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + '@aws-sdk/client-bedrock-runtime@3.1127.0': + resolution: {integrity: sha512-IDl/lrPb90aH+pZFHGNDmgH9nAUQj5PlZH1sJ3w7RikctyjHSnY3oNjZhrLoaBoQn/rNK0zsP6OHEqEhj2tdLA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.979.1': + resolution: {integrity: sha512-ayOlV1VhLNMiq8PW9ZkVobG4gkldnUoHjG69yy2b8j+VYFlhz/uAL7ZdYQAoQ+eShtGUdNDnnG5rxDg6yUJrOg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.80': + resolution: {integrity: sha512-uO5TiSvb6j1w9RTe5z28OaOmsrOxVQUPaC5+wZl50RfOXcrYD/KgnS8I9lfyGXc7sGbkCopK6VnhNIAoLqfewQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.82': + resolution: {integrity: sha512-vQzlDHlC24Yz42p+rT4NBoP/5VdetkBoujbOLq9h6QYWC8THuZSPYnXIR9OP3XcOrdMgPBBipOrdssfxnIiWUg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.973.25': + resolution: {integrity: sha512-b5xl71uCRdWsu7P1VeGpiGuCf6zfYETsSNOv8AmYVTrsgbvTJRXdmmOPVfxkvGGVNsiKkGctClQW3jAn0SZLug==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.87': + resolution: {integrity: sha512-C4ZFmcwM+Zj14Z8huVUk54Gv4uzT1ZdzG9cY3Gdh8FePakq7Bss1OFsgZjAhWFOkFOGpBvFBeu0OWjWf8HNKRQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.92': + resolution: {integrity: sha512-jC3zmOo7Qxlk+oBivvSsS1Ph7sFGm0QfuhqDGbqgRXi4cxUYMG+dWtBwS2XYYXgc8TIVXJiwgQ65sbNpbYupTA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.80': + resolution: {integrity: sha512-xqfixO5aclje7tbQJDias+iNND5wp/3QZixCdZcVQpgWPTZ0V5DfTW9A804A5Z7ezu7sH3WRO+vzUjtiJBA8yg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.973.24': + resolution: {integrity: sha512-nlg3BKG03Cgzvv/dMnhxvDJPhAhQSpaN0RUdwgU7ziwkasoev7iI8el8Xy4Rd5sqzgaDUbV269fOXck5SargqA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.86': + resolution: {integrity: sha512-zKTyLJBEyfQBVlYqT6Jt+mYV4r8kGbWM7dn6v4Y4MRARizhYafjDXP/iMu76UMRhToSPNmIKX1XAb24gtpBFIA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/eventstream-handler-node@3.972.40': + resolution: {integrity: sha512-LR2mRKFb9EMlGHmJNXMtuMPvVQqN1b9/v68A1UQaE72pb8pHqiHxTtS++vaab8844YesGVzBVrloAis7+EXq/w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-eventstream@3.972.36': + resolution: {integrity: sha512-OkV+alDzmJ3DOs2mRPLSM9FUVSul7G1wkpKrbO+tyxxGOT7qLMPZHOlj4Ipjw9OayRmU7nMVPSFo7hvUqOSeJg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-websocket@3.972.62': + resolution: {integrity: sha512-pNkKv8TlxEBpL26J8zpeHLzW//As72QapCPfciTs7k7H+ppFZxE/zVteAH9zdFI83Xx7Ki5rE0hJjVh+TmOH7g==} + engines: {node: '>= 14.0.0'} + + '@aws-sdk/nested-clients@3.997.54': + resolution: {integrity: sha512-/OczlavsS7U5sOodRO10Ug4R/AKFVZiXTstKipWe68EfjPkmdDFNXNl9FCJyXWkg1DG6RBm3qAuVB8FNW9O8KQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.51': + resolution: {integrity: sha512-+aHvUugVBuMPXxgkPfWnTMyGdE/yWS8GHi5+qQ37HowS0YqzrbuaDLznj+Dtmtu5jgCdqz36gXmujOToUhNNWw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1127.0': + resolution: {integrity: sha512-Dv2TMWBshJ+tF6ahs2Sy5bh4Iabsd4GAQqVvE9XZmYmnoaVbpS2QKIKE/HRacc7bTtbjEEvP+laGzHvHlf1CiQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1161.0': + resolution: {integrity: sha512-ypbXSofNcSHwf/KA40bFCXJb0MLaAKrfT7sot4dFaA+2lsuXNah4zLRJIAU7Qmvs9PFJ6rnY0bZFEC+mE7attA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.975.0': + resolution: {integrity: sha512-WQlhs6juByzWOzj5fwxOx0mPJaEqMcG/sDpl3Id61B3XhKE63xY+0QWcnG+sWR/sCfprmreyUo1lM7IKZmHMzw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.42': + resolution: {integrity: sha512-smX8/gNP8Kjq+CWTBMZQLQ912vOQjiWExzwzvMO+eujPB5RY8IbtF0AGdUUXHKN7KoDWK9dIcuW0WG269kiP6w==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + + '@babel/runtime@7.29.10': + resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==} + engines: {node: '>=6.9.0'} + + '@earendil-works/pi-agent-core@1.5.0': + resolution: {integrity: sha512-FOj+jXj48DbzMycR/ghNa2KcsqWmvOln24e0FnQOVgnd8vn5H79g335WdHaZUVdRo+zCeogb40/qPPlCWFuvmA==} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-ai@1.5.0': + resolution: {integrity: sha512-VeWxNqTHSqRCf8AcY/6kvNwh+neRL6MPxZPePVztIQy6qqK9MR/YoCSN/ZgDnIyCD3pqEXgRyQbzk4KV4uvjbg==} + engines: {node: '>=22.19.0'} + hasBin: true + + '@earendil-works/pi-codemode@1.5.0': + resolution: {integrity: sha512-PLBJZ17AEWGzOlAAcFuOkw3PxkOti0JlFuZQyeF2GRTqbpuYo4k9t8YqUV2uOQVzrUnKdxahwaFUqc1eNS1WAg==} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-coding-agent@1.5.0': + resolution: {integrity: sha512-xNDZ5kqowO6Z03FS2gwAkPEGk1ssxVcYKQ6qZId84mFEvwACvvyc2l0vAMOS+c5zvxLRMLBE9r4y44IGYkMJaQ==} + engines: {node: '>=22.19.0'} + hasBin: true + + '@earendil-works/pi-mcp@1.5.0': + resolution: {integrity: sha512-DURw3IwHCTMHV8CzUJWVp9css27NowsCWfXt4xg2r09IZ+P1O/Uq58hp0hb9WJO0dbkaKGXKBIkV4LVXcu2nlg==} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-schema@1.5.0': + resolution: {integrity: sha512-RW0MbT2tR65OY6xMsgB/yVBb7m/RTNwA+9hurSN1vVXfHZRVmVnXbruBPrx/PzbrQnW5yLVlMRWxErGDQtF1Hg==} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-telemetry@1.5.0': + resolution: {integrity: sha512-VW3tcVdnNfl/Vk8wHPTzZdKkxcsCOM12ppiO4DXnd9wMxUCmOfLt/wvUnWeKWbTVlVSRg0uqKtnnhE2UCsG2Bw==} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-tui@1.5.0': + resolution: {integrity: sha512-56KqrDVeLF18k+syppVbT35Ai1opbM44ot+sL7O3K40CaWwXCiwOpqEFugDBAILtAhseZh6uYIuYxPPOJHLFAA==} + engines: {node: '>=22.19.0'} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@google/genai@2.21.0': + resolution: {integrity: sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + + '@isaacs/nonrecursive-glob-match@1.0.3': + resolution: {integrity: sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA==} + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.2': + resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==} + + '@silvia-odwyer/photon-node@0.3.4': + resolution: {integrity: sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==} + + '@smithy/core@3.38.1': + resolution: {integrity: sha512-U/mB2XiwTLaf+NSgLSWwJfptsMT/H8aBi0aTAA/E5e2/TagFeGCqAyhKGG0w0A9IWTkm4qYEmLoD/HB6aYfnRA==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.5.2': + resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.8.0': + resolution: {integrity: sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.12.1': + resolution: {integrity: sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.14.1': + resolution: {integrity: sha512-/T6E1jzCL0fErS9uq8JqES/5yYQQ4ppVmmYHwXw2nlov8KFzLb+3QqHR5iAun1BsuAB50IPxZliV8YMgghLwhA==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.7.5': + resolution: {integrity: sha512-WPjdr5oWOk1LArpI4pavu+fSo+/evi0Re1olxGvqwuZopd4gYR1UGCi+Soyn/IjmafqbWs+ZC+3AKxJS3ZpwUg==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.19.0': + resolution: {integrity: sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==} + engines: {node: '>=18.0.0'} + + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + + '@types/node@22.20.5': + resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==} + + '@types/retry@0.12.0': + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + agent-base@9.0.0: + resolution: {integrity: sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==} + engines: {node: '>= 20'} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + + bowser@2.15.0: + resolution: {integrity: sha512-xic6KSZglg/I4ymp45fdM4GWfYpBK79Fxd/TOvJktlaPgQqqDxvmGwOPZl2V0dyeS3Lj5c180bLhXF43iyAjZQ==} + + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + chalk@6.0.0: + resolution: {integrity: sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg==} + engines: {node: '>=22'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gaxios@7.3.1: + resolution: {integrity: sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + google-auth-library@10.9.1: + resolution: {integrity: sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==} + engines: {node: '>=18'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + grok-mermaid@0.2.3: + resolution: {integrity: sha512-/4KopAbsjvuRP9MdPtlDjOHUmUVEohOX73JNcsWpzAtFxh+bq5+Dhb6gzvRieLDwIPQIR3/vy8V1NNTuz4Zsmg==} + engines: {node: '>=18'} + + highlight.js@10.7.3: + resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + + hosted-git-info@9.0.3: + resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==} + engines: {node: ^20.17.0 || >=22.9.0} + + http-proxy-agent@9.1.0: + resolution: {integrity: sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==} + engines: {node: '>= 20'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + https-proxy-agent@9.1.0: + resolution: {integrity: sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==} + engines: {node: '>= 20'} + + ignore@7.0.8: + resolution: {integrity: sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==} + engines: {node: '>= 4'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + + marked-cjk-friendly@0.1.2: + resolution: {integrity: sha512-GUj8HjiEbur9yhgUo52FBgKizggF1QtT6gVdrYdWW6ZB4lVVjgS3EyyXPyfyRD0V4ghGRPlO7MO80rgSWbm0eA==} + engines: {node: '>=18'} + peerDependencies: + marked: '>=15.0.0' + + marked@18.0.11: + resolution: {integrity: sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==} + engines: {node: '>= 20'} + hasBin: true + + minimatch@11.0.3: + resolution: {integrity: sha512-iny/40PEGkNJQ5/Pv6z3nm6FbRAby32GeZRjCUJk2WnNDbgPSTcdI+DDvLqsgBdD4cD5sMtMheqPPnehCCa8Nw==} + engines: {node: 18 || 20 || >=22} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + openai@7.19.0: + resolution: {integrity: sha512-MX2s3u2L5racTO0CC/SWpCOasJQBCJrqLKXK+l82cAhdeF8mPMBEe/gxMm0ZFa2xpKpOFLRjxv5afYEZbBXmbQ==} + engines: {node: '>=22.0.0'} + peerDependencies: + '@aws-sdk/credential-provider-node': '>=3.972.0 <4' + '@smithy/hash-node': '>=4.3.0 <5' + '@smithy/signature-v4': '>=5.4.0 <6' + undici: '>=5 <9' + ws: ^8.21.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@aws-sdk/credential-provider-node': + optional: true + '@smithy/hash-node': + optional: true + '@smithy/signature-v4': + optional: true + undici: + optional: true + ws: + optional: true + zod: + optional: true + + p-retry@4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} + engines: {node: '>=8'} + + partial-json@0.1.7: + resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + protobufjs@7.6.6: + resolution: {integrity: sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==} + engines: {node: '>=12.0.0'} + + proxy-agent-negotiate@1.1.0: + resolution: {integrity: sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==} + engines: {node: '>= 20'} + peerDependencies: + kerberos: ^2.0.0 + peerDependenciesMeta: + kerberos: + optional: true + + quickjs-wasi@3.6.2: + resolution: {integrity: sha512-FCqGtGOrMgzUiIrMNMA2YnsOxCNwo31dzqXvclXUC6xeT35NJLKXQJsvbeCTjvoFAwZgEAPg8U6+KAPDGXn8Mg==} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + standardwebhooks@1.1.1: + resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==} + + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + + tslib@2.9.1: + resolution: {integrity: sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==} + + tsx@4.23.15: + resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} + engines: {node: '>=18.0.0'} + hasBin: true + + typebox@1.3.27: + resolution: {integrity: sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici@8.12.3: + resolution: {integrity: sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==} + engines: {node: '>=22.19.0'} + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + ws@8.22.1: + resolution: {integrity: sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==} + 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 + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + +snapshots: + + '@anthropic-ai/sdk@0.129.0': + dependencies: + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.1.1 + + '@aws-sdk/client-bedrock-runtime@3.1127.0': + dependencies: + '@aws-sdk/core': 3.979.1 + '@aws-sdk/credential-provider-node': 3.972.92 + '@aws-sdk/eventstream-handler-node': 3.972.40 + '@aws-sdk/middleware-eventstream': 3.972.36 + '@aws-sdk/middleware-websocket': 3.972.62 + '@aws-sdk/token-providers': 3.1127.0 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.38.1 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 + tslib: 2.9.1 + + '@aws-sdk/core@3.979.1': + dependencies: + '@aws-sdk/types': 3.975.0 + '@aws-sdk/xml-builder': 3.972.42 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.38.1 + '@smithy/signature-v4': 5.7.5 + '@smithy/types': 4.19.0 + bowser: 2.15.0 + tslib: 2.9.1 + + '@aws-sdk/credential-provider-env@3.972.80': + dependencies: + '@aws-sdk/core': 3.979.1 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.38.1 + '@smithy/types': 4.19.0 + tslib: 2.9.1 + + '@aws-sdk/credential-provider-http@3.972.82': + dependencies: + '@aws-sdk/core': 3.979.1 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.38.1 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.14.1 + '@smithy/types': 4.19.0 + tslib: 2.9.1 + + '@aws-sdk/credential-provider-ini@3.973.25': + dependencies: + '@aws-sdk/core': 3.979.1 + '@aws-sdk/credential-provider-env': 3.972.80 + '@aws-sdk/credential-provider-http': 3.972.82 + '@aws-sdk/credential-provider-login': 3.972.87 + '@aws-sdk/credential-provider-process': 3.972.80 + '@aws-sdk/credential-provider-sso': 3.973.24 + '@aws-sdk/credential-provider-web-identity': 3.972.86 + '@aws-sdk/nested-clients': 3.997.54 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.38.1 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.19.0 + tslib: 2.9.1 + + '@aws-sdk/credential-provider-login@3.972.87': + dependencies: + '@aws-sdk/core': 3.979.1 + '@aws-sdk/nested-clients': 3.997.54 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.38.1 + '@smithy/types': 4.19.0 + tslib: 2.9.1 + + '@aws-sdk/credential-provider-node@3.972.92': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.80 + '@aws-sdk/credential-provider-http': 3.972.82 + '@aws-sdk/credential-provider-ini': 3.973.25 + '@aws-sdk/credential-provider-process': 3.972.80 + '@aws-sdk/credential-provider-sso': 3.973.24 + '@aws-sdk/credential-provider-web-identity': 3.972.86 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.38.1 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.19.0 + tslib: 2.9.1 + + '@aws-sdk/credential-provider-process@3.972.80': + dependencies: + '@aws-sdk/core': 3.979.1 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.38.1 + '@smithy/types': 4.19.0 + tslib: 2.9.1 + + '@aws-sdk/credential-provider-sso@3.973.24': + dependencies: + '@aws-sdk/core': 3.979.1 + '@aws-sdk/nested-clients': 3.997.54 + '@aws-sdk/token-providers': 3.1161.0 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.38.1 + '@smithy/types': 4.19.0 + tslib: 2.9.1 + + '@aws-sdk/credential-provider-web-identity@3.972.86': + dependencies: + '@aws-sdk/core': 3.979.1 + '@aws-sdk/nested-clients': 3.997.54 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.38.1 + '@smithy/types': 4.19.0 + tslib: 2.9.1 + + '@aws-sdk/eventstream-handler-node@3.972.40': + dependencies: + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.38.1 + '@smithy/types': 4.19.0 + tslib: 2.9.1 + + '@aws-sdk/middleware-eventstream@3.972.36': + dependencies: + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.38.1 + '@smithy/types': 4.19.0 + tslib: 2.9.1 + + '@aws-sdk/middleware-websocket@3.972.62': + dependencies: + '@aws-sdk/core': 3.979.1 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.38.1 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/signature-v4': 5.7.5 + '@smithy/types': 4.19.0 + tslib: 2.9.1 + + '@aws-sdk/nested-clients@3.997.54': + dependencies: + '@aws-sdk/core': 3.979.1 + '@aws-sdk/signature-v4-multi-region': 3.996.51 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.38.1 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.14.1 + '@smithy/types': 4.19.0 + tslib: 2.9.1 + + '@aws-sdk/signature-v4-multi-region@3.996.51': + dependencies: + '@aws-sdk/types': 3.975.0 + '@smithy/signature-v4': 5.7.5 + '@smithy/types': 4.19.0 + tslib: 2.9.1 + + '@aws-sdk/token-providers@3.1127.0': + dependencies: + '@aws-sdk/core': 3.979.1 + '@aws-sdk/nested-clients': 3.997.54 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.38.1 + '@smithy/types': 4.19.0 + tslib: 2.9.1 + + '@aws-sdk/token-providers@3.1161.0': + dependencies: + '@aws-sdk/core': 3.979.1 + '@aws-sdk/nested-clients': 3.997.54 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.38.1 + '@smithy/types': 4.19.0 + tslib: 2.9.1 + + '@aws-sdk/types@3.975.0': + dependencies: + '@smithy/types': 4.19.0 + tslib: 2.9.1 + + '@aws-sdk/xml-builder@3.972.42': + dependencies: + '@smithy/types': 4.19.0 + tslib: 2.9.1 + + '@aws/lambda-invoke-store@0.3.0': {} + + '@babel/runtime@7.29.10': {} + + '@earendil-works/pi-agent-core@1.5.0(@aws-sdk/credential-provider-node@3.972.92)(@smithy/signature-v4@5.7.5)(undici@8.12.3)(ws@8.22.1)': + dependencies: + '@earendil-works/pi-ai': 1.5.0(@aws-sdk/credential-provider-node@3.972.92)(@smithy/signature-v4@5.7.5)(undici@8.12.3)(ws@8.22.1) + '@earendil-works/pi-schema': 1.5.0 + transitivePeerDependencies: + - '@aws-sdk/credential-provider-node' + - '@modelcontextprotocol/sdk' + - '@smithy/hash-node' + - '@smithy/signature-v4' + - bufferutil + - kerberos + - supports-color + - undici + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-ai@1.5.0(@aws-sdk/credential-provider-node@3.972.92)(@smithy/signature-v4@5.7.5)(undici@8.12.3)(ws@8.22.1)': + dependencies: + '@anthropic-ai/sdk': 0.129.0 + '@aws-sdk/client-bedrock-runtime': 3.1127.0 + '@earendil-works/pi-schema': 1.5.0 + '@earendil-works/pi-telemetry': 1.5.0 + '@google/genai': 2.21.0 + '@smithy/node-http-handler': 4.12.1 + http-proxy-agent: 9.1.0 + https-proxy-agent: 9.1.0 + openai: 7.19.0(@aws-sdk/credential-provider-node@3.972.92)(@smithy/signature-v4@5.7.5)(undici@8.12.3)(ws@8.22.1) + partial-json: 0.1.7 + transitivePeerDependencies: + - '@aws-sdk/credential-provider-node' + - '@modelcontextprotocol/sdk' + - '@smithy/hash-node' + - '@smithy/signature-v4' + - bufferutil + - kerberos + - supports-color + - undici + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-codemode@1.5.0': + dependencies: + quickjs-wasi: 3.6.2 + + '@earendil-works/pi-coding-agent@1.5.0(@aws-sdk/credential-provider-node@3.972.92)(@smithy/signature-v4@5.7.5)(ws@8.22.1)': + dependencies: + '@earendil-works/pi-agent-core': 1.5.0(@aws-sdk/credential-provider-node@3.972.92)(@smithy/signature-v4@5.7.5)(undici@8.12.3)(ws@8.22.1) + '@earendil-works/pi-ai': 1.5.0(@aws-sdk/credential-provider-node@3.972.92)(@smithy/signature-v4@5.7.5)(undici@8.12.3)(ws@8.22.1) + '@earendil-works/pi-codemode': 1.5.0 + '@earendil-works/pi-mcp': 1.5.0 + '@earendil-works/pi-tui': 1.5.0 + '@silvia-odwyer/photon-node': 0.3.4 + brace-expansion: 5.0.12 + chalk: 6.0.0 + cross-spawn: 7.0.6 + diff: 8.0.4 + grok-mermaid: 0.2.3 + highlight.js: 10.7.3 + hosted-git-info: 9.0.3 + ignore: 7.0.8 + jiti: 2.7.0 + minimatch: 11.0.3 + proper-lockfile: 4.1.2 + quickjs-wasi: 3.6.2 + semver: 7.8.5 + typebox: 1.3.27 + undici: 8.12.3 + yaml: 2.9.0 + transitivePeerDependencies: + - '@aws-sdk/credential-provider-node' + - '@modelcontextprotocol/sdk' + - '@smithy/hash-node' + - '@smithy/signature-v4' + - bufferutil + - kerberos + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-mcp@1.5.0': + dependencies: + cross-spawn: 7.0.6 + + '@earendil-works/pi-schema@1.5.0': {} + + '@earendil-works/pi-telemetry@1.5.0': {} + + '@earendil-works/pi-tui@1.5.0': + dependencies: + get-east-asian-width: 1.6.0 + marked: 18.0.11 + marked-cjk-friendly: 0.1.2(marked@18.0.11) + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@google/genai@2.21.0': + dependencies: + google-auth-library: 10.9.1 + p-retry: 4.6.2 + protobufjs: 7.6.6 + ws: 8.22.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@isaacs/nonrecursive-glob-match@1.0.3': {} + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.2': {} + + '@silvia-odwyer/photon-node@0.3.4': {} + + '@smithy/core@3.38.1': + dependencies: + '@smithy/types': 4.19.0 + tslib: 2.9.1 + + '@smithy/credential-provider-imds@4.5.2': + dependencies: + '@smithy/core': 3.38.1 + '@smithy/types': 4.19.0 + tslib: 2.9.1 + + '@smithy/fetch-http-handler@5.8.0': + dependencies: + '@smithy/core': 3.38.1 + '@smithy/types': 4.19.0 + tslib: 2.9.1 + + '@smithy/node-http-handler@4.12.1': + dependencies: + '@smithy/core': 3.38.1 + '@smithy/types': 4.19.0 + tslib: 2.9.1 + + '@smithy/node-http-handler@4.14.1': + dependencies: + '@smithy/core': 3.38.1 + '@smithy/types': 4.19.0 + tslib: 2.9.1 + + '@smithy/signature-v4@5.7.5': + dependencies: + '@smithy/core': 3.38.1 + '@smithy/types': 4.19.0 + tslib: 2.9.1 + + '@smithy/types@4.19.0': + dependencies: + tslib: 2.9.1 + + '@stablelib/base64@1.0.1': {} + + '@types/node@22.20.5': + dependencies: + undici-types: 6.21.0 + + '@types/retry@0.12.0': {} + + agent-base@7.1.4: {} + + agent-base@9.0.0: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + bignumber.js@9.3.1: {} + + bowser@2.15.0: {} + + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + + buffer-equal-constant-time@1.0.1: {} + + chalk@6.0.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + data-uri-to-buffer@4.0.1: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + diff@8.0.4: {} + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + extend@3.0.2: {} + + fast-sha256@1.3.0: {} + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + fsevents@2.3.3: + optional: true + + gaxios@7.3.1: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.3.1 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + + get-east-asian-width@1.6.0: {} + + google-auth-library@10.9.1: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.3.1 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-logging-utils@1.1.3: {} + + graceful-fs@4.2.11: {} + + grok-mermaid@0.2.3: {} + + highlight.js@10.7.3: {} + + hosted-git-info@9.0.3: + dependencies: + lru-cache: 11.5.3 + + http-proxy-agent@9.1.0: + dependencies: + agent-base: 9.0.0 + debug: 4.4.3 + proxy-agent-negotiate: 1.1.0 + transitivePeerDependencies: + - kerberos + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@9.1.0: + dependencies: + agent-base: 9.0.0 + debug: 4.4.3 + proxy-agent-negotiate: 1.1.0 + transitivePeerDependencies: + - kerberos + - supports-color + + ignore@7.0.8: {} + + isexe@2.0.0: {} + + jiti@2.7.0: {} + + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.10 + ts-algebra: 2.0.0 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + long@5.3.2: {} + + lru-cache@11.5.3: {} + + marked-cjk-friendly@0.1.2(marked@18.0.11): + dependencies: + marked: 18.0.11 + + marked@18.0.11: {} + + minimatch@11.0.3: + dependencies: + '@isaacs/nonrecursive-glob-match': 1.0.3 + brace-expansion: 5.0.12 + + ms@2.1.3: {} + + node-domexception@1.0.0: {} + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + openai@7.19.0(@aws-sdk/credential-provider-node@3.972.92)(@smithy/signature-v4@5.7.5)(undici@8.12.3)(ws@8.22.1): + optionalDependencies: + '@aws-sdk/credential-provider-node': 3.972.92 + '@smithy/signature-v4': 5.7.5 + undici: 8.12.3 + ws: 8.22.1 + + p-retry@4.6.2: + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + + partial-json@0.1.7: {} + + path-key@3.1.1: {} + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + protobufjs@7.6.6: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.2 + '@types/node': 22.20.5 + long: 5.3.2 + + proxy-agent-negotiate@1.1.0: {} + + quickjs-wasi@3.6.2: {} + + retry@0.12.0: {} + + retry@0.13.1: {} + + safe-buffer@5.2.1: {} + + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@3.0.7: {} + + standardwebhooks@1.1.1: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + + ts-algebra@2.0.0: {} + + tslib@2.9.1: {} + + tsx@4.23.15: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + typebox@1.3.27: {} + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + undici@8.12.3: {} + + web-streams-polyfill@3.3.3: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + ws@8.22.1: {} + + yaml@2.9.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..47d14ec --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +# No dependency needs install scripts: tsx ships esbuild's binary as an +# optional dependency, and the others are transitive pi-ai dependencies. +allowBuilds: + '@google/genai': false + esbuild: false + protobufjs: false diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..01172fd --- /dev/null +++ b/src/index.ts @@ -0,0 +1,47 @@ +import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; +import { createUnigptProvider, PROVIDER_ID } from "./provider.ts"; + +const REFRESH_TIMEOUT_MS = 20_000; + +type Level = "info" | "warning" | "error"; + +function report(ctx: ExtensionCommandContext, message: string, level: Level) { + if (ctx.hasUI) ctx.ui.notify(message, level); + // Without a UI (print/JSON modes), keep stdout clean for the agent's output. + else console.error(`[unigpt] ${message}`); +} + +export async function refreshCatalog(ctx: ExtensionCommandContext): Promise { + // A status check, unlike resolving auth, never runs a configured key command. + if (!ctx.modelRegistry.getProviderAuthStatus(PROVIDER_ID).configured) { + report(ctx, "UniGPT is not configured. Run /login unigpt first.", "warning"); + return; + } + const result = await ctx.modelRegistry.refresh({ + providers: [PROVIDER_ID], + force: true, + signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS), + }); + const error = result.errors.get(PROVIDER_ID); + if (result.aborted || error) { + const reason = error?.message ?? `no response within ${REFRESH_TIMEOUT_MS / 1000}s`; + report(ctx, `UniGPT refresh failed (${reason}); keeping the cached model list.`, "warning"); + return; + } + const count = ctx.modelRegistry.getProvider(PROVIDER_ID)?.getModels().length ?? 0; + report( + ctx, + count > 0 + ? `UniGPT: ${count} models available. Select one with /model.` + : "UniGPT lists no models for this API key.", + count > 0 ? "info" : "warning", + ); +} + +export default function unigpt(pi: ExtensionAPI) { + pi.registerProvider(createUnigptProvider()); + pi.registerCommand("unigpt-refresh", { + description: "Reload the UniGPT model list now", + handler: (_args, ctx) => refreshCatalog(ctx), + }); +} diff --git a/src/provider.ts b/src/provider.ts new file mode 100644 index 0000000..7226367 --- /dev/null +++ b/src/provider.ts @@ -0,0 +1,228 @@ +import { + createProvider, + type Model, + type Provider, +} from "@earendil-works/pi-ai"; +import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy"; +import { getBuiltinModels } from "@earendil-works/pi-ai/providers/all"; + +export const PROVIDER_ID = "unigpt"; +export const BASE_URL = "https://api.mygpt.uni-graz.at/api/v1/llms/openai"; +export const WEB_URL = "https://unigpt.uni-graz.at"; + +/** pi's own defaults for custom models.json entries, used when an ID is not in pi's catalog. */ +export const FALLBACK_CONTEXT_WINDOW = 128_000; +export const FALLBACK_MAX_TOKENS = 16_384; +export const REQUEST_TIMEOUT_MS = 15_000; + +type Fetch = typeof globalThis.fetch; + +/** Capabilities of a deployment, as far as they can be known locally. */ +export interface ModelMetadata { + name: string; + input: ("text" | "image")[]; + contextWindow: number; + maxTokens: number; +} + +export type MetadataLookup = (id: string) => ModelMetadata | undefined; + +/** + * UniGPT's catalog returns bare deployment IDs, and those IDs are OpenAI model IDs. + * Look them up in pi's bundled catalog, which pi keeps current, instead of keeping a + * table here. OpenAI and Azure list different limits for some models; take the + * conservative intersection so pi never plans for more than either host allows. + */ +export const lookupCatalogMetadata: MetadataLookup = (id) => { + const matches = (["openai", "azure"] as const).flatMap((provider) => + getBuiltinModels(provider).filter((model) => model.id === id), + ); + if (matches.length === 0) return undefined; + return { + name: matches[0].name, + input: matches.every((model) => model.input.includes("image")) ? ["text", "image"] : ["text"], + contextWindow: Math.min(...matches.map((model) => model.contextWindow)), + maxTokens: Math.min(...matches.map((model) => model.maxTokens)), + }; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isValidId(id: unknown): id is string { + // IDs become an HTTP header value, so reject padding and control characters. + return typeof id === "string" && id.length > 0 && id.trim() === id && !/[\u0000-\u001f\u007f]/.test(id); +} + +export interface ParsedCatalog { + ids: string[]; + /** Entries that were present but unusable, e.g. missing or malformed IDs. */ + skipped: number; +} + +/** + * Skip individual bad entries so one odd deployment cannot hide all others, but reject + * a malformed or entirely unusable catalog rather than replacing a good cache with it. + */ +export function parseCatalog(payload: unknown): ParsedCatalog { + if (!isRecord(payload) || !Array.isArray(payload.data)) { + throw new Error("UniGPT returned an invalid model catalog: expected a data array."); + } + const ids = payload.data + .map((entry: unknown) => (isRecord(entry) ? entry.id : undefined)) + .filter(isValidId); + if (payload.data.length > 0 && ids.length === 0) { + throw new Error("UniGPT returned a model catalog without any valid model IDs."); + } + return { ids: [...new Set(ids)], skipped: payload.data.length - ids.length }; +} + +export function makeModel( + id: string, + baseUrl: string, + lookup: MetadataLookup = lookupCatalogMetadata, +): Model<"openai-responses"> { + const known = lookup(id); + return { + id, + name: known?.name ?? id, + provider: PROVIDER_ID, + api: "openai-responses", + baseUrl, + headers: { "deployment-id": id }, + input: known?.input ?? ["text"], + // UniGPT ignores reasoning.effort and applies a fixed effort per deployment, so + // exposing pi's thinking levels would offer controls that have no effect. + reasoning: false, + contextWindow: known?.contextWindow ?? FALLBACK_CONTEXT_WINDOW, + maxTokens: known?.maxTokens ?? FALLBACK_MAX_TOKENS, + // University users are not billed per token; zero disables cost estimates. + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }; +} + +function statusError(response: Response): Error { + const status = response.status; + if (status === 401 || status === 403) { + return new Error(`UniGPT rejected the API key (HTTP ${status}). Check your key and API access.`); + } + if (status === 429) { + const retryAfter = Number(response.headers.get("retry-after")); + const wait = Number.isFinite(retryAfter) && retryAfter > 0 ? ` Retry in ${Math.ceil(retryAfter)}s.` : " Try again shortly."; + return new Error(`UniGPT is rate-limiting requests (HTTP 429).${wait}`); + } + return new Error(`UniGPT model discovery failed (HTTP ${status}). Try again later.`); +} + +export async function fetchCatalog( + baseUrl: string, + apiKey: string, + signal: AbortSignal, + fetchImpl: Fetch = globalThis.fetch, + timeoutMs = REQUEST_TIMEOUT_MS, +): Promise { + signal.throwIfAborted(); + const timeout = AbortSignal.timeout(timeoutMs); + const requestSignal = AbortSignal.any([signal, timeout]); + // Caller cancellation propagates as-is; our own deadline becomes a readable error. + const checkAborted = () => { + signal.throwIfAborted(); + if (timeout.aborted) { + throw new Error(`UniGPT did not respond within ${Math.round(timeoutMs / 1000)}s. Try again later.`); + } + }; + let response: Response; + try { + response = await fetchImpl(`${baseUrl.replace(/\/+$/, "")}/models`, { + headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" }, + signal: requestSignal, + // Do not forward a credential to a redirect target. + redirect: "error", + }); + } catch { + checkAborted(); + throw new Error("Could not reach the UniGPT model catalog. Check the endpoint and network connection."); + } + if (!response.ok) { + // Do not echo server bodies: they could contain credentials or other private data. + void response.body?.cancel().catch(() => {}); + throw statusError(response); + } + let payload: unknown; + try { + payload = await response.json(); + } catch { + checkAborted(); + throw new Error("UniGPT returned a model catalog that is not valid JSON."); + } + checkAborted(); + return parseCatalog(payload); +} + +export interface UnigptProviderOptions { + /** Primarily useful for tests; users can override baseUrl through models.json. */ + baseUrl?: string; + fetch?: Fetch; + lookup?: MetadataLookup; +} + +export function createUnigptProvider(options: UnigptProviderOptions = {}): Provider<"openai-responses"> { + const baseUrl = options.baseUrl ?? BASE_URL; + const fetchImpl = options.fetch ?? globalThis.fetch; + const lookup = options.lookup ?? lookupCatalogMetadata; + return createProvider<"openai-responses">({ + id: PROVIDER_ID, + name: "UniGPT", + baseUrl, + models: [], + api: openAIResponsesApi(), + auth: { + apiKey: { + name: "UniGPT API key", + async login(interaction) { + interaction.signal.throwIfAborted(); + interaction.notify({ + type: "info", + message: "Paste your UniGPT API key. Pi stores it in its private auth.json.", + links: [{ url: WEB_URL, label: "UniGPT (University of Graz)" }], + }); + const key = (await interaction.prompt({ + type: "secret", + message: "UniGPT API key", + })).trim(); + interaction.signal.throwIfAborted(); + if (!key) throw new Error("An API key is required."); + interaction.notify({ type: "progress", message: "Checking API access…" }); + const { ids, skipped } = await fetchCatalog(baseUrl, key, interaction.signal, fetchImpl); + const skippedNote = skipped > 0 ? ` (${skipped} malformed catalog entries ignored)` : ""; + interaction.notify({ + type: "info", + message: ids.length > 0 + ? `API key accepted: ${ids.length} UniGPT models available${skippedNote}. Select one with /model.` + : "API key accepted, but UniGPT lists no models for it yet. Ask your UniGPT administrator, then run /unigpt-refresh.", + }); + return { type: "api_key", key }; + }, + async resolve({ credential, signal }) { + signal.throwIfAborted(); + if (!credential?.key) return undefined; + return { + auth: { apiKey: credential.key }, + env: credential.env, + source: "stored credential", + }; + }, + }, + }, + async fetchModels(context) { + context.signal.throwIfAborted(); + if (context.credential?.type !== "api_key" || !context.credential.key) { + throw new Error("Run /login unigpt before discovering models."); + } + const endpoint = context.baseUrl ?? baseUrl; + const { ids } = await fetchCatalog(endpoint, context.credential.key, context.signal, fetchImpl); + return ids.map((id) => makeModel(id, endpoint, lookup)); + }, + }); +} diff --git a/test/command.test.ts b/test/command.test.ts new file mode 100644 index 0000000..864e1ef --- /dev/null +++ b/test/command.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; +import { refreshCatalog } from "../src/index.ts"; + +interface Fake { + configured?: boolean; + hasUI?: boolean; + result?: { aborted: boolean; errors: Map }; + count?: number; +} + +function fakeContext(fake: Fake) { + const notices: { message: string; level: string }[] = []; + const refreshes: unknown[] = []; + const ctx = { + hasUI: fake.hasUI ?? true, + ui: { notify: (message: string, level: string) => notices.push({ message, level }) }, + modelRegistry: { + getProviderAuthStatus: () => ({ configured: fake.configured ?? true }), + getProviderAuth: () => { throw new Error("must not resolve credentials"); }, + refresh: async (options: unknown) => { + refreshes.push(options); + return fake.result ?? { aborted: false, errors: new Map() }; + }, + getProvider: () => ({ getModels: () => Array.from({ length: fake.count ?? 0 }) }), + }, + } as unknown as ExtensionCommandContext; + return { ctx, notices, refreshes }; +} + +test("refresh asks to log in without resolving credentials or calling the network", async () => { + const { ctx, notices, refreshes } = fakeContext({ configured: false }); + await refreshCatalog(ctx); + assert.equal(refreshes.length, 0); + assert.deepEqual(notices, [{ message: "UniGPT is not configured. Run /login unigpt first.", level: "warning" }]); +}); + +test("refresh targets only UniGPT, bounded by a deadline, and reports the model count", async () => { + const { ctx, notices, refreshes } = fakeContext({ count: 3 }); + await refreshCatalog(ctx); + const [options] = refreshes as { providers: string[]; signal: AbortSignal }[]; + assert.deepEqual(options.providers, ["unigpt"]); + assert.ok(options.signal instanceof AbortSignal); + assert.deepEqual(notices, [{ message: "UniGPT: 3 models available. Select one with /model.", level: "info" }]); +}); + +test("refresh reports errors, timeouts, and empty catalogs as warnings", async () => { + const failed = fakeContext({ result: { aborted: false, errors: new Map([["unigpt", new Error("HTTP 503")]]) } }); + await refreshCatalog(failed.ctx); + assert.match(failed.notices[0].message, /refresh failed \(HTTP 503\); keeping the cached/); + const timedOut = fakeContext({ result: { aborted: true, errors: new Map() } }); + await refreshCatalog(timedOut.ctx); + assert.match(timedOut.notices[0].message, /no response within 20s/); + const empty = fakeContext({ count: 0 }); + await refreshCatalog(empty.ctx); + assert.deepEqual(empty.notices, [{ message: "UniGPT lists no models for this API key.", level: "warning" }]); +}); + +test("without a UI, refresh reports on stderr instead of staying silent", async (t) => { + const lines: string[] = []; + t.mock.method(console, "error", (line: string) => lines.push(line)); + const { ctx, notices } = fakeContext({ hasUI: false, count: 2 }); + await refreshCatalog(ctx); + assert.equal(notices.length, 0); + assert.deepEqual(lines, ["[unigpt] UniGPT: 2 models available. Select one with /model."]); +}); diff --git a/test/provider.test.ts b/test/provider.test.ts new file mode 100644 index 0000000..5225fba --- /dev/null +++ b/test/provider.test.ts @@ -0,0 +1,372 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { test, type TestContext } from "node:test"; +import { + createModels, + InMemoryCredentialStore, + InMemoryModelsStore, + type AuthEvent, + type AuthInteraction, +} from "@earendil-works/pi-ai"; +import { + BASE_URL, + createUnigptProvider, + FALLBACK_CONTEXT_WINDOW, + FALLBACK_MAX_TOKENS, + fetchCatalog, + lookupCatalogMetadata, + makeModel, + parseCatalog, + type ModelMetadata, +} from "../src/provider.ts"; + +const KEY = "test-key-not-a-real-secret"; +const signal = () => new AbortController().signal; +const catalog = (...ids: string[]) => Response.json({ data: ids.map((id) => ({ id })) }); + +function interaction(key = KEY, events: AuthEvent[] = []): AuthInteraction { + return { prompt: async () => key, notify: (event) => events.push(event) }; +} + +const ids = async (...args: Parameters) => (await fetchCatalog(...args)).ids; + +async function runtime(fetchImpl: typeof fetch, withCredential = true) { + const credentials = new InMemoryCredentialStore(); + if (withCredential) { + await credentials.modify("unigpt", async () => ({ type: "api_key", key: KEY })); + } + const modelsStore = new InMemoryModelsStore(); + const models = createModels({ credentials, modelsStore }); + const provider = createUnigptProvider({ fetch: fetchImpl }); + models.setProvider(provider); + return { models, provider, credentials, modelsStore }; +} + +test("parses IDs, deduplicates in catalog order, and permits an empty catalog", () => { + assert.deepEqual(parseCatalog({ data: [{ id: "second" }, { id: "first" }, { id: "second" }] }), { ids: ["second", "first"], skipped: 0 }); + assert.deepEqual(parseCatalog({ data: [] }), { ids: [], skipped: 0 }); +}); + +test("skips unusable entries but rejects malformed or entirely unusable catalogs", () => { + assert.deepEqual( + parseCatalog({ data: [{ id: "good" }, null, {}, { id: 42 }, { id: "" }, { id: " padded " }, { id: "header\ninjection" }] }), + { ids: ["good"], skipped: 6 }, + ); + for (const payload of [null, [], {}, { data: {} }, { data: [null] }, { data: [{ id: "a\r\nb" }] }]) { + assert.throws(() => parseCatalog(payload), /invalid|without any valid/); + } +}); + +const known: Record = { + "model-a": { name: "Model A", input: ["text", "image"], contextWindow: 272_000, maxTokens: 128_000 }, +}; +const lookup = (id: string) => known[id]; + +test("known deployments take pi's catalog metadata; each gets its own header", () => { + const a = makeModel("model-a", BASE_URL, lookup); + const b = makeModel("model-b", BASE_URL, lookup); + assert.deepEqual(a.headers, { "deployment-id": "model-a" }); + assert.deepEqual(b.headers, { "deployment-id": "model-b" }); + assert.equal(a.provider, "unigpt"); + assert.equal(a.api, "openai-responses"); + assert.equal(a.name, "Model A"); + assert.deepEqual(a.input, ["text", "image"]); + assert.equal(a.contextWindow, 272_000); + assert.equal(a.maxTokens, 128_000); + // UniGPT ignores reasoning.effort, so no deployment advertises thinking controls. + assert.equal(a.reasoning, false); + assert.deepEqual(a.cost, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }); + assert.equal(a.promptCache, undefined); +}); + +test("unknown deployments fall back to pi's custom-model defaults", () => { + const b = makeModel("model-b", BASE_URL, lookup); + assert.equal(b.name, "model-b"); + assert.deepEqual(b.input, ["text"]); + assert.equal(b.contextWindow, FALLBACK_CONTEXT_WINDOW); + assert.equal(b.maxTokens, FALLBACK_MAX_TOKENS); + assert.equal(b.reasoning, false); +}); + +test("catalog lookup uses pi's bundled OpenAI/Azure entries and takes the conservative limit", () => { + const gpt4o = lookupCatalogMetadata("gpt-4o"); + assert.ok(gpt4o, "pi's catalog should know gpt-4o"); + assert.deepEqual(gpt4o.input, ["text", "image"]); + assert.ok(gpt4o.contextWindow >= 100_000 && gpt4o.maxTokens >= 4_096); + assert.equal(lookupCatalogMetadata("definitely-not-a-model"), undefined); +}); + +test("discovery uses bearer auth, no deployment header, a timeout signal, and no redirects", async () => { + const fakeFetch: typeof fetch = async (url, options) => { + assert.equal(url, BASE_URL + "/models"); + const headers = new Headers(options?.headers); + assert.equal(headers.get("Authorization"), `Bearer ${KEY}`); + assert.equal(headers.get("deployment-id"), null); + assert.equal(headers.get("Accept"), "application/json"); + assert.equal(options?.redirect, "error"); + assert.ok(options?.signal instanceof AbortSignal); + return catalog("model-a"); + }; + assert.deepEqual(await ids(BASE_URL + "///", KEY, signal(), fakeFetch), ["model-a"]); +}); + +test("unconfigured providers do not fetch or become available", async () => { + let calls = 0; + const { models } = await runtime(async () => { calls++; return catalog("a"); }, false); + assert.deepEqual(await models.getAvailable("unigpt"), []); + assert.equal(await models.getAuth("unigpt"), undefined); + assert.equal((await models.refresh()).errors.size, 0); + assert.equal(calls, 0); +}); + +test("native login masks input, validates access, and lets pi persist the credential", async () => { + let calls = 0; + const events: AuthEvent[] = []; + const { models, credentials } = await runtime(async () => { calls++; return catalog("a", "b"); }, false); + await models.login("unigpt", "api_key", { + prompt: async (prompt) => { + assert.equal(prompt.type, "secret"); + return ` ${KEY} `; + }, + notify: (event) => events.push(event), + }); + assert.equal(calls, 1); + assert.ok(!JSON.stringify(events).includes(KEY)); + assert.ok(events.some((e) => e.type === "info" && e.links?.some((link) => link.url.startsWith("https://unigpt.uni-graz.at")))); + assert.ok(events.some((e) => e.type === "info" && /2 UniGPT models available/.test(e.message))); + assert.deepEqual(await credentials.read("unigpt"), { type: "api_key", key: KEY }); + assert.equal((await models.refresh()).errors.size, 0); + assert.deepEqual(models.getModels("unigpt").map((m) => m.id), ["a", "b"]); + assert.equal((await models.getAvailable("unigpt")).length, 2); + await models.logout("unigpt"); + assert.equal(await credentials.read("unigpt"), undefined); + assert.deepEqual(await models.getAvailable("unigpt"), []); +}); + +test("empty and rejected API keys never overwrite a saved credential", async () => { + let calls = 0; + const { models, credentials } = await runtime(async () => { calls++; return new Response("private upstream body", { status: 401 }); }); + await assert.rejects(models.login("unigpt", "api_key", interaction(" ")), /required/); + assert.equal(calls, 0); + await assert.rejects(models.login("unigpt", "api_key", interaction("invalid-new-key")), /401/); + assert.deepEqual(await credentials.read("unigpt"), { type: "api_key", key: KEY }); +}); + +test("login with an empty catalog still saves the key but explains the empty model list", async () => { + const events: AuthEvent[] = []; + const { models, credentials } = await runtime(async () => catalog(), false); + await models.login("unigpt", "api_key", interaction(KEY, events)); + assert.deepEqual(await credentials.read("unigpt"), { type: "api_key", key: KEY }); + assert.ok(events.some((e) => e.type === "info" && /lists no models/.test(e.message))); +}); + +test("cancelled login never saves a key or sends validation traffic", async () => { + const controller = new AbortController(); + let calls = 0; + const { models, credentials } = await runtime(async () => { calls++; return catalog("a"); }, false); + await assert.rejects(models.login("unigpt", "api_key", { + signal: controller.signal, + prompt: async () => { controller.abort(); return KEY; }, + notify: () => {}, + }), { name: "AbortError" }); + assert.equal(calls, 0); + assert.equal(await credentials.read("unigpt"), undefined); +}); + +test("discovery errors are useful but never echo secrets or server error bodies", async () => { + for (const status of [401, 403, 404, 429, 500]) { + await assert.rejects(fetchCatalog(BASE_URL, KEY, signal(), async () => new Response(KEY, { status })), (error: Error) => { + assert.match(error.message, new RegExp(String(status))); + assert.ok(!error.message.includes(KEY)); + return true; + }); + } + await assert.rejects(fetchCatalog(BASE_URL, KEY, signal(), async () => { throw new Error(KEY); }), (error: Error) => { + assert.match(error.message, /Could not reach/); + assert.ok(!error.message.includes(KEY)); + return true; + }); + await assert.rejects(fetchCatalog(BASE_URL, KEY, signal(), async () => new Response("not-json")), /not valid JSON/); +}); + +test("rate limits surface Retry-After", async () => { + const limited = async () => new Response(null, { status: 429, headers: { "Retry-After": "30" } }); + await assert.rejects(fetchCatalog(BASE_URL, KEY, signal(), limited), /rate-limiting.*Retry in 30s/); +}); + +test("a stalled endpoint produces a readable timeout, not an AbortError", async () => { + const stalled: typeof fetch = (_url, options) => new Promise((_resolve, reject) => { + options!.signal!.addEventListener("abort", () => reject(options!.signal!.reason), { once: true }); + }); + await assert.rejects(fetchCatalog(BASE_URL, KEY, signal(), stalled, 20), (error: Error) => { + assert.equal(error.name, "Error"); + assert.match(error.message, /did not respond within/); + return true; + }); +}); + +test("discovery propagates cancellation to fetch", async () => { + const controller = new AbortController(); + let started!: () => void; + const ready = new Promise((resolve) => { started = resolve; }); + const promise = fetchCatalog(BASE_URL, KEY, controller.signal, async (_url: unknown, options?: RequestInit) => { + started(); + return new Promise((_resolve, reject) => { + options!.signal!.addEventListener("abort", () => reject(options!.signal!.reason), { once: true }); + }); + }); + await ready; + controller.abort(); + await assert.rejects(promise, { name: "AbortError" }); +}); + +test("refresh persists catalog metadata only, restores offline, and keeps cache on failure", async () => { + let fail = false; + const { models, provider, credentials, modelsStore } = await runtime(async () => { + if (fail) return new Response("unavailable", { status: 503 }); + return catalog("a", "b"); + }); + assert.equal((await models.refresh()).errors.size, 0); + const stored = await modelsStore.read("unigpt"); + assert.ok(stored); + assert.ok(!JSON.stringify(stored).includes(KEY)); + assert.deepEqual(stored.models.map((m) => m.id), ["a", "b"]); + fail = true; + assert.equal((await models.refresh()).errors.size, 1); + assert.deepEqual(provider.getModels().map((m) => m.id), ["a", "b"]); + + let calls = 0; + const offline = createModels({ credentials, modelsStore }); + offline.setProvider(createUnigptProvider({ fetch: async () => { calls++; throw new Error("no network"); } })); + assert.equal((await offline.refresh({ allowNetwork: false })).errors.size, 0); + assert.equal(calls, 0); + assert.deepEqual(offline.getModels("unigpt").map((m) => m.id), ["a", "b"]); +}); + +test("malformed refresh preserves the previous catalog; partial and valid refreshes replace it", async () => { + let response = () => catalog("a", "b"); + const { models } = await runtime(async () => response()); + await models.refresh(); + response = () => Response.json({ data: [{}, null] }); + assert.equal((await models.refresh()).errors.size, 1); + assert.deepEqual(models.getModels("unigpt").map((m) => m.id), ["a", "b"]); + response = () => Response.json({ data: [{ id: "a" }, {}] }); + assert.equal((await models.refresh()).errors.size, 0); + assert.deepEqual(models.getModels("unigpt").map((m) => m.id), ["a"]); + response = () => catalog("b", "c"); + await models.refresh(); + assert.deepEqual(models.getModels("unigpt").map((m) => m.id), ["b", "c"]); + response = () => catalog(); + await models.refresh(); + assert.deepEqual(models.getModels("unigpt"), []); +}); + +test("discovery honors the effective base URL supplied by pi", async () => { + const alternate = "https://gateway.example.test/v1"; + const provider = createUnigptProvider({ fetch: async (url) => { + assert.equal(url, alternate + "/models"); + return catalog("a"); + } }); + await provider.refreshModels!({ + credential: { type: "api_key", key: KEY }, + baseUrl: alternate, + signal: signal(), + allowNetwork: true, + publish: async (publication) => { publication.update?.(); return true; }, + }); + assert.equal(provider.getModels()[0].baseUrl, alternate); +}); + +/** A local Responses endpoint plus a configured runtime whose catalog lists `ids`. */ +async function localGateway(t: TestContext, ids: string[], handler: (req: IncomingMessage, res: ServerResponse) => void) { + const server = createServer(handler); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + t.after(() => new Promise((resolve) => { server.closeAllConnections(); server.close(() => resolve()); })); + const address = server.address(); + assert.ok(address && typeof address !== "string"); + const credentials = new InMemoryCredentialStore(); + await credentials.modify("unigpt", async () => ({ type: "api_key", key: KEY })); + const models = createModels({ credentials }); + models.setProvider(createUnigptProvider({ + baseUrl: `http://127.0.0.1:${address.port}/v1`, + fetch: async () => catalog(...ids), + lookup: () => undefined, + })); + await models.refresh(); + return models; +} + +function sendEvents(res: ServerResponse, events: unknown[]) { + res.writeHead(200, { "Content-Type": "text/event-stream" }); + for (const event of events) res.write(`data: ${JSON.stringify(event)}\n\n`); + res.end("data: [DONE]\n\n"); +} + +test("real Responses transport sends deployment/auth headers, streams text, and records usage", async (t) => { + const requests: { url?: string; headers: IncomingMessage["headers"]; body: Record }[] = []; + const models = await localGateway(t, ["deployment-a", "deployment-b"], (req, res) => { + let body = ""; + req.on("data", (chunk) => { body += chunk; }); + req.on("end", () => { + requests.push({ url: req.url, headers: req.headers, body: JSON.parse(body) }); + const item = { id: "msg_1", type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: "Hello!", annotations: [] }] }; + sendEvents(res, [ + { type: "response.created", response: { id: "resp_1", status: "in_progress" } }, + { type: "response.output_item.added", output_index: 0, item: { ...item, content: [] } }, + { type: "response.output_text.delta", item_id: "msg_1", output_index: 0, content_index: 0, delta: "Hello!" }, + { type: "response.output_item.done", output_index: 0, item }, + { type: "response.completed", response: { id: "resp_1", status: "completed", output: [item], usage: { input_tokens: 12, output_tokens: 3, total_tokens: 15 } } }, + ]); + }); + }); + for (const id of ["deployment-a", "deployment-b"]) { + const model = models.getModel("unigpt", id)!; + const stream = models.streamSimple(model, { messages: [{ role: "user", content: "Hello", timestamp: Date.now() }] }); + const eventTypes: string[] = []; + for await (const event of stream) eventTypes.push(event.type); + const result = await stream.result(); + assert.equal(result.stopReason, "stop", result.errorMessage); + assert.ok(eventTypes.includes("text_delta")); + assert.equal(result.content[0].type, "text"); + assert.equal(result.usage.input, 12); + assert.equal(result.usage.output, 3); + } + assert.equal(requests.length, 2); + for (const [index, request] of requests.entries()) { + const id = ["deployment-a", "deployment-b"][index]; + assert.equal(request.url, "/v1/responses"); + assert.equal(request.headers.authorization, `Bearer ${KEY}`); + assert.equal(request.headers["deployment-id"], id); + assert.equal(request.body.model, id); + assert.equal(request.body.stream, true); + assert.equal(request.body.reasoning, undefined); + assert.equal(request.body.max_output_tokens, FALLBACK_MAX_TOKENS); + } +}); + +test("Responses transport preserves structured tool calls", async (t) => { + const models = await localGateway(t, ["a"], (req, res) => { + req.resume(); + req.on("end", () => { + const item = { type: "function_call", id: "fc_1", call_id: "call_1", name: "ping", arguments: '{"target":"api"}', status: "completed" }; + sendEvents(res, [ + { type: "response.created", response: { id: "resp_2", status: "in_progress" } }, + { type: "response.output_item.added", output_index: 0, item: { ...item, arguments: "" } }, + { type: "response.function_call_arguments.delta", item_id: "fc_1", output_index: 0, delta: item.arguments }, + { type: "response.output_item.done", output_index: 0, item }, + { type: "response.completed", response: { id: "resp_2", status: "completed", output: [item], usage: { input_tokens: 5, output_tokens: 4, total_tokens: 9 } } }, + ]); + }); + }); + const result = await models.completeSimple(models.getModel("unigpt", "a")!, { + messages: [{ role: "user", content: "Ping the API", timestamp: Date.now() }], + tools: [{ name: "ping", description: "Ping a target", parameters: { type: "object", properties: { target: { type: "string" } }, required: ["target"] } }], + }); + assert.equal(result.stopReason, "toolUse", result.errorMessage); + const call = result.content.find((part) => part.type === "toolCall"); + assert.ok(call && call.type === "toolCall"); + assert.equal(call.name, "ping"); + assert.deepEqual(call.arguments, { target: "api" }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8ba6068 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "types": [ + "node" + ] + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ] +}