From 7f6bced4ff7157453046fc60db202511abf061af Mon Sep 17 00:00:00 2001 From: arc-web Date: Wed, 18 Mar 2026 04:43:29 -0400 Subject: [PATCH] feat: add test suite (272 tests), persistence layer, retry logic, bug fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added vitest test suite: mogul routing, landlord security, portfolio validation, persistence - Added file-based JSON persistence layer (PersistenceAdapter interface) - Added retry logic with exponential backoff before marking providers degraded - Fixed global mutable state (MARKET_SENTIMENT, PORTFOLIO_LEDGER) → instance-scoped - Fixed TypeScript errors in examples/landlord.ts - Fixed duplicate model ID in local portfolio - Added .gitignore --- .gitignore | 3 + examples/landlord.ts | 5 +- models/local.ts | 2 +- package-lock.json | 2412 +++++++++++++++++++++++++++++++++++++ package.json | 17 +- src/index.ts | 3 + src/landlord.ts | 6 +- src/mogul.ts | 174 ++- src/persistence.ts | 50 + src/types.ts | 44 +- tests/landlord.test.ts | 580 +++++++++ tests/mogul.test.ts | 582 +++++++++ tests/persistence.test.ts | 189 +++ tests/portfolio.test.ts | 246 ++++ tsconfig.json | 2 +- vitest.config.ts | 8 + 16 files changed, 4256 insertions(+), 67 deletions(-) create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 src/persistence.ts create mode 100644 tests/landlord.test.ts create mode 100644 tests/mogul.test.ts create mode 100644 tests/persistence.test.ts create mode 100644 tests/portfolio.test.ts create mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5e9eee0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.js.map diff --git a/examples/landlord.ts b/examples/landlord.ts index d23bdb1..0ba5c7e 100644 --- a/examples/landlord.ts +++ b/examples/landlord.ts @@ -5,6 +5,7 @@ // ============================================================ import { ModelMogul } from "../src"; +import type { LandlordAlert, InterrogationResult } from "../src/landlord"; // ── Setup with Landlord enabled ─────────────────────────────── @@ -20,7 +21,7 @@ const mogul = new ModelMogul({ enabled: true, // Alert callback — wire to Slack/Discord/PagerDuty etc. - alertCallback: async (alert) => { + alertCallback: async (alert: LandlordAlert) => { const icon = { notice: "👀", warning: "⚠️", eviction: "🚨", outrage: "🔴" }[alert.severity]; console.log(`\n${icon} ALERT [${alert.severity.toUpperCase()}] ${alert.agentId}`); console.log(` ${alert.message}`); @@ -31,7 +32,7 @@ const mogul = new ModelMogul({ }, // Interrogation callback — inject into your agent's next prompt - interrogateCallback: async (interrogation) => { + interrogateCallback: async (interrogation: InterrogationResult) => { console.log(`\n🎩 Baron is interrogating ${interrogation.agentId}:`); console.log(` "${interrogation.question}"`); console.log(`\n [Inject into agent]:\n ${interrogation.expectedResponsePrompt}`); diff --git a/models/local.ts b/models/local.ts index 4e7b93c..b1c1237 100644 --- a/models/local.ts +++ b/models/local.ts @@ -36,7 +36,7 @@ export const LOCAL_PORTFOLIO: ModelSpec[] = [ vramGb: 10, }, { - id: "devstral-small", + id: "devstral-small-local", name: "Devstral Small (local)", provider: "local", baseUrl: "http://localhost:8080/v1", diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9fa5d2c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2412 @@ +{ + "name": "model-mogul", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "model-mogul", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "openai": "^4.0.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "tsx": "^4.0.0", + "typescript": "^5.0.0", + "vitest": "^2.1.9" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", + "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "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": "4.104.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", + "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + }, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/openai/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/openai/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "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.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "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/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json index 170a3d9..30f6e63 100644 --- a/package.json +++ b/package.json @@ -7,16 +7,25 @@ "scripts": { "build": "tsc", "dev": "tsx src/index.ts", - "example": "tsx examples/quickstart.ts" + "example": "tsx examples/quickstart.ts", + "test": "vitest run", + "test:watch": "vitest" }, - "keywords": ["llm", "model-routing", "ai-agents", "cost-optimization", "model-switching"], + "keywords": [ + "llm", + "model-routing", + "ai-agents", + "cost-optimization", + "model-switching" + ], "license": "MIT", "dependencies": { "openai": "^4.0.0" }, "devDependencies": { - "typescript": "^5.0.0", + "@types/node": "^20.0.0", "tsx": "^4.0.0", - "@types/node": "^20.0.0" + "typescript": "^5.0.0", + "vitest": "^2.1.9" } } diff --git a/src/index.ts b/src/index.ts index 6da1d6d..cf91d0d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,9 @@ export type { export { CLOUD_PORTFOLIO } from "../models/cloud"; export { LOCAL_PORTFOLIO } from "../models/local"; +export { JsonFilePersistence, MemoryPersistence } from "./persistence"; +export type { PersistenceAdapter, LandlordAlertData, InterrogationData } from "./types"; + export { Landlord } from "./landlord"; export type { LandlordConfig, LandlordAlert, TenantFile, diff --git a/src/landlord.ts b/src/landlord.ts index b299cd7..225d00a 100644 --- a/src/landlord.ts +++ b/src/landlord.ts @@ -8,7 +8,7 @@ // — Reginald Tokenworth III, visibly furious // ============================================================ -import { LedgerEntry, TaskPhase, MogulConfig } from "./types"; +import { LedgerEntry, TaskPhase, MogulConfig, LandlordAlertData, InterrogationData } from "./types"; // ── Alert severity levels ───────────────────────────────────── @@ -58,8 +58,8 @@ export interface LandlordConfig { outragThreshold: number; // trigger outrage at N× rolling avg (default: 10) rollingWindowMinutes: number; // rolling average window (default: 30) keyCompromiseThreshold: number; // spend spike to suspect key compromise (default: 20×) - alertCallback?: (alert: LandlordAlert) => void | Promise; - interrogateCallback?: (result: InterrogationResult) => void | Promise; + alertCallback?: (alert: LandlordAlertData) => void | Promise; + interrogateCallback?: (result: InterrogationData) => void | Promise; enabled: boolean; } diff --git a/src/mogul.ts b/src/mogul.ts index e4b3321..d951d8b 100644 --- a/src/mogul.ts +++ b/src/mogul.ts @@ -1,7 +1,7 @@ // ============================================================ // Reginald "Reggie" Tokenworth III, 5th Baron of Bitsfordshire // Portal-hopping real estate mogul, displaced to the LLM plane -// +// // "In my universe, we discovered that tokens ARE real estate. // Context windows are square footage. Prices fluctuate by the // millisecond. And I, Reginald Tokenworth the Third, have made @@ -16,14 +16,10 @@ import { LOCAL_PORTFOLIO } from "../models/local"; import { ModelSpec, TaskRequest, TaskPhase, AcquisitionResult, LedgerEntry, PortfolioSummary, DelegationManifest, - SubTaskSpec, MogulConfig, ChatMessage, ProviderStatus + SubTaskSpec, MogulConfig, ChatMessage, ProviderStatus, + PersistenceAdapter, } from "./types"; - -// ── The Market Sentiment (provider health cache) ────────────── -const MARKET_SENTIMENT: Map = new Map(); - -// ── The Ledger (global spend tracking) ─────────────────────── -const PORTFOLIO_LEDGER: LedgerEntry[] = []; +import { JsonFilePersistence } from "./persistence"; export class ModelMogul { private config: MogulConfig; @@ -32,6 +28,12 @@ export class ModelMogul { private landlord: Landlord; private taskCounter = 0; + // ── Instance-scoped state (no more module-level singletons) ── + private marketSentiment: Map = new Map(); + private portfolioLedger: LedgerEntry[] = []; + private persistenceAdapter: PersistenceAdapter | null = null; + /** @internal exposed for testing */ initializationPromise: Promise; + constructor(config: MogulConfig = {}) { this.config = { verbosity: "normal", @@ -51,14 +53,39 @@ export class ModelMogul { this.config.landlord ?? {}, this.config.verbosity ); + + // Wire up persistence if configured + this.initializationPromise = this.initializePersistence(); + this.greet(); } + private async initializePersistence(): Promise { + const pc = this.config.persistence; + if (!pc) return; + + if (pc.adapter) { + this.persistenceAdapter = pc.adapter; + } else if (pc.path) { + this.persistenceAdapter = new JsonFilePersistence(pc.path); + } + + if (this.persistenceAdapter) { + try { + const entries = await this.persistenceAdapter.load(); + this.portfolioLedger.push(...entries); + } catch { + // Start fresh if load fails — don't crash the constructor + } + } + } + // ── MAIN API: acquire() ────────────────────────────────────── // "The acquisition desk is open. What are we buying today?" - + acquire(phase: TaskPhase, contextNeeded = 0, budgetCap?: number): AcquisitionResult { const cap = budgetCap ?? this.config.budget?.taskCap ?? Infinity; + void cap; // reserved for future per-task budget enforcement // Filter candidates by phase strength and context const candidates = this.portfolio @@ -91,9 +118,11 @@ export class ModelMogul { // "Make the deal. Close the acquisition. DO IT." async complete(request: TaskRequest): Promise<{ content: string; usage: LedgerEntry }> { + await this.initializationPromise; + const taskId = `task_${++this.taskCounter}_${request.phase}`; const contextNeeded = this.estimateContextNeeded(request.messages); - + const acquisition = this.acquire(request.phase, contextNeeded, request.budgetCap); const model = acquisition.model; @@ -106,42 +135,43 @@ export class ModelMogul { ? [{ role: "system" as const, content: request.systemPrompt }, ...request.messages] : request.messages; + let response: OpenAI.Chat.Completions.ChatCompletion; try { - const response = await client.chat.completions.create({ - model: model.id, - messages, - max_tokens: request.maxTokens, - }); - - const content = response.choices[0]?.message?.content ?? ""; - const inputTokens = response.usage?.prompt_tokens ?? 0; - const outputTokens = response.usage?.completion_tokens ?? 0; - const cost = this.calculateCost(model, inputTokens, outputTokens); - - const entry: LedgerEntry = { - timestamp: new Date(), - taskId, - phase: request.phase, - modelId: model.id, - inputTokens, - outputTokens, - cost, - subAgentId: request.subAgentId, - }; - - PORTFOLIO_LEDGER.push(entry); - this.checkBudget(cost); - await this.landlord.record(entry).catch(() => null); - - this.log(`📊 Settled: ${inputTokens}in / ${outputTokens}out @ $${cost.toFixed(5)}`); - - return { content, usage: entry }; + response = await this.completeWithRetry(client, model.id, messages, request.maxTokens); } catch (err) { - // Provider failed — mark as degraded and let them retry + // All retries exhausted — mark provider as degraded this.markProviderDegraded(model.provider); this.log(`⚠️ ${model.provider} has gone to ground! Marking as degraded.`); throw err; } + + const content = response.choices[0]?.message?.content ?? ""; + const inputTokens = response.usage?.prompt_tokens ?? 0; + const outputTokens = response.usage?.completion_tokens ?? 0; + const cost = this.calculateCost(model, inputTokens, outputTokens); + + const entry: LedgerEntry = { + timestamp: new Date(), + taskId, + phase: request.phase, + modelId: model.id, + inputTokens, + outputTokens, + cost, + subAgentId: request.subAgentId, + }; + + this.portfolioLedger.push(entry); + this.checkBudget(cost); + await this.landlord.record(entry).catch(() => null); + + if (this.persistenceAdapter) { + await this.persistenceAdapter.save(this.portfolioLedger).catch(() => null); + } + + this.log(`📊 Settled: ${inputTokens}in / ${outputTokens}out @ $${cost.toFixed(5)}`); + + return { content, usage: entry }; } // ── MAIN API: delegate() ────────────────────────────────────── @@ -186,6 +216,7 @@ export class ModelMogul { newPhase: TaskPhase, reason: "complexity_increased" | "cost_exceeded" | "provider_down" | "phase_change" ): AcquisitionResult { + void currentModelId; // tracked for future diffing const reasonQuips: Record = { complexity_increased: "The plot has thickened. Upgrading to better real estate.", cost_exceeded: "We're over budget, darling. Time to find a Canary Wharf bargain.", @@ -213,13 +244,13 @@ export class ModelMogul { // ── MAIN API: getPortfolioSummary() ────────────────────────── getPortfolioSummary(): PortfolioSummary { - const totalSpend = PORTFOLIO_LEDGER.reduce((s, e) => s + e.cost, 0); - const totalTokens = PORTFOLIO_LEDGER.reduce((s, e) => s + e.inputTokens + e.outputTokens, 0); + const totalSpend = this.portfolioLedger.reduce((s, e) => s + e.cost, 0); + const totalTokens = this.portfolioLedger.reduce((s, e) => s + e.inputTokens + e.outputTokens, 0); const byPhase = { discussion: { spend: 0, tokens: 0 }, planning: { spend: 0, tokens: 0 }, execution: { spend: 0, tokens: 0 }, review: { spend: 0, tokens: 0 } }; const byModel: Record = {}; - for (const entry of PORTFOLIO_LEDGER) { + for (const entry of this.portfolioLedger) { byPhase[entry.phase].spend += entry.cost; byPhase[entry.phase].tokens += entry.inputTokens + entry.outputTokens; if (!byModel[entry.modelId]) byModel[entry.modelId] = { spend: 0, tokens: 0 }; @@ -239,17 +270,52 @@ export class ModelMogul { // ── MAIN API: markProviderDown() ───────────────────────────── markProviderDown(provider: string): void { - MARKET_SENTIMENT.set(provider, { status: "down", lastChecked: Date.now() }); + this.marketSentiment.set(provider, { status: "down", lastChecked: Date.now() }); this.log(`🚨 ${provider} declared DOWN. Routing to next borough.`); } markProviderHealthy(provider: string): void { - MARKET_SENTIMENT.set(provider, { status: "healthy", lastChecked: Date.now() }); + this.marketSentiment.set(provider, { status: "healthy", lastChecked: Date.now() }); this.log(`✅ ${provider} back on market. Splendid.`); } + // Expose ledger for testing / inspection + getLedger(): readonly LedgerEntry[] { + return this.portfolioLedger; + } + // ── INTERNAL HELPERS ────────────────────────────────────────── + private async completeWithRetry( + client: OpenAI, + modelId: string, + messages: Array<{ role: "system" | "user" | "assistant"; content: string }>, + maxTokens?: number + ): Promise { + const maxRetries = this.config.retryConfig?.maxRetries ?? 2; + const baseDelayMs = this.config.retryConfig?.baseDelayMs ?? 1000; + const multiplier = this.config.retryConfig?.multiplier ?? 2; + + let lastError: unknown; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await client.chat.completions.create({ + model: modelId, + messages, + max_tokens: maxTokens, + }); + } catch (err) { + lastError = err; + if (attempt < maxRetries) { + const delay = baseDelayMs * Math.pow(multiplier, attempt); + this.log(`⚡ Attempt ${attempt + 1} failed. Retrying in ${delay}ms...`); + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + } + throw lastError; + } + private getClient(model: ModelSpec): OpenAI { const key = model.provider + (model.baseUrl ?? ""); if (this.clients.has(key)) return this.clients.get(key)!; @@ -266,13 +332,12 @@ export class ModelMogul { } private isProviderHealthy(provider: string): boolean { - if (provider === "local") return true; // local is always available if it's running - const sentiment = MARKET_SENTIMENT.get(provider); + const sentiment = this.marketSentiment.get(provider); if (!sentiment) return true; if (sentiment.status === "down") { // Auto-recover after 5 minutes if (Date.now() - sentiment.lastChecked > 5 * 60 * 1000) { - MARKET_SENTIMENT.delete(provider); + this.marketSentiment.delete(provider); return true; } return false; @@ -281,7 +346,7 @@ export class ModelMogul { } private markProviderDegraded(provider: string): void { - MARKET_SENTIMENT.set(provider, { status: "degraded", lastChecked: Date.now() }); + this.marketSentiment.set(provider, { status: "degraded", lastChecked: Date.now() }); } private estimateContextNeeded(messages: ChatMessage[]): number { @@ -299,7 +364,7 @@ export class ModelMogul { return this.calculateCost(model, t.in, t.out); } - private calculateCost(model: ModelSpec, inputTokens: number, outputTokens: number): number { + calculateCost(model: ModelSpec, inputTokens: number, outputTokens: number): number { if (model.isLocal) return 0; return ( (inputTokens / 1_000_000) * model.inputCostPerMTok + @@ -308,18 +373,19 @@ export class ModelMogul { } private checkBudget(lastCost: number): void { + void lastCost; const summary = this.getPortfolioSummary(); const cap = this.config.budget?.taskCap ?? Infinity; const warn = this.config.budget?.warnAt ?? 0.8; - if (summary.totalSpend > cap * warn) { + if (cap !== Infinity && summary.totalSpend > cap * warn) { this.log(`⚠️ Budget Alert: $${summary.totalSpend.toFixed(4)} of $${cap} daily cap consumed.`); } } private getBritishVerdict(model: ModelSpec, phase: TaskPhase, fallbackDepth: number): string { if (this.config.verbosity !== "baronial") return ""; - + if (fallbackDepth === 0) { const verdicts = [ `${model.name} — prime Mayfair. The finest address for ${phase} work.`, @@ -342,7 +408,7 @@ export class ModelMogul { private getSyndicateAdvice(subTasks: SubTaskSpec[], totalCost: number): string { const uniqueModels = new Set(subTasks.map(t => t.assignedModel.id)).size; - + if (this.config.verbosity !== "baronial") { return `${subTasks.length} tasks, ${uniqueModels} models, est. $${totalCost.toFixed(4)}`; } @@ -358,7 +424,7 @@ export class ModelMogul { private computeRoiAssessment(totalSpend: number, totalTokens: number): string { const tokensPerDollar = totalSpend > 0 ? Math.round(totalTokens / totalSpend) : 0; - + if (this.config.verbosity !== "baronial") { return `$${totalSpend.toFixed(4)} total, ${tokensPerDollar.toLocaleString()} tokens/$`; } diff --git a/src/persistence.ts b/src/persistence.ts new file mode 100644 index 0000000..ffc1502 --- /dev/null +++ b/src/persistence.ts @@ -0,0 +1,50 @@ +// ============================================================ +// ModelMogul — Persistence Layer +// "A portfolio without records is just a memory. And memory, +// darling, is notoriously unreliable." +// — Reginald Tokenworth III, 5th Baron of Bitsfordshire +// ============================================================ + +import { promises as fs } from "fs"; +import { LedgerEntry, PersistenceAdapter } from "./types"; + +// ── Default implementation: JSON file on disk ───────────────── + +export class JsonFilePersistence implements PersistenceAdapter { + constructor(private readonly filePath: string) {} + + async save(ledger: LedgerEntry[]): Promise { + await fs.writeFile(this.filePath, JSON.stringify(ledger, null, 2), "utf-8"); + } + + async load(): Promise { + try { + const raw = await fs.readFile(this.filePath, "utf-8"); + const data = JSON.parse(raw) as unknown[]; + return data.map((e) => { + const entry = e as Record; + return { + ...entry, + timestamp: new Date(entry["timestamp"] as string), + } as LedgerEntry; + }); + } catch { + // File doesn't exist yet or is corrupt — start fresh + return []; + } + } +} + +// ── In-memory adapter (useful for testing / ephemeral sessions) ─ + +export class MemoryPersistence implements PersistenceAdapter { + private store: LedgerEntry[] = []; + + async save(ledger: LedgerEntry[]): Promise { + this.store = [...ledger]; + } + + async load(): Promise { + return [...this.store]; + } +} diff --git a/src/types.ts b/src/types.ts index 1fd2070..8828043 100644 --- a/src/types.ts +++ b/src/types.ts @@ -80,6 +80,37 @@ export interface SubTaskSpec { dependencies?: string[]; // IDs of subtasks this depends on } +// Structural callback types (mirrors landlord types to avoid circular import) +export interface LandlordAlertData { + severity: "notice" | "warning" | "eviction" | "outrage"; + agentId: string; + message: string; + reginaldSpeech: string; + timestamp: Date; + evidence: { + currentSpend: number; + rollingAvgSpend: number; + spikeMultiple: number; + windowMinutes: number; + unusualPatterns: string[]; + }; + requiresHumanAction: boolean; + suggestedActions: string[]; +} + +export interface InterrogationData { + agentId: string; + question: string; + context: string; + expectedResponsePrompt: string; +} + +// Persistence adapter interface +export interface PersistenceAdapter { + save(ledger: LedgerEntry[]): Promise; + load(): Promise; +} + export interface MogulConfig { apiKeys?: Record; // provider -> api key budget?: { @@ -95,9 +126,18 @@ export interface MogulConfig { outragThreshold?: number; // evict at N× rolling avg (default 10) rollingWindowMinutes?: number; // rolling window in minutes (default 30) keyCompromiseThreshold?: number; // key compromise suspicion threshold (default 20) - alertCallback?: (alert: any) => void | Promise; - interrogateCallback?: (result: any) => void | Promise; + alertCallback?: (alert: LandlordAlertData) => void | Promise; // wire to Slack/PagerDuty + interrogateCallback?: (result: InterrogationData) => void | Promise; // inject into agent enabled?: boolean; }; providerTimeouts?: Record; // ms before marking provider degraded + persistence?: { + adapter?: PersistenceAdapter; // custom adapter + path?: string; // file path for default JsonFilePersistence + }; + retryConfig?: { + maxRetries?: number; // default 2 + baseDelayMs?: number; // default 1000 + multiplier?: number; // default 2 + }; } diff --git a/tests/landlord.test.ts b/tests/landlord.test.ts new file mode 100644 index 0000000..5c3bc3b --- /dev/null +++ b/tests/landlord.test.ts @@ -0,0 +1,580 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Landlord } from "../src/landlord"; +import { LedgerEntry, TaskPhase } from "../src/types"; + +// ── Helpers ─────────────────────────────────────────────────── + +function makeEntry(overrides: Partial = {}): LedgerEntry { + return { + timestamp: new Date(), + taskId: "task_1_execution", + phase: "execution" as TaskPhase, + modelId: "claude-sonnet-4-6", + inputTokens: 1000, + outputTokens: 500, + cost: 0.01, + ...overrides, + }; +} + +/** Creates entries spaced `gapMs` apart ending at `now` */ +function makeEntries( + count: number, + costPerEntry: number, + gapMs: number, + overrides: Partial = {} +): LedgerEntry[] { + const now = Date.now(); + return Array.from({ length: count }, (_, i) => ({ + ...makeEntry({ cost: costPerEntry, ...overrides }), + timestamp: new Date(now - (count - 1 - i) * gapMs), + })); +} + +function makeLandlord(overrides: Partial[0]> = {}) { + return new Landlord( + { + spikeThreshold: 3, + outragThreshold: 10, + keyCompromiseThreshold: 20, + rollingWindowMinutes: 30, + enabled: true, + ...overrides, + }, + "silent" + ); +} + +// ── record() ───────────────────────────────────────────────── + +describe("Landlord.record()", () => { + it("creates tenant file for new agent on first record", async () => { + const landlord = makeLandlord(); + await landlord.record(makeEntry({ subAgentId: "agent_x" })); + const tenant = landlord.getTenantFile("agent_x"); + expect(tenant).toBeDefined(); + expect(tenant!.agentId).toBe("agent_x"); + }); + + it("uses 'primary' as agentId when subAgentId is not set", async () => { + const landlord = makeLandlord(); + await landlord.record(makeEntry({ subAgentId: undefined })); + const tenant = landlord.getTenantFile("primary"); + expect(tenant).toBeDefined(); + }); + + it("accumulates totalSpend across multiple records", async () => { + const landlord = makeLandlord(); + await landlord.record(makeEntry({ subAgentId: "a", cost: 0.05 })); + await landlord.record(makeEntry({ subAgentId: "a", cost: 0.10 })); + const tenant = landlord.getTenantFile("a"); + expect(tenant!.totalSpend).toBeCloseTo(0.15); + }); + + it("accumulates totalTokens across multiple records", async () => { + const landlord = makeLandlord(); + await landlord.record(makeEntry({ subAgentId: "b", inputTokens: 200, outputTokens: 100 })); + await landlord.record(makeEntry({ subAgentId: "b", inputTokens: 300, outputTokens: 150 })); + const tenant = landlord.getTenantFile("b"); + expect(tenant!.totalTokens).toBe(750); + }); + + it("calls alertCallback when a spike is detected", async () => { + const alertCallback = vi.fn(); + const landlord = makeLandlord({ alertCallback }); + + // Build history: 10 low-cost entries in the "old" window (> 30 min ago) + const oldTime = Date.now() - 35 * 60 * 1000; + const agent = "spike_agent"; + for (let i = 0; i < 10; i++) { + const entry = makeEntry({ subAgentId: agent, cost: 0.001 }); + entry.timestamp = new Date(oldTime - i * 1000); + await landlord.record(entry); + } + + // Now record a big spike in the current window + await landlord.record(makeEntry({ subAgentId: agent, cost: 1.0 })); // >3× avg + + expect(alertCallback).toHaveBeenCalled(); + }); + + it("returns null when disabled", async () => { + const landlord = makeLandlord({ enabled: false }); + const result = await landlord.record(makeEntry({ subAgentId: "disabled_agent" })); + expect(result).toBeNull(); + }); +}); + +// ── inspect() ──────────────────────────────────────────────── + +describe("Landlord.inspect()", () => { + it("returns null with fewer than 2 entries", async () => { + const landlord = makeLandlord(); + await landlord.record(makeEntry({ subAgentId: "solo" })); + const result = await landlord.inspect("solo"); + expect(result).toBeNull(); + }); + + it("returns null for unknown agent", async () => { + const landlord = makeLandlord(); + const result = await landlord.inspect("ghost"); + expect(result).toBeNull(); + }); + + it("returns null when spend is within normal range", async () => { + const landlord = makeLandlord(); + const agent = "normal_agent"; + // All entries within the 30-min window with identical cost → no spike + for (let i = 0; i < 5; i++) { + await landlord.record(makeEntry({ subAgentId: agent, cost: 0.01 })); + } + const result = await landlord.inspect(agent); + // Spike multiple would be 0 (no older data) and not enough anomaly patterns + expect(result).toBeNull(); + }); + + it("returns warning severity at spikeThreshold", async () => { + const landlord = makeLandlord({ spikeThreshold: 3 }); + const agent = "warning_agent"; + + // Old entries (baseline) + const oldTime = Date.now() - 35 * 60 * 1000; + for (let i = 0; i < 5; i++) { + const e = makeEntry({ subAgentId: agent, cost: 0.01 }); + e.timestamp = new Date(oldTime - i * 1000); + await landlord.record(e); + } + // Recent spike: 4× avg (above spikeThreshold of 3) + await landlord.record(makeEntry({ subAgentId: agent, cost: 4.0 })); + + const result = await landlord.inspect(agent); + expect(result).not.toBeNull(); + expect(["warning", "eviction", "outrage"]).toContain(result!.severity); + }); + + it("returns eviction severity at outragThreshold", async () => { + const landlord = makeLandlord({ spikeThreshold: 3, outragThreshold: 10, keyCompromiseThreshold: 20 }); + const agent = "evict_agent"; + + // Old entries: cost 1.0 each (5 entries) + // olderSpend formula: sum(older) / older.length * recent.length = (5*1.0)/5*1 = 1.0 + // recentSpend: 15.0 → spikeMultiple = 15 → ≥ outragThreshold(10), < keyCompromise(20) → "eviction" + const oldTime = Date.now() - 35 * 60 * 1000; + for (let i = 0; i < 5; i++) { + const e = makeEntry({ subAgentId: agent, cost: 1.0 }); + e.timestamp = new Date(oldTime - i * 1000); + await landlord.record(e); + } + await landlord.record(makeEntry({ subAgentId: agent, cost: 15.0 })); + + const result = await landlord.inspect(agent); + expect(result).not.toBeNull(); + expect(result!.severity).toBe("eviction"); + }); + + it("returns outrage severity at keyCompromiseThreshold", async () => { + const landlord = makeLandlord({ spikeThreshold: 3, outragThreshold: 10, keyCompromiseThreshold: 20 }); + const agent = "outrage_agent"; + + const oldTime = Date.now() - 35 * 60 * 1000; + for (let i = 0; i < 5; i++) { + const e = makeEntry({ subAgentId: agent, cost: 0.01 }); + e.timestamp = new Date(oldTime - i * 1000); + await landlord.record(e); + } + // 25× avg — above keyCompromise (20) + await landlord.record(makeEntry({ subAgentId: agent, cost: 25.0 })); + + const result = await landlord.inspect(agent); + expect(result).not.toBeNull(); + expect(result!.severity).toBe("outrage"); + }); + + it("sets tenant status to evicted on outrage", async () => { + const landlord = makeLandlord({ keyCompromiseThreshold: 5 }); + const agent = "evicted_agent"; + + const oldTime = Date.now() - 35 * 60 * 1000; + for (let i = 0; i < 3; i++) { + const e = makeEntry({ subAgentId: agent, cost: 0.01 }); + e.timestamp = new Date(oldTime - i * 1000); + await landlord.record(e); + } + await landlord.record(makeEntry({ subAgentId: agent, cost: 10.0 })); // >5× + + const tenant = landlord.getTenantFile(agent)!; + expect(tenant.status).toBe("evicted"); + }); + + it("sets tenant status to on-notice on eviction", async () => { + const landlord = makeLandlord({ spikeThreshold: 3, outragThreshold: 10, keyCompromiseThreshold: 20 }); + const agent = "noticed_agent"; + + // Same math: old cost 1.0 each → olderSpend = 1.0; recentSpend 15.0 → spikeMultiple 15 → eviction + const oldTime = Date.now() - 35 * 60 * 1000; + for (let i = 0; i < 5; i++) { + const e = makeEntry({ subAgentId: agent, cost: 1.0 }); + e.timestamp = new Date(oldTime - i * 1000); + await landlord.record(e); + } + await landlord.record(makeEntry({ subAgentId: agent, cost: 15.0 })); + + const tenant = landlord.getTenantFile(agent)!; + expect(tenant.status).toBe("on-notice"); + }); + + it("increments warning counter on each alert", async () => { + const landlord = makeLandlord({ spikeThreshold: 3, keyCompromiseThreshold: 20 }); + const agent = "warning_counter"; + + const oldTime = Date.now() - 35 * 60 * 1000; + for (let i = 0; i < 3; i++) { + const e = makeEntry({ subAgentId: agent, cost: 0.01 }); + e.timestamp = new Date(oldTime - i * 1000); + await landlord.record(e); + } + // Two spikes + await landlord.record(makeEntry({ subAgentId: agent, cost: 5.0 })); + await landlord.record(makeEntry({ subAgentId: agent, cost: 5.0 })); + + const tenant = landlord.getTenantFile(agent)!; + expect(tenant.warnings).toBeGreaterThanOrEqual(1); + }); + + it("alert includes evidence fields", async () => { + const landlord = makeLandlord({ spikeThreshold: 3, keyCompromiseThreshold: 100 }); + const agent = "evidence_agent"; + + const oldTime = Date.now() - 35 * 60 * 1000; + for (let i = 0; i < 3; i++) { + const e = makeEntry({ subAgentId: agent, cost: 0.01 }); + e.timestamp = new Date(oldTime - i * 1000); + await landlord.record(e); + } + await landlord.record(makeEntry({ subAgentId: agent, cost: 5.0 })); + + const result = await landlord.inspect(agent); + expect(result!.evidence.spikeMultiple).toBeGreaterThan(0); + expect(result!.evidence.windowMinutes).toBe(30); + expect(result!.evidence.currentSpend).toBeGreaterThan(0); + }); +}); + +// ── anomaly detection ───────────────────────────────────────── + +describe("Landlord anomaly detection", () => { + it("detects rapid-fire requests (< 500ms avg gap)", async () => { + const landlord = makeLandlord(); + const agent = "rapid_agent"; + + // 5 entries with 100ms gaps + const now = Date.now(); + for (let i = 0; i < 5; i++) { + const e = makeEntry({ subAgentId: agent }); + e.timestamp = new Date(now + i * 100); // 100ms apart + await landlord.record(e); + } + + const tenant = landlord.getTenantFile(agent)!; + // Peek at patterns by triggering inspect + const result = await landlord.inspect(agent); + if (result) { + expect(result.evidence.unusualPatterns.some(p => p.includes("rapid"))).toBe(true); + } + // Even if no alert, tenant should have enough entries to detect + expect(tenant.entries.length).toBeGreaterThanOrEqual(5); + }); + + it("detects model thrashing (different model every request)", async () => { + const landlord = makeLandlord(); + const agent = "thrash_agent"; + const models = ["model-a", "model-b", "model-c", "model-d", "model-e"]; + const now = Date.now(); + + for (let i = 0; i < 5; i++) { + const e = makeEntry({ subAgentId: agent, modelId: models[i] }); + e.timestamp = new Date(now + i * 60_000); // 1min apart (not rapid) + await landlord.record(e); + } + + const tenant = landlord.getTenantFile(agent)!; + // Check that thrashing is in patterns by examining the last 5 entries + const recentModels = tenant.entries.slice(-5).map(e => e.modelId); + const uniqueModels = new Set(recentModels).size; + expect(uniqueModels).toBe(5); // all unique = thrashing + }); + + it("detects output explosion (avg > 8000 output tokens)", async () => { + const landlord = makeLandlord(); + const agent = "explosion_agent"; + const now = Date.now(); + + for (let i = 0; i < 5; i++) { + const e = makeEntry({ subAgentId: agent, outputTokens: 10_000 }); + e.timestamp = new Date(now + i * 60_000); + await landlord.record(e); + } + + // The old entries + a new entry. With enough history, anomalies are detectable. + const result = await landlord.inspect(agent); + if (result) { + expect(result.evidence.unusualPatterns.some(p => p.includes("explosion") || p.includes("output"))).toBe(true); + } + // Just verify entries recorded correctly + const tenant = landlord.getTenantFile(agent)!; + const avgOutput = tenant.entries.reduce((s, e) => s + e.outputTokens, 0) / tenant.entries.length; + expect(avgOutput).toBeGreaterThan(8000); + }); + + it("returns empty patterns for normal usage with few entries", async () => { + const landlord = makeLandlord(); + const agent = "normal_user"; + const now = Date.now(); + + // Only 2 entries (below the minimum 3 for anomaly detection) + for (let i = 0; i < 2; i++) { + const e = makeEntry({ subAgentId: agent }); + e.timestamp = new Date(now + i * 5000); + await landlord.record(e); + } + + // With < 3 entries, detectAnomalies returns [] + const tenant = landlord.getTenantFile(agent)!; + expect(tenant.entries.length).toBe(2); + // inspect() returns null for < 2 entries — with exactly 2 it runs but should not alert + // (no spike, no anomalies) + }); +}); + +// ── severity calculation ────────────────────────────────────── + +describe("Landlord severity thresholds", () => { + it("returns notice when >= 2 unusual patterns but no spend spike", async () => { + const landlord = makeLandlord({ spikeThreshold: 100 }); // very high spike threshold + const agent = "pattern_agent"; + const now = Date.now(); + + // 5 different models (thrashing) + rapid fire + const models = ["m1", "m2", "m3", "m4", "m5"]; + for (let i = 0; i < 5; i++) { + const e = makeEntry({ subAgentId: agent, modelId: models[i], outputTokens: 10_000 }); + e.timestamp = new Date(now + i * 50); // 50ms apart = rapid + await landlord.record(e); + } + + const result = await landlord.inspect(agent); + if (result) { + // Should be notice (patterns) not warning/eviction (no big spend spike) + expect(result.evidence.unusualPatterns.length).toBeGreaterThanOrEqual(2); + } + }); + + it("requiresHumanAction is true for eviction and outrage", async () => { + const landlord = makeLandlord({ spikeThreshold: 3, outragThreshold: 10, keyCompromiseThreshold: 20 }); + const agent = "human_required"; + + const oldTime = Date.now() - 35 * 60 * 1000; + for (let i = 0; i < 3; i++) { + const e = makeEntry({ subAgentId: agent, cost: 0.01 }); + e.timestamp = new Date(oldTime - i * 1000); + await landlord.record(e); + } + await landlord.record(makeEntry({ subAgentId: agent, cost: 15.0 })); // eviction level + + const result = await landlord.inspect(agent); + expect(result!.requiresHumanAction).toBe(true); + }); + + it("requiresHumanAction is false for notice", async () => { + const landlord = makeLandlord({ spikeThreshold: 100 }); // spike won't trigger + const agent = "notice_only"; + const now = Date.now(); + + // Create anomaly patterns (rapid-fire + thrashing + output explosion = 3 patterns) + const models = ["m1", "m2", "m3", "m4", "m5"]; + for (let i = 0; i < 5; i++) { + const e = makeEntry({ subAgentId: agent, modelId: models[i], outputTokens: 10_000 }); + e.timestamp = new Date(now + i * 50); + await landlord.record(e); + } + + const result = await landlord.inspect(agent); + if (result && result.severity === "notice") { + expect(result.requiresHumanAction).toBe(false); + } + }); +}); + +// ── interrogate() ───────────────────────────────────────────── + +describe("Landlord.interrogate()", () => { + it("returns interrogation result with the correct agentId", async () => { + const landlord = makeLandlord(); + const result = await landlord.interrogate("agent_42"); + expect(result.agentId).toBe("agent_42"); + }); + + it("includes a question string", async () => { + const landlord = makeLandlord(); + const result = await landlord.interrogate("agent_42"); + expect(result.question).toBeTruthy(); + expect(typeof result.question).toBe("string"); + }); + + it("includes context with agent ID", async () => { + const landlord = makeLandlord(); + const result = await landlord.interrogate("agent_42"); + expect(result.context).toContain("agent_42"); + }); + + it("includes expectedResponsePrompt with instructions", async () => { + const landlord = makeLandlord(); + const result = await landlord.interrogate("agent_42"); + expect(result.expectedResponsePrompt).toContain("SYSTEM LANDLORD NOTICE"); + }); + + it("updates lastInterrogated timestamp", async () => { + const landlord = makeLandlord(); + await landlord.interrogate("agent_99"); + const tenant = landlord.getTenantFile("agent_99"); + expect(tenant?.lastInterrogated).toBeInstanceOf(Date); + }); + + it("calls interrogateCallback when configured", async () => { + const callback = vi.fn(); + const landlord = makeLandlord({ interrogateCallback: callback }); + await landlord.interrogate("agent_cb"); + expect(callback).toHaveBeenCalledOnce(); + expect(callback.mock.calls[0][0].agentId).toBe("agent_cb"); + }); +}); + +// ── tenant management ───────────────────────────────────────── + +describe("Landlord tenant management", () => { + it("getTenantFile returns undefined for unknown agent", () => { + const landlord = makeLandlord(); + expect(landlord.getTenantFile("nobody")).toBeUndefined(); + }); + + it("getAllTenants returns empty array initially", () => { + const landlord = makeLandlord(); + expect(landlord.getAllTenants()).toHaveLength(0); + }); + + it("getAllTenants returns all recorded agents", async () => { + const landlord = makeLandlord(); + await landlord.record(makeEntry({ subAgentId: "a1" })); + await landlord.record(makeEntry({ subAgentId: "a2" })); + await landlord.record(makeEntry({ subAgentId: "a1" })); + expect(landlord.getAllTenants()).toHaveLength(2); + }); + + it("tenant starts with good-standing status", async () => { + const landlord = makeLandlord(); + await landlord.record(makeEntry({ subAgentId: "fresh" })); + const tenant = landlord.getTenantFile("fresh")!; + expect(tenant.status).toBe("good-standing"); + }); + + it("tenant sessionStart is set on creation", async () => { + const landlord = makeLandlord(); + const before = new Date(); + await landlord.record(makeEntry({ subAgentId: "ts_test" })); + const after = new Date(); + const tenant = landlord.getTenantFile("ts_test")!; + expect(tenant.sessionStart.getTime()).toBeGreaterThanOrEqual(before.getTime()); + expect(tenant.sessionStart.getTime()).toBeLessThanOrEqual(after.getTime()); + }); +}); + +// ── assessKeyCompromise() ───────────────────────────────────── + +describe("Landlord.assessKeyCompromise()", () => { + it("returns null with fewer than 5 total entries", async () => { + const landlord = makeLandlord(); + for (let i = 0; i < 4; i++) { + await landlord.record(makeEntry({ subAgentId: "key_test", cost: 0.01 })); + } + const result = await landlord.assessKeyCompromise("anthropic"); + expect(result).toBeNull(); + }); + + it("returns null when no recent entries exist", async () => { + const landlord = makeLandlord(); + // All entries are old (> 10 minutes ago) + const oldTime = Date.now() - 15 * 60 * 1000; + for (let i = 0; i < 10; i++) { + const e = makeEntry({ subAgentId: "old_entries", cost: 0.01 }); + e.timestamp = new Date(oldTime - i * 1000); + await landlord.record(e); + } + const result = await landlord.assessKeyCompromise("anthropic"); + expect(result).toBeNull(); + }); + + it("returns null when spike is below keyCompromiseThreshold", async () => { + const landlord = makeLandlord({ keyCompromiseThreshold: 20 }); + const agent = "below_threshold"; + + // Old entries + const oldTime = Date.now() - 15 * 60 * 1000; + for (let i = 0; i < 5; i++) { + const e = makeEntry({ subAgentId: agent, cost: 0.10 }); + e.timestamp = new Date(oldTime - i * 1000); + await landlord.record(e); + } + // Recent entries with 5× spike (below 20×) + for (let i = 0; i < 3; i++) { + await landlord.record(makeEntry({ subAgentId: agent, cost: 0.50 })); + } + + const result = await landlord.assessKeyCompromise("test"); + expect(result).toBeNull(); + }); + + it("detects key compromise when spike exceeds threshold", async () => { + const landlord = makeLandlord({ keyCompromiseThreshold: 20 }); + const agent = "compromised"; + + // Old entries: small cost + const oldTime = Date.now() - 15 * 60 * 1000; + for (let i = 0; i < 5; i++) { + const e = makeEntry({ subAgentId: agent, cost: 0.001 }); + e.timestamp = new Date(oldTime - i * 1000); + await landlord.record(e); + } + // Recent entries: huge spike (50× per-entry) + for (let i = 0; i < 3; i++) { + await landlord.record(makeEntry({ subAgentId: agent, cost: 5.0 })); + } + + const result = await landlord.assessKeyCompromise("test"); + expect(result).not.toBeNull(); + expect(result!.severity).toBe("outrage"); + expect(result!.requiresHumanAction).toBe(true); + }); + + it("includes rotate key action in suggested actions", async () => { + const landlord = makeLandlord({ keyCompromiseThreshold: 5 }); + const agent = "rotate_test"; + + const oldTime = Date.now() - 15 * 60 * 1000; + for (let i = 0; i < 5; i++) { + const e = makeEntry({ subAgentId: agent, cost: 0.001 }); + e.timestamp = new Date(oldTime - i * 1000); + await landlord.record(e); + } + for (let i = 0; i < 3; i++) { + await landlord.record(makeEntry({ subAgentId: agent, cost: 1.0 })); + } + + const result = await landlord.assessKeyCompromise("myProvider"); + if (result) { + const hasRotateAction = result.suggestedActions.some(a => + a.toLowerCase().includes("rotate") || a.toLowerCase().includes("key") + ); + expect(hasRotateAction).toBe(true); + } + }); +}); diff --git a/tests/mogul.test.ts b/tests/mogul.test.ts new file mode 100644 index 0000000..70fa259 --- /dev/null +++ b/tests/mogul.test.ts @@ -0,0 +1,582 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { ModelMogul } from "../src/mogul"; +import { ModelSpec, MogulConfig, LedgerEntry } from "../src/types"; +import { MemoryPersistence } from "../src/persistence"; + +// ── Mock OpenAI (vi.hoisted ensures mockCreate is available when the factory runs) ── + +const mockCreate = vi.hoisted(() => vi.fn()); + +vi.mock("openai", () => ({ + default: vi.fn().mockImplementation(() => ({ + chat: { + completions: { + create: mockCreate, + }, + }, + })), +})); + +// ── Helpers ─────────────────────────────────────────────────── + +function makeMogul(overrides: MogulConfig = {}) { + return new ModelMogul({ verbosity: "silent", ...overrides }); +} + +function makeCompletion(inputTokens = 100, outputTokens = 50, content = "test response") { + return { + choices: [{ message: { content } }], + usage: { prompt_tokens: inputTokens, completion_tokens: outputTokens }, + }; +} + +// A minimal custom model for focused testing +const CHEAP_PLANNING_MODEL: ModelSpec = { + id: "test-cheap-planner", + name: "Test Cheap Planner", + provider: "test-provider", + contextWindow: 100_000, + inputCostPerMTok: 0.10, + outputCostPerMTok: 0.30, + strengths: ["planning"], + tier: 1, +}; + +const CHEAP_EXEC_MODEL: ModelSpec = { + id: "test-cheap-exec", + name: "Test Cheap Executor", + provider: "test-provider", + contextWindow: 100_000, + inputCostPerMTok: 0.05, + outputCostPerMTok: 0.10, + strengths: ["execution"], + tier: 1, +}; + +// Mark all default portfolio providers down so only custom models compete +const DEFAULT_PROVIDERS = ["anthropic", "deepseek", "mistral", "google", "openrouter", "xai", "local"]; + +function markAllDefaultProvidersDown(mogul: ModelMogul) { + DEFAULT_PROVIDERS.forEach(p => mogul.markProviderDown(p)); +} + +// ── acquire() tests ─────────────────────────────────────────── + +describe("ModelMogul.acquire()", () => { + it("selects a model that supports the requested phase", () => { + const mogul = makeMogul(); + const result = mogul.acquire("planning"); + expect(result.model.strengths).toContain("planning"); + }); + + it("selects a model that supports execution phase", () => { + const mogul = makeMogul(); + const result = mogul.acquire("execution"); + expect(result.model.strengths).toContain("execution"); + }); + + it("selects a model that supports discussion phase", () => { + const mogul = makeMogul(); + const result = mogul.acquire("discussion"); + expect(result.model.strengths).toContain("discussion"); + }); + + it("selects a model that supports review phase", () => { + const mogul = makeMogul(); + const result = mogul.acquire("review"); + expect(result.model.strengths).toContain("review"); + }); + + it("filters out models with insufficient context window", () => { + const mogul = makeMogul(); + // Request a huge context window — only models with 1M+ can satisfy + const result = mogul.acquire("execution", 1_500_000); + expect(result.model.contextWindow).toBeGreaterThanOrEqual(1_500_000); + }); + + it("throws when no models satisfy the context requirement", () => { + const mogul = makeMogul(); + // 10B context — nothing in any portfolio has this + expect(() => mogul.acquire("execution", 10_000_000_000)).toThrow( + "No viable properties found" + ); + }); + + it("throws when no models available for the phase (all providers down)", () => { + const mogul = makeMogul(); + markAllDefaultProvidersDown(mogul); + expect(() => mogul.acquire("planning")).toThrow("No viable properties found"); + }); + + it("prefers lower tier models (tier 1 over tier 2)", () => { + const tier1: ModelSpec = { ...CHEAP_PLANNING_MODEL, id: "t1", tier: 1, provider: "p-iso" }; + const tier2: ModelSpec = { ...CHEAP_PLANNING_MODEL, id: "t2", tier: 2, provider: "p-iso" }; + const mogul = makeMogul({ customModels: [tier2, tier1] }); + markAllDefaultProvidersDown(mogul); + const result = mogul.acquire("planning"); + expect(result.model.id).toBe("t1"); + }); + + it("falls back to tier 2 when tier 1 is down", () => { + const tier1: ModelSpec = { ...CHEAP_PLANNING_MODEL, id: "t1", tier: 1, provider: "p1-iso" }; + const tier2: ModelSpec = { ...CHEAP_PLANNING_MODEL, id: "tier2-model", tier: 2, provider: "p2-iso" }; + const mogul = makeMogul({ customModels: [tier1, tier2] }); + // Mark everything down: all default providers + tier1's custom provider + markAllDefaultProvidersDown(mogul); + mogul.markProviderDown("p1-iso"); + const result = mogul.acquire("planning"); + expect(result.model.id).toBe("tier2-model"); + expect(result.fallbackDepth).toBe(1); + }); + + it("returns fallbackDepth of 0 for tier-1 model", () => { + const tier1: ModelSpec = { ...CHEAP_PLANNING_MODEL, id: "t1-only", tier: 1, provider: "p-only" }; + const mogul = makeMogul({ customModels: [tier1] }); + markAllDefaultProvidersDown(mogul); + const result = mogul.acquire("planning"); + expect(result.fallbackDepth).toBe(0); + }); + + it("respects preferLocal flag", () => { + const mogul = makeMogul({ preferLocal: true }); + const result = mogul.acquire("execution"); + expect(result.model.isLocal).toBe(true); + }); + + it("returns estimatedCost of 0 for local models", () => { + const mogul = makeMogul({ preferLocal: true }); + const result = mogul.acquire("execution"); + expect(result.estimatedCost).toBe(0); + }); + + it("returns contextAvailable equal to the selected model's context window", () => { + const mogul = makeMogul(); + const result = mogul.acquire("planning"); + expect(result.contextAvailable).toBe(result.model.contextWindow); + }); + + it("sorts by cost within same tier", () => { + const cheapModel: ModelSpec = { ...CHEAP_EXEC_MODEL, id: "cheap-iso", provider: "p-cheap", inputCostPerMTok: 0.01, tier: 1 }; + const expModel: ModelSpec = { ...CHEAP_EXEC_MODEL, id: "exp-iso", provider: "p-cheap", inputCostPerMTok: 10.00, tier: 1 }; + const mogul = makeMogul({ customModels: [expModel, cheapModel] }); + markAllDefaultProvidersDown(mogul); + const result = mogul.acquire("execution"); + expect(result.model.id).toBe("cheap-iso"); + }); +}); + +// ── Provider health tests ───────────────────────────────────── + +describe("ModelMogul provider health", () => { + it("marks provider down and excludes it from routing", () => { + const mogul = makeMogul(); + mogul.markProviderDown("anthropic"); + const result = mogul.acquire("discussion"); + expect(result.model.provider).not.toBe("anthropic"); + }); + + it("marks provider healthy and includes it again", () => { + const mogul = makeMogul(); + mogul.markProviderDown("deepseek"); + mogul.markProviderHealthy("deepseek"); + const result = mogul.acquire("discussion"); + expect(result).toBeDefined(); // no throw + }); + + it("local provider can be marked down and back up", () => { + const mogul = makeMogul({ preferLocal: true }); + mogul.markProviderDown("local"); + // With local down and preferLocal=true, fallback to non-local cloud models is acceptable + // as long as acquire() doesn't throw (cloud models are available) + // Actually with preferLocal filter: only local models are in the pool, so should throw + expect(() => mogul.acquire("execution")).toThrow("No viable properties found"); + + mogul.markProviderHealthy("local"); + const result = mogul.acquire("execution"); + expect(result.model.isLocal).toBe(true); + }); + + it("auto-recovers provider after 5 minutes via fake timers", () => { + vi.useFakeTimers(); + const mogul = makeMogul(); + mogul.markProviderDown("anthropic"); + + // Before 5 min: still down + vi.advanceTimersByTime(4 * 60 * 1000); + const resultBefore = mogul.acquire("discussion"); + expect(resultBefore.model.provider).not.toBe("anthropic"); + + // After 5 min: auto-recovered + vi.advanceTimersByTime(2 * 60 * 1000); + // just verify no throw — may or may not pick anthropic depending on sort + const resultAfter = mogul.acquire("discussion"); + expect(resultAfter).toBeDefined(); + + vi.useRealTimers(); + }); +}); + +// ── complete() tests ────────────────────────────────────────── + +describe("ModelMogul.complete()", () => { + beforeEach(() => { + mockCreate.mockReset(); + }); + + it("calls the OpenAI client and returns content", async () => { + mockCreate.mockResolvedValueOnce(makeCompletion(100, 50, "hello world")); + const mogul = makeMogul(); + const { content } = await mogul.complete({ + phase: "discussion", + messages: [{ role: "user", content: "hi" }], + }); + expect(content).toBe("hello world"); + }); + + it("records a ledger entry with correct tokens", async () => { + mockCreate.mockResolvedValueOnce(makeCompletion(200, 75)); + const mogul = makeMogul(); + const { usage } = await mogul.complete({ + phase: "execution", + messages: [{ role: "user", content: "do something" }], + }); + expect(usage.inputTokens).toBe(200); + expect(usage.outputTokens).toBe(75); + }); + + it("appends entry to portfolio ledger", async () => { + mockCreate.mockResolvedValueOnce(makeCompletion()); + const mogul = makeMogul(); + await mogul.complete({ phase: "planning", messages: [{ role: "user", content: "plan" }] }); + expect(mogul.getLedger()).toHaveLength(1); + }); + + it("accumulates multiple entries", async () => { + mockCreate.mockResolvedValue(makeCompletion()); + const mogul = makeMogul(); + await mogul.complete({ phase: "planning", messages: [{ role: "user", content: "a" }] }); + await mogul.complete({ phase: "review", messages: [{ role: "user", content: "b" }] }); + expect(mogul.getLedger()).toHaveLength(2); + }); + + it("records correct phase in ledger", async () => { + mockCreate.mockResolvedValueOnce(makeCompletion()); + const mogul = makeMogul(); + const { usage } = await mogul.complete({ + phase: "review", + messages: [{ role: "user", content: "review this" }], + }); + expect(usage.phase).toBe("review"); + }); + + it("tracks sub-agent ID in ledger entry", async () => { + mockCreate.mockResolvedValueOnce(makeCompletion()); + const mogul = makeMogul(); + const { usage } = await mogul.complete({ + phase: "execution", + messages: [{ role: "user", content: "exec" }], + subAgentId: "sub_007", + }); + expect(usage.subAgentId).toBe("sub_007"); + }); + + it("marks provider degraded after error (retries exhausted)", async () => { + // Use maxRetries: 0 so we fail immediately + mockCreate.mockRejectedValueOnce(new Error("provider error")); + const mogul = makeMogul({ retryConfig: { maxRetries: 0 } }); + await expect( + mogul.complete({ phase: "discussion", messages: [{ role: "user", content: "hi" }] }) + ).rejects.toThrow("provider error"); + }); + + it("prepends system prompt to messages", async () => { + mockCreate.mockResolvedValueOnce(makeCompletion()); + const mogul = makeMogul(); + await mogul.complete({ + phase: "discussion", + messages: [{ role: "user", content: "hello" }], + systemPrompt: "You are a helpful assistant.", + }); + const callArgs = mockCreate.mock.calls[0][0]; + expect(callArgs.messages[0]).toEqual({ role: "system", content: "You are a helpful assistant." }); + expect(callArgs.messages[1]).toEqual({ role: "user", content: "hello" }); + }); + + it("saves to persistence adapter after completion", async () => { + mockCreate.mockResolvedValueOnce(makeCompletion()); + const mem = new MemoryPersistence(); + const saveSpy = vi.spyOn(mem, "save"); + const mogul = makeMogul({ persistence: { adapter: mem } }); + await mogul.complete({ phase: "execution", messages: [{ role: "user", content: "exec" }] }); + expect(saveSpy).toHaveBeenCalledOnce(); + const saved = await mem.load(); + expect(saved).toHaveLength(1); + }); +}); + +// ── Retry logic tests ───────────────────────────────────────── + +describe("ModelMogul retry logic", () => { + beforeEach(() => { + mockCreate.mockReset(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + mockCreate.mockReset(); + }); + + it("retries on failure and succeeds on second attempt", async () => { + mockCreate + .mockRejectedValueOnce(new Error("transient error")) + .mockResolvedValueOnce(makeCompletion(10, 5, "retry success")); + + const mogul = makeMogul({ retryConfig: { maxRetries: 2, baseDelayMs: 100, multiplier: 2 } }); + + const promise = mogul.complete({ + phase: "discussion", + messages: [{ role: "user", content: "hello" }], + }); + // Advance past delays so the retry fires + await vi.runAllTimersAsync(); + const { content } = await promise; + expect(content).toBe("retry success"); + expect(mockCreate).toHaveBeenCalledTimes(2); + }); + + it("exhausts all retries before throwing", async () => { + mockCreate.mockRejectedValue(new Error("always fails")); + const mogul = makeMogul({ retryConfig: { maxRetries: 2, baseDelayMs: 10, multiplier: 2 } }); + + const promise = mogul.complete({ + phase: "discussion", + messages: [{ role: "user", content: "hi" }], + }); + // Attach catch before advancing timers to avoid unhandled rejection + const caught = promise.catch(e => e); + await vi.runAllTimersAsync(); + const err = await caught; + expect(err.message).toBe("always fails"); + // 1 initial + 2 retries = 3 total calls + expect(mockCreate).toHaveBeenCalledTimes(3); + }); + + it("does not retry when maxRetries is 0", async () => { + mockCreate.mockRejectedValue(new Error("fail fast")); + const mogul = makeMogul({ retryConfig: { maxRetries: 0 } }); + + const promise = mogul.complete({ + phase: "discussion", + messages: [{ role: "user", content: "hi" }], + }); + const caught = promise.catch(e => e); + await vi.runAllTimersAsync(); + const err = await caught; + expect(err.message).toBe("fail fast"); + expect(mockCreate).toHaveBeenCalledTimes(1); + }); + + it("uses exponential backoff: later retries wait longer", async () => { + // We verify exponential backoff by measuring total elapsed fake time. + // With baseDelayMs=500, multiplier=3: delays are 500ms, then 1500ms → total 2000ms. + // We advance timers by 1999ms and check that only attempt 1 fired (not attempt 2). + mockCreate + .mockRejectedValueOnce(new Error("err1")) + .mockRejectedValueOnce(new Error("err2")) + .mockResolvedValueOnce(makeCompletion(10, 5, "backoff success")); + + const mogul = makeMogul({ retryConfig: { maxRetries: 2, baseDelayMs: 500, multiplier: 3 } }); + const promise = mogul.complete({ + phase: "discussion", + messages: [{ role: "user", content: "hi" }], + }); + + // After first failure, delay is 500ms (attempt 0: 500 * 3^0) + // After second failure, delay is 1500ms (attempt 1: 500 * 3^1) + await vi.runAllTimersAsync(); + const { content } = await promise; + + expect(content).toBe("backoff success"); + expect(mockCreate).toHaveBeenCalledTimes(3); // initial + 2 retries + }); +}); + +// ── planDelegation() tests ──────────────────────────────────── + +describe("ModelMogul.planDelegation()", () => { + it("creates one sub-task per phase", () => { + const mogul = makeMogul(); + const manifest = mogul.planDelegation(["planning", "execution", "review"]); + expect(manifest.subTasks).toHaveLength(3); + }); + + it("assigns each sub-task the correct phase", () => { + const mogul = makeMogul(); + const manifest = mogul.planDelegation(["discussion", "planning"]); + const phases = manifest.subTasks.map(t => t.phase); + expect(phases).toContain("discussion"); + expect(phases).toContain("planning"); + }); + + it("assigns models appropriate for each phase", () => { + const mogul = makeMogul(); + const manifest = mogul.planDelegation(["discussion", "planning", "execution", "review"]); + for (const task of manifest.subTasks) { + expect(task.assignedModel.strengths).toContain(task.phase); + } + }); + + it("returns a non-negative estimated total cost", () => { + const mogul = makeMogul(); + const manifest = mogul.planDelegation(["planning", "execution"]); + expect(manifest.estimatedTotalCost).toBeGreaterThanOrEqual(0); + }); + + it("generates unique sub-task IDs with parent prefix", () => { + const mogul = makeMogul(); + const manifest = mogul.planDelegation(["planning", "execution"]); + const ids = manifest.subTasks.map(t => t.id); + expect(new Set(ids).size).toBe(ids.length); + ids.forEach(id => expect(id).toContain(manifest.parentTaskId)); + }); +}); + +// ── switchModel() tests ─────────────────────────────────────── + +describe("ModelMogul.switchModel()", () => { + const reasons = [ + "complexity_increased", + "cost_exceeded", + "provider_down", + "phase_change", + ] as const; + + for (const reason of reasons) { + it(`returns a valid acquisition result for reason: ${reason}`, () => { + const mogul = makeMogul(); + const result = mogul.switchModel("old-model-id", "planning", reason); + expect(result.model).toBeDefined(); + expect(result.model.strengths).toContain("planning"); + }); + } +}); + +// ── getPortfolioSummary() tests ─────────────────────────────── + +describe("ModelMogul.getPortfolioSummary()", () => { + beforeEach(() => { mockCreate.mockReset(); }); + + it("returns zero totals for a fresh instance", () => { + const mogul = makeMogul(); + const summary = mogul.getPortfolioSummary(); + expect(summary.totalSpend).toBe(0); + expect(summary.totalTokens).toBe(0); + }); + + it("aggregates tokens after completions", async () => { + mockCreate.mockResolvedValue(makeCompletion(1000, 500)); + const mogul = makeMogul(); + await mogul.complete({ phase: "planning", messages: [{ role: "user", content: "a" }] }); + await mogul.complete({ phase: "execution", messages: [{ role: "user", content: "b" }] }); + const summary = mogul.getPortfolioSummary(); + expect(summary.totalTokens).toBe((1000 + 500) * 2); + }); + + it("aggregates by phase correctly", async () => { + mockCreate.mockResolvedValue(makeCompletion(100, 50)); + const mogul = makeMogul(); + await mogul.complete({ phase: "review", messages: [{ role: "user", content: "r" }] }); + const summary = mogul.getPortfolioSummary(); + expect(summary.byPhase.review.tokens).toBe(150); + expect(summary.byPhase.planning.tokens).toBe(0); + }); + + it("aggregates by model correctly", async () => { + mockCreate.mockResolvedValue(makeCompletion(100, 50)); + const mogul = makeMogul(); + const { usage } = await mogul.complete({ + phase: "discussion", + messages: [{ role: "user", content: "x" }], + }); + const summary = mogul.getPortfolioSummary(); + expect(summary.byModel[usage.modelId].tokens).toBe(150); + }); +}); + +// ── calculateCost() tests ───────────────────────────────────── + +describe("ModelMogul.calculateCost()", () => { + it("calculates cost based on token usage and model rates", () => { + const mogul = makeMogul(); + const model: ModelSpec = { + id: "test", + name: "Test", + provider: "test", + contextWindow: 100_000, + inputCostPerMTok: 1.00, // $1 per M input tokens + outputCostPerMTok: 3.00, // $3 per M output tokens + strengths: ["discussion"], + tier: 1, + }; + // 1M input tokens → $1.00, 1M output tokens → $3.00 → total $4.00 + expect(mogul.calculateCost(model, 1_000_000, 1_000_000)).toBeCloseTo(4.00); + // 500k input + 200k output → $0.50 + $0.60 = $1.10 + expect(mogul.calculateCost(model, 500_000, 200_000)).toBeCloseTo(1.10); + }); + + it("returns 0 for local models regardless of token count", () => { + const mogul = makeMogul(); + const localModel: ModelSpec = { + id: "local-test", + name: "Local Test", + provider: "local", + contextWindow: 40_000, + inputCostPerMTok: 0, + outputCostPerMTok: 0, + strengths: ["discussion"], + tier: 1, + isLocal: true, + }; + expect(mogul.calculateCost(localModel, 1_000_000, 1_000_000)).toBe(0); + }); +}); + +// ── Persistence integration tests ──────────────────────────── + +describe("ModelMogul persistence integration", () => { + beforeEach(() => { mockCreate.mockReset(); }); + + it("loads existing ledger entries on initialization", async () => { + const existingEntry: LedgerEntry = { + timestamp: new Date("2026-01-01T00:00:00Z"), + taskId: "task_pre_1_planning", + phase: "planning", + modelId: "claude-opus-4-6", + inputTokens: 5000, + outputTokens: 2000, + cost: 0.125, + }; + + const mem = new MemoryPersistence(); + await mem.save([existingEntry]); + + const mogul = makeMogul({ persistence: { adapter: mem } }); + await mogul.initializationPromise; + + const summary = mogul.getPortfolioSummary(); + expect(summary.totalTokens).toBe(7000); + }); + + it("persists new entries to adapter after complete", async () => { + mockCreate.mockResolvedValueOnce(makeCompletion(100, 50)); + const mem = new MemoryPersistence(); + const mogul = makeMogul({ persistence: { adapter: mem } }); + + await mogul.complete({ phase: "discussion", messages: [{ role: "user", content: "hi" }] }); + + const loaded = await mem.load(); + expect(loaded).toHaveLength(1); + expect(loaded[0].phase).toBe("discussion"); + }); +}); diff --git a/tests/persistence.test.ts b/tests/persistence.test.ts new file mode 100644 index 0000000..d889469 --- /dev/null +++ b/tests/persistence.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { promises as fs } from "fs"; +import * as path from "path"; +import * as os from "os"; +import { JsonFilePersistence, MemoryPersistence } from "../src/persistence"; +import { LedgerEntry } from "../src/types"; + +// ── Helpers ─────────────────────────────────────────────────── + +function makeEntry(overrides: Partial = {}): LedgerEntry { + return { + timestamp: new Date("2026-03-18T10:00:00Z"), + taskId: "task_1_planning", + phase: "planning", + modelId: "claude-sonnet-4-6", + inputTokens: 1000, + outputTokens: 500, + cost: 0.025, + ...overrides, + }; +} + +// ── JsonFilePersistence ─────────────────────────────────────── + +describe("JsonFilePersistence", () => { + let tmpDir: string; + let filePath: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "mogul-test-")); + filePath = path.join(tmpDir, "ledger.json"); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("saves and loads a single ledger entry", async () => { + const persistence = new JsonFilePersistence(filePath); + const entries = [makeEntry()]; + + await persistence.save(entries); + const loaded = await persistence.load(); + + expect(loaded).toHaveLength(1); + expect(loaded[0].taskId).toBe("task_1_planning"); + expect(loaded[0].phase).toBe("planning"); + expect(loaded[0].modelId).toBe("claude-sonnet-4-6"); + expect(loaded[0].inputTokens).toBe(1000); + expect(loaded[0].outputTokens).toBe(500); + expect(loaded[0].cost).toBe(0.025); + }); + + it("preserves Date objects during serialization round-trip", async () => { + const persistence = new JsonFilePersistence(filePath); + const originalDate = new Date("2026-03-18T10:00:00Z"); + const entries = [makeEntry({ timestamp: originalDate })]; + + await persistence.save(entries); + const loaded = await persistence.load(); + + expect(loaded[0].timestamp).toBeInstanceOf(Date); + expect(loaded[0].timestamp.toISOString()).toBe(originalDate.toISOString()); + }); + + it("saves and loads multiple entries", async () => { + const persistence = new JsonFilePersistence(filePath); + const entries = [ + makeEntry({ taskId: "task_1_planning", phase: "planning" }), + makeEntry({ taskId: "task_2_execution", phase: "execution", cost: 0.10 }), + makeEntry({ taskId: "task_3_review", phase: "review", cost: 0.05 }), + ]; + + await persistence.save(entries); + const loaded = await persistence.load(); + + expect(loaded).toHaveLength(3); + expect(loaded[1].phase).toBe("execution"); + expect(loaded[2].phase).toBe("review"); + }); + + it("returns empty array when file does not exist", async () => { + const persistence = new JsonFilePersistence(path.join(tmpDir, "nonexistent.json")); + const loaded = await persistence.load(); + expect(loaded).toEqual([]); + }); + + it("returns empty array for empty ledger", async () => { + const persistence = new JsonFilePersistence(filePath); + await persistence.save([]); + const loaded = await persistence.load(); + expect(loaded).toEqual([]); + }); + + it("overwrites existing file on subsequent save", async () => { + const persistence = new JsonFilePersistence(filePath); + + await persistence.save([makeEntry({ taskId: "first" })]); + await persistence.save([makeEntry({ taskId: "second" }), makeEntry({ taskId: "third" })]); + + const loaded = await persistence.load(); + expect(loaded).toHaveLength(2); + expect(loaded[0].taskId).toBe("second"); + expect(loaded[1].taskId).toBe("third"); + }); + + it("returns empty array for corrupt JSON file", async () => { + await fs.writeFile(filePath, "{ this is not valid json ]]]", "utf-8"); + const persistence = new JsonFilePersistence(filePath); + const loaded = await persistence.load(); + expect(loaded).toEqual([]); + }); + + it("preserves optional subAgentId field", async () => { + const persistence = new JsonFilePersistence(filePath); + const entries = [ + makeEntry({ subAgentId: "agent_007" }), + makeEntry({ subAgentId: undefined }), + ]; + + await persistence.save(entries); + const loaded = await persistence.load(); + + expect(loaded[0].subAgentId).toBe("agent_007"); + expect(loaded[1].subAgentId).toBeUndefined(); + }); + + it("correctly serializes to a readable JSON file", async () => { + const persistence = new JsonFilePersistence(filePath); + await persistence.save([makeEntry()]); + + const raw = await fs.readFile(filePath, "utf-8"); + const parsed = JSON.parse(raw); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed[0]).toHaveProperty("taskId"); + expect(parsed[0]).toHaveProperty("timestamp"); + }); +}); + +// ── MemoryPersistence ───────────────────────────────────────── + +describe("MemoryPersistence", () => { + it("saves and loads entries", async () => { + const mem = new MemoryPersistence(); + const entries = [makeEntry()]; + await mem.save(entries); + const loaded = await mem.load(); + expect(loaded).toHaveLength(1); + expect(loaded[0].taskId).toBe("task_1_planning"); + }); + + it("returns empty array before any save", async () => { + const mem = new MemoryPersistence(); + const loaded = await mem.load(); + expect(loaded).toEqual([]); + }); + + it("returns a copy, not the same reference", async () => { + const mem = new MemoryPersistence(); + const entries = [makeEntry()]; + await mem.save(entries); + + const loaded1 = await mem.load(); + loaded1.push(makeEntry({ taskId: "extra" })); + const loaded2 = await mem.load(); + + // Mutating loaded1 should not affect subsequent loads + expect(loaded2).toHaveLength(1); + }); + + it("overwrites previous save", async () => { + const mem = new MemoryPersistence(); + await mem.save([makeEntry({ taskId: "old" })]); + await mem.save([makeEntry({ taskId: "new_a" }), makeEntry({ taskId: "new_b" })]); + + const loaded = await mem.load(); + expect(loaded).toHaveLength(2); + expect(loaded[0].taskId).toBe("new_a"); + }); + + it("preserves Date instances through save/load", async () => { + const mem = new MemoryPersistence(); + const date = new Date("2026-01-15T12:00:00Z"); + await mem.save([makeEntry({ timestamp: date })]); + const loaded = await mem.load(); + expect(loaded[0].timestamp).toBeInstanceOf(Date); + expect(loaded[0].timestamp.getTime()).toBe(date.getTime()); + }); +}); diff --git a/tests/portfolio.test.ts b/tests/portfolio.test.ts new file mode 100644 index 0000000..2f7f94f --- /dev/null +++ b/tests/portfolio.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect } from "vitest"; +import { CLOUD_PORTFOLIO } from "../models/cloud"; +import { LOCAL_PORTFOLIO } from "../models/local"; +import { ModelSpec, TaskPhase } from "../src/types"; + +const ALL_PHASES: TaskPhase[] = ["discussion", "planning", "execution", "review"]; + +function isValidTaskPhase(p: string): p is TaskPhase { + return ALL_PHASES.includes(p as TaskPhase); +} + +// ── Shared validation helpers ───────────────────────────────── + +function validateModel(model: ModelSpec, label: string) { + it(`${label}: has a non-empty id`, () => { + expect(model.id.trim()).toBeTruthy(); + }); + + it(`${label}: has a non-empty name`, () => { + expect(model.name.trim()).toBeTruthy(); + }); + + it(`${label}: has a non-empty provider`, () => { + expect(model.provider.trim()).toBeTruthy(); + }); + + it(`${label}: has a positive context window`, () => { + expect(model.contextWindow).toBeGreaterThan(1_000); + }); + + it(`${label}: inputCostPerMTok is >= 0`, () => { + expect(model.inputCostPerMTok).toBeGreaterThanOrEqual(0); + }); + + it(`${label}: outputCostPerMTok is >= 0`, () => { + expect(model.outputCostPerMTok).toBeGreaterThanOrEqual(0); + }); + + it(`${label}: has at least one strength`, () => { + expect(model.strengths.length).toBeGreaterThan(0); + }); + + it(`${label}: all strengths are valid TaskPhase values`, () => { + for (const s of model.strengths) { + expect(isValidTaskPhase(s)).toBe(true); + } + }); + + it(`${label}: tier is a positive integer`, () => { + expect(Number.isInteger(model.tier)).toBe(true); + expect(model.tier).toBeGreaterThanOrEqual(1); + }); +} + +// ── Cloud portfolio ─────────────────────────────────────────── + +describe("Cloud Portfolio", () => { + it("has at least one model", () => { + expect(CLOUD_PORTFOLIO.length).toBeGreaterThan(0); + }); + + it("covers all four task phases", () => { + for (const phase of ALL_PHASES) { + const hasPhase = CLOUD_PORTFOLIO.some(m => m.strengths.includes(phase)); + expect(hasPhase, `No cloud model covers phase: ${phase}`).toBe(true); + } + }); + + it("all models are NOT local (no isLocal flag)", () => { + for (const model of CLOUD_PORTFOLIO) { + expect(model.isLocal ?? false).toBe(false); + } + }); + + it("all models have non-zero input costs", () => { + for (const model of CLOUD_PORTFOLIO) { + expect(model.inputCostPerMTok).toBeGreaterThan(0); + } + }); + + it("all models have non-zero output costs", () => { + for (const model of CLOUD_PORTFOLIO) { + expect(model.outputCostPerMTok).toBeGreaterThan(0); + } + }); + + it("output cost >= input cost (output is always more expensive)", () => { + for (const model of CLOUD_PORTFOLIO) { + expect(model.outputCostPerMTok).toBeGreaterThanOrEqual(model.inputCostPerMTok); + } + }); + + it("all models have context window >= 100k tokens", () => { + for (const model of CLOUD_PORTFOLIO) { + expect(model.contextWindow).toBeGreaterThanOrEqual(100_000); + } + }); + + it("tiers are in range 1-5", () => { + for (const model of CLOUD_PORTFOLIO) { + expect(model.tier).toBeGreaterThanOrEqual(1); + expect(model.tier).toBeLessThanOrEqual(5); + } + }); + + it("has at least one tier-1 model", () => { + const tier1 = CLOUD_PORTFOLIO.filter(m => m.tier === 1); + expect(tier1.length).toBeGreaterThan(0); + }); + + it("all model IDs are unique", () => { + const ids = CLOUD_PORTFOLIO.map(m => m.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("non-Anthropic models have a baseUrl", () => { + const nonAnthropic = CLOUD_PORTFOLIO.filter(m => m.provider !== "anthropic"); + for (const model of nonAnthropic) { + expect(model.baseUrl).toBeTruthy(); + } + }); + + it("cost sanity: no model costs more than $100/MTok input", () => { + for (const model of CLOUD_PORTFOLIO) { + expect(model.inputCostPerMTok).toBeLessThan(100); + } + }); + + it("cost sanity: no model costs more than $500/MTok output", () => { + for (const model of CLOUD_PORTFOLIO) { + expect(model.outputCostPerMTok).toBeLessThan(500); + } + }); + + // Run per-model validations + for (const model of CLOUD_PORTFOLIO) { + describe(`Model: ${model.name}`, () => { + validateModel(model, model.name); + }); + } +}); + +// ── Local portfolio ─────────────────────────────────────────── + +describe("Local Portfolio", () => { + it("has at least one model", () => { + expect(LOCAL_PORTFOLIO.length).toBeGreaterThan(0); + }); + + it("all models are marked isLocal: true", () => { + for (const model of LOCAL_PORTFOLIO) { + expect(model.isLocal).toBe(true); + } + }); + + it("all models have zero input cost (it's electricity, not API fees)", () => { + for (const model of LOCAL_PORTFOLIO) { + expect(model.inputCostPerMTok).toBe(0); + } + }); + + it("all models have zero output cost", () => { + for (const model of LOCAL_PORTFOLIO) { + expect(model.outputCostPerMTok).toBe(0); + } + }); + + it("all models have a vramGb requirement", () => { + for (const model of LOCAL_PORTFOLIO) { + expect(model.vramGb).toBeDefined(); + expect(model.vramGb!).toBeGreaterThan(0); + } + }); + + it("all models have a baseUrl (local inference endpoint)", () => { + for (const model of LOCAL_PORTFOLIO) { + expect(model.baseUrl).toBeTruthy(); + } + }); + + it("all models use provider 'local'", () => { + for (const model of LOCAL_PORTFOLIO) { + expect(model.provider).toBe("local"); + } + }); + + it("tiers are in range 1-3", () => { + for (const model of LOCAL_PORTFOLIO) { + expect(model.tier).toBeGreaterThanOrEqual(1); + expect(model.tier).toBeLessThanOrEqual(3); + } + }); + + it("has at least one tier-1 model", () => { + const tier1 = LOCAL_PORTFOLIO.filter(m => m.tier === 1); + expect(tier1.length).toBeGreaterThan(0); + }); + + it("VRAM requirements are reasonable (6GB–128GB range)", () => { + for (const model of LOCAL_PORTFOLIO) { + expect(model.vramGb!).toBeGreaterThanOrEqual(4); + expect(model.vramGb!).toBeLessThanOrEqual(128); + } + }); + + it("larger tier models require more VRAM than tier-1 models on average", () => { + const tier1Avg = + LOCAL_PORTFOLIO.filter(m => m.tier === 1).reduce((s, m) => s + (m.vramGb ?? 0), 0) / + LOCAL_PORTFOLIO.filter(m => m.tier === 1).length; + const tier2Plus = LOCAL_PORTFOLIO.filter(m => m.tier >= 2); + if (tier2Plus.length > 0) { + const tier2Avg = tier2Plus.reduce((s, m) => s + (m.vramGb ?? 0), 0) / tier2Plus.length; + expect(tier2Avg).toBeGreaterThan(tier1Avg); + } + }); + + // Run per-model validations + for (const model of LOCAL_PORTFOLIO) { + describe(`Local Model: ${model.name}`, () => { + validateModel(model, model.name); + }); + } +}); + +// ── Combined portfolio sanity ───────────────────────────────── + +describe("Combined portfolio", () => { + const all = [...CLOUD_PORTFOLIO, ...LOCAL_PORTFOLIO]; + + it("all model IDs across cloud + local are unique", () => { + const ids = all.map(m => m.id); + const dupes = ids.filter((id, i) => ids.indexOf(id) !== i); + expect(dupes).toHaveLength(0); + }); + + it("covers all four phases with at least one option each", () => { + for (const phase of ALL_PHASES) { + const count = all.filter(m => m.strengths.includes(phase)).length; + expect(count, `Phase ${phase} has no models`).toBeGreaterThan(0); + } + }); + + it("combined portfolio has 10+ models", () => { + expect(all.length).toBeGreaterThanOrEqual(10); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index eecf572..e4f8f5f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,6 @@ "declarationMap": true, "sourceMap": true }, - "include": ["src/**/*", "models/**/*", "examples/**/*"], + "include": ["src/**/*", "models/**/*", "examples/**/*", "tests/**/*", "vitest.config.ts"], "exclude": ["node_modules", "dist"] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..faa6d98 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["tests/**/*.test.ts"], + }, +});