diff --git a/package-lock.json b/package-lock.json index 120d67d..c58adfd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "PROPRIETARY", "dependencies": { - "openai": "^4.50.0", + "openai": "^6.48.0", "zod": "^3.23.0" }, "devDependencies": { @@ -1000,21 +1000,12 @@ "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, "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/@typescript-eslint/eslint-plugin": { "version": "8.65.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", @@ -1410,18 +1401,6 @@ "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/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -1445,18 +1424,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "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/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -1507,12 +1474,6 @@ "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/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1541,19 +1502,6 @@ "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/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1628,18 +1576,6 @@ "dev": true, "license": "MIT" }, - "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/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1697,47 +1633,6 @@ "dev": true, "license": "MIT" }, - "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", @@ -1745,33 +1640,6 @@ "dev": true, "license": "MIT" }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "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.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1988,15 +1856,6 @@ "node": ">=0.10.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.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -2097,41 +1956,6 @@ "dev": true, "license": "ISC" }, - "node_modules/form-data": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", - "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" - }, - "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", @@ -2147,52 +1971,6 @@ "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/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2219,18 +1997,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "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-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2241,54 +2007,6 @@ "node": ">=8" } }, - "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.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "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/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2464,36 +2182,6 @@ "@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/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -2511,6 +2199,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -2539,68 +2228,28 @@ "dev": true, "license": "MIT" }, - "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==", + "version": "6.48.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.48.0.tgz", + "integrity": "sha512-KhVp+FyV50QrXNextvL9hIU5l6ox5HYuKQjGVk7lIqprgJol90+dQXWONV6S1lRWsKA1bXjrow8RsUT14M1hNA==", "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": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", "ws": "^8.18.0", - "zod": "^3.23.8" + "zod": "^3.25 || ^4.0" }, "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, "ws": { "optional": true }, @@ -2609,21 +2258,6 @@ } } }, - "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/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3002,12 +2636,6 @@ "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/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -3076,6 +2704,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, "license": "MIT" }, "node_modules/uri-js": { @@ -3237,31 +2866,6 @@ } } }, - "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/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 54760d3..69abea9 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "registry": "https://npm.pkg.github.com" }, "dependencies": { - "openai": "^4.50.0", + "openai": "^6.48.0", "zod": "^3.23.0" }, "devDependencies": { diff --git a/src/index.ts b/src/index.ts index d4e7c7f..4f3c7b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -110,9 +110,20 @@ export class L9LLMRouter { const config = resolvePerplexityConfig(task); if (!Object.values(SonarModel).includes(decision.model as SonarModel)) throw new Error('Perplexity route resolved a non-Sonar model'); config.model = decision.model as SonarModel; - response = options?.consensus && config.variations > 1 - ? (await this.perplexity.completeWithConsensus(config, systemPrompt, userPrompt, options.assistantContext, options.signal)).best - : await this.perplexity.complete(config, systemPrompt, userPrompt, options?.assistantContext, options?.signal); + if (options?.consensus && config.variations > 1) { + const consensus = await this.perplexity.completeWithConsensus(config, systemPrompt, userPrompt, options.assistantContext, options.signal); + response = { + ...consensus.best, + inputTokens: consensus.aggregate.inputTokens, + outputTokens: consensus.aggregate.outputTokens, + totalTokens: consensus.aggregate.totalTokens, + cost: consensus.aggregate.cost, + latencyMs: consensus.aggregate.latencyMs, + citations: consensus.aggregate.citations, + }; + } else { + response = await this.perplexity.complete(config, systemPrompt, userPrompt, options?.assistantContext, options?.signal); + } } else if (VISION_TASKS.has(task.type) && images?.length) { const config = resolveVisionConfig(task.type as TaskType.VISUAL_QA | TaskType.SCREENSHOT_ANALYSIS | TaskType.LAYOUT_VALIDATION, task.complexity, images.length); config.model = decision.model as GeneralModel; diff --git a/src/providers/openai-transport.ts b/src/providers/openai-transport.ts new file mode 100644 index 0000000..fab324e --- /dev/null +++ b/src/providers/openai-transport.ts @@ -0,0 +1,59 @@ +import OpenAI from 'openai'; + +export interface ChatTextPart { type: 'text'; text: string } +export interface ChatImagePart { type: 'image_url'; image_url: { url: string; detail?: 'low' | 'high' | 'auto' } } +export type ChatContentPart = ChatTextPart | ChatImagePart; +export interface ChatMessage { role: 'system' | 'user' | 'assistant'; content: string | ChatContentPart[] } + +export interface ChatCompletionRequest { + model: string; + messages: ChatMessage[]; + temperature?: number; + max_tokens?: number; + response_format?: { type: 'json_object' }; + [key: string]: unknown; +} + +export interface ChatCompletionResult { + id?: string; + _request_id?: string; + choices: Array<{ message?: { content?: string | null }; finish_reason?: string | null }>; + usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; + citations?: string[]; +} + +export interface ChatRequestOptions { signal?: AbortSignal } +export interface ChatTransport { + create(request: ChatCompletionRequest, options?: ChatRequestOptions): Promise; +} + +export interface OpenAIChatTransportConfig { + apiKey: string; + baseURL: string; + timeoutMs: number; + maxRetries: 0; + defaultHeaders?: Record; +} + +/** Isolates the OpenAI SDK from provider and router contracts. */ +export class OpenAIChatTransport implements ChatTransport { + private readonly client: OpenAI; + + constructor(config: OpenAIChatTransportConfig) { + this.client = new OpenAI({ + apiKey: config.apiKey, + baseURL: config.baseURL, + timeout: config.timeoutMs, + maxRetries: config.maxRetries, + defaultHeaders: config.defaultHeaders, + }); + } + + async create(request: ChatCompletionRequest, options?: ChatRequestOptions): Promise { + const response = await this.client.chat.completions.create( + request as unknown as OpenAI.Chat.ChatCompletionCreateParamsNonStreaming, + options?.signal ? { signal: options.signal } : undefined, + ); + return response as unknown as ChatCompletionResult; + } +} diff --git a/src/providers/openrouter.ts b/src/providers/openrouter.ts index e7fe604..ff86d47 100644 --- a/src/providers/openrouter.ts +++ b/src/providers/openrouter.ts @@ -1,24 +1,30 @@ -import OpenAI from 'openai'; import { calculateOpenRouterCost } from '../pricing.js'; -import { classifyProviderError, ProviderRequestError } from '../provider-errors.js'; +import { classifyProviderError, ProviderRequestError, throwIfAborted } from '../provider-errors.js'; import { - GeneralModel, Provider, + type GeneralModel, type GeneralModelConfig, type LLMResponse, type VisionConfig, } from '../types.js'; +import { + OpenAIChatTransport, + type ChatContentPart, + type ChatMessage, + type ChatTransport, +} from './openai-transport.js'; +import { GeneralModel as GeneralModelValue } from '../types.js'; const MODEL_IDS: Record = { - [GeneralModel.GPT4O_MINI]: 'openai/gpt-4o-mini', - [GeneralModel.GEMINI_FLASH]: 'google/gemini-2.5-flash', - [GeneralModel.CLAUDE_HAIKU]: 'anthropic/claude-haiku-4', - [GeneralModel.GPT4O]: 'openai/gpt-4o', - [GeneralModel.CLAUDE_SONNET]: 'anthropic/claude-sonnet-4', - [GeneralModel.GEMINI_PRO]: 'google/gemini-2.5-pro', - [GeneralModel.CLAUDE_OPUS]: 'anthropic/claude-opus-4', - [GeneralModel.O1]: 'openai/o1', - [GeneralModel.O3]: 'openai/o3', + [GeneralModelValue.GPT4O_MINI]: 'openai/gpt-4o-mini', + [GeneralModelValue.GEMINI_FLASH]: 'google/gemini-2.5-flash', + [GeneralModelValue.CLAUDE_HAIKU]: 'anthropic/claude-haiku-4', + [GeneralModelValue.GPT4O]: 'openai/gpt-4o', + [GeneralModelValue.CLAUDE_SONNET]: 'anthropic/claude-sonnet-4', + [GeneralModelValue.GEMINI_PRO]: 'google/gemini-2.5-pro', + [GeneralModelValue.CLAUDE_OPUS]: 'anthropic/claude-opus-4', + [GeneralModelValue.O1]: 'openai/o1', + [GeneralModelValue.O3]: 'openai/o3', }; const MAX_INLINE_IMAGE_BYTES = 10 * 1024 * 1024; @@ -66,17 +72,14 @@ export function validateImageUrl(url: string): void { if (url.startsWith('data:')) { const match = SAFE_DATA_URI.exec(url); if (!match) throw new UnsafeImageUrlError('Image data URI must be a supported base64 image', url); - const estimatedBytes = Math.floor(match[2].length * 3 / 4); - if (estimatedBytes > MAX_INLINE_IMAGE_BYTES) throw new UnsafeImageUrlError('Inline image exceeds the 10 MiB limit', url); + if (Math.floor(match[2].length * 3 / 4) > MAX_INLINE_IMAGE_BYTES) throw new UnsafeImageUrlError('Inline image exceeds the 10 MiB limit', url); return; } let parsed: URL; try { parsed = new URL(url); } catch { throw new UnsafeImageUrlError('Image URL must be absolute', url); } if (parsed.protocol !== 'https:') throw new UnsafeImageUrlError('Image URL must use HTTPS', url); const host = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase(); - if (host === 'localhost' || host.endsWith('.local') || host.endsWith('.internal') || isPrivateIpv4(host) || isPrivateIpv6(host)) { - throw new UnsafeImageUrlError('Image URL targets a private, loopback, link-local, or reserved address', url); - } + if (host === 'localhost' || host.endsWith('.local') || host.endsWith('.internal') || isPrivateIpv4(host) || isPrivateIpv6(host)) throw new UnsafeImageUrlError('Image URL targets a private, loopback, link-local, or reserved address', url); } export interface OpenRouterClientLike { @@ -86,37 +89,51 @@ export interface OpenRouterClientLike { } export class OpenRouterClient implements OpenRouterClientLike { - private readonly client: OpenAI; - constructor(apiKey: string, appName = 'L9-LLM-Router', private readonly timeoutMs = 60_000) { - this.client = new OpenAI({ apiKey, baseURL: 'https://openrouter.ai/api/v1', timeout: timeoutMs, maxRetries: 0, defaultHeaders: { 'HTTP-Referer': 'https://l9.systems', 'X-Title': appName } }); + private readonly transport: ChatTransport; + constructor(apiKey: string, appName = 'L9-LLM-Router', timeoutMs = 60_000, transport?: ChatTransport) { + this.transport = transport ?? new OpenAIChatTransport({ apiKey, baseURL: 'https://openrouter.ai/api/v1', timeoutMs, maxRetries: 0, defaultHeaders: { 'HTTP-Referer': 'https://l9.systems', 'X-Title': appName } }); } async complete(config: GeneralModelConfig, systemPrompt: string, userPrompt: string, signal?: AbortSignal): Promise { + throwIfAborted(signal, Provider.OPENROUTER); const started = Date.now(); + const messages: ChatMessage[] = [{ role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }]; try { - const response = await this.client.chat.completions.create({ model: MODEL_IDS[config.model], messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }], temperature: config.temperature, max_tokens: config.maxTokens, ...(config.responseFormat === 'json' ? { response_format: { type: 'json_object' as const } } : {}) }, { signal }); - return { content: response.choices[0]?.message?.content ?? '', model: config.model, provider: Provider.OPENROUTER, inputTokens: response.usage?.prompt_tokens ?? 0, outputTokens: response.usage?.completion_tokens ?? 0, totalTokens: response.usage?.total_tokens ?? 0, cost: calculateOpenRouterCost(config.model, response.usage), latencyMs: Date.now() - started, cached: false, requestId: response._request_id ?? undefined, finishReason: response.choices[0]?.finish_reason ?? undefined }; + const response = await this.transport.create({ model: MODEL_IDS[config.model], messages, temperature: config.temperature, max_tokens: config.maxTokens, ...(config.responseFormat === 'json' ? { response_format: { type: 'json_object' as const } } : {}) }, { signal }); + return { content: response.choices[0]?.message?.content ?? '', model: config.model, provider: Provider.OPENROUTER, inputTokens: response.usage?.prompt_tokens ?? 0, outputTokens: response.usage?.completion_tokens ?? 0, totalTokens: response.usage?.total_tokens ?? 0, cost: calculateOpenRouterCost(config.model, response.usage), latencyMs: Date.now() - started, cached: false, requestId: response._request_id ?? response.id, finishReason: response.choices[0]?.finish_reason ?? undefined }; } catch (error) { throw classifyProviderError(error, Provider.OPENROUTER); } } async completeWithVision(config: VisionConfig, systemPrompt: string, userPrompt: string, imageUrls: string[], signal?: AbortSignal): Promise { + throwIfAborted(signal, Provider.OPENROUTER); for (const url of imageUrls) validateImageUrl(url); const started = Date.now(); + const content: ChatContentPart[] = [{ type: 'text', text: userPrompt }, ...imageUrls.map(url => ({ type: 'image_url' as const, image_url: { url, detail: config.detail } }))]; try { - const content: OpenAI.Chat.ChatCompletionContentPart[] = [{ type: 'text', text: userPrompt }, ...imageUrls.map(url => ({ type: 'image_url' as const, image_url: { url, detail: config.detail } }))]; - const response = await this.client.chat.completions.create({ model: MODEL_IDS[config.model], messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content }], temperature: 0.2, max_tokens: config.maxTokens }, { signal }); - return { content: response.choices[0]?.message?.content ?? '', model: config.model, provider: Provider.OPENROUTER, inputTokens: response.usage?.prompt_tokens ?? 0, outputTokens: response.usage?.completion_tokens ?? 0, totalTokens: response.usage?.total_tokens ?? 0, cost: calculateOpenRouterCost(config.model, response.usage), latencyMs: Date.now() - started, cached: false, requestId: response._request_id ?? undefined, finishReason: response.choices[0]?.finish_reason ?? undefined }; + const response = await this.transport.create({ model: MODEL_IDS[config.model], messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content }], temperature: 0.2, max_tokens: config.maxTokens }, { signal }); + return { content: response.choices[0]?.message?.content ?? '', model: config.model, provider: Provider.OPENROUTER, inputTokens: response.usage?.prompt_tokens ?? 0, outputTokens: response.usage?.completion_tokens ?? 0, totalTokens: response.usage?.total_tokens ?? 0, cost: calculateOpenRouterCost(config.model, response.usage), latencyMs: Date.now() - started, cached: false, requestId: response._request_id ?? response.id, finishReason: response.choices[0]?.finish_reason ?? undefined }; } catch (error) { throw classifyProviderError(error, Provider.OPENROUTER); } } async completeWithFallback(config: GeneralModelConfig, fallbacks: GeneralModel[], systemPrompt: string, userPrompt: string, signal?: AbortSignal): Promise { - const attempts = [config.model, ...fallbacks.filter(model => model !== config.model)]; + const attempts = [...new Set([config.model, ...fallbacks])]; const errors: ProviderRequestError[] = []; for (const model of attempts) { + throwIfAborted(signal, Provider.OPENROUTER); try { return await this.complete({ ...config, model }, systemPrompt, userPrompt, signal); } - catch (error) { errors.push(classifyProviderError(error, Provider.OPENROUTER)); } + catch (error) { + const classified = classifyProviderError(error, Provider.OPENROUTER); + errors.push(classified); + if (!classified.retryable || classified.kind === 'cancelled') throw classified; + } } - throw new ProviderRequestError(`All OpenRouter models failed: ${errors.map(error => `${error.kind}:${error.message}`).join(' | ')}`, { provider: Provider.OPENROUTER, kind: errors.some(error => error.retryable) ? 'server' : 'client', retryable: errors.some(error => error.retryable), cause: errors }); + throw new ProviderRequestError('All OpenRouter fallback attempts failed', { + provider: Provider.OPENROUTER, + kind: errors.at(-1)?.kind ?? 'unknown', + retryable: errors.some(error => error.retryable), + code: 'ALL_FALLBACKS_FAILED', + cause: errors, + }); } } diff --git a/src/providers/perplexity.ts b/src/providers/perplexity.ts index 6d75082..61787c3 100644 --- a/src/providers/perplexity.ts +++ b/src/providers/perplexity.ts @@ -1,5 +1,4 @@ -import OpenAI from 'openai'; -import { classifyProviderError, ProviderRequestError } from '../provider-errors.js'; +import { classifyProviderError, ProviderRequestError, throwIfAborted } from '../provider-errors.js'; import { MessageStrategy, Provider, @@ -7,13 +6,28 @@ import { type LLMResponse, type PerplexityConfig, } from '../types.js'; +import { OpenAIChatTransport, type ChatMessage, type ChatTransport } from './openai-transport.js'; export interface PerplexityClientLike { complete(config: PerplexityConfig, systemPrompt: string, userPrompt: string, assistantContext?: string, signal?: AbortSignal): Promise; - completeWithConsensus(config: PerplexityConfig, systemPrompt: string, userPrompt: string, assistantContext?: string, signal?: AbortSignal): Promise<{ best: LLMResponse; all: LLMResponse[]; consensusScore: number }>; + completeWithConsensus(config: PerplexityConfig, systemPrompt: string, userPrompt: string, assistantContext?: string, signal?: AbortSignal): Promise; } -export function buildRequestBody(config: PerplexityConfig, messages: OpenAI.Chat.ChatCompletionMessageParam[]): Record { +export interface PerplexityConsensusResult { + best: LLMResponse; + all: LLMResponse[]; + consensusScore: number; + aggregate: { + inputTokens: number; + outputTokens: number; + totalTokens: number; + cost: number; + latencyMs: number; + citations: string[]; + }; +} + +export function buildRequestBody(config: PerplexityConfig, messages: ChatMessage[]): Record { const body: Record = { model: config.model, messages, temperature: config.temperature, max_tokens: config.maxTokens }; if (!config.disableSearch) { const web: Record = { search_context_size: config.searchContextSize }; @@ -27,32 +41,76 @@ export function buildRequestBody(config: PerplexityConfig, messages: OpenAI.Chat } export class PerplexityClient implements PerplexityClientLike { - private readonly client: OpenAI; - constructor(apiKey: string, timeoutMs = 60_000) { this.client = new OpenAI({ apiKey, baseURL: 'https://api.perplexity.ai', timeout: timeoutMs, maxRetries: 0 }); } + private readonly transport: ChatTransport; + constructor(apiKey: string, timeoutMs = 60_000, transport?: ChatTransport) { + this.transport = transport ?? new OpenAIChatTransport({ apiKey, baseURL: 'https://api.perplexity.ai', timeoutMs, maxRetries: 0 }); + } async complete(config: PerplexityConfig, systemPrompt: string, userPrompt: string, assistantContext?: string, signal?: AbortSignal): Promise { + throwIfAborted(signal, Provider.PERPLEXITY); const started = Date.now(); - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [{ role: 'system', content: systemPrompt }]; + const messages: ChatMessage[] = [{ role: 'system', content: systemPrompt }]; if (config.messageStrategy === MessageStrategy.SYSTEM_USER_ASSISTANT && assistantContext) messages.push({ role: 'assistant', content: assistantContext }); messages.push({ role: 'user', content: userPrompt }); try { - const response = await this.client.chat.completions.create(buildRequestBody(config, messages) as unknown as OpenAI.Chat.ChatCompletionCreateParamsNonStreaming, { signal }); - const citations = (response as unknown as { citations?: string[] }).citations; + const response = await this.transport.create(buildRequestBody(config, messages) as never, { signal }); const inputTokens = response.usage?.prompt_tokens ?? 0; const outputTokens = response.usage?.completion_tokens ?? 0; const rates = config.model === SonarModel.SONAR ? { input: 0.001, output: 0.001 } : { input: 0.003, output: 0.015 }; - return { content: response.choices[0]?.message?.content ?? '', model: config.model, provider: Provider.PERPLEXITY, inputTokens, outputTokens, totalTokens: response.usage?.total_tokens ?? inputTokens + outputTokens, cost: response.usage ? Math.round(((inputTokens / 1000) * rates.input + (outputTokens / 1000) * rates.output) * 100000) / 100000 : config.estimatedCostPerCall, latencyMs: Date.now() - started, cached: false, citations, requestId: response._request_id ?? undefined, finishReason: response.choices[0]?.finish_reason ?? undefined }; + return { content: response.choices[0]?.message?.content ?? '', model: config.model, provider: Provider.PERPLEXITY, inputTokens, outputTokens, totalTokens: response.usage?.total_tokens ?? inputTokens + outputTokens, cost: response.usage ? Math.round(((inputTokens / 1000) * rates.input + (outputTokens / 1000) * rates.output) * 100000) / 100000 : config.estimatedCostPerCall, latencyMs: Date.now() - started, cached: false, citations: response.citations, requestId: response._request_id ?? response.id, finishReason: response.choices[0]?.finish_reason ?? undefined }; } catch (error) { throw classifyProviderError(error, Provider.PERPLEXITY); } } - async completeWithConsensus(config: PerplexityConfig, systemPrompt: string, userPrompt: string, assistantContext?: string, signal?: AbortSignal): Promise<{ best: LLMResponse; all: LLMResponse[]; consensusScore: number }> { - if (config.variations <= 1) { const result = await this.complete(config, systemPrompt, userPrompt, assistantContext, signal); return { best: result, all: [result], consensusScore: 1 }; } + async completeWithConsensus(config: PerplexityConfig, systemPrompt: string, userPrompt: string, assistantContext?: string, signal?: AbortSignal): Promise { + throwIfAborted(signal, Provider.PERPLEXITY); + if (!Number.isInteger(config.variations) || config.variations < 1 || config.variations > 5) { + throw new ProviderRequestError('Perplexity consensus variations must be an integer between 1 and 5', { + provider: Provider.PERPLEXITY, + kind: 'local', + retryable: false, + code: 'INVALID_VARIATION_COUNT', + }); + } + if (config.variations <= 1) { + const result = await this.complete(config, systemPrompt, userPrompt, assistantContext, signal); + return { best: result, all: [result], consensusScore: 1, aggregate: aggregateResponses([result]) }; + } const settled = await Promise.allSettled(Array.from({ length: config.variations }, () => this.complete(config, systemPrompt, userPrompt, assistantContext, signal))); const successes = settled.filter((entry): entry is PromiseFulfilledResult => entry.status === 'fulfilled').map(entry => entry.value); - if (successes.length === 0) throw new ProviderRequestError('All Perplexity consensus variations failed', { provider: Provider.PERPLEXITY, kind: 'server', retryable: true, cause: settled }); - return { best: successes.reduce((best, candidate) => candidate.content.length > best.content.length ? candidate : best, successes[0]), all: successes, consensusScore: successes.length / config.variations }; + if (successes.length === 0) { + throwIfAborted(signal, Provider.PERPLEXITY); + const failures = settled + .filter((entry): entry is PromiseRejectedResult => entry.status === 'rejected') + .map(entry => classifyProviderError(entry.reason, Provider.PERPLEXITY)); + const terminal = failures.find(failure => !failure.retryable || failure.kind === 'cancelled'); + if (terminal) throw terminal; + throw new ProviderRequestError('All Perplexity consensus variations failed', { + provider: Provider.PERPLEXITY, + kind: failures.at(-1)?.kind ?? 'unknown', + retryable: failures.some(failure => failure.retryable), + code: 'ALL_CONSENSUS_FAILED', + cause: failures, + }); + } + return { + best: successes.reduce((best, candidate) => candidate.content.length > best.content.length ? candidate : best, successes[0]), + all: successes, + consensusScore: successes.length / config.variations, + aggregate: aggregateResponses(successes), + }; } } +function aggregateResponses(responses: LLMResponse[]): PerplexityConsensusResult['aggregate'] { + return { + inputTokens: responses.reduce((total, response) => total + response.inputTokens, 0), + outputTokens: responses.reduce((total, response) => total + response.outputTokens, 0), + totalTokens: responses.reduce((total, response) => total + response.totalTokens, 0), + cost: Math.round(responses.reduce((total, response) => total + response.cost, 0) * 1_000_000) / 1_000_000, + latencyMs: Math.max(...responses.map(response => response.latencyMs)), + citations: [...new Set(responses.flatMap(response => response.citations ?? []))].sort((left, right) => left.localeCompare(right)), + }; +} + /** @deprecated Direct provider access bypasses router budget and circuit controls. */ export class PerplexityError extends ProviderRequestError {} diff --git a/tests/provider-contracts.test.ts b/tests/provider-contracts.test.ts new file mode 100644 index 0000000..5cef369 --- /dev/null +++ b/tests/provider-contracts.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { OpenRouterClient } from '../src/providers/openrouter.js'; +import { PerplexityClient, buildRequestBody } from '../src/providers/perplexity.js'; +import { ProviderRequestError } from '../src/provider-errors.js'; +import { GeneralModel, MessageStrategy, Provider, RecencyFilter, SearchContextSize, SearchMode, SonarModel, type GeneralModelConfig, type PerplexityConfig } from '../src/types.js'; +import type { ChatCompletionRequest, ChatCompletionResult, ChatTransport } from '../src/providers/openai-transport.js'; + +class CapturingTransport implements ChatTransport { + requests: ChatCompletionRequest[] = []; + constructor(private readonly result: ChatCompletionResult = { id: 'req', choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }], usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 } }) {} + async create(request: ChatCompletionRequest): Promise { this.requests.push(request); return this.result; } +} + +class ScriptedTransport implements ChatTransport { + requests: ChatCompletionRequest[] = []; + constructor(private readonly outcomes: Array) {} + async create(request: ChatCompletionRequest): Promise { + this.requests.push(request); + const outcome = this.outcomes.shift(); + if (outcome instanceof Error || (outcome && typeof outcome === 'object' && ('status' in outcome || 'code' in outcome))) throw outcome; + if (!outcome) throw new Error('Scripted transport exhausted'); + return outcome as ChatCompletionResult; + } +} + +const general: GeneralModelConfig = { model: GeneralModel.GPT4O_MINI, provider: Provider.OPENROUTER, temperature: 0.1, maxTokens: 100, responseFormat: 'json', estimatedCostPerCall: 0.01, resolutionReason: 'test' }; +const perplexity: PerplexityConfig = { model: SonarModel.SONAR_PRO, searchContextSize: SearchContextSize.HIGH, searchMode: SearchMode.WEB, recencyFilter: RecencyFilter.WEEK, messageStrategy: MessageStrategy.SYSTEM_USER, temperature: 0.2, maxTokens: 100, domainFilter: ['example.com'], variations: 1, disableSearch: false, estimatedCostPerCall: 0.01, resolutionReason: 'test' }; + +describe('provider transport contracts', () => { + it('preserves OpenRouter JSON request shape and usage', async () => { + const transport = new CapturingTransport(); + const client = new OpenRouterClient('key', 'app', 1000, transport); + const result = await client.complete(general, 'system', 'user'); + expect(transport.requests[0]).toMatchObject({ model: 'openai/gpt-4o-mini', response_format: { type: 'json_object' }, max_tokens: 100 }); + expect(result).toMatchObject({ content: 'ok', inputTokens: 10, outputTokens: 5, totalTokens: 15, requestId: 'req' }); + }); + + it('preserves OpenRouter vision content', async () => { + const transport = new CapturingTransport(); + const client = new OpenRouterClient('key', 'app', 1000, transport); + await client.completeWithVision({ model: GeneralModel.GPT4O, provider: Provider.OPENROUTER, maxTokens: 100, detail: 'high', estimatedCostPerCall: 0.01, resolutionReason: 'test' }, 'system', 'user', ['https://cdn.example.com/a.png']); + expect(transport.requests[0].messages[1].content).toEqual([{ type: 'text', text: 'user' }, { type: 'image_url', image_url: { url: 'https://cdn.example.com/a.png', detail: 'high' } }]); + }); + + it('keeps Perplexity search extensions outside SDK types', async () => { + const transport = new CapturingTransport({ id: 'p', citations: ['https://example.com'], choices: [{ message: { content: 'answer' }, finish_reason: 'stop' }], usage: { prompt_tokens: 4, completion_tokens: 6, total_tokens: 10 } }); + const client = new PerplexityClient('key', 1000, transport); + const result = await client.complete(perplexity, 'system', 'user'); + expect(transport.requests[0]).toMatchObject({ web_search_options: { search_context_size: 'high', search_recency_filter: 'week', search_domain_filter: ['example.com'] } }); + expect(result.citations).toEqual(['https://example.com']); + }); + + it('omits web search options when search is disabled', () => { + const body = buildRequestBody({ ...perplexity, disableSearch: true }, []); + expect(body).not.toHaveProperty('web_search_options'); + }); + + it('falls back only after retryable OpenRouter failures', async () => { + const transport = new ScriptedTransport([ + { status: 503, message: 'temporary outage' }, + { id: 'ok', choices: [{ message: { content: 'fallback' } }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }, + ]); + const client = new OpenRouterClient('key', 'app', 1000, transport); + const result = await client.completeWithFallback(general, [GeneralModel.GEMINI_FLASH], 'system', 'user'); + expect(result.content).toBe('fallback'); + expect(transport.requests.map(request => request.model)).toEqual(['openai/gpt-4o-mini', 'google/gemini-2.5-flash']); + }); + + it('does not retry non-retryable or cancelled OpenRouter failures', async () => { + const clientErrorTransport = new ScriptedTransport([{ status: 400, message: 'invalid request' }]); + const client = new OpenRouterClient('key', 'app', 1000, clientErrorTransport); + await expect(client.completeWithFallback(general, [GeneralModel.GEMINI_FLASH], 'system', 'user')).rejects.toMatchObject({ kind: 'client', retryable: false }); + expect(clientErrorTransport.requests).toHaveLength(1); + + const abortedTransport = new ScriptedTransport([]); + const abortedClient = new OpenRouterClient('key', 'app', 1000, abortedTransport); + const controller = new AbortController(); + controller.abort('cancelled by caller'); + await expect(abortedClient.completeWithFallback(general, [GeneralModel.GEMINI_FLASH], 'system', 'user', controller.signal)).rejects.toMatchObject({ kind: 'cancelled', retryable: false }); + expect(abortedTransport.requests).toHaveLength(0); + }); + + it('reports aggregate consensus usage and cost', async () => { + const transport = new ScriptedTransport([ + { id: 'a', choices: [{ message: { content: 'one' } }], usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, citations: ['https://a.example'] }, + { id: 'b', choices: [{ message: { content: 'a longer answer' } }], usage: { prompt_tokens: 20, completion_tokens: 10, total_tokens: 30 }, citations: ['https://b.example'] }, + { id: 'c', choices: [{ message: { content: 'three' } }], usage: { prompt_tokens: 30, completion_tokens: 15, total_tokens: 45 }, citations: ['https://a.example'] }, + ]); + const client = new PerplexityClient('key', 1000, transport); + const result = await client.completeWithConsensus({ ...perplexity, variations: 3 }, 'system', 'user'); + expect(result.best.content).toBe('a longer answer'); + expect(result.aggregate).toMatchObject({ inputTokens: 60, outputTokens: 30, totalTokens: 90 }); + expect(result.aggregate.cost).toBeCloseTo(result.all.reduce((total, response) => total + response.cost, 0)); + expect(result.aggregate.citations).toEqual(['https://a.example', 'https://b.example']); + }); + + it('preserves terminal consensus failure classification', async () => { + const transport = new ScriptedTransport([{ status: 400, message: 'bad' }, { status: 400, message: 'bad' }]); + const client = new PerplexityClient('key', 1000, transport); + const failure = client.completeWithConsensus({ ...perplexity, variations: 2 }, 'system', 'user'); + await expect(failure).rejects.toBeInstanceOf(ProviderRequestError); + await expect(failure).rejects.toMatchObject({ kind: 'client', retryable: false }); + }); + + it('bounds direct consensus fan-out', async () => { + const transport = new ScriptedTransport([]); + const client = new PerplexityClient('key', 1000, transport); + await expect(client.completeWithConsensus({ ...perplexity, variations: 6 }, 'system', 'user')).rejects.toMatchObject({ kind: 'local', code: 'INVALID_VARIATION_COUNT' }); + expect(transport.requests).toHaveLength(0); + }); +}); diff --git a/tests/router-execution.test.ts b/tests/router-execution.test.ts index 7909b85..0dcb98f 100644 --- a/tests/router-execution.test.ts +++ b/tests/router-execution.test.ts @@ -11,7 +11,12 @@ const fakeOpenRouter = { }; const fakePerplexity = { complete: async (_config: PerplexityConfig) => ({ ...response, provider: Provider.PERPLEXITY }), - completeWithConsensus: async (_config: PerplexityConfig) => ({ best: { ...response, provider: Provider.PERPLEXITY }, all: [{ ...response, provider: Provider.PERPLEXITY }], consensusScore: 1 }), + completeWithConsensus: async (_config: PerplexityConfig) => ({ + best: { ...response, provider: Provider.PERPLEXITY }, + all: [{ ...response, provider: Provider.PERPLEXITY }], + consensusScore: 1, + aggregate: { inputTokens: 1, outputTokens: 1, totalTokens: 2, cost: 0.1, latencyMs: 5, citations: [] }, + }), }; describe('router execution', () => { @@ -69,6 +74,23 @@ describe('router execution', () => { expect(router.getCallLog()[0]).toMatchObject({ model: GeneralModel.CLAUDE_SONNET_VISION, estimatedCost: 0.03 }); }); + it('reconciles the aggregate cost of consensus execution', async () => { + const consensusPerplexity = { + ...fakePerplexity, + completeWithConsensus: async (_config: PerplexityConfig) => ({ + best: { ...response, provider: Provider.PERPLEXITY, cost: 0.1 }, + all: [], + consensusScore: 1, + aggregate: { inputTokens: 30, outputTokens: 15, totalTokens: 45, cost: 0.6, latencyMs: 20, citations: ['https://example.com'] }, + }), + }; + const router = new L9LLMRouter({ perplexityApiKey: 'p', openrouterApiKey: 'o' }, { openrouterClient: fakeOpenRouter, perplexityClient: consensusPerplexity }); + router.initClient('a'); + const result = await router.execute({ clientId: 'a', type: TaskType.MARKET_RESEARCH, complexity: TaskComplexity.HIGH }, 's', 'u', { consensus: true }); + expect(result).toMatchObject({ cost: 0.6, inputTokens: 30, outputTokens: 15, totalTokens: 45 }); + expect(router.getClientBudgetReport('a').monthSpend).toBe(0.6); + }); + it('activates the public budget-exhausted error contract', async () => { let providerCalls = 0; const guardedOpenRouter = {