diff --git a/package-lock.json b/package-lock.json index c7d6c33..421f5b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,24 +1,26 @@ { "name": "vectorlint", - "version": "2.1.1", + "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vectorlint", - "version": "2.1.1", + "version": "2.3.0", "license": "Apache-2.0", "dependencies": { - "@anthropic-ai/sdk": "^0.30.1", - "@google/generative-ai": "^0.24.1", - "@perplexity-ai/perplexity_ai": "^0.16.0", + "@ai-sdk/anthropic": "^1.0.0", + "@ai-sdk/azure": "^3.0.31", + "@ai-sdk/google": "^1.0.0", + "@ai-sdk/openai": "^1.0.0", + "@ai-sdk/perplexity": "^1.0.0", "@types/micromatch": "^4.0.9", + "ai": "^4.0.0", "chalk": "^5.3.0", "commander": "^12.0.0", "fast-glob": "^3.3.2", "fuzzball": "^2.2.3", "micromatch": "^4.0.5", - "openai": "^4.0.0", "smol-toml": "^1.6.0", "strip-ansi": "^7.1.0", "yaml": "^2.5.0", @@ -49,6 +51,202 @@ "node": ">=18.0.0" } }, + "node_modules/@ai-sdk/anthropic": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-1.2.12.tgz", + "integrity": "sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "@ai-sdk/provider-utils": "2.2.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + } + }, + "node_modules/@ai-sdk/azure": { + "version": "3.0.31", + "resolved": "https://registry.npmjs.org/@ai-sdk/azure/-/azure-3.0.31.tgz", + "integrity": "sha512-W9x6nt+yf+Ns0/Wx7U9TXHLmfu7mOUqy1b/drtVd3DvNfDudyruQM/YjM2268Q0FatSrPlA2RlnPVPGRH/4V8Q==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/openai": "3.0.30", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.15" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/azure/node_modules/@ai-sdk/openai": { + "version": "3.0.30", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.30.tgz", + "integrity": "sha512-YDht3t7TDyWKP+JYZp20VuYqSjyF2brHYh47GGFDUPf2wZiqNQ263ecL+quar2bP3GZ3BeQA8f0m2B7UwLPR+g==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.15" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/azure/node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/azure/node_modules/@ai-sdk/provider-utils": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.15.tgz", + "integrity": "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/google": { + "version": "1.2.22", + "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-1.2.22.tgz", + "integrity": "sha512-Ppxu3DIieF1G9pyQ5O1Z646GYR0gkC57YdBqXJ82qvCdhEhZHu0TWhmnOoeIWe2olSbuDeoOY+MfJrW8dzS3Hw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "@ai-sdk/provider-utils": "2.2.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + } + }, + "node_modules/@ai-sdk/openai": { + "version": "1.3.24", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-1.3.24.tgz", + "integrity": "sha512-GYXnGJTHRTZc4gJMSmFRgEQudjqd4PUN0ZjQhPwOAYH1yOAvQoG/Ikqs+HyISRbLPCrhbZnPKCNHuRU4OfpW0Q==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "@ai-sdk/provider-utils": "2.2.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + } + }, + "node_modules/@ai-sdk/perplexity": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@ai-sdk/perplexity/-/perplexity-1.1.9.tgz", + "integrity": "sha512-Ytolh/v2XupXbTvjE18EFBrHLoNMH0Ueji3lfSPhCoRUfkwrgZ2D9jlNxvCNCCRiGJG5kfinSHvzrH5vGDklYA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "@ai-sdk/provider-utils": "2.2.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.3.tgz", + "integrity": "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.2.8.tgz", + "integrity": "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "nanoid": "^3.3.8", + "secure-json-parse": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.23.8" + } + }, + "node_modules/@ai-sdk/react": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-1.2.12.tgz", + "integrity": "sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider-utils": "2.2.8", + "@ai-sdk/ui-utils": "1.2.11", + "swr": "^2.2.5", + "throttleit": "2.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@ai-sdk/ui-utils": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-1.2.11.tgz", + "integrity": "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "@ai-sdk/provider-utils": "2.2.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.23.8" + } + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -63,36 +261,6 @@ "node": ">=6.0.0" } }, - "node_modules/@anthropic-ai/sdk": { - "version": "0.30.1", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.30.1.tgz", - "integrity": "sha512-nuKvp7wOIz6BFei8WrTdhmSsx5mwnArYyJgh4+vYu3V4J0Ltb8Xm3odPm51n1aSI0XxNCrDl7O88cxCtUdAkaw==", - "license": "MIT", - "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" - } - }, - "node_modules/@anthropic-ai/sdk/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/@anthropic-ai/sdk/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/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -783,15 +951,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@google/generative-ai": { - "version": "0.24.1", - "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz", - "integrity": "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -982,11 +1141,14 @@ "node": ">= 8" } }, - "node_modules/@perplexity-ai/perplexity_ai": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@perplexity-ai/perplexity_ai/-/perplexity_ai-0.16.0.tgz", - "integrity": "sha512-VF6UHAhYl9MyPtvmsz7QmXCWZTj0RbixdMDYPxIAzqsD42saN/Tsie5qdsjJ+/c5JQiweh9CdBlhl5Qq7ofhLA==", - "license": "Apache-2.0" + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", @@ -1307,6 +1469,12 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -1324,6 +1492,12 @@ "integrity": "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==", "license": "MIT" }, + "node_modules/@types/diff-match-patch": { + "version": "1.0.36", + "resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz", + "integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1351,21 +1525,12 @@ "version": "20.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz", "integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==", + "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.46.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.1.tgz", @@ -2039,18 +2204,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.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2074,16 +2227,30 @@ "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", + "node_modules/ai": { + "version": "4.3.19", + "resolved": "https://registry.npmjs.org/ai/-/ai-4.3.19.tgz", + "integrity": "sha512-dIE2bfNpqHN3r6IINp9znguYdhIOheKW2LDigAMrgt/upT3B8eBGPSCblENvaZGoq+hxaN9fSMzjWpbqloP+7Q==", + "license": "Apache-2.0", "dependencies": { - "humanize-ms": "^1.2.1" + "@ai-sdk/provider": "1.1.3", + "@ai-sdk/provider-utils": "2.2.8", + "@ai-sdk/react": "1.2.12", + "@ai-sdk/ui-utils": "1.2.11", + "@opentelemetry/api": "1.9.0", + "jsondiffpatch": "0.6.0" }, "engines": { - "node": ">= 8.0.0" + "node": ">=18" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } } }, "node_modules/ajv": { @@ -2155,12 +2322,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", @@ -2274,19 +2435,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", @@ -2439,18 +2587,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/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -2558,28 +2694,20 @@ "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==", + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "license": "MIT", "engines": { - "node": ">=0.4.0" + "node": ">=6" } }, - "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/diff-match-patch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", + "license": "Apache-2.0" }, "node_modules/eastasianwidth": { "version": "0.2.0", @@ -2616,24 +2744,6 @@ "node": ">=10.13.0" } }, - "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", @@ -2641,33 +2751,6 @@ "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.25.10", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", @@ -3198,13 +3281,13 @@ "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==", + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", "license": "MIT", "engines": { - "node": ">=6" + "node": ">=18.0.0" } }, "node_modules/expect-type": { @@ -3368,41 +3451,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", - "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", @@ -3418,15 +3466,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/fuzzball": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/fuzzball/-/fuzzball-2.2.3.tgz", @@ -3438,43 +3477,6 @@ "setimmediate": "^1.0.5" } }, - "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.10.1", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", @@ -3567,18 +3569,6 @@ "dev": true, "license": "MIT" }, - "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/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -3603,45 +3593,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.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/heap": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.7.tgz", @@ -3655,15 +3606,6 @@ "dev": true, "license": "MIT" }, - "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", @@ -3900,6 +3842,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -3914,6 +3862,23 @@ "dev": true, "license": "MIT" }, + "node_modules/jsondiffpatch": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/jsondiffpatch/-/jsondiffpatch-0.6.0.tgz", + "integrity": "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==", + "license": "MIT", + "dependencies": { + "@types/diff-match-patch": "^1.0.36", + "chalk": "^5.3.0", + "diff-match-patch": "^1.0.5" + }, + "bin": { + "jsondiffpatch": "bin/jsondiffpatch.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4056,15 +4021,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "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/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4087,27 +4043,6 @@ "node": ">=8.6" } }, - "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.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -4155,6 +4090,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/mz": { @@ -4173,7 +4109,6 @@ "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", @@ -4211,46 +4146,6 @@ "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/node-releases": { "version": "2.0.23", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz", @@ -4268,51 +4163,6 @@ "node": ">=0.10.0" } }, - "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.127", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.127.tgz", - "integrity": "sha512-gSjxjrnKXML/yo0BO099uPixMqfpJU0TKYjpfLU7TrtA2WWDki412Np/RSTPRil1saKBhvVVKzVx/p/6p94nVA==", - "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", @@ -4607,6 +4457,16 @@ ], "license": "MIT" }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -4752,6 +4612,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -5078,6 +4944,19 @@ "node": ">=8" } }, + "node_modules/swr": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.0.tgz", + "integrity": "sha512-sUlC20T8EOt1pHmDiqueUWMmRRX03W7w5YxovWX7VR2KHEPCTMly85x05vpkP5i6Bu4h44ePSMD9Tc+G2MItFw==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/tapable": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", @@ -5156,6 +5035,18 @@ "node": ">=0.8" } }, + "node_modules/throttleit": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", + "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -5260,12 +5151,6 @@ "node": ">=8.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/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -5485,6 +5370,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/unrs-resolver": { @@ -5563,6 +5449,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -6142,31 +6037,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", @@ -6338,6 +6208,15 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } } } } diff --git a/package.json b/package.json index ba1c5f6..2b6f7d2 100644 --- a/package.json +++ b/package.json @@ -54,16 +54,18 @@ "node": ">=18.0.0" }, "dependencies": { - "@anthropic-ai/sdk": "^0.30.1", - "@google/generative-ai": "^0.24.1", - "@perplexity-ai/perplexity_ai": "^0.16.0", + "@ai-sdk/anthropic": "^1.0.0", + "@ai-sdk/azure": "^3.0.31", + "@ai-sdk/google": "^1.0.0", + "@ai-sdk/openai": "^1.0.0", + "@ai-sdk/perplexity": "^1.0.0", "@types/micromatch": "^4.0.9", + "ai": "^4.0.0", "chalk": "^5.3.0", "commander": "^12.0.0", "fast-glob": "^3.3.2", "fuzzball": "^2.2.3", "micromatch": "^4.0.5", - "openai": "^4.0.0", "smol-toml": "^1.6.0", "strip-ansi": "^7.1.0", "yaml": "^2.5.0", @@ -86,4 +88,4 @@ "typescript-eslint": "^8.46.1", "vitest": "^2.0.0" } -} \ No newline at end of file +} diff --git a/src/boundaries/api-client.ts b/src/boundaries/api-client.ts deleted file mode 100644 index 7a4d98d..0000000 --- a/src/boundaries/api-client.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { - OPENAI_RESPONSE_SCHEMA, - ANTHROPIC_MESSAGE_SCHEMA, - type OpenAIResponse, - type AnthropicMessage -} from '../schemas/api-schemas'; -import { ValidationError, handleUnknownError } from '../errors/index'; - -export function validateApiResponse(raw: unknown): OpenAIResponse { - try { - return OPENAI_RESPONSE_SCHEMA.parse(raw); - } catch (e: unknown) { - if (e instanceof Error && 'issues' in e) { - // Zod error - throw new ValidationError(`Invalid API response: ${e.message}`); - } - const err = handleUnknownError(e, 'API response validation'); - throw new ValidationError(`API response validation failed: ${err.message}`); - } -} - -export function validateAnthropicResponse(raw: unknown): AnthropicMessage { - try { - return ANTHROPIC_MESSAGE_SCHEMA.parse(raw); - } catch (e: unknown) { - if (e instanceof Error && 'issues' in e) { - // Zod error - throw new ValidationError(`Invalid Anthropic API response: ${e.message}`); - } - const err = handleUnknownError(e, 'Anthropic API response validation'); - throw new ValidationError(`Anthropic API response validation failed: ${err.message}`); - } -} diff --git a/src/boundaries/index.ts b/src/boundaries/index.ts index 467ffc1..c273c05 100644 --- a/src/boundaries/index.ts +++ b/src/boundaries/index.ts @@ -2,5 +2,4 @@ export * from './cli-parser'; export * from './config-loader'; export * from './yaml-parser'; -export * from './api-client'; export * from './env-parser'; diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 8629321..01309c7 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -26,6 +26,25 @@ const __filename = fileURLToPath(import.meta.url); // eslint-disable-next-line @typescript-eslint/naming-convention const __dirname = dirname(__filename); +/** + * Resolves the presets directory for both dev and built modes. + * - Built mode: __dirname is `dist/`, so `../presets` resolves to project root `presets/` + * - Dev mode: __dirname is `src/cli/`, so `../../presets` resolves to project root `presets/` + */ +function resolvePresetsDir(dir: string): string { + const buildPath = path.resolve(dir, '../presets'); + if (existsSync(path.join(buildPath, 'meta.json'))) { + return buildPath; + } + // Dev mode fallback: src/cli/ → ../../presets + const devPath = path.resolve(dir, '../../presets'); + if (existsSync(path.join(devPath, 'meta.json'))) { + return devPath; + } + + throw new Error(`Could not locate presets directory containing meta.json. Looked in ${buildPath} and ${devPath}`); +} + /* * Registers the main evaluation command with Commander. * This is the default command that runs content evaluations against target files. @@ -117,7 +136,7 @@ export function registerMainCommand(program: Command): void { const prompts: PromptFile[] = []; try { - const presetsDir = path.resolve(__dirname, '../presets'); + const presetsDir = resolvePresetsDir(__dirname); const presetLoader = new PresetLoader(presetsDir); const loader = new RulePackLoader(presetLoader); diff --git a/src/providers/anthropic-provider.ts b/src/providers/anthropic-provider.ts deleted file mode 100644 index 9dbaf4c..0000000 --- a/src/providers/anthropic-provider.ts +++ /dev/null @@ -1,241 +0,0 @@ -import Anthropic from '@anthropic-ai/sdk'; -import { z } from 'zod'; -import { LLMProvider, LLMResult } from './llm-provider'; -import { DefaultRequestBuilder, RequestBuilder } from './request-builder'; -import { - ANTHROPIC_RESPONSE_SCHEMA, - type AnthropicResponse, - type AnthropicToolUseBlock, - isToolUseBlock, - isTextBlock -} from '../schemas/anthropic-responses'; -import { ValidationError, APIResponseError } from '../errors/validation-errors'; -import { handleUnknownError } from '../errors/index'; - -export interface AnthropicConfig { - apiKey: string; - model?: string; - maxTokens?: number; - temperature?: number; - debug?: boolean; - showPrompt?: boolean; - showPromptTrunc?: boolean; -} - -export const ANTHROPIC_DEFAULT_CONFIG = { - model: 'claude-3-sonnet-20240229', - maxTokens: 4096, - temperature: 0.2, -}; - -export class AnthropicProvider implements LLMProvider { - private client: Anthropic; - private config: AnthropicConfig; - private builder: RequestBuilder; - - constructor(config: AnthropicConfig, builder?: RequestBuilder) { - this.client = new Anthropic({ - apiKey: config.apiKey, - maxRetries: 2, - }); - this.config = { - ...config, - model: config.model ?? ANTHROPIC_DEFAULT_CONFIG.model, - maxTokens: config.maxTokens ?? ANTHROPIC_DEFAULT_CONFIG.maxTokens, - temperature: config.temperature ?? ANTHROPIC_DEFAULT_CONFIG.temperature, - }; - this.builder = builder ?? new DefaultRequestBuilder(); - } - - /** - * Validates Anthropic API response using schema validation - * Replaces unsafe type assertions with proper runtime validation - */ - private validateResponse(response: unknown): AnthropicResponse { - try { - return ANTHROPIC_RESPONSE_SCHEMA.parse(response); - } catch (e: unknown) { - if (e instanceof z.ZodError) { - throw new APIResponseError( - `Invalid Anthropic API response structure: ${e.message}`, - response, - e - ); - } - const err = handleUnknownError(e, 'Anthropic response validation'); - throw new ValidationError(`Anthropic response validation failed: ${err.message}`, e); - } - } - - async runPromptStructured( - content: string, - promptText: string, - schema: { name: string; schema: Record } - ): Promise> { - const systemPrompt = this.builder.buildPromptBodyForStructured(promptText); - - // Create tool schema for structured response - const toolSchema = this.convertToAnthropicToolSchema(schema); - - // Create request with both official Anthropic fields and E2E mock compatibility aliases - const params: Anthropic.Messages.MessageCreateParams & Record = { - // Official Anthropic fields (snake_case) - model: this.config.model!, - system: systemPrompt, - messages: [ - { - role: 'user', - content: `Input:\n\n${content}`, - }, - ], - max_tokens: this.config.maxTokens!, - tools: [toolSchema], - tool_choice: { type: 'tool', name: schema.name }, - maxTokens: this.config.maxTokens!, - toolChoice: { type: 'tool', name: schema.name }, - }; - - if (this.config.temperature !== undefined) { - params.temperature = this.config.temperature; - } - - if (this.config.debug) { - console.log('[vectorlint] Sending request to Anthropic:', { - model: this.config.model, - maxTokens: this.config.maxTokens, - temperature: this.config.temperature, - }); - if (this.config.showPrompt) { - console.log('[vectorlint] System prompt (full):'); - console.log(systemPrompt); - console.log('[vectorlint] User content (full):'); - console.log(content); - } else if (this.config.showPromptTrunc) { - console.log('[vectorlint] System prompt (first 500 chars):'); - console.log(systemPrompt.slice(0, 500)); - if (systemPrompt.length > 500) console.log('... [truncated]'); - const preview = content.slice(0, 500); - console.log('[vectorlint] User content preview (first 500 chars):'); - console.log(preview); - if (content.length > 500) console.log('... [truncated]'); - } - } - - // Create clean params for Anthropic API (remove E2E mock compatibility fields) - const anthropicParams: Anthropic.Messages.MessageCreateParams = { - model: params.model, - messages: params.messages, - max_tokens: params.max_tokens, - stream: false, - system: systemPrompt, - tools: [toolSchema], - tool_choice: { type: 'tool', name: schema.name }, - ...(params.temperature !== undefined && { temperature: params.temperature }), - }; - - let rawResponse: unknown; - try { - rawResponse = await this.client.messages.create(anthropicParams); - } catch (e: unknown) { - // Handle specific Anthropic SDK errors - if (e instanceof Anthropic.APIError) { - throw new Error(`Anthropic API error (${e.status}): ${e.message}`); - } - if (e instanceof Anthropic.RateLimitError) { - throw new Error(`Anthropic rate limit exceeded: ${e.message}`); - } - if (e instanceof Anthropic.AuthenticationError) { - throw new Error(`Anthropic authentication failed: ${e.message}`); - } - if (e instanceof Anthropic.BadRequestError) { - throw new Error(`Anthropic bad request: ${e.message}`); - } - - const err = handleUnknownError(e, 'Anthropic API call'); - throw new Error(`Anthropic API call failed: ${err.message}`); - } - - // Validate the API response structure using schema validation - const validatedResponse = this.validateResponse(rawResponse); - - const data = this.extractStructuredResponse(validatedResponse, schema.name); - - return { - data, - usage: { - inputTokens: validatedResponse.usage.input_tokens, - outputTokens: validatedResponse.usage.output_tokens, - } - }; - } - - private convertToAnthropicToolSchema(schema: { name: string; schema: Record }): Anthropic.Messages.Tool { - return { - name: schema.name, - description: `Submit ${schema.name} evaluation results`, - input_schema: { - type: 'object', - ...schema.schema, - }, - }; - } - - private extractStructuredResponse(response: AnthropicResponse, expectedToolName: string): T { - // Debug logging with type-safe property access - if (this.config.debug) { - const usage = response.usage; - const stopReason = response.stop_reason; - if (usage || stopReason) { - console.log('[vectorlint] LLM response meta:', { - usage: { - input_tokens: usage.input_tokens, - output_tokens: usage.output_tokens, - }, - stop_reason: stopReason, - }); - } - - } - - // Type-safe content validation - response is already validated by schema - const blocks = response.content; - if (blocks.length === 0) { - throw new Error('Empty response from Anthropic API (no content blocks).'); - } - - // Find the expected tool use block using type-safe filtering - const toolBlock = blocks.find((block): block is AnthropicToolUseBlock => - isToolUseBlock(block) && block.name === expectedToolName - ); - - if (!toolBlock) { - // Check if there are any tool use blocks at all - const toolUseBlocks = blocks.filter(isToolUseBlock); - if (toolUseBlocks.length > 0) { - const availableTools = toolUseBlocks.map(block => block.name); - throw new Error(`Expected tool call '${expectedToolName}' but received: ${availableTools.join(', ')}`); - } - - // Check if response contains text instead of tool use - const textBlocks = blocks.filter(isTextBlock); - const firstTextBlock = textBlocks[0]; - if (firstTextBlock) { - const textContent = firstTextBlock.text.slice(0, 200); - throw new Error(`No tool call received for ${expectedToolName}. Response contains text instead: ${textContent}${textContent.length >= 200 ? '...' : ''}`); - } - - throw new Error(`No tool call received for ${expectedToolName}. Response may not contain structured data.`); - } - - const input = toolBlock.input; - if (input == null || (typeof input === 'object' && !Array.isArray(input) && Object.keys(input).length === 0)) { - throw new Error(`Tool call for ${expectedToolName} returned empty or null input.`); - } - - if (typeof input !== 'object' || Array.isArray(input)) { - throw new Error(`Tool call for ${expectedToolName} returned invalid input type: ${typeof input}`); - } - - return input as T; - } -} diff --git a/src/providers/azure-openai-provider.ts b/src/providers/azure-openai-provider.ts deleted file mode 100644 index 403898a..0000000 --- a/src/providers/azure-openai-provider.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { AzureOpenAI } from 'openai'; -import { LLMProvider, LLMResult } from './llm-provider'; -import { DefaultRequestBuilder, RequestBuilder } from './request-builder'; -import { validateApiResponse } from '../boundaries/api-client'; -import { handleUnknownError } from '../errors/index'; - -export interface AzureOpenAIConfig { - apiKey: string; - endpoint: string; - deploymentName: string; - apiVersion?: string | undefined; - temperature?: number | undefined; - debug?: boolean | undefined; - showPrompt?: boolean | undefined; // full prompt and content - showPromptTrunc?: boolean | undefined; // truncated previews (500 chars) -} - -export const AZURE_OPENAI_DEFAULT_CONFIG = { - apiVersion: '2024-02-15-preview', - temperature: 0.2, -}; - -export class AzureOpenAIProvider implements LLMProvider { - private client: AzureOpenAI; - private deploymentName: string; - private temperature?: number | undefined; - private apiVersion?: string | undefined; - private debug?: boolean | undefined; - private showPrompt?: boolean | undefined; - private showPromptTrunc?: boolean | undefined; - private builder: RequestBuilder; - - constructor(config: AzureOpenAIConfig, builder?: RequestBuilder) { - this.client = new AzureOpenAI({ - apiKey: config.apiKey, - endpoint: config.endpoint, - apiVersion: config.apiVersion || AZURE_OPENAI_DEFAULT_CONFIG.apiVersion, - }); - this.deploymentName = config.deploymentName; - this.temperature = config.temperature; - this.apiVersion = config.apiVersion; - this.debug = config.debug; - this.showPrompt = config.showPrompt; - this.showPromptTrunc = config.showPromptTrunc; - this.builder = builder ?? new DefaultRequestBuilder(); - } - - async runPromptStructured(content: string, promptText: string, schema: { name: string; schema: Record }): Promise> { - const prompt = this.builder.buildPromptBodyForStructured(promptText); - - const params: Parameters[0] = { - model: this.deploymentName, - messages: [ - { role: 'system', content: prompt }, - { role: 'user', content: `Input:\n\n${content}` } - ], - response_format: { - type: 'json_schema', - json_schema: schema, - }, - }; - if (this.temperature !== undefined) { - params.temperature = this.temperature; - } - - if (this.debug) { - console.log('[vectorlint] Sending request to Azure OpenAI:', { - model: this.deploymentName, - apiVersion: this.apiVersion || AZURE_OPENAI_DEFAULT_CONFIG.apiVersion, - temperature: this.temperature, - }); - if (this.showPrompt) { - console.log('[vectorlint] Prompt (full):'); - console.log(prompt); - console.log('[vectorlint] Injected content (full):'); - console.log(content); - } else if (this.showPromptTrunc) { - console.log('[vectorlint] Prompt (first 500 chars):'); - console.log(prompt.slice(0, 500)); - if (prompt.length > 500) console.log('... [truncated]'); - const preview = content.slice(0, 500); - console.log('[vectorlint] Injected content preview (first 500 chars):'); - console.log(preview); - if (content.length > 500) console.log('... [truncated]'); - } - } - - let response; - try { - response = await this.client.chat.completions.create(params); - } catch (e: unknown) { - const err = handleUnknownError(e, 'OpenAI API call'); - throw new Error(`OpenAI API call failed: ${err.message}`); - } - - // Type guard to ensure we have a ChatCompletion, not a Stream - if (!('choices' in response)) { - throw new Error('Received streaming response when expecting structured response'); - } - - // Validate the API response structure - let validatedResponse; - try { - validatedResponse = validateApiResponse(response); - } catch (e: unknown) { - const err = handleUnknownError(e, 'API response validation'); - throw new Error(`Invalid API response structure: ${err.message}`); - } - - const responseTextRaw = validatedResponse.choices[0]?.message?.content; - const responseText = (responseTextRaw ?? '').trim(); - if (this.debug) { - const usage = validatedResponse.usage; - const finish = validatedResponse.choices[0]?.finish_reason; - if (usage || finish) { - console.log('[vectorlint] LLM response meta:', { usage, finish_reason: finish }); - } - - } - if (!responseText) { - throw new Error('Empty response from LLM (no content).'); - } - try { - const data = JSON.parse(responseText) as T; - const usage = validatedResponse.usage; - - const result: LLMResult = { data }; - if (usage) { - result.usage = { - inputTokens: usage.prompt_tokens, - outputTokens: usage.completion_tokens, - }; - } - return result; - } catch (e: unknown) { - const err = handleUnknownError(e, 'JSON parsing'); - const preview = responseText.slice(0, 200); - throw new Error(`Failed to parse structured JSON response: ${err.message}. Preview: ${preview}${responseText.length > 200 ? ' ...' : ''}`); - } - } -} diff --git a/src/providers/gemini-provider.ts b/src/providers/gemini-provider.ts deleted file mode 100644 index 53e87ed..0000000 --- a/src/providers/gemini-provider.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { GoogleGenerativeAI, GenerativeModel } from '@google/generative-ai'; -import { LLMProvider, LLMResult } from './llm-provider'; -import { DefaultRequestBuilder, RequestBuilder } from './request-builder'; -import { handleUnknownError } from '../errors/index'; - -export interface GeminiConfig { - apiKey: string; - model?: string; - temperature?: number; - debug?: boolean; - showPrompt?: boolean; - showPromptTrunc?: boolean; -} - -export const GEMINI_DEFAULT_CONFIG = { - model: 'gemini-2.5-flash', - temperature: 0.2, -}; - -export class GeminiProvider implements LLMProvider { - private client: GoogleGenerativeAI; - private model: GenerativeModel; - private config: GeminiConfig; - private builder: RequestBuilder; - - constructor(config: GeminiConfig, builder?: RequestBuilder) { - this.client = new GoogleGenerativeAI(config.apiKey); - this.config = { - ...config, - model: config.model ?? GEMINI_DEFAULT_CONFIG.model, - temperature: config.temperature ?? GEMINI_DEFAULT_CONFIG.temperature, - }; - this.model = this.client.getGenerativeModel({ - model: this.config.model!, - generationConfig: { - ...(this.config.temperature !== undefined && { temperature: this.config.temperature }), - responseMimeType: "application/json", - } - }); - this.builder = builder ?? new DefaultRequestBuilder(); - } - - async runPromptStructured( - content: string, - promptText: string, - schema: { name: string; schema: Record } - ): Promise> { - const systemPrompt = this.builder.buildPromptBodyForStructured(promptText); - - const fullPrompt = `${systemPrompt} - You must output valid JSON that adheres to the following schema: - ${JSON.stringify(schema.schema, null, 2)} - Input: - ${content} - `; - - if (this.config.debug) { - console.error('[vectorlint] Sending request to Gemini:', { - model: this.config.model, - temperature: this.config.temperature, - }); - if (this.config.showPrompt) { - console.error('[vectorlint] Full prompt:'); - console.error(fullPrompt); - } else if (this.config.showPromptTrunc) { - console.error('[vectorlint] Prompt preview (first 500 chars):'); - console.error(fullPrompt.slice(0, 500)); - if (fullPrompt.length > 500) console.error('... [truncated]'); - } - } - - try { - const result = await this.model.generateContent(fullPrompt); - const response = result.response; - const text = response.text(); - const usageMetadata = response.usageMetadata; - - - - const data = JSON.parse(text) as T; - - const llmResult: LLMResult = { data }; - if (usageMetadata) { - llmResult.usage = { - inputTokens: usageMetadata.promptTokenCount ?? 0, - outputTokens: usageMetadata.candidatesTokenCount ?? 0, - }; - } - return llmResult; - } catch (e: unknown) { - const err = handleUnknownError(e, 'Gemini API call'); - throw new Error(`Gemini API call failed: ${err.message}`); - } - } -} diff --git a/src/providers/index.ts b/src/providers/index.ts index 4c0ab21..4bdb538 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,6 +1,7 @@ -export { LLMProvider } from './llm-provider'; -export { AzureOpenAIProvider, type AzureOpenAIConfig } from './azure-openai-provider'; -export { AnthropicProvider, type AnthropicConfig } from './anthropic-provider'; -export { OpenAIProvider, type OpenAIConfig } from './openai-provider'; -export { createProvider, type ProviderOptions } from './provider-factory'; +export { LLMProvider, type LLMResult } from './llm-provider'; +export { VercelAIProvider, type VercelAIConfig } from './vercel-ai-provider'; +export { SearchProvider } from './search-provider'; +export { PerplexitySearchProvider, type PerplexitySearchConfig } from './perplexity-provider'; +export { createProvider, type ProviderOptions, ProviderType } from './provider-factory'; export { RequestBuilder, DefaultRequestBuilder } from './request-builder'; +export { TokenUsage, TokenUsageStats, PricingConfig, calculateCost } from './token-usage'; diff --git a/src/providers/openai-provider.ts b/src/providers/openai-provider.ts deleted file mode 100644 index c54ea9b..0000000 --- a/src/providers/openai-provider.ts +++ /dev/null @@ -1,180 +0,0 @@ -import OpenAI from 'openai'; -import { z } from 'zod'; -import { LLMProvider, LLMResult } from './llm-provider'; -import { DefaultRequestBuilder, RequestBuilder } from './request-builder'; -import { OPENAI_RESPONSE_SCHEMA, type OpenAIResponse } from '../schemas/openai-responses'; -import { ValidationError, APIResponseError } from '../errors/validation-errors'; -import { handleUnknownError } from '../errors/index'; - -export interface OpenAIConfig { - apiKey: string; - model?: string; - temperature?: number; - debug?: boolean; - showPrompt?: boolean; - showPromptTrunc?: boolean; -} - -export const OPENAI_DEFAULT_CONFIG = { - model: 'gpt-4o', - temperature: 0.2, -}; - -export class OpenAIProvider implements LLMProvider { - private client: OpenAI; - private config: OpenAIConfig; - private builder: RequestBuilder; - - constructor(config: OpenAIConfig, builder?: RequestBuilder) { - this.client = new OpenAI({ - apiKey: config.apiKey, - maxRetries: 2, - }); - this.config = { - ...config, - model: config.model ?? OPENAI_DEFAULT_CONFIG.model, - temperature: config.temperature ?? OPENAI_DEFAULT_CONFIG.temperature, - }; - this.builder = builder ?? new DefaultRequestBuilder(); - } - - /** - * Validates OpenAI API response using schema validation - * Replaces unsafe type assertions with proper runtime validation - */ - private validateResponse(response: unknown): OpenAIResponse { - try { - return OPENAI_RESPONSE_SCHEMA.parse(response); - } catch (e: unknown) { - if (e instanceof z.ZodError) { - throw new APIResponseError( - `Invalid OpenAI API response structure: ${e.message}`, - response, - e - ); - } - const err = handleUnknownError(e, 'OpenAI response validation'); - throw new ValidationError(`OpenAI response validation failed: ${err.message}`, e); - } - } - - async runPromptStructured( - content: string, - promptText: string, - schema: { name: string; schema: Record } - ): Promise> { - const systemPrompt = this.builder.buildPromptBodyForStructured(promptText); - - const params: OpenAI.Chat.Completions.ChatCompletionCreateParams = { - model: this.config.model!, - messages: [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: `Input:\n\n${content}` } - ], - response_format: { - type: 'json_schema', - json_schema: { - name: schema.name, - schema: schema.schema, - }, - }, - }; - - if (this.config.temperature !== undefined) { - params.temperature = this.config.temperature; - } - - if (this.config.debug) { - console.log('[vectorlint] Sending request to OpenAI:', { - model: this.config.model, - temperature: this.config.temperature, - }); - - if (this.config.showPrompt) { - console.log('[vectorlint] System prompt (full):'); - console.log(systemPrompt); - console.log('[vectorlint] User content (full):'); - console.log(content); - } else if (this.config.showPromptTrunc) { - console.log('[vectorlint] System prompt (first 500 chars):'); - console.log(systemPrompt.slice(0, 500)); - if (systemPrompt.length > 500) console.log('... [truncated]'); - const preview = content.slice(0, 500); - console.log('[vectorlint] User content preview (first 500 chars):'); - console.log(preview); - if (content.length > 500) console.log('... [truncated]'); - } - } - - let rawResponse: unknown; - try { - rawResponse = await this.client.chat.completions.create(params); - } catch (e: unknown) { - // Handle specific OpenAI SDK errors - check more specific errors first - if (e instanceof OpenAI.RateLimitError) { - throw new Error(`OpenAI rate limit exceeded: ${e.message}`); - } - if (e instanceof OpenAI.AuthenticationError) { - throw new Error(`OpenAI authentication failed: ${e.message}`); - } - if (e instanceof OpenAI.APIError) { - throw new Error(`OpenAI API error (${e.status}): ${e.message}`); - } - - const err = handleUnknownError(e, 'OpenAI API call'); - throw new Error(`OpenAI API call failed: ${err.message}`); - } - - // Validate the API response structure using schema validation - const validatedResponse = this.validateResponse(rawResponse); - - // Debug logging after successful response - if (this.config.debug) { - const usage = validatedResponse.usage; - const firstChoice = validatedResponse.choices[0]; - if (usage || firstChoice) { - console.log('[vectorlint] LLM response meta:', { - usage: usage ? { - prompt_tokens: usage.prompt_tokens, - completion_tokens: usage.completion_tokens, - total_tokens: usage.total_tokens, - } : undefined, - finish_reason: firstChoice?.finish_reason, - }); - } - - } - - // Type-safe property access with proper null checks - const firstChoice = validatedResponse.choices[0]; - if (!firstChoice) { - throw new Error('Empty response from OpenAI API (no choices).'); - } - - const responseText = firstChoice.message.content?.trim(); - if (!responseText) { - throw new Error('Empty response from OpenAI API (no content).'); - } - - try { - const data = JSON.parse(responseText) as T; - const usage = validatedResponse.usage; - - const result: LLMResult = { data }; - if (usage) { - result.usage = { - inputTokens: usage.prompt_tokens, - outputTokens: usage.completion_tokens, - }; - } - return result; - } catch (e: unknown) { - const err = handleUnknownError(e, 'JSON parsing'); - const preview = responseText.slice(0, 200); - throw new ValidationError( - `Failed to parse structured JSON response: ${err.message}. Preview: ${preview}${responseText.length > 200 ? ' ...' : ''}`, - e - ); - } - } -} diff --git a/src/providers/perplexity-provider.ts b/src/providers/perplexity-provider.ts index 0f237eb..105f840 100644 --- a/src/providers/perplexity-provider.ts +++ b/src/providers/perplexity-provider.ts @@ -1,23 +1,38 @@ -import Perplexity from '@perplexity-ai/perplexity_ai'; +import { generateText } from 'ai'; +import { z } from 'zod'; +import { createPerplexity } from '@ai-sdk/perplexity'; import type { SearchProvider } from './search-provider'; -import { PERPLEXITY_RESPONSE_SCHEMA, type PerplexityResult } from '../schemas/perplexity-responses'; +import type { PerplexityResult } from '../schemas/perplexity-responses'; + +// Boundary validation schema for Perplexity source data. +// The AI SDK's typed Source may not include provider-specific fields (text, publishedDate), +// so we validate the raw data at the boundary to safely extract them. +const PERPLEXITY_SOURCE_SCHEMA = z.object({ + title: z.string().optional(), + text: z.string().optional(), + url: z.string().optional(), + publishedDate: z.string().optional(), +}).passthrough(); export interface PerplexitySearchConfig { + apiKey?: string; maxResults?: number; - maxTokensPerPage?: number; debug?: boolean; } export class PerplexitySearchProvider implements SearchProvider { - private client: Perplexity; + private client: ReturnType; private maxResults: number; - private maxTokensPerPage: number; private debug: boolean; constructor(config: PerplexitySearchConfig = {}) { - this.client = new Perplexity(); + // Use provided API key or fall back to environment variable + const apiKey = config.apiKey || process.env.PERPLEXITY_API_KEY; + if (!apiKey) { + throw new Error('Perplexity API key is required. Set PERPLEXITY_API_KEY environment variable or pass apiKey in config.'); + } + this.client = createPerplexity({ apiKey }); this.maxResults = config.maxResults ?? 5; - this.maxTokensPerPage = config.maxTokensPerPage ?? 1024; this.debug = config.debug ?? false; } @@ -27,18 +42,26 @@ export class PerplexitySearchProvider implements SearchProvider { if (this.debug) console.log(`[Perplexity] Searching: "${query}"`); try { - const rawResponse: unknown = await this.client.search.create({ - query, - max_results: this.maxResults, - max_tokens_per_page: this.maxTokensPerPage, + const result = await generateText({ + model: this.client('sonar-pro'), + prompt: query, }); - /* - * Validate response with schema at boundary. - * Schema provides defaults for missing fields and ensures type safety. - */ - const validated = PERPLEXITY_RESPONSE_SCHEMA.parse(rawResponse); - const results = validated.results; + // Validate sources at the boundary — the SDK may include provider-specific + // fields not present in the typed Source interface + const rawSources: unknown = result.sources ?? []; + const parseResult = z.array(PERPLEXITY_SOURCE_SCHEMA).safeParse(rawSources); + if (!parseResult.success && this.debug) { + console.warn(`[Perplexity] Source validation failed for raw sources:`, parseResult.error); + } + const sources = parseResult.success ? parseResult.data : []; + + const results: PerplexityResult[] = sources.slice(0, this.maxResults).map(source => ({ + title: source.title || 'Untitled', + snippet: source.text || '', + url: source.url || '', + date: source.publishedDate || '', + })); if (this.debug) { console.log(`[Perplexity] Found ${results.length} results`); diff --git a/src/providers/provider-factory.ts b/src/providers/provider-factory.ts index 5e276aa..9feac28 100644 --- a/src/providers/provider-factory.ts +++ b/src/providers/provider-factory.ts @@ -1,10 +1,12 @@ +import { createOpenAI } from '@ai-sdk/openai'; +import { createAzure } from '@ai-sdk/azure'; +import { createAnthropic } from '@ai-sdk/anthropic'; +import { createGoogleGenerativeAI } from '@ai-sdk/google'; +import type { LanguageModel } from 'ai'; import { LLMProvider } from './llm-provider'; -import { AzureOpenAIProvider, type AzureOpenAIConfig } from './azure-openai-provider'; -import { AnthropicProvider, type AnthropicConfig } from './anthropic-provider'; -import { OpenAIProvider, type OpenAIConfig } from './openai-provider'; +import { VercelAIProvider, type VercelAIConfig } from './vercel-ai-provider'; import { RequestBuilder } from './request-builder'; import type { EnvConfig } from '../schemas/env-schemas'; -import { GeminiConfig, GeminiProvider } from './gemini-provider'; export interface ProviderOptions { debug?: boolean; @@ -31,60 +33,64 @@ export function createProvider( options: ProviderOptions = {}, builder?: RequestBuilder ): LLMProvider { + let model: LanguageModel; + let temperature = 0.2; + switch (envConfig.LLM_PROVIDER) { case ProviderType.AzureOpenAI: { - const azureConfig: AzureOpenAIConfig = { + const azure = createAzure({ apiKey: envConfig.AZURE_OPENAI_API_KEY, - endpoint: envConfig.AZURE_OPENAI_ENDPOINT, - deploymentName: envConfig.AZURE_OPENAI_DEPLOYMENT_NAME, - apiVersion: envConfig.AZURE_OPENAI_API_VERSION, - ...(envConfig.AZURE_OPENAI_TEMPERATURE !== undefined && { temperature: envConfig.AZURE_OPENAI_TEMPERATURE }), - ...(options.debug !== undefined && { debug: options.debug }), - ...(options.showPrompt !== undefined && { showPrompt: options.showPrompt }), - ...(options.showPromptTrunc !== undefined && { showPromptTrunc: options.showPromptTrunc }), - }; - return new AzureOpenAIProvider(azureConfig, builder); + baseURL: envConfig.AZURE_OPENAI_ENDPOINT, + apiVersion: envConfig.AZURE_OPENAI_API_VERSION ?? '2024-02-15-preview', + }); + // Cast required: @ai-sdk/azure's factory returns a provider-specific type + // that is not directly assignable to the generic LanguageModel from 'ai'. + // Tested with @ai-sdk/azure@1.x — revisit if the SDK adds a typed adapter. + model = azure(envConfig.AZURE_OPENAI_DEPLOYMENT_NAME) as unknown as LanguageModel; + temperature = envConfig.AZURE_OPENAI_TEMPERATURE ?? 0.2; + break; } case ProviderType.Anthropic: { - const anthropicConfig: AnthropicConfig = { + const anthropic = createAnthropic({ apiKey: envConfig.ANTHROPIC_API_KEY, - model: envConfig.ANTHROPIC_MODEL, - maxTokens: envConfig.ANTHROPIC_MAX_TOKENS, - ...(envConfig.ANTHROPIC_TEMPERATURE !== undefined && { temperature: envConfig.ANTHROPIC_TEMPERATURE }), - ...(options.debug !== undefined && { debug: options.debug }), - ...(options.showPrompt !== undefined && { showPrompt: options.showPrompt }), - ...(options.showPromptTrunc !== undefined && { showPromptTrunc: options.showPromptTrunc }), - }; - return new AnthropicProvider(anthropicConfig, builder); + }); + model = anthropic(envConfig.ANTHROPIC_MODEL); + temperature = envConfig.ANTHROPIC_TEMPERATURE ?? 0.2; + break; } case ProviderType.OpenAI: { - const openaiConfig: OpenAIConfig = { + const openai = createOpenAI({ apiKey: envConfig.OPENAI_API_KEY, - model: envConfig.OPENAI_MODEL, - ...(envConfig.OPENAI_TEMPERATURE !== undefined && { temperature: envConfig.OPENAI_TEMPERATURE }), - ...(options.debug !== undefined && { debug: options.debug }), - ...(options.showPrompt !== undefined && { showPrompt: options.showPrompt }), - ...(options.showPromptTrunc !== undefined && { showPromptTrunc: options.showPromptTrunc }), - }; - return new OpenAIProvider(openaiConfig, builder); + }); + model = openai(envConfig.OPENAI_MODEL); + temperature = envConfig.OPENAI_TEMPERATURE ?? 0.2; + break; } case ProviderType.Gemini: { - const geminiConfig: GeminiConfig = { + const google = createGoogleGenerativeAI({ apiKey: envConfig.GEMINI_API_KEY, - model: envConfig.GEMINI_MODEL, - ...(envConfig.GEMINI_TEMPERATURE !== undefined && { temperature: envConfig.GEMINI_TEMPERATURE }), - ...(options.debug !== undefined && { debug: options.debug }), - ...(options.showPrompt !== undefined && { showPrompt: options.showPrompt }), - ...(options.showPromptTrunc !== undefined && { showPromptTrunc: options.showPromptTrunc }), - }; - return new GeminiProvider(geminiConfig, builder); + }); + model = google(envConfig.GEMINI_MODEL); + temperature = envConfig.GEMINI_TEMPERATURE ?? 0.2; + break; } default: // TypeScript should prevent this, but add runtime safety throw new Error(`Unsupported provider type: ${(envConfig as { LLM_PROVIDER: string }).LLM_PROVIDER}`); } + + const config: VercelAIConfig = { + model, + temperature, + ...(envConfig.LLM_PROVIDER === ProviderType.Anthropic && envConfig.ANTHROPIC_MAX_TOKENS !== undefined && { maxTokens: envConfig.ANTHROPIC_MAX_TOKENS }), + ...(options.debug !== undefined && { debug: options.debug }), + ...(options.showPrompt !== undefined && { showPrompt: options.showPrompt }), + ...(options.showPromptTrunc !== undefined && { showPromptTrunc: options.showPromptTrunc }), + }; + + return new VercelAIProvider(config, builder); } diff --git a/src/providers/vercel-ai-provider.ts b/src/providers/vercel-ai-provider.ts new file mode 100644 index 0000000..0350379 --- /dev/null +++ b/src/providers/vercel-ai-provider.ts @@ -0,0 +1,215 @@ +import { generateText, Output, NoObjectGeneratedError } from 'ai'; +import type { LanguageModel } from 'ai'; +import { z } from 'zod'; +import { LLMProvider, LLMResult } from './llm-provider'; +import { DefaultRequestBuilder, RequestBuilder } from './request-builder'; + +export interface VercelAIConfig { + model: LanguageModel; + temperature?: number; + maxTokens?: number; + debug?: boolean; + showPrompt?: boolean; + showPromptTrunc?: boolean; +} + +export class VercelAIProvider implements LLMProvider { + private config: VercelAIConfig; + private builder: RequestBuilder; + + constructor(config: VercelAIConfig, builder?: RequestBuilder) { + this.config = { + ...config, + temperature: config.temperature ?? 0.2, + ...(config.maxTokens !== undefined && { maxTokens: config.maxTokens }), + }; + this.builder = builder ?? new DefaultRequestBuilder(); + } + + async runPromptStructured( + content: string, + promptText: string, + schema: { name: string; schema: Record } + ): Promise> { + const systemPrompt = this.builder.buildPromptBodyForStructured(promptText); + + // Convert JSON Schema to Zod for Vercel AI SDK + const zodSchema = this.jsonSchemaToZod(schema); + + if (this.config.debug) { + console.log('[vectorlint] Sending request via Vercel AI SDK:', { + model: this.config.model, + temperature: this.config.temperature, + ...(this.config.maxTokens !== undefined && { maxTokens: this.config.maxTokens }), + }); + + if (this.config.showPrompt) { + console.log('[vectorlint] System prompt (full):'); + console.log(systemPrompt); + console.log('[vectorlint] User content (full):'); + console.log(content); + } else if (this.config.showPromptTrunc) { + console.log('[vectorlint] System prompt (first 500 chars):'); + console.log(systemPrompt.slice(0, 500)); + if (systemPrompt.length > 500) console.log('... [truncated]'); + const preview = content.slice(0, 500); + console.log('[vectorlint] User content preview (first 500 chars):'); + console.log(preview); + if (content.length > 500) console.log('... [truncated]'); + } + } + + try { + const result = await generateText({ + model: this.config.model, + system: systemPrompt, + prompt: `Input:\n\n${content}`, + ...(this.config.temperature !== undefined && { temperature: this.config.temperature }), + ...(this.config.maxTokens !== undefined && { maxTokens: this.config.maxTokens }), + experimental_output: Output.object({ + schema: zodSchema, + }), + }); + + if (this.config.debug && result.usage) { + console.log('[vectorlint] LLM response meta:', { + usage: { + prompt_tokens: result.usage.promptTokens, + completion_tokens: result.usage.completionTokens, + total_tokens: result.usage.totalTokens, + }, + finish_reason: result.finishReason, + }); + } + + // Map Vercel AI SDK usage (promptTokens/completionTokens) + // to VectorLint TokenUsage (inputTokens/outputTokens) + const usage = result.usage ? { + inputTokens: result.usage.promptTokens, + outputTokens: result.usage.completionTokens, + } : undefined; + + // experimental_output is validated by the Zod schema passed to Output.object(), + // but can be undefined/null if the LLM response doesn't match the schema + const output: unknown = result.experimental_output; + if (output === undefined || output === null) { + throw new Error( + `LLM returned no structured output. Raw text: ${result.text?.slice(0, 500) ?? '(empty)'}` + ); + } + + const llmResult: LLMResult = { data: output as T }; + if (usage) { + llmResult.usage = usage; + } + return llmResult; + } catch (e: unknown) { + // Handle Vercel AI SDK's NoObjectGeneratedError with proper type narrowing + if (NoObjectGeneratedError.isInstance(e)) { + const rawText = e instanceof Error && 'text' in e ? String(e.text) : 'unknown'; + throw new Error( + `LLM failed to generate valid structured output. Raw text: ${rawText}` + ); + } + const err = e instanceof Error ? e : new Error(String(e)); + throw new Error(`Vercel AI SDK call failed: ${err.message}`); + } + } + + /** + * Entry point: converts the VectorLint schema wrapper to a Zod schema. + * Schema format: { name: string, schema: { type, properties, required, ... } } + */ + private jsonSchemaToZod(schema: { name: string; schema: Record }): z.ZodType { + return this.convertSchemaNode(schema.schema); + } + + /** + * Recursively converts a JSON Schema node to a Zod schema. + * Handles nested objects, arrays with typed items, enums, and primitives. + */ + private convertSchemaNode(node: Record): z.ZodType { + let type = node.type as string | string[] | undefined; + const enumValues = node.enum as string[] | undefined; + const isNullable = node.nullable === true || (Array.isArray(type) && type.includes('null')); + + // Normalize type array: remove 'null' (tracked via isNullable) and handle multi-type unions + if (Array.isArray(type)) { + const types = type.filter(t => t !== 'null'); + if (types.length === 0) { + type = undefined; + } else if (types.length === 1) { + type = types[0]; + } else { + // Multi-type (e.g. ['string','number']): build a union of each type's Zod schema + const schemas = types.map(t => this.convertSchemaNode({ ...node, type: t, enum: undefined })); + const unionSchema = z.union(schemas as unknown as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]]); + return isNullable ? unionSchema.nullable() : unionSchema; + } + } + + // Enums take priority over type (JSON Schema allows enum without type) + if (enumValues) { + let enumSchema: z.ZodType; + const allStrings = enumValues.every((v): v is string => typeof v === 'string'); + if (allStrings && enumValues.length > 0) { + enumSchema = z.enum(enumValues as [string, ...string[]]); + } else { + // Mixed or non-string enums: build a union of literals + const literals = (enumValues as unknown[]).map(v => z.literal(v as z.Primitive)); + enumSchema = literals.length === 1 + ? literals[0]! + : z.union(literals as unknown as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]]); + } + return isNullable ? enumSchema.nullable() : enumSchema; + } + + let schema: z.ZodType; + switch (type) { + case 'string': + schema = z.string(); + break; + case 'number': + case 'integer': + schema = z.number(); + break; + case 'boolean': + schema = z.boolean(); + break; + case 'array': { + const items = node.items as Record | undefined; + if (items) { + schema = z.array(this.convertSchemaNode(items)); + } else { + schema = z.array(z.unknown()); + } + break; + } + case 'object': { + const properties = node.properties as Record> | undefined; + const required = (node.required as string[]) || []; + const additionalProperties = node.additionalProperties; + + if (properties) { + const zodFields: Record = {}; + for (const [key, value] of Object.entries(properties)) { + const fieldSchema = this.convertSchemaNode(value); + zodFields[key] = required.includes(key) ? fieldSchema : fieldSchema.optional(); + } + let objSchema: z.ZodType = z.object(zodFields); + if (additionalProperties === false) { + objSchema = z.object(zodFields).strict(); + } + schema = objSchema; + } else { + schema = z.record(z.unknown()); + } + break; + } + default: + schema = z.unknown(); + } + + return isNullable ? schema.nullable() : schema; + } +} diff --git a/src/schemas/anthropic-responses.ts b/src/schemas/anthropic-responses.ts deleted file mode 100644 index f96a65a..0000000 --- a/src/schemas/anthropic-responses.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { z } from 'zod'; - -export const ANTHROPIC_TEXT_BLOCK_SCHEMA = z.object({ - type: z.literal('text'), - text: z.string(), -}); - -export const ANTHROPIC_TOOL_USE_BLOCK_SCHEMA = z.object({ - type: z.literal('tool_use'), - id: z.string(), - name: z.string(), - input: z.unknown(), // Tool input can be any valid JSON -}); - -export const ANTHROPIC_CONTENT_BLOCK_SCHEMA = z.discriminatedUnion('type', [ - ANTHROPIC_TEXT_BLOCK_SCHEMA, - ANTHROPIC_TOOL_USE_BLOCK_SCHEMA, -]); - -export const ANTHROPIC_USAGE_SCHEMA = z.object({ - input_tokens: z.number(), - output_tokens: z.number(), -}); - -export const ANTHROPIC_RESPONSE_SCHEMA = z.object({ - id: z.string(), - type: z.literal('message'), - role: z.literal('assistant'), - content: z.array(ANTHROPIC_CONTENT_BLOCK_SCHEMA), - model: z.string(), - stop_reason: z.enum(['max_tokens', 'end_turn', 'stop_sequence', 'tool_use']).nullable(), - stop_sequence: z.string().nullable(), - usage: ANTHROPIC_USAGE_SCHEMA, -}); - -// Inferred TypeScript types -export type AnthropicTextBlock = z.infer; -export type AnthropicToolUseBlock = z.infer; -export type AnthropicContentBlock = z.infer; -export type AnthropicUsage = z.infer; -export type AnthropicResponse = z.infer; - -// Helper type guards -export function isTextBlock(block: AnthropicContentBlock): block is AnthropicTextBlock { - return block.type === 'text'; -} - -export function isToolUseBlock(block: AnthropicContentBlock): block is AnthropicToolUseBlock { - return block.type === 'tool_use'; -} diff --git a/src/schemas/api-schemas.ts b/src/schemas/api-schemas.ts deleted file mode 100644 index fbe2e88..0000000 --- a/src/schemas/api-schemas.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { z } from 'zod'; - -// OpenAI API response schemas -export const OPENAI_CHOICE_SCHEMA = z.object({ - message: z.object({ - content: z.string().nullable(), - }), - finish_reason: z.string(), -}); - -export const OPENAI_USAGE_SCHEMA = z.object({ - prompt_tokens: z.number(), - completion_tokens: z.number(), - total_tokens: z.number(), -}); - -export const OPENAI_RESPONSE_SCHEMA = z.object({ - choices: z.array(OPENAI_CHOICE_SCHEMA).min(1), - usage: OPENAI_USAGE_SCHEMA.optional(), -}); - -// Anthropic API response schemas -export const ANTHROPIC_USAGE_SCHEMA = z.object({ - input_tokens: z.number(), - output_tokens: z.number(), -}); - -export const ANTHROPIC_TEXT_BLOCK_SCHEMA = z.object({ - type: z.literal('text'), - text: z.string(), -}); - -export const ANTHROPIC_TOOL_USE_BLOCK_SCHEMA = z.object({ - type: z.literal('tool_use'), - id: z.string(), - name: z.string(), - input: z.record(z.unknown()), -}); - -export const ANTHROPIC_CONTENT_BLOCK_SCHEMA = z.discriminatedUnion('type', [ - ANTHROPIC_TEXT_BLOCK_SCHEMA, - ANTHROPIC_TOOL_USE_BLOCK_SCHEMA, -]); - -export const ANTHROPIC_MESSAGE_SCHEMA = z.object({ - id: z.string(), - type: z.literal('message'), - role: z.literal('assistant'), - content: z.array(ANTHROPIC_CONTENT_BLOCK_SCHEMA), - model: z.string(), - stop_reason: z.enum(['max_tokens', 'end_turn', 'stop_sequence', 'tool_use']).nullable(), - stop_sequence: z.string().nullable(), - usage: ANTHROPIC_USAGE_SCHEMA, -}); - -// Inferred types -export type OpenAIChoice = z.infer; -export type OpenAIUsage = z.infer; -export type OpenAIResponse = z.infer; - -export type AnthropicUsage = z.infer; -export type AnthropicTextBlock = z.infer; -export type AnthropicToolUseBlock = z.infer; -export type AnthropicContentBlock = z.infer; -export type AnthropicMessage = z.infer; diff --git a/src/schemas/env-schemas.ts b/src/schemas/env-schemas.ts index 54c0e63..ac849f2 100644 --- a/src/schemas/env-schemas.ts +++ b/src/schemas/env-schemas.ts @@ -1,9 +1,23 @@ import { z } from 'zod'; import { ProviderType } from '../providers/provider-factory'; -import { GEMINI_DEFAULT_CONFIG } from '../providers/gemini-provider'; -import { OPENAI_DEFAULT_CONFIG } from '../providers/openai-provider'; -import { ANTHROPIC_DEFAULT_CONFIG } from '../providers/anthropic-provider'; -import { AZURE_OPENAI_DEFAULT_CONFIG } from '../providers/azure-openai-provider'; + +// Default configurations (previously from provider files) +export const AZURE_OPENAI_DEFAULT_CONFIG = { + apiVersion: '2024-02-15-preview', +}; + +export const ANTHROPIC_DEFAULT_CONFIG = { + model: 'claude-3-sonnet-20240229', + maxTokens: 4096, +}; + +export const OPENAI_DEFAULT_CONFIG = { + model: 'gpt-4o', +}; + +export const GEMINI_DEFAULT_CONFIG = { + model: 'gemini-2.5-flash', +}; // Azure OpenAI configuration schema const AZURE_OPENAI_CONFIG_SCHEMA = z.object({ @@ -25,15 +39,10 @@ const ANTHROPIC_CONFIG_SCHEMA = z.object({ // OpenAI configuration schema const OPENAI_CONFIG_SCHEMA = z.object({ OPENAI_API_KEY: z.string().min(1), - OPENAI_MODEL: z.string().optional().default(OPENAI_DEFAULT_CONFIG.model), + OPENAI_MODEL: z.string().default(OPENAI_DEFAULT_CONFIG.model), OPENAI_TEMPERATURE: z.coerce.number().min(0).max(2).optional(), }); -// Base environment schema with shared optional variables -const BASE_ENV_SCHEMA = z.object({ - INPUT_PRICE_PER_MILLION: z.coerce.number().positive().optional(), - OUTPUT_PRICE_PER_MILLION: z.coerce.number().positive().optional(), -}); // Gemini configuration schema const GEMINI_CONFIG_SCHEMA = z.object({ GEMINI_API_KEY: z.string().min(1), @@ -41,6 +50,12 @@ const GEMINI_CONFIG_SCHEMA = z.object({ GEMINI_TEMPERATURE: z.coerce.number().min(0).max(1).optional(), }); +// Base environment schema with shared optional variables +const BASE_ENV_SCHEMA = z.object({ + INPUT_PRICE_PER_MILLION: z.coerce.number().positive().optional(), + OUTPUT_PRICE_PER_MILLION: z.coerce.number().positive().optional(), +}); + // Discriminated union based on provider type export const ENV_SCHEMA = z.discriminatedUnion('LLM_PROVIDER', [ z.object({ LLM_PROVIDER: z.literal(ProviderType.AzureOpenAI) }).merge(AZURE_OPENAI_CONFIG_SCHEMA).merge(BASE_ENV_SCHEMA), @@ -50,7 +65,7 @@ export const ENV_SCHEMA = z.discriminatedUnion('LLM_PROVIDER', [ ]); export const GLOBAL_CONFIG_SCHEMA = z.object({ - env: z.record(z.union([z.string(), z.number(), z.boolean()])).optional(), + env: z.record(z.union([z.string(), z.number(), z.boolean()])).optional(), }); // Inferred types diff --git a/src/schemas/index.ts b/src/schemas/index.ts index 7df61aa..d17675b 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -3,4 +3,4 @@ export * from './prompt-schemas'; export * from './cli-schemas'; export * from './config-schemas'; export * from './env-schemas'; -export * from './api-schemas'; +export * from './perplexity-responses'; diff --git a/src/schemas/openai-responses.ts b/src/schemas/openai-responses.ts deleted file mode 100644 index 90a16f6..0000000 --- a/src/schemas/openai-responses.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { z } from 'zod'; - -export const OPENAI_MESSAGE_SCHEMA = z.object({ - content: z.string().nullable(), - role: z.string().optional(), - tool_calls: z.array(z.object({ - id: z.string().optional(), - type: z.literal('function').optional(), - function: z.object({ - name: z.string().optional(), - arguments: z.string(), - }), - })).optional(), -}); - -export const OPENAI_CHOICE_SCHEMA = z.object({ - index: z.number().optional(), - message: OPENAI_MESSAGE_SCHEMA, - finish_reason: z.string(), - logprobs: z.unknown().nullable().optional(), -}); - -export const OPENAI_USAGE_SCHEMA = z.object({ - prompt_tokens: z.number(), - completion_tokens: z.number(), - total_tokens: z.number(), -}); - -export const OPENAI_RESPONSE_SCHEMA = z.object({ - id: z.string().optional(), - object: z.string().optional(), - created: z.number().optional(), - model: z.string().optional(), - choices: z.array(OPENAI_CHOICE_SCHEMA).min(1), - usage: OPENAI_USAGE_SCHEMA.optional(), - system_fingerprint: z.string().optional(), -}); - -// Inferred TypeScript types -export type OpenAIMessage = z.infer; -export type OpenAIChoice = z.infer; -export type OpenAIUsage = z.infer; -export type OpenAIResponse = z.infer; - -// Tool call specific types -export type OpenAIToolCall = NonNullable[number]; -export type OpenAIFunction = OpenAIToolCall['function']; diff --git a/tests/anthropic-e2e.test.ts b/tests/anthropic-e2e.test.ts deleted file mode 100644 index 11833bc..0000000 --- a/tests/anthropic-e2e.test.ts +++ /dev/null @@ -1,679 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { createProvider, ProviderType } from '../src/providers/provider-factory'; -import { parseEnvironment } from '../src/boundaries/env-parser'; -import { ANTHROPIC_DEFAULT_CONFIG, AnthropicProvider } from '../src/providers/anthropic-provider'; -import type { AnthropicMessage } from '../src/schemas/api-schemas'; -import type { - MockAPIErrorParams, - MockAuthenticationErrorParams, - MockRateLimitErrorParams, - MockBadRequestErrorParams, - MockAnthropicClient -} from './schemas/mock-schemas'; - -// Create a shared mock function that all E2E instances will use -const SHARED_E2E_MOCK_CREATE = vi.fn(); - -// Mock the Anthropic SDK for E2E tests -vi.mock('@anthropic-ai/sdk', () => { - return { - default: vi.fn((): MockAnthropicClient => ({ - messages: { - create: SHARED_E2E_MOCK_CREATE, - }, - })), - APIError: class APIError extends Error { - status: number; - constructor(params: MockAPIErrorParams) { - super(params.message); - this.status = params.status || 500; - this.name = 'APIError'; - } - }, - RateLimitError: class RateLimitError extends Error { - constructor(params: Partial = {}) { - super(params.message ?? 'Rate limit exceeded'); - this.name = 'RateLimitError'; - } - }, - AuthenticationError: class AuthenticationError extends Error { - constructor(params: Partial = {}) { - super(params.message ?? 'Authentication failed'); - this.name = 'AuthenticationError'; - } - }, - BadRequestError: class BadRequestError extends Error { - constructor(params: Partial = {}) { - super(params.message ?? 'Bad request'); - this.name = 'BadRequestError'; - } - }, - }; -}); - -// Mock the API client validation -vi.mock('../src/boundaries/api-client', () => ({ - validateAnthropicResponse: vi.fn(), -})); - -describe('Anthropic End-to-End Integration', () => { - let mockValidateAnthropicResponse: ReturnType; - - beforeEach(async () => { - vi.clearAllMocks(); - - // Get reference to the mocked validation function - const apiClient = await import('../src/boundaries/api-client'); - mockValidateAnthropicResponse = vi.mocked(apiClient.validateAnthropicResponse); - - // Default mock behavior - return the response as-is - mockValidateAnthropicResponse.mockImplementation((response: unknown) => response as AnthropicMessage); - }); - - describe('Complete Flow from Environment to Provider', () => { - it('processes complete Anthropic configuration flow', async () => { - // Step 1: Environment configuration - const env = { - LLM_PROVIDER: 'anthropic', - ANTHROPIC_API_KEY: 'sk-ant-test-key-12345', - ANTHROPIC_MODEL: 'claude-3-sonnet-20240229', - ANTHROPIC_MAX_TOKENS: '4096', - ANTHROPIC_TEMPERATURE: '0.3', - }; - - // Step 2: Parse environment - const envConfig = parseEnvironment(env); - expect(envConfig.LLM_PROVIDER).toBe('anthropic'); - - // Step 3: Create provider via factory - const provider = createProvider(envConfig, { - debug: true, - showPrompt: false, - }); - expect(provider).toBeInstanceOf(AnthropicProvider); - - // Step 4: Mock successful API response - const mockResponse: AnthropicMessage = { - id: 'msg_e2e_test', - type: 'message', - role: 'assistant', - content: [ - { - type: 'tool_use', - id: 'tool_e2e_test', - name: 'content_evaluation', - input: { - score: 92, - feedback: 'Excellent content quality', - categories: ['clarity', 'accuracy'], - }, - }, - ], - model: 'claude-3-sonnet-20240229', - stop_reason: 'tool_use', - stop_sequence: null, - usage: { - input_tokens: 150, - output_tokens: 75, - }, - }; - - SHARED_E2E_MOCK_CREATE.mockResolvedValue(mockResponse); - - // Step 5: Execute structured prompt - const schema = { - name: 'content_evaluation', - schema: { - properties: { - score: { type: 'number' }, - feedback: { type: 'string' }, - categories: { type: 'array', items: { type: 'string' } }, - }, - required: ['score', 'feedback'], - }, - }; - - const result = await provider.runPromptStructured( - 'Test content for evaluation', - 'Evaluate this content for quality', - schema - ); - - // Step 6: Verify results - expect(result.data).toEqual({ - score: 92, - feedback: 'Excellent content quality', - categories: ['clarity', 'accuracy'], - }); - expect(result.usage).toEqual({ - inputTokens: 150, - outputTokens: 75, - }); - - // Verify API was called with correct parameters - expect(SHARED_E2E_MOCK_CREATE).toHaveBeenCalledWith( - expect.objectContaining({ - model: 'claude-3-sonnet-20240229', - max_tokens: 4096, - temperature: 0.3, - tools: [ - { - name: 'content_evaluation', - description: 'Submit content_evaluation evaluation results', - input_schema: { - type: 'object', - properties: { - score: { type: 'number' }, - feedback: { type: 'string' }, - categories: { type: 'array', items: { type: 'string' } }, - }, - required: ['score', 'feedback'], - }, - }, - ], - tool_choice: { type: 'tool', name: 'content_evaluation' }, - }) - ); - }); - - it('handles minimal Anthropic configuration with defaults', async () => { - // Step 1: Minimal environment configuration - const env = { - LLM_PROVIDER: 'anthropic', - ANTHROPIC_API_KEY: 'sk-ant-minimal-key', - }; - - // Step 2: Parse environment (should apply defaults) - const envConfig = parseEnvironment(env); - expect(envConfig.LLM_PROVIDER).toBe(ProviderType.Anthropic); - if (envConfig.LLM_PROVIDER === ProviderType.Anthropic) { - expect(envConfig.ANTHROPIC_MODEL).toBe(ANTHROPIC_DEFAULT_CONFIG.model); - expect(envConfig.ANTHROPIC_MAX_TOKENS).toBe(ANTHROPIC_DEFAULT_CONFIG.maxTokens); - } - - // Step 3: Create provider - const provider = createProvider(envConfig); - expect(provider).toBeInstanceOf(AnthropicProvider); - - // Step 4: Mock response - const mockResponse: AnthropicMessage = { - id: 'msg_minimal_test', - type: 'message', - role: 'assistant', - content: [ - { - type: 'tool_use', - id: 'tool_minimal_test', - name: 'simple_eval', - input: { result: 'success' }, - }, - ], - model: 'claude-3-sonnet-20240229', - stop_reason: 'tool_use', - stop_sequence: null, - usage: { - input_tokens: 50, - output_tokens: 25, - }, - }; - - SHARED_E2E_MOCK_CREATE.mockResolvedValue(mockResponse); - - // Step 5: Execute with defaults - const schema = { - name: 'simple_eval', - schema: { - properties: { result: { type: 'string' } }, - required: ['result'], - }, - }; - - const result = await provider.runPromptStructured( - 'Simple test', - 'Simple prompt', - schema - ); - - expect(result.data).toEqual({ result: 'success' }); - expect(result.usage).toEqual({ - inputTokens: 50, - outputTokens: 25, - }); - - // Verify defaults were used - expect(SHARED_E2E_MOCK_CREATE).toHaveBeenCalledWith( - expect.objectContaining({ - model: 'claude-3-sonnet-20240229', - max_tokens: 4096, - temperature: 0.2, // Default temperature - }) - ); - }); - }); - // This closing brace was missing for the 'describe('Anthropic End-to-End Integration', () => {' block - // The 'describe('Error Scenarios and Recovery', () => {' block should be at the same level as 'describe('Complete Flow from Environment to Provider', () => {' - // and both should be nested within 'describe('Anthropic End-to-End Integration', () => {' - // The original code had an extra '});' after the 'it' block, and then another '});' for the 'describe' block. - // This structure implies that 'Error Scenarios and Recovery' was outside the main 'Anthropic End-to-End Integration' describe block. - // By adding this brace here, 'Error Scenarios and Recovery' is now correctly nested within the main describe block. - - describe('Error Scenarios and Recovery', () => { - it('handles Anthropic API authentication errors end-to-end', async () => { - const env = { - LLM_PROVIDER: 'anthropic', - ANTHROPIC_API_KEY: 'invalid-key', - }; - - const envConfig = parseEnvironment(env); - const provider = createProvider(envConfig); - - // Mock authentication error - const anthropic = await import('@anthropic-ai/sdk'); - // @ts-expect-error - Mock constructor signature differs from real SDK - SHARED_E2E_MOCK_CREATE.mockRejectedValue(new anthropic.AuthenticationError({ - message: 'Invalid API key' - })); - - const schema = { - name: 'test_eval', - schema: { properties: { result: { type: 'string' } } }, - }; - - await expect( - provider.runPromptStructured('Test', 'Test prompt', schema) - ).rejects.toThrow(); - }); - - it('handles Anthropic API rate limit errors end-to-end', async () => { - const env = { - LLM_PROVIDER: 'anthropic', - ANTHROPIC_API_KEY: 'sk-ant-test-key', - }; - - const envConfig = parseEnvironment(env); - const provider = createProvider(envConfig); - - // Mock rate limit error - const anthropic = await import('@anthropic-ai/sdk'); - // @ts-expect-error - Mock constructor signature differs from real SDK - SHARED_E2E_MOCK_CREATE.mockRejectedValue(new anthropic.RateLimitError({ - message: 'Rate limit exceeded' - })); - - const schema = { - name: 'test_eval', - schema: { properties: { result: { type: 'string' } } }, - }; - - await expect( - provider.runPromptStructured('Test', 'Test prompt', schema) - ).rejects.toThrow(); - }); - - it('handles invalid environment configuration', () => { - const env = { - LLM_PROVIDER: 'anthropic', - // Missing required ANTHROPIC_API_KEY - }; - - expect(() => parseEnvironment(env)).toThrow(/Missing required Anthropic environment variables/); - }); - - it('handles malformed API responses', async () => { - const env = { - LLM_PROVIDER: 'anthropic', - ANTHROPIC_API_KEY: 'sk-ant-test-key', - }; - - const envConfig = parseEnvironment(env); - const provider = createProvider(envConfig); - - // Mock malformed response (missing content) - const malformedResponse = { - id: 'msg_malformed', - type: 'message', - role: 'assistant', - content: [], // Empty content array - model: 'claude-3-sonnet-20240229', - stop_reason: 'end_turn', - stop_sequence: null, - usage: { input_tokens: 10, output_tokens: 0 }, - }; - - SHARED_E2E_MOCK_CREATE.mockResolvedValue(malformedResponse); - - const schema = { - name: 'test_eval', - schema: { properties: { result: { type: 'string' } } }, - }; - - await expect( - provider.runPromptStructured('Test', 'Test prompt', schema) - ).rejects.toThrow('Empty response from Anthropic API (no content blocks)'); - }); - - it('handles response with wrong tool name', async () => { - const env = { - LLM_PROVIDER: 'anthropic', - ANTHROPIC_API_KEY: 'sk-ant-test-key', - }; - - const envConfig = parseEnvironment(env); - const provider = createProvider(envConfig); - - // Mock response with wrong tool name - const wrongToolResponse: AnthropicMessage = { - id: 'msg_wrong_tool', - type: 'message', - role: 'assistant', - content: [ - { - type: 'tool_use', - id: 'tool_wrong', - name: 'unexpected_tool', - input: { result: 'wrong' }, - }, - ], - model: 'claude-3-sonnet-20240229', - stop_reason: 'tool_use', - stop_sequence: null, - usage: { input_tokens: 50, output_tokens: 25 }, - }; - - SHARED_E2E_MOCK_CREATE.mockResolvedValue(wrongToolResponse); - - const schema = { - name: 'expected_tool', - schema: { properties: { result: { type: 'string' } } }, - }; - - await expect( - provider.runPromptStructured('Test', 'Test prompt', schema) - ).rejects.toThrow('Expected tool call \'expected_tool\' but received: unexpected_tool'); - }); - }); - - describe('Structured Response Parsing and Validation', () => { - it('correctly parses complex structured responses', async () => { - const env = { - LLM_PROVIDER: 'anthropic', - ANTHROPIC_API_KEY: 'sk-ant-test-key', - }; - - const envConfig = parseEnvironment(env); - const provider = createProvider(envConfig); - - // Mock complex structured response - const complexResponse: AnthropicMessage = { - id: 'msg_complex', - type: 'message', - role: 'assistant', - content: [ - { - type: 'tool_use', - id: 'tool_complex', - name: 'detailed_evaluation', - input: { - overall_score: 87.5, - detailed_scores: { - clarity: 90, - accuracy: 85, - completeness: 88, - }, - recommendations: [ - 'Improve technical accuracy', - 'Add more examples', - ], - metadata: { - word_count: 1250, - reading_level: 'intermediate', - topics: ['technology', 'education'], - }, - }, - }, - ], - model: 'claude-3-sonnet-20240229', - stop_reason: 'tool_use', - stop_sequence: null, - usage: { input_tokens: 200, output_tokens: 100 }, - }; - - SHARED_E2E_MOCK_CREATE.mockResolvedValue(complexResponse); - - const schema = { - name: 'detailed_evaluation', - schema: { - properties: { - overall_score: { type: 'number' }, - detailed_scores: { - type: 'object', - properties: { - clarity: { type: 'number' }, - accuracy: { type: 'number' }, - completeness: { type: 'number' }, - }, - }, - recommendations: { - type: 'array', - items: { type: 'string' }, - }, - metadata: { - type: 'object', - properties: { - word_count: { type: 'number' }, - reading_level: { type: 'string' }, - topics: { type: 'array', items: { type: 'string' } }, - }, - }, - }, - required: ['overall_score', 'detailed_scores'], - }, - }; - - const result = await provider.runPromptStructured( - 'Complex content to evaluate', - 'Provide detailed evaluation', - schema - ); - - expect(result.data).toEqual({ - overall_score: 87.5, - detailed_scores: { - clarity: 90, - accuracy: 85, - completeness: 88, - }, - recommendations: [ - 'Improve technical accuracy', - 'Add more examples', - ], - metadata: { - word_count: 1250, - reading_level: 'intermediate', - topics: ['technology', 'education'], - }, - }); - expect(result.usage).toEqual({ - inputTokens: 200, - outputTokens: 100, - }); - }); - - it('handles responses with text content alongside tool use', async () => { - const env = { - LLM_PROVIDER: 'anthropic', - ANTHROPIC_API_KEY: 'sk-ant-test-key', - }; - - const envConfig = parseEnvironment(env); - const provider = createProvider(envConfig); - - // Mock response with both text and tool use - const mixedResponse: AnthropicMessage = { - id: 'msg_mixed', - type: 'message', - role: 'assistant', - content: [ - { - type: 'text', - text: 'I\'ll evaluate this content for you.', - }, - { - type: 'tool_use', - id: 'tool_mixed', - name: 'content_score', - input: { - score: 78, - notes: 'Good overall quality', - }, - }, - ], - model: 'claude-3-sonnet-20240229', - stop_reason: 'tool_use', - stop_sequence: null, - usage: { input_tokens: 80, output_tokens: 40 }, - }; - - SHARED_E2E_MOCK_CREATE.mockResolvedValue(mixedResponse); - - const schema = { - name: 'content_score', - schema: { - properties: { - score: { type: 'number' }, - notes: { type: 'string' }, - }, - required: ['score'], - }, - }; - - const result = await provider.runPromptStructured( - 'Content to score', - 'Score this content', - schema - ); - - // Should extract the tool use result, ignoring the text - expect(result.data).toEqual({ - score: 78, - notes: 'Good overall quality', - }); - expect(result.usage).toEqual({ - inputTokens: 80, - outputTokens: 40, - }); - }); - }); - - describe('Configuration Integration', () => { - it('integrates debug options through the complete flow', async () => { - const env = { - LLM_PROVIDER: 'anthropic', - ANTHROPIC_API_KEY: 'sk-ant-debug-key', - }; - - const envConfig = parseEnvironment(env); - const provider = createProvider(envConfig, { - debug: true, - showPrompt: true, - }); - - const mockResponse: AnthropicMessage = { - id: 'msg_debug', - type: 'message', - role: 'assistant', - content: [ - { - type: 'tool_use', - id: 'tool_debug', - name: 'debug_eval', - input: { status: 'debug_success' }, - }, - ], - model: 'claude-3-sonnet-20240229', - stop_reason: 'tool_use', - stop_sequence: null, - usage: { input_tokens: 30, output_tokens: 15 }, - }; - - SHARED_E2E_MOCK_CREATE.mockResolvedValue(mockResponse); - - const schema = { - name: 'debug_eval', - schema: { properties: { status: { type: 'string' } } }, - }; - - // Mock console.log to verify debug output - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { }); - - const result = await provider.runPromptStructured( - 'Debug test content', - 'Debug test prompt', - schema - ); - - expect(result.data).toEqual({ status: 'debug_success' }); - expect(result.usage).toEqual({ - inputTokens: 30, - outputTokens: 15, - }); - - // Verify debug logging occurred - expect(consoleSpy).toHaveBeenCalledWith( - '[vectorlint] Sending request to Anthropic:', - expect.any(Object) - ); - - consoleSpy.mockRestore(); - }); - - it('handles temperature configuration through complete flow', async () => { - const env = { - LLM_PROVIDER: 'anthropic', - ANTHROPIC_API_KEY: 'sk-ant-temp-key', - ANTHROPIC_TEMPERATURE: '0.8', - }; - - const envConfig = parseEnvironment(env); - const provider = createProvider(envConfig); - - const mockResponse: AnthropicMessage = { - id: 'msg_temp', - type: 'message', - role: 'assistant', - content: [ - { - type: 'tool_use', - id: 'tool_temp', - name: 'temp_eval', - input: { creativity: 'high' }, - }, - ], - model: 'claude-3-sonnet-20240229', - stop_reason: 'tool_use', - stop_sequence: null, - usage: { input_tokens: 40, output_tokens: 20 }, - }; - - SHARED_E2E_MOCK_CREATE.mockResolvedValue(mockResponse); - - const schema = { - name: 'temp_eval', - schema: { properties: { creativity: { type: 'string' } } }, - }; - - await provider.runPromptStructured( - 'Creative content', - 'Creative prompt', - schema - ); - - // Verify temperature was passed through - expect(SHARED_E2E_MOCK_CREATE).toHaveBeenCalledWith( - expect.objectContaining({ - temperature: 0.8, - }) - ); - }); - }); -}); diff --git a/tests/anthropic-provider.test.ts b/tests/anthropic-provider.test.ts deleted file mode 100644 index 31f3f98..0000000 --- a/tests/anthropic-provider.test.ts +++ /dev/null @@ -1,848 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { AnthropicProvider } from '../src/providers/anthropic-provider'; -import { DefaultRequestBuilder } from '../src/providers/request-builder'; -import type { AnthropicMessage } from '../src/schemas/api-schemas'; -import type { - MockAPIErrorParams, - MockAuthenticationErrorParams, - MockRateLimitErrorParams, - MockBadRequestErrorParams, - MockAnthropicClient -} from './schemas/mock-schemas'; - -// Create a shared mock function that all instances will use -const SHARED_MOCK_CREATE = vi.fn(); - -// Mock the Anthropic SDK module -vi.mock('@anthropic-ai/sdk', () => { - // Create error classes inside the mock factory - class APIError extends Error { - status: number; - constructor(params: MockAPIErrorParams) { - super(params.message); - this.status = params.status || 500; - this.name = 'APIError'; - } - } - - class RateLimitError extends Error { - constructor(params: Partial = {}) { - super(params.message ?? 'Rate limit exceeded'); - this.name = 'RateLimitError'; - } - } - - class AuthenticationError extends Error { - constructor(params: Partial = {}) { - super(params.message ?? 'Authentication failed'); - this.name = 'AuthenticationError'; - } - } - - class BadRequestError extends Error { - constructor(params: Partial = {}) { - super(params.message ?? 'Bad request'); - this.name = 'BadRequestError'; - } - } - - return { - default: vi.fn((): MockAnthropicClient => ({ - messages: { - create: SHARED_MOCK_CREATE, - }, - })), - APIError, - RateLimitError, - AuthenticationError, - BadRequestError, - }; -}); - -// Mock the API client validation -vi.mock('../src/boundaries/api-client', () => ({ - validateAnthropicResponse: vi.fn(), -})); - -describe('AnthropicProvider', () => { - let mockValidateAnthropicResponse: ReturnType; - - beforeEach(async () => { - vi.clearAllMocks(); - - // Get reference to the mocked validation function - const apiClient = await import('../src/boundaries/api-client'); - mockValidateAnthropicResponse = vi.mocked(apiClient.validateAnthropicResponse); - - // Default mock behavior - return the response as-is - mockValidateAnthropicResponse.mockImplementation((response: unknown) => response as AnthropicMessage); - }); - - describe('Constructor', () => { - it('creates provider with required config', () => { - const config = { - apiKey: 'sk-ant-test-key', - }; - - const provider = new AnthropicProvider(config); - expect(provider).toBeInstanceOf(AnthropicProvider); - }); - - it('applies default values for optional config', () => { - const config = { - apiKey: 'sk-ant-test-key', - }; - - // Should not throw - defaults should be applied internally - expect(() => new AnthropicProvider(config)).not.toThrow(); - }); - - it('accepts custom request builder', () => { - const config = { - apiKey: 'sk-ant-test-key', - }; - const customBuilder = new DefaultRequestBuilder('custom directive'); - - expect(() => new AnthropicProvider(config, customBuilder)).not.toThrow(); - }); - - it('accepts all configuration options', () => { - const config = { - apiKey: 'sk-ant-test-key', - model: 'claude-3-haiku-20240307', - maxTokens: 2048, - temperature: 0.5, - debug: true, - showPrompt: true, - showPromptTrunc: false, - }; - - expect(() => new AnthropicProvider(config)).not.toThrow(); - }); - }); - - describe('Structured Response Handling', () => { - it('successfully extracts structured response from tool use', async () => { - const config = { - apiKey: 'sk-ant-test-key', - }; - - const mockResponse: AnthropicMessage = { - id: 'msg_123', - type: 'message', - role: 'assistant', - content: [ - { - type: 'tool_use', - id: 'tool_123', - name: 'submit_evaluation', - input: { - score: 85, - feedback: 'Good content', - }, - }, - ], - model: 'claude-3-sonnet-20240229', - stop_reason: 'tool_use', - stop_sequence: null, - usage: { - input_tokens: 100, - output_tokens: 50, - }, - }; - - SHARED_MOCK_CREATE.mockResolvedValue(mockResponse); - - const provider = new AnthropicProvider(config); - const schema = { - name: 'submit_evaluation', - schema: { - properties: { - score: { type: 'number' }, - feedback: { type: 'string' }, - }, - required: ['score', 'feedback'], - }, - }; - - const result = await provider.runPromptStructured( - 'Test content', - 'Test prompt', - schema - ); - - expect(result.data).toEqual({ - score: 85, - feedback: 'Good content', - }); - expect(result.usage).toBeDefined(); - if (result.usage) { - expect(result.usage.inputTokens).toBe(100); - expect(result.usage.outputTokens).toBe(50); - } - }); - - it('converts schema to Anthropic tool format correctly', async () => { - const config = { - apiKey: 'sk-ant-test-key', - }; - - const mockResponse: AnthropicMessage = { - id: 'msg_123', - type: 'message', - role: 'assistant', - content: [ - { - type: 'tool_use', - id: 'tool_123', - name: 'test_tool', - input: { result: 'success' }, - }, - ], - model: 'claude-3-sonnet-20240229', - stop_reason: 'tool_use', - stop_sequence: null, - usage: { - input_tokens: 100, - output_tokens: 50, - }, - }; - - SHARED_MOCK_CREATE.mockResolvedValue(mockResponse); - - const provider = new AnthropicProvider(config); - const schema = { - name: 'test_tool', - schema: { - properties: { - result: { type: 'string' }, - }, - required: ['result'], - }, - }; - - await provider.runPromptStructured('Test content', 'Test prompt', schema); - - expect(SHARED_MOCK_CREATE).toHaveBeenCalledWith( - expect.objectContaining({ - tools: [ - { - name: 'test_tool', - description: 'Submit test_tool evaluation results', - input_schema: { - type: 'object', - properties: { - result: { type: 'string' }, - }, - required: ['result'], - }, - }, - ], - tool_choice: { type: 'tool', name: 'test_tool' }, - }) - ); - }); - - it('includes temperature in API call when configured', async () => { - const config = { - apiKey: 'sk-ant-test-key', - temperature: 0.7, - }; - - const mockResponse: AnthropicMessage = { - id: 'msg_123', - type: 'message', - role: 'assistant', - content: [ - { - type: 'tool_use', - id: 'tool_123', - name: 'test_tool', - input: { result: 'success' }, - }, - ], - model: 'claude-3-sonnet-20240229', - stop_reason: 'tool_use', - stop_sequence: null, - usage: { - input_tokens: 100, - output_tokens: 50, - }, - }; - - SHARED_MOCK_CREATE.mockResolvedValue(mockResponse); - - const provider = new AnthropicProvider(config); - const schema = { - name: 'test_tool', - schema: { properties: { result: { type: 'string' } } }, - }; - - await provider.runPromptStructured('Test content', 'Test prompt', schema); - - expect(SHARED_MOCK_CREATE).toHaveBeenCalledWith( - expect.objectContaining({ - temperature: 0.7, - }) - ); - }); - - it('uses default temperature when not explicitly configured', async () => { - const config = { - apiKey: 'sk-ant-test-key', - }; - - const mockResponse: AnthropicMessage = { - id: 'msg_123', - type: 'message', - role: 'assistant', - content: [ - { - type: 'tool_use', - id: 'tool_123', - name: 'test_tool', - input: { result: 'success' }, - }, - ], - model: 'claude-3-sonnet-20240229', - stop_reason: 'tool_use', - stop_sequence: null, - usage: { - input_tokens: 100, - output_tokens: 50, - }, - }; - - SHARED_MOCK_CREATE.mockResolvedValue(mockResponse); - - const provider = new AnthropicProvider(config); - const schema = { - name: 'test_tool', - schema: { properties: { result: { type: 'string' } } }, - }; - - await provider.runPromptStructured('Test content', 'Test prompt', schema); - - const callArgs = SHARED_MOCK_CREATE.mock.calls[0]?.[0] as Record | undefined; - expect(callArgs?.temperature).toBe(0.2); // Default temperature - }); - }); - - describe('Error Handling', () => { - it('handles Anthropic API errors with status codes', async () => { - const config = { - apiKey: 'sk-ant-test-key', - }; - - const anthropic = await import('@anthropic-ai/sdk'); - const mockApiError = anthropic.APIError as unknown as new (params: MockAPIErrorParams) => Error; - SHARED_MOCK_CREATE.mockRejectedValue(new mockApiError({ - message: 'Invalid request', - status: 400 - })); - - const provider = new AnthropicProvider(config); - const schema = { - name: 'test_tool', - schema: { properties: { result: { type: 'string' } } }, - }; - - await expect( - provider.runPromptStructured('Test content', 'Test prompt', schema) - ).rejects.toThrow(); - }); - - it('handles Anthropic rate limit errors', async () => { - const config = { - apiKey: 'sk-ant-test-key', - }; - - const anthropic = await import('@anthropic-ai/sdk'); - const mockRateLimitError = anthropic.RateLimitError as unknown as new (params: Partial) => Error; - SHARED_MOCK_CREATE.mockRejectedValue(new mockRateLimitError({ - message: 'Rate limit exceeded' - })); - - const provider = new AnthropicProvider(config); - const schema = { - name: 'test_tool', - schema: { properties: { result: { type: 'string' } } }, - }; - - await expect( - provider.runPromptStructured('Test content', 'Test prompt', schema) - ).rejects.toThrow(); - }); - - it('handles Anthropic authentication errors', async () => { - const config = { - apiKey: 'sk-ant-test-key', - }; - - const anthropic = await import('@anthropic-ai/sdk'); - const mockAuthenticationError = anthropic.AuthenticationError as unknown as new (params: Partial) => Error; - SHARED_MOCK_CREATE.mockRejectedValue(new mockAuthenticationError({ - message: 'Invalid API key' - })); - - const provider = new AnthropicProvider(config); - const schema = { - name: 'test_tool', - schema: { properties: { result: { type: 'string' } } }, - }; - - await expect( - provider.runPromptStructured('Test content', 'Test prompt', schema) - ).rejects.toThrow(); - }); - - it('handles Anthropic bad request errors', async () => { - const config = { - apiKey: 'sk-ant-test-key', - }; - - const anthropic = await import('@anthropic-ai/sdk'); - const mockBadRequestError = anthropic.BadRequestError as unknown as new (params: Partial) => Error; - SHARED_MOCK_CREATE.mockRejectedValue(new mockBadRequestError({ - message: 'Bad request' - })); - - const provider = new AnthropicProvider(config); - const schema = { - name: 'test_tool', - schema: { properties: { result: { type: 'string' } } }, - }; - - await expect( - provider.runPromptStructured('Test content', 'Test prompt', schema) - ).rejects.toThrow(); - }); - - it('handles unknown errors', async () => { - const config = { - apiKey: 'sk-ant-test-key', - }; - - SHARED_MOCK_CREATE.mockRejectedValue(new Error('Unknown error')); - - const provider = new AnthropicProvider(config); - const schema = { - name: 'test_tool', - schema: { properties: { result: { type: 'string' } } }, - }; - - await expect( - provider.runPromptStructured('Test content', 'Test prompt', schema) - ).rejects.toThrow(); - }); - - it('handles response validation errors', async () => { - const config = { - apiKey: 'sk-ant-test-key', - }; - - // Mock an invalid response that will fail schema validation - const invalidResponse = { - // Missing required fields like 'id', 'type', 'role', 'content', etc. - model: 'claude-3-sonnet-20240229', - }; - - SHARED_MOCK_CREATE.mockResolvedValue(invalidResponse); - - const provider = new AnthropicProvider(config); - const schema = { - name: 'test_tool', - schema: { properties: { result: { type: 'string' } } }, - }; - - await expect( - provider.runPromptStructured('Test content', 'Test prompt', schema) - ).rejects.toThrow('API Response Error: Invalid Anthropic API response structure'); - }); - - it('throws error when response has no content', async () => { - const config = { - apiKey: 'sk-ant-test-key', - }; - - const mockResponse: AnthropicMessage = { - id: 'msg_123', - type: 'message', - role: 'assistant', - content: [], - model: 'claude-3-sonnet-20240229', - stop_reason: 'end_turn', - stop_sequence: null, - usage: { - input_tokens: 100, - output_tokens: 0, - }, - }; - - SHARED_MOCK_CREATE.mockResolvedValue(mockResponse); - - const provider = new AnthropicProvider(config); - const schema = { - name: 'test_tool', - schema: { properties: { result: { type: 'string' } } }, - }; - - await expect( - provider.runPromptStructured('Test content', 'Test prompt', schema) - ).rejects.toThrow('Empty response from Anthropic API (no content blocks).'); - }); - - it('throws error when no tool use is found', async () => { - const config = { - apiKey: 'sk-ant-test-key', - }; - - const mockResponse: AnthropicMessage = { - id: 'msg_123', - type: 'message', - role: 'assistant', - content: [ - { - type: 'text', - text: 'I cannot provide structured output', - }, - ], - model: 'claude-3-sonnet-20240229', - stop_reason: 'end_turn', - stop_sequence: null, - usage: { - input_tokens: 100, - output_tokens: 20, - }, - }; - - SHARED_MOCK_CREATE.mockResolvedValue(mockResponse); - - const provider = new AnthropicProvider(config); - const schema = { - name: 'test_tool', - schema: { properties: { result: { type: 'string' } } }, - }; - - await expect( - provider.runPromptStructured('Test content', 'Test prompt', schema) - ).rejects.toThrow('No tool call received for test_tool. Response contains text instead: I cannot provide structured output'); - }); - - it('throws error when wrong tool name is used', async () => { - const config = { - apiKey: 'sk-ant-test-key', - }; - - const mockResponse: AnthropicMessage = { - id: 'msg_123', - type: 'message', - role: 'assistant', - content: [ - { - type: 'tool_use', - id: 'tool_123', - name: 'wrong_tool', - input: { result: 'success' }, - }, - ], - model: 'claude-3-sonnet-20240229', - stop_reason: 'tool_use', - stop_sequence: null, - usage: { - input_tokens: 100, - output_tokens: 50, - }, - }; - - SHARED_MOCK_CREATE.mockResolvedValue(mockResponse); - - const provider = new AnthropicProvider(config); - const schema = { - name: 'expected_tool', - schema: { properties: { result: { type: 'string' } } }, - }; - - await expect( - provider.runPromptStructured('Test content', 'Test prompt', schema) - ).rejects.toThrow('Expected tool call \'expected_tool\' but received: wrong_tool'); - }); - - it('throws error when tool input is empty', async () => { - const config = { - apiKey: 'sk-ant-test-key', - }; - - const mockResponse: AnthropicMessage = { - id: 'msg_123', - type: 'message', - role: 'assistant', - content: [ - { - type: 'tool_use', - id: 'tool_123', - name: 'test_tool', - input: {}, - }, - ], - model: 'claude-3-sonnet-20240229', - stop_reason: 'tool_use', - stop_sequence: null, - usage: { - input_tokens: 100, - output_tokens: 50, - }, - }; - - SHARED_MOCK_CREATE.mockResolvedValue(mockResponse); - - const provider = new AnthropicProvider(config); - const schema = { - name: 'test_tool', - schema: { properties: { result: { type: 'string' } } }, - }; - - await expect( - provider.runPromptStructured('Test content', 'Test prompt', schema) - ).rejects.toThrow('Tool call for test_tool returned empty or null input.'); - }); - - it('throws error when tool input is not an object', async () => { - const config = { - apiKey: 'sk-ant-test-key', - }; - - const mockResponse = { - id: 'msg_123', - type: 'message', - role: 'assistant', - content: [ - { - type: 'tool_use', - id: 'tool_123', - name: 'test_tool', - input: 'not an object', - }, - ], - model: 'claude-3-sonnet-20240229', - stop_reason: 'tool_use', - stop_sequence: null, - usage: { - input_tokens: 100, - output_tokens: 50, - }, - }; - - SHARED_MOCK_CREATE.mockResolvedValue(mockResponse); - - const provider = new AnthropicProvider(config); - const schema = { - name: 'test_tool', - schema: { properties: { result: { type: 'string' } } }, - }; - - await expect( - provider.runPromptStructured('Test content', 'Test prompt', schema) - ).rejects.toThrow('Tool call for test_tool returned invalid input type: string'); - }); - }); - - describe('Debugging and Logging', () => { - let consoleSpy: ReturnType; - - beforeEach(() => { - consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { }); - }); - - afterEach(() => { - consoleSpy.mockRestore(); - }); - - it('logs debug information when debug is enabled', async () => { - const config = { - apiKey: 'sk-ant-test-key', - debug: true, - }; - - const mockResponse: AnthropicMessage = { - id: 'msg_123', - type: 'message', - role: 'assistant', - content: [ - { - type: 'tool_use', - id: 'tool_123', - name: 'test_tool', - input: { result: 'success' }, - }, - ], - model: 'claude-3-sonnet-20240229', - stop_reason: 'tool_use', - stop_sequence: null, - usage: { - input_tokens: 100, - output_tokens: 50, - }, - }; - - SHARED_MOCK_CREATE.mockResolvedValue(mockResponse); - - const provider = new AnthropicProvider(config); - const schema = { - name: 'test_tool', - schema: { properties: { result: { type: 'string' } } }, - }; - - await provider.runPromptStructured('Test content', 'Test prompt', schema); - - expect(consoleSpy).toHaveBeenCalledWith( - '[vectorlint] Sending request to Anthropic:', - expect.objectContaining({ - model: 'claude-3-sonnet-20240229', - maxTokens: 4096, - temperature: 0.2, - }) - ); - - expect(consoleSpy).toHaveBeenCalledWith( - '[vectorlint] LLM response meta:', - expect.objectContaining({ - usage: { - input_tokens: 100, - output_tokens: 50, - }, - stop_reason: 'tool_use', - }) - ); - }); - - it('shows full prompt when showPrompt is enabled', async () => { - const config = { - apiKey: 'sk-ant-test-key', - debug: true, - showPrompt: true, - }; - - const mockResponse: AnthropicMessage = { - id: 'msg_123', - type: 'message', - role: 'assistant', - content: [ - { - type: 'tool_use', - id: 'tool_123', - name: 'test_tool', - input: { result: 'success' }, - }, - ], - model: 'claude-3-sonnet-20240229', - stop_reason: 'tool_use', - stop_sequence: null, - usage: { - input_tokens: 100, - output_tokens: 50, - }, - }; - - SHARED_MOCK_CREATE.mockResolvedValue(mockResponse); - - const provider = new AnthropicProvider(config); - const schema = { - name: 'test_tool', - schema: { properties: { result: { type: 'string' } } }, - }; - - await provider.runPromptStructured('Test content', 'Test prompt', schema); - - expect(consoleSpy).toHaveBeenCalledWith('[vectorlint] System prompt (full):'); - expect(consoleSpy).toHaveBeenCalledWith('[vectorlint] User content (full):'); - expect(consoleSpy).toHaveBeenCalledWith('Test content'); - }); - - it('shows truncated prompt when showPromptTrunc is enabled', async () => { - const config = { - apiKey: 'sk-ant-test-key', - debug: true, - showPromptTrunc: true, - }; - - const mockResponse: AnthropicMessage = { - id: 'msg_123', - type: 'message', - role: 'assistant', - content: [ - { - type: 'tool_use', - id: 'tool_123', - name: 'test_tool', - input: { result: 'success' }, - }, - ], - model: 'claude-3-sonnet-20240229', - stop_reason: 'tool_use', - stop_sequence: null, - usage: { - input_tokens: 100, - output_tokens: 50, - }, - }; - - SHARED_MOCK_CREATE.mockResolvedValue(mockResponse); - - const provider = new AnthropicProvider(config); - const schema = { - name: 'test_tool', - schema: { properties: { result: { type: 'string' } } }, - }; - - // Use long content to test truncation - const longContent = 'A'.repeat(600); - await provider.runPromptStructured(longContent, 'Test prompt', schema); - - expect(consoleSpy).toHaveBeenCalledWith('[vectorlint] System prompt (first 500 chars):'); - expect(consoleSpy).toHaveBeenCalledWith('[vectorlint] User content preview (first 500 chars):'); - expect(consoleSpy).toHaveBeenCalledWith('A'.repeat(500)); - expect(consoleSpy).toHaveBeenCalledWith('... [truncated]'); - }); - - it('does not log when debug is disabled', async () => { - const config = { - apiKey: 'sk-ant-test-key', - debug: false, - }; - - const mockResponse: AnthropicMessage = { - id: 'msg_123', - type: 'message', - role: 'assistant', - content: [ - { - type: 'tool_use', - id: 'tool_123', - name: 'test_tool', - input: { result: 'success' }, - }, - ], - model: 'claude-3-sonnet-20240229', - stop_reason: 'tool_use', - stop_sequence: null, - usage: { - input_tokens: 100, - output_tokens: 50, - }, - }; - - SHARED_MOCK_CREATE.mockResolvedValue(mockResponse); - - const provider = new AnthropicProvider(config); - const schema = { - name: 'test_tool', - schema: { properties: { result: { type: 'string' } } }, - }; - - await provider.runPromptStructured('Test content', 'Test prompt', schema); - - expect(consoleSpy).not.toHaveBeenCalled(); - }); - }); -}); \ No newline at end of file diff --git a/tests/env-parser.test.ts b/tests/env-parser.test.ts index 369465a..fb6b50b 100644 --- a/tests/env-parser.test.ts +++ b/tests/env-parser.test.ts @@ -2,9 +2,7 @@ import { describe, it, expect } from 'vitest'; import { parseEnvironment } from '../src/boundaries/env-parser'; import { ValidationError } from '../src/errors/index'; import { ProviderType } from '../src/providers/provider-factory'; -import { AZURE_OPENAI_DEFAULT_CONFIG } from '../src/providers/azure-openai-provider'; -import { ANTHROPIC_DEFAULT_CONFIG } from '../src/providers/anthropic-provider'; -import { OPENAI_DEFAULT_CONFIG } from '../src/providers/openai-provider'; +import { AZURE_OPENAI_DEFAULT_CONFIG, ANTHROPIC_DEFAULT_CONFIG, OPENAI_DEFAULT_CONFIG } from '../src/schemas/env-schemas'; describe('Environment Parser', () => { describe('Azure OpenAI Configuration', () => { diff --git a/tests/openai-provider.test.ts b/tests/openai-provider.test.ts deleted file mode 100644 index 93eff8e..0000000 --- a/tests/openai-provider.test.ts +++ /dev/null @@ -1,1019 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import type { - MockAPIErrorParams, - MockAuthenticationErrorParams, - MockRateLimitErrorParams, - MockOpenAIClient -} from './schemas/mock-schemas'; - -// Shared spy used by all tests -const SHARED_CREATE = vi.fn(); - -// Hoist error classes to avoid TDZ issues -const ERRORS = vi.hoisted(() => { - class APIError extends Error { - status: number; - constructor(params: MockAPIErrorParams) { - super(params.message); - this.name = 'APIError'; - this.status = params.status ?? 500; - } - } - - class AuthenticationError extends APIError { - constructor(params: Partial = {}) { - super({ - message: params.message ?? 'Unauthorized', - status: 401, - options: params.options, - body: params.body, - }); - this.name = 'AuthenticationError'; - } - } - - class RateLimitError extends APIError { - constructor(params: Partial = {}) { - super({ - message: params.message ?? 'Rate Limited', - status: 429, - options: params.options, - body: params.body, - }); - this.name = 'RateLimitError'; - } - } - - return { APIError, AuthenticationError, RateLimitError }; -}); - -// Mock OpenAI SDK - must come before importing SUT -vi.mock('openai', () => { - // Pull hoisted classes so we reuse the same identities everywhere - const { APIError, AuthenticationError, RateLimitError } = ERRORS; - - // Default export client with proper typing - const openAI = vi.fn((): MockOpenAIClient => ({ - // Support either surface your provider might call - chat: { completions: { create: SHARED_CREATE } }, - responses: { create: SHARED_CREATE }, - })); - - // Attach error classes on the default export too - // @ts-expect-error - Mock needs to add error classes to constructor function - openAI.APIError = APIError; - // @ts-expect-error - Mock needs to add error classes to constructor function - openAI.AuthenticationError = AuthenticationError; - // @ts-expect-error - Mock needs to add error classes to constructor function - openAI.RateLimitError = RateLimitError; - - // Some codebases read from OpenAI.errors.* - // @ts-expect-error - Mock needs to add errors object to constructor function - openAI.errors = { - APIError, - AuthenticationError, - RateLimitError, - }; - - return { - __esModule: true, - default: openAI, - // Also expose named exports in case the provider uses named imports - APIError, - AuthenticationError, - RateLimitError, - }; -}); - -// Mock the API client validation -vi.mock('../src/boundaries/api-client', () => ({ - validateApiResponse: vi.fn(), -})); - -// Now import SUT after mocks are set up -import { OpenAIProvider } from '../src/providers/openai-provider'; -import { DefaultRequestBuilder } from '../src/providers/request-builder'; -import type { OpenAIResponse } from '../src/schemas/api-schemas'; - -describe('OpenAIProvider', () => { - let mockValidateApiResponse: ReturnType; - - beforeEach(async () => { - vi.clearAllMocks(); - - // Get reference to the mocked validation function - const apiClient = await import('../src/boundaries/api-client'); - mockValidateApiResponse = vi.mocked(apiClient.validateApiResponse); - - // Default mock behavior - return the response as-is - mockValidateApiResponse.mockImplementation((response: unknown) => response as OpenAIResponse); - }); - - describe('Constructor', () => { - it('creates provider with required config', () => { - const config = { - apiKey: 'sk-test-key', - }; - - const provider = new OpenAIProvider(config); - expect(provider).toBeInstanceOf(OpenAIProvider); - }); - - it('applies default values for optional config', () => { - const config = { - apiKey: 'sk-test-key', - }; - - // Should not throw - defaults should be applied internally - expect(() => new OpenAIProvider(config)).not.toThrow(); - }); - - it('accepts custom request builder', () => { - const config = { - apiKey: 'sk-test-key', - }; - const customBuilder = new DefaultRequestBuilder('custom directive'); - - expect(() => new OpenAIProvider(config, customBuilder)).not.toThrow(); - }); - - it('accepts all configuration options', () => { - const config = { - apiKey: 'sk-test-key', - model: 'gpt-4o-mini', - temperature: 0.5, - debug: true, - showPrompt: true, - showPromptTrunc: false, - }; - - expect(() => new OpenAIProvider(config)).not.toThrow(); - }); - }); - - describe('Structured Response Handling', () => { - it('successfully parses structured JSON response', async () => { - const config = { - apiKey: 'sk-test-key', - }; - - const mockResponse: OpenAIResponse = { - choices: [ - { - message: { - content: JSON.stringify({ - score: 85, - feedback: 'Good content', - }), - }, - finish_reason: 'stop', - }, - ], - usage: { - prompt_tokens: 100, - completion_tokens: 50, - total_tokens: 150, - }, - }; - - SHARED_CREATE.mockResolvedValue(mockResponse); - - const provider = new OpenAIProvider(config); - const schema = { - name: 'submit_evaluation', - schema: { - properties: { - score: { type: 'number' }, - feedback: { type: 'string' }, - }, - required: ['score', 'feedback'], - }, - }; - - const result = await provider.runPromptStructured( - 'Test content', - 'Test prompt', - schema - ); - - expect(result.data).toEqual({ - score: 85, - feedback: 'Good content', - }); - - expect(result.usage).toBeDefined(); - if (result.usage) { - expect(result.usage.inputTokens).toBe(100); - expect(result.usage.outputTokens).toBe(50); - } - }); - - it('configures OpenAI API call with JSON schema response format', async () => { - const config = { - apiKey: 'sk-test-key', - }; - - const mockResponse: OpenAIResponse = { - choices: [ - { - message: { - content: JSON.stringify({ result: 'success' }), - }, - finish_reason: 'stop', - }, - ], - }; - - SHARED_CREATE.mockResolvedValue(mockResponse); - - const provider = new OpenAIProvider(config); - const schema = { - name: 'test_schema', - schema: { - properties: { - result: { type: 'string' }, - }, - required: ['result'], - }, - }; - - await provider.runPromptStructured('Test content', 'Test prompt', schema); - - expect(SHARED_CREATE).toHaveBeenCalledWith( - expect.objectContaining({ - model: 'gpt-4o', - messages: [ - { role: 'system', content: expect.any(String) as string }, - { role: 'user', content: 'Input:\n\nTest content' }, - ], - response_format: { - type: 'json_schema', - json_schema: { - name: 'test_schema', - schema: { - properties: { - result: { type: 'string' }, - }, - required: ['result'], - }, - }, - }, - temperature: 0.2, - }) - ); - }); - - it('properly formats complex JSON schema for OpenAI', async () => { - const config = { - apiKey: 'sk-test-key', - }; - - const mockResponse: OpenAIResponse = { - choices: [ - { - message: { - content: JSON.stringify({ - score: 85, - feedback: 'Good', - categories: ['content', 'style'] - }), - }, - finish_reason: 'stop', - }, - ], - }; - - SHARED_CREATE.mockResolvedValue(mockResponse); - - const provider = new OpenAIProvider(config); - const complexSchema = { - name: 'evaluation_result', - schema: { - type: 'object', - properties: { - score: { - type: 'number', - minimum: 0, - maximum: 100 - }, - feedback: { - type: 'string', - minLength: 1 - }, - categories: { - type: 'array', - items: { type: 'string' } - } - }, - required: ['score', 'feedback'], - additionalProperties: false - }, - }; - - await provider.runPromptStructured('Test content', 'Test prompt', complexSchema); - - const callArgs = SHARED_CREATE.mock.calls[0]?.[0] as Record | undefined; - expect(callArgs?.response_format).toEqual({ - type: 'json_schema', - json_schema: { - name: 'evaluation_result', - schema: complexSchema.schema, - }, - }); - }); - - it('includes temperature in API call when configured', async () => { - const config = { - apiKey: 'sk-test-key', - temperature: 0.7, - }; - - const mockResponse: OpenAIResponse = { - choices: [ - { - message: { - content: JSON.stringify({ result: 'success' }), - }, - finish_reason: 'stop', - }, - ], - }; - - SHARED_CREATE.mockResolvedValue(mockResponse); - - const provider = new OpenAIProvider(config); - const schema = { - name: 'test_schema', - schema: { properties: { result: { type: 'string' } } }, - }; - - await provider.runPromptStructured('Test content', 'Test prompt', schema); - - expect(SHARED_CREATE).toHaveBeenCalledWith( - expect.objectContaining({ - temperature: 0.7, - }) - ); - }); - - it('uses default temperature when not explicitly configured', async () => { - const config = { - apiKey: 'sk-test-key', - }; - - const mockResponse: OpenAIResponse = { - choices: [ - { - message: { - content: JSON.stringify({ result: 'success' }), - }, - finish_reason: 'stop', - }, - ], - }; - - SHARED_CREATE.mockResolvedValue(mockResponse); - - const provider = new OpenAIProvider(config); - const schema = { - name: 'test_schema', - schema: { properties: { result: { type: 'string' } } }, - }; - - await provider.runPromptStructured('Test content', 'Test prompt', schema); - - const callArgs = SHARED_CREATE.mock.calls[0]?.[0] as Record | undefined; - expect(callArgs?.temperature).toBe(0.2); // Default temperature - }); - - it('uses default model when not explicitly configured', async () => { - const config = { - apiKey: 'sk-test-key', - }; - - const mockResponse: OpenAIResponse = { - choices: [ - { - message: { - content: JSON.stringify({ result: 'success' }), - }, - finish_reason: 'stop', - }, - ], - }; - - SHARED_CREATE.mockResolvedValue(mockResponse); - - const provider = new OpenAIProvider(config); - const schema = { - name: 'test_schema', - schema: { properties: { result: { type: 'string' } } }, - }; - - await provider.runPromptStructured('Test content', 'Test prompt', schema); - - const callArgs = SHARED_CREATE.mock.calls[0]?.[0] as Record | undefined; - expect(callArgs?.model).toBe('gpt-4o'); // Default model - }); - - it('uses custom model when configured', async () => { - const config = { - apiKey: 'sk-test-key', - model: 'gpt-4o-mini', - }; - - const mockResponse: OpenAIResponse = { - choices: [ - { - message: { - content: JSON.stringify({ result: 'success' }), - }, - finish_reason: 'stop', - }, - ], - }; - - SHARED_CREATE.mockResolvedValue(mockResponse); - - const provider = new OpenAIProvider(config); - const schema = { - name: 'test_schema', - schema: { properties: { result: { type: 'string' } } }, - }; - - await provider.runPromptStructured('Test content', 'Test prompt', schema); - - const callArgs = SHARED_CREATE.mock.calls[0]?.[0] as Record | undefined; - expect(callArgs?.model).toBe('gpt-4o-mini'); - }); - }); - - describe('Error Handling', () => { - it('mock sanity check', async () => { - const mod = await import('openai'); - expect(mod.default.APIError).toBe(mod.APIError); - expect(typeof mod.default.APIError).toBe('function'); - expect(typeof new mod.default().chat.completions.create).toBe('function'); - }); - - it('handles OpenAI API errors', async () => { - const config = { - apiKey: 'sk-test-key', - }; - - const openAI = await import('openai'); - // @ts-expect-error - Mock constructor signature differs from real SDK - SHARED_CREATE.mockRejectedValue(new openAI.APIError({ - message: 'API request failed', - status: 429 - })); - - const provider = new OpenAIProvider(config); - const schema = { - name: 'test_schema', - schema: { properties: { result: { type: 'string' } } }, - }; - - await expect( - provider.runPromptStructured('Test content', 'Test prompt', schema) - ).rejects.toThrow('OpenAI API error (429): API request failed'); - }); - - it('handles OpenAI rate limit errors', async () => { - const config = { - apiKey: 'sk-test-key', - }; - - const openAI = await import('openai'); - // @ts-expect-error - Mock constructor signature differs from real SDK - SHARED_CREATE.mockRejectedValue(new openAI.RateLimitError({ - message: 'Rate limit exceeded' - })); - - const provider = new OpenAIProvider(config); - const schema = { - name: 'test_schema', - schema: { properties: { result: { type: 'string' } } }, - }; - - await expect( - provider.runPromptStructured('Test content', 'Test prompt', schema) - ).rejects.toThrow('OpenAI rate limit exceeded: Rate limit exceeded'); - }); - - it('handles OpenAI authentication errors', async () => { - const config = { - apiKey: 'sk-test-key', - }; - - const openAI = await import('openai'); - // @ts-expect-error - Mock constructor signature differs from real SDK - SHARED_CREATE.mockRejectedValue(new openAI.AuthenticationError({ - message: 'Invalid API key' - })); - - const provider = new OpenAIProvider(config); - const schema = { - name: 'test_schema', - schema: { properties: { result: { type: 'string' } } }, - }; - - await expect( - provider.runPromptStructured('Test content', 'Test prompt', schema) - ).rejects.toThrow('OpenAI authentication failed: Invalid API key'); - }); - - it('handles unknown errors', async () => { - const config = { - apiKey: 'sk-test-key', - }; - - SHARED_CREATE.mockRejectedValue(new Error('Unknown error')); - - const provider = new OpenAIProvider(config); - const schema = { - name: 'test_schema', - schema: { properties: { result: { type: 'string' } } }, - }; - - await expect( - provider.runPromptStructured('Test content', 'Test prompt', schema) - ).rejects.toThrow('OpenAI API call failed: Unknown error'); - }); - - it('handles response validation errors', async () => { - const config = { - apiKey: 'sk-test-key', - }; - - // Mock an invalid response that will fail schema validation - const invalidResponse = { - // Missing required 'choices' field - usage: { - prompt_tokens: 10, - completion_tokens: 20, - total_tokens: 30, - }, - }; - - SHARED_CREATE.mockResolvedValue(invalidResponse); - - const provider = new OpenAIProvider(config); - const schema = { - name: 'test_schema', - schema: { properties: { result: { type: 'string' } } }, - }; - - await expect( - provider.runPromptStructured('Test content', 'Test prompt', schema) - ).rejects.toThrow('API Response Error: Invalid OpenAI API response structure'); - }); - - it('throws error when response has no content', async () => { - const config = { - apiKey: 'sk-test-key', - }; - - const mockResponse: OpenAIResponse = { - choices: [ - { - message: { - content: null, - }, - finish_reason: 'stop', - }, - ], - }; - - SHARED_CREATE.mockResolvedValue(mockResponse); - - const provider = new OpenAIProvider(config); - const schema = { - name: 'test_schema', - schema: { properties: { result: { type: 'string' } } }, - }; - - await expect( - provider.runPromptStructured('Test content', 'Test prompt', schema) - ).rejects.toThrow('Empty response from OpenAI API (no content).'); - }); - - it('throws error when response has empty content', async () => { - const config = { - apiKey: 'sk-test-key', - }; - - const mockResponse: OpenAIResponse = { - choices: [ - { - message: { - content: ' ', - }, - finish_reason: 'stop', - }, - ], - }; - - SHARED_CREATE.mockResolvedValue(mockResponse); - - const provider = new OpenAIProvider(config); - const schema = { - name: 'test_schema', - schema: { properties: { result: { type: 'string' } } }, - }; - - await expect( - provider.runPromptStructured('Test content', 'Test prompt', schema) - ).rejects.toThrow('Empty response from OpenAI API (no content).'); - }); - - it('throws error when JSON parsing fails', async () => { - const config = { - apiKey: 'sk-test-key', - }; - - const mockResponse: OpenAIResponse = { - choices: [ - { - message: { - content: 'invalid json content', - }, - finish_reason: 'stop', - }, - ], - }; - - SHARED_CREATE.mockResolvedValue(mockResponse); - - const provider = new OpenAIProvider(config); - const schema = { - name: 'test_schema', - schema: { properties: { result: { type: 'string' } } }, - }; - - await expect( - provider.runPromptStructured('Test content', 'Test prompt', schema) - ).rejects.toThrow('Failed to parse structured JSON response'); - }); - - it('includes response preview in JSON parsing error', async () => { - const config = { - apiKey: 'sk-test-key', - }; - - const longInvalidJson = 'invalid json content that is longer than 200 characters '.repeat(10); - const mockResponse: OpenAIResponse = { - choices: [ - { - message: { - content: longInvalidJson, - }, - finish_reason: 'stop', - }, - ], - }; - - SHARED_CREATE.mockResolvedValue(mockResponse); - - const provider = new OpenAIProvider(config); - const schema = { - name: 'test_schema', - schema: { properties: { result: { type: 'string' } } }, - }; - - await expect( - provider.runPromptStructured('Test content', 'Test prompt', schema) - ).rejects.toThrow(/Preview:.*\.\.\./); - }); - }); - - describe('Debugging and Logging', () => { - let consoleSpy: ReturnType; - - beforeEach(() => { - consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { }); - }); - - afterEach(() => { - consoleSpy.mockRestore(); - }); - - it('logs debug information when debug is enabled', async () => { - const config = { - apiKey: 'sk-test-key', - debug: true, - }; - - const mockResponse: OpenAIResponse = { - choices: [ - { - message: { - content: JSON.stringify({ result: 'success' }), - }, - finish_reason: 'stop', - }, - ], - usage: { - prompt_tokens: 100, - completion_tokens: 50, - total_tokens: 150, - }, - }; - - SHARED_CREATE.mockResolvedValue(mockResponse); - - const provider = new OpenAIProvider(config); - const schema = { - name: 'test_schema', - schema: { properties: { result: { type: 'string' } } }, - }; - - await provider.runPromptStructured('Test content', 'Test prompt', schema); - - expect(consoleSpy).toHaveBeenCalledWith( - '[vectorlint] Sending request to OpenAI:', - expect.objectContaining({ - model: 'gpt-4o', - temperature: 0.2, - }) - ); - - expect(consoleSpy).toHaveBeenCalledWith( - '[vectorlint] LLM response meta:', - expect.objectContaining({ - usage: { - prompt_tokens: 100, - completion_tokens: 50, - total_tokens: 150, - }, - finish_reason: 'stop', - }) - ); - }); - - it('shows full prompt when showPrompt is enabled', async () => { - const config = { - apiKey: 'sk-test-key', - debug: true, - showPrompt: true, - }; - - const mockResponse: OpenAIResponse = { - choices: [ - { - message: { - content: JSON.stringify({ result: 'success' }), - }, - finish_reason: 'stop', - }, - ], - }; - - SHARED_CREATE.mockResolvedValue(mockResponse); - - const provider = new OpenAIProvider(config); - const schema = { - name: 'test_schema', - schema: { properties: { result: { type: 'string' } } }, - }; - - await provider.runPromptStructured('Test content', 'Test prompt', schema); - - expect(consoleSpy).toHaveBeenCalledWith('[vectorlint] System prompt (full):'); - expect(consoleSpy).toHaveBeenCalledWith('[vectorlint] User content (full):'); - expect(consoleSpy).toHaveBeenCalledWith('Test content'); - }); - - it('shows truncated prompt when showPromptTrunc is enabled', async () => { - const config = { - apiKey: 'sk-test-key', - debug: true, - showPromptTrunc: true, - }; - - const mockResponse: OpenAIResponse = { - choices: [ - { - message: { - content: JSON.stringify({ result: 'success' }), - }, - finish_reason: 'stop', - }, - ], - }; - - SHARED_CREATE.mockResolvedValue(mockResponse); - - const provider = new OpenAIProvider(config); - const schema = { - name: 'test_schema', - schema: { properties: { result: { type: 'string' } } }, - }; - - // Use long content to test truncation - const longContent = 'A'.repeat(600); - await provider.runPromptStructured(longContent, 'Test prompt', schema); - - expect(consoleSpy).toHaveBeenCalledWith('[vectorlint] System prompt (first 500 chars):'); - expect(consoleSpy).toHaveBeenCalledWith('[vectorlint] User content preview (first 500 chars):'); - expect(consoleSpy).toHaveBeenCalledWith('A'.repeat(500)); - expect(consoleSpy).toHaveBeenCalledWith('... [truncated]'); - }); - - it('does not log when debug is disabled', async () => { - const config = { - apiKey: 'sk-test-key', - debug: false, - }; - - const mockResponse: OpenAIResponse = { - choices: [ - { - message: { - content: JSON.stringify({ result: 'success' }), - }, - finish_reason: 'stop', - }, - ], - }; - - SHARED_CREATE.mockResolvedValue(mockResponse); - - const provider = new OpenAIProvider(config); - const schema = { - name: 'test_schema', - schema: { properties: { result: { type: 'string' } } }, - }; - - await provider.runPromptStructured('Test content', 'Test prompt', schema); - - expect(consoleSpy).not.toHaveBeenCalled(); - }); - - it('never exposes API keys in debug logs', async () => { - const sensitiveApiKey = 'sk-very-secret-api-key-12345'; - const config = { - apiKey: sensitiveApiKey, - debug: true, - showPrompt: true, - }; - - const mockResponse: OpenAIResponse = { - choices: [ - { - message: { - content: JSON.stringify({ result: 'success' }), - }, - finish_reason: 'stop', - }, - ], - usage: { - prompt_tokens: 100, - completion_tokens: 50, - total_tokens: 150, - }, - }; - - SHARED_CREATE.mockResolvedValue(mockResponse); - - const provider = new OpenAIProvider(config); - const schema = { - name: 'test_schema', - schema: { properties: { result: { type: 'string' } } }, - }; - - await provider.runPromptStructured('Test content', 'Test prompt', schema); - - // Check all console.log calls to ensure API key is never exposed - const allLogCalls = consoleSpy.mock.calls.flat(); - const allLogContent = allLogCalls.join(' '); - - expect(allLogContent).not.toContain(sensitiveApiKey); - expect(allLogContent).not.toContain('sk-very-secret-api-key'); - expect(allLogContent).not.toContain('very-secret-api-key'); - }); - - it('never exposes API keys in debug logs even during errors', async () => { - const sensitiveApiKey = 'sk-another-secret-key-67890'; - const config = { - apiKey: sensitiveApiKey, - debug: true, - }; - - const openAI = await import('openai'); - // @ts-expect-error - Mock constructor signature differs from real SDK - SHARED_CREATE.mockRejectedValue(new openAI.APIError({ - message: 'Authentication failed', - status: 401 - })); - - const provider = new OpenAIProvider(config); - const schema = { - name: 'test_schema', - schema: { properties: { result: { type: 'string' } } }, - }; - - try { - await provider.runPromptStructured('Test content', 'Test prompt', schema); - } catch { - // Error handling is expected, we're testing debug logs - } - - // Check console logs don't contain API key - this is the main security test - const allLogCalls = consoleSpy.mock.calls.flat(); - const allLogContent = allLogCalls.join(' '); - - expect(allLogContent).not.toContain(sensitiveApiKey); - expect(allLogContent).not.toContain('sk-another-secret-key'); - expect(allLogContent).not.toContain('another-secret-key'); - - // Verify debug logs were actually called (so test is meaningful) - expect(consoleSpy).toHaveBeenCalledWith( - '[vectorlint] Sending request to OpenAI:', - expect.objectContaining({ - model: 'gpt-4o', - temperature: 0.2, - }) - ); - }); - }); - - describe('Request Building', () => { - it('uses request builder to build system prompt', async () => { - const config = { - apiKey: 'sk-test-key', - }; - - const mockResponse: OpenAIResponse = { - choices: [ - { - message: { - content: JSON.stringify({ result: 'success' }), - }, - finish_reason: 'stop', - }, - ], - }; - - SHARED_CREATE.mockResolvedValue(mockResponse); - - const mockBuilder = { - buildPromptBodyForStructured: vi.fn().mockReturnValue('Built system prompt'), - }; - - const provider = new OpenAIProvider(config, mockBuilder); - const schema = { - name: 'test_schema', - schema: { properties: { result: { type: 'string' } } }, - }; - - await provider.runPromptStructured('Test content', 'Test prompt', schema); - - expect(mockBuilder.buildPromptBodyForStructured).toHaveBeenCalledWith('Test prompt'); - - expect(SHARED_CREATE).toHaveBeenCalledWith( - expect.objectContaining({ - messages: [ - { role: 'system', content: 'Built system prompt' }, - { role: 'user', content: 'Input:\n\nTest content' }, - ], - }) - ); - }); - - it('formats user message correctly', async () => { - const config = { - apiKey: 'sk-test-key', - }; - - const mockResponse: OpenAIResponse = { - choices: [ - { - message: { - content: JSON.stringify({ result: 'success' }), - }, - finish_reason: 'stop', - }, - ], - }; - - SHARED_CREATE.mockResolvedValue(mockResponse); - - const provider = new OpenAIProvider(config); - const schema = { - name: 'test_schema', - schema: { properties: { result: { type: 'string' } } }, - }; - - await provider.runPromptStructured('User input content', 'Test prompt', schema); - - expect(SHARED_CREATE).toHaveBeenCalledWith( - expect.objectContaining({ - messages: expect.arrayContaining([ - { role: 'user', content: 'Input:\n\nUser input content' }, - ]) as unknown[], - }) - ); - }); - }); -}); \ No newline at end of file diff --git a/tests/perplexity-provider.test.ts b/tests/perplexity-provider.test.ts index d49315c..3140736 100644 --- a/tests/perplexity-provider.test.ts +++ b/tests/perplexity-provider.test.ts @@ -1,22 +1,34 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { PerplexitySearchProvider } from '../src/providers/perplexity-provider'; -import { createMockPerplexityClient } from './schemas/mock-schemas'; - -const SHARED_CREATE = vi.fn(); - -// Mock the Perplexity SDK before importing SUT to avoid TDZ issues -vi.mock('@perplexity-ai/perplexity_ai', () => { - return { - __esModule: true, - default: vi.fn(() => ({ - search: { - create: SHARED_CREATE, - }, - })), - }; -}); -const MOCK_RESULTS = [ + +// Mock the Vercel AI SDK — use vi.hoisted so the mock is available in the vi.mock factory +const MOCK_GENERATE_TEXT = vi.hoisted(() => vi.fn()); + +vi.mock('ai', () => ({ + generateText: MOCK_GENERATE_TEXT, +})); + +vi.mock('@ai-sdk/perplexity', () => ({ + createPerplexity: vi.fn(() => vi.fn((model: string) => ({ _type: 'perplexity', model }))), +})); + +// Mock data matching the raw Perplexity API source shape (uses `text`, not `snippet`) +const MOCK_SOURCES = [ + { + title: 'AI Overview', + text: 'AI tools in 2025 are evolving fast.', + url: 'https://example.com/ai-overview', + }, + { + title: 'Developer Productivity', + text: 'AI improves developer efficiency by 40%.', + url: 'https://example.com/dev-productivity', + }, +]; + +// Expected mapped output (provider maps `text` → `snippet`, adds `date` default) +const EXPECTED_RESULTS = [ { title: 'AI Overview', snippet: 'AI tools in 2025 are evolving fast.', @@ -32,114 +44,218 @@ const MOCK_RESULTS = [ ]; describe('PerplexitySearchProvider', () => { + const ORIGINAL_ENV = { ...process.env }; + beforeEach(() => { vi.clearAllMocks(); + // Mock process.env.PERPLEXITY_API_KEY for tests + process.env.PERPLEXITY_API_KEY = 'test-api-key'; + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; }); describe('Constructor', () => { - it('initializes with defaults', () => { + it('initializes with defaults using environment variable', () => { const provider = new PerplexitySearchProvider(); expect(provider).toBeInstanceOf(PerplexitySearchProvider); }); - it('accepts override config', () => { - const provider = new PerplexitySearchProvider({ maxResults: 10, maxTokensPerPage: 512, debug: true }); + it('accepts override config with apiKey', () => { + const provider = new PerplexitySearchProvider({ apiKey: 'custom-key', maxResults: 10, debug: true }); expect(provider).toBeInstanceOf(PerplexitySearchProvider); }); - }); - describe('Search behavior', () => { - it('calls the Perplexity API with valid parameters and normalizes results', async () => { - SHARED_CREATE.mockResolvedValueOnce({ results: MOCK_RESULTS }); + it('throws error when no API key is provided', () => { + delete process.env.PERPLEXITY_API_KEY; + expect(() => new PerplexitySearchProvider()).toThrow('Perplexity API key is required'); + }); - const provider = new PerplexitySearchProvider({ maxResults: 2, maxTokensPerPage: 512 }); - const res = await provider.search('AI tools in 2025'); + it('accepts partial config with maxResults', () => { + const provider = new PerplexitySearchProvider({ maxResults: 2 }); + expect(provider).toBeInstanceOf(PerplexitySearchProvider); + }); + }); - expect(SHARED_CREATE).toHaveBeenCalledWith({ - query: 'AI tools in 2025', - max_results: 2, - max_tokens_per_page: 512, + describe('search', () => { + it('executes search query successfully', async () => { + MOCK_GENERATE_TEXT.mockResolvedValue({ + sources: MOCK_SOURCES, }); - expect(res).toEqual(MOCK_RESULTS); - }); + const provider = new PerplexitySearchProvider({ maxResults: 2 }); + const results = await provider.search('AI tools for developers'); - it('handles optional max_results and tokens by sending defaults when not specified', async () => { - // provider default constructor sets maxResults=5 and maxTokensPerPage=1024 - SHARED_CREATE.mockResolvedValueOnce({ results: MOCK_RESULTS }); + expect(results).toHaveLength(2); + expect(results[0]).toEqual(EXPECTED_RESULTS[0]); + }); + it('throws error for empty query', async () => { const provider = new PerplexitySearchProvider(); - await provider.search('modern LLM architectures'); - - const args = SHARED_CREATE.mock.calls[0]?.[0] as { - query: string; - max_results?: number; - max_tokens_per_page?: number; - } | undefined; - expect(args).toBeDefined(); - expect(args).toHaveProperty('query', 'modern LLM architectures'); - // provider sets defaults; tests should expect those defaults to be present - expect(args).toHaveProperty('max_results', 5); - expect(args).toHaveProperty('max_tokens_per_page', 1024); + await expect(provider.search('')).rejects.toThrow('Search query cannot be empty'); + await expect(provider.search(' ')).rejects.toThrow('Search query cannot be empty'); }); - it('returns empty array if Perplexity returns no results', async () => { - SHARED_CREATE.mockResolvedValueOnce({ results: [] }); + it('handles empty sources array', async () => { + MOCK_GENERATE_TEXT.mockResolvedValue({ + sources: [], + }); const provider = new PerplexitySearchProvider(); - const results = await provider.search('nonexistent query'); - expect(results).toEqual([]); + const results = await provider.search('unknown topic'); + + expect(results).toHaveLength(0); }); - it('throws helpful error when API call fails', async () => { - SHARED_CREATE.mockRejectedValueOnce(new Error('Network error')); + it('limits results to maxResults', async () => { + const manyResults = Array.from({ length: 10 }, (_, i) => ({ + title: `Result ${i}`, + text: `Snippet ${i}`, + url: `https://example.com/${i}`, + publishedDate: '', + })); + + MOCK_GENERATE_TEXT.mockResolvedValue({ + sources: manyResults, + }); + + const provider = new PerplexitySearchProvider({ maxResults: 5 }); + const results = await provider.search('test query'); + + expect(results).toHaveLength(5); + }); + + // This test relies on PERPLEXITY_SOURCE_SCHEMA marking all fields as optional + // with .passthrough(), so objects with missing fields still pass validation. + it('handles missing fields gracefully', async () => { + const incompleteResults = [ + { + // Missing all fields + }, + { + title: 'Has Title', + // Missing other fields + }, + { + text: 'Has snippet', + url: 'https://example.com', + publishedDate: '2025-01-01', + }, + ]; + + MOCK_GENERATE_TEXT.mockResolvedValue({ + sources: incompleteResults, + }); const provider = new PerplexitySearchProvider(); - await expect(provider.search('AI')).rejects.toThrow('Perplexity API call failed: Network error'); + const results = await provider.search('test'); + + expect(results).toHaveLength(3); + expect(results[0]!.title).toBe('Untitled'); + expect(results[0]!.snippet).toBe(''); + expect(results[1]!.title).toBe('Has Title'); + expect(results[1]!.snippet).toBe(''); }); }); - describe('Error and schema validation', () => { - it('throws when query is empty', async () => { - const provider = new PerplexitySearchProvider(); - await expect(provider.search('')).rejects.toThrow('Search query cannot be empty.'); + describe('Debug Logging', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { }); }); - it('validates mock schema using createMockPerplexityClient', async () => { - const fn = vi.fn().mockResolvedValue({}); - const client = createMockPerplexityClient(fn); - await client.search.create({ query: 'test query', max_results: 3 }); - expect(fn).toHaveBeenCalledWith({ query: 'test query', max_results: 3 }); + afterEach(() => { + consoleSpy.mockRestore(); }); - }); - describe('Debug logging', () => { - it('logs debug info when debug is enabled', async () => { - SHARED_CREATE.mockResolvedValueOnce({ results: MOCK_RESULTS }); - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - const provider = new PerplexitySearchProvider({ debug: true }); + it('logs search query when debug is enabled', async () => { + MOCK_GENERATE_TEXT.mockResolvedValue({ + sources: MOCK_SOURCES, + }); + const provider = new PerplexitySearchProvider({ debug: true }); await provider.search('test query'); - // provider emits Perplexity-specific debug messages - expect(logSpy).toHaveBeenCalledWith('[Perplexity] Searching: "test query"'); - expect(logSpy).toHaveBeenCalledWith('[Perplexity] Found 2 results'); - // provider also logs a preview of results (array), accept any array - expect(logSpy).toHaveBeenCalledWith(expect.any(Array)); + expect(consoleSpy).toHaveBeenCalledWith('[Perplexity] Searching: "test query"'); + }); + + it('logs results count when debug is enabled', async () => { + MOCK_GENERATE_TEXT.mockResolvedValue({ + sources: MOCK_SOURCES, + }); - logSpy.mockRestore(); + const provider = new PerplexitySearchProvider({ debug: true }); + await provider.search('test query'); + + expect(consoleSpy).toHaveBeenCalledWith('[Perplexity] Found 2 results'); }); it('does not log when debug is disabled', async () => { - SHARED_CREATE.mockResolvedValueOnce({ results: MOCK_RESULTS }); - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - const provider = new PerplexitySearchProvider({ debug: false }); + MOCK_GENERATE_TEXT.mockResolvedValue({ + sources: MOCK_SOURCES, + }); + const provider = new PerplexitySearchProvider({ debug: false }); await provider.search('test query'); - expect(logSpy).not.toHaveBeenCalled(); - logSpy.mockRestore(); + expect(consoleSpy).not.toHaveBeenCalled(); + }); + }); + + describe('Error Handling', () => { + it('throws descriptive error for API failures', async () => { + MOCK_GENERATE_TEXT.mockRejectedValue(new Error('Network error')); + + const provider = new PerplexitySearchProvider(); + + await expect(provider.search('test')).rejects.toThrow('Perplexity API call failed: Network error'); + }); + + it('handles unknown error types', async () => { + MOCK_GENERATE_TEXT.mockRejectedValue('String error'); + + const provider = new PerplexitySearchProvider(); + + await expect(provider.search('test')).rejects.toThrow('Perplexity API call failed: String error'); + }); + }); + + describe('Configuration', () => { + it('returns all sources when maxResults exceeds available count', async () => { + const results = Array.from({ length: 3 }, (_, i) => ({ + title: `Result ${i}`, + text: `Snippet ${i}`, + url: `https://example.com/${i}`, + publishedDate: '', + })); + + MOCK_GENERATE_TEXT.mockResolvedValue({ sources: results }); + + const provider = new PerplexitySearchProvider({ maxResults: 10 }); + const searchResults = await provider.search('test'); + + // maxResults (10) > available sources (3), so all 3 should be returned + expect(searchResults).toHaveLength(3); + }); + + it('uses default maxResults when not specified', async () => { + const results = Array.from({ length: 10 }, (_, i) => ({ + title: `Result ${i}`, + text: `Snippet ${i}`, + url: `https://example.com/${i}`, + publishedDate: '', + })); + + MOCK_GENERATE_TEXT.mockResolvedValue({ sources: results }); + + const provider = new PerplexitySearchProvider(); + const searchResults = await provider.search('test'); + + // Default maxResults is 5 + expect(searchResults).toHaveLength(5); }); }); }); diff --git a/tests/provider-factory.test.ts b/tests/provider-factory.test.ts index 8e009f0..0d2abb8 100644 --- a/tests/provider-factory.test.ts +++ b/tests/provider-factory.test.ts @@ -1,14 +1,29 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { createProvider, ProviderType } from '../src/providers/provider-factory'; -import { AzureOpenAIProvider } from '../src/providers/azure-openai-provider'; -import { AnthropicProvider } from '../src/providers/anthropic-provider'; -import { OpenAIProvider } from '../src/providers/openai-provider'; +import { VercelAIProvider } from '../src/providers/vercel-ai-provider'; import { DefaultRequestBuilder } from '../src/providers/request-builder'; import type { EnvConfig } from '../src/schemas/env-schemas'; +// Mock the Vercel AI SDK provider creators +vi.mock('@ai-sdk/openai', () => ({ + createOpenAI: vi.fn(() => vi.fn((model: string) => ({ _type: 'openai', model }))), +})); + +vi.mock('@ai-sdk/azure', () => ({ + createAzure: vi.fn(() => vi.fn((model: string) => ({ _type: 'azure', model }))), +})); + +vi.mock('@ai-sdk/anthropic', () => ({ + createAnthropic: vi.fn(() => vi.fn((model: string) => ({ _type: 'anthropic', model }))), +})); + +vi.mock('@ai-sdk/google', () => ({ + createGoogleGenerativeAI: vi.fn(() => vi.fn((model: string) => ({ _type: 'google', model }))), +})); + describe('Provider Factory', () => { describe('Provider Instantiation', () => { - it('creates Azure OpenAI provider when configured', () => { + it('creates VercelAIProvider for Azure OpenAI when configured', () => { const envConfig: EnvConfig = { LLM_PROVIDER: ProviderType.AzureOpenAI, AZURE_OPENAI_API_KEY: 'test-key', @@ -18,10 +33,10 @@ describe('Provider Factory', () => { }; const provider = createProvider(envConfig, { debug: true }); - expect(provider).toBeInstanceOf(AzureOpenAIProvider); + expect(provider).toBeInstanceOf(VercelAIProvider); }); - it('creates Anthropic provider when configured', () => { + it('creates VercelAIProvider for Anthropic when configured', () => { const envConfig: EnvConfig = { LLM_PROVIDER: ProviderType.Anthropic, ANTHROPIC_API_KEY: 'sk-ant-test-key', @@ -30,10 +45,10 @@ describe('Provider Factory', () => { }; const provider = createProvider(envConfig, { debug: true }); - expect(provider).toBeInstanceOf(AnthropicProvider); + expect(provider).toBeInstanceOf(VercelAIProvider); }); - it('creates OpenAI provider when configured', () => { + it('creates VercelAIProvider for OpenAI when configured', () => { const envConfig: EnvConfig = { LLM_PROVIDER: ProviderType.OpenAI, OPENAI_API_KEY: 'sk-test-key', @@ -42,57 +57,18 @@ describe('Provider Factory', () => { }; const provider = createProvider(envConfig, { debug: true }); - expect(provider).toBeInstanceOf(OpenAIProvider); - }); - - it('creates Azure OpenAI provider with minimal configuration', () => { - const envConfig: EnvConfig = { - LLM_PROVIDER: ProviderType.AzureOpenAI, - AZURE_OPENAI_API_KEY: 'test-key', - AZURE_OPENAI_ENDPOINT: 'https://test.openai.azure.com', - AZURE_OPENAI_DEPLOYMENT_NAME: 'test-deployment', - AZURE_OPENAI_API_VERSION: '2024-02-15-preview', - }; - - const provider = createProvider(envConfig); - expect(provider).toBeInstanceOf(AzureOpenAIProvider); - }); - - it('creates Anthropic provider with minimal configuration', () => { - const envConfig: EnvConfig = { - LLM_PROVIDER: ProviderType.Anthropic, - ANTHROPIC_API_KEY: 'sk-ant-test-key', - ANTHROPIC_MODEL: 'claude-3-sonnet-20240229', - ANTHROPIC_MAX_TOKENS: 4096, - }; - - const provider = createProvider(envConfig); - expect(provider).toBeInstanceOf(AnthropicProvider); + expect(provider).toBeInstanceOf(VercelAIProvider); }); - it('creates OpenAI provider with minimal configuration', () => { + it('creates VercelAIProvider for Gemini when configured', () => { const envConfig: EnvConfig = { - LLM_PROVIDER: ProviderType.OpenAI, - OPENAI_API_KEY: 'sk-test-key', - OPENAI_MODEL: 'gpt-4o', + LLM_PROVIDER: ProviderType.Gemini, + GEMINI_API_KEY: 'test-key', + GEMINI_MODEL: 'gemini-2.5-flash', }; const provider = createProvider(envConfig); - expect(provider).toBeInstanceOf(OpenAIProvider); - }); - - it('creates provider with custom request builder', () => { - const envConfig: EnvConfig = { - LLM_PROVIDER: ProviderType.AzureOpenAI, - AZURE_OPENAI_API_KEY: 'test-key', - AZURE_OPENAI_ENDPOINT: 'https://test.openai.azure.com', - AZURE_OPENAI_DEPLOYMENT_NAME: 'test-deployment', - AZURE_OPENAI_API_VERSION: '2024-02-15-preview', - }; - - const customBuilder = new DefaultRequestBuilder('Custom directive'); - const provider = createProvider(envConfig, {}, customBuilder); - expect(provider).toBeInstanceOf(AzureOpenAIProvider); + expect(provider).toBeInstanceOf(VercelAIProvider); }); }); @@ -107,7 +83,6 @@ describe('Provider Factory', () => { AZURE_OPENAI_TEMPERATURE: 0.8, }; - // Should not throw - configuration should be valid expect(() => createProvider(envConfig)).not.toThrow(); }); @@ -120,7 +95,6 @@ describe('Provider Factory', () => { ANTHROPIC_TEMPERATURE: 0.5, }; - // Should not throw - configuration should be valid expect(() => createProvider(envConfig)).not.toThrow(); }); @@ -132,53 +106,14 @@ describe('Provider Factory', () => { OPENAI_TEMPERATURE: 0.8, }; - // Should not throw - configuration should be valid expect(() => createProvider(envConfig)).not.toThrow(); }); - it('passes debug options to Azure OpenAI provider', () => { - const envConfig: EnvConfig = { - LLM_PROVIDER: ProviderType.AzureOpenAI, - AZURE_OPENAI_API_KEY: 'test-key', - AZURE_OPENAI_ENDPOINT: 'https://test.openai.azure.com', - AZURE_OPENAI_DEPLOYMENT_NAME: 'test-deployment', - AZURE_OPENAI_API_VERSION: '2024-02-15-preview', - }; - - const options = { - debug: true, - showPrompt: true, - showPromptTrunc: false, - }; - - // Should not throw - provider creation should work with options - expect(() => createProvider(envConfig, options)).not.toThrow(); - }); - - it('passes debug options to Anthropic provider', () => { - const envConfig: EnvConfig = { - LLM_PROVIDER: ProviderType.Anthropic, - ANTHROPIC_API_KEY: 'sk-ant-test-key', - ANTHROPIC_MODEL: 'claude-3-sonnet-20240229', - ANTHROPIC_MAX_TOKENS: 4096, - }; - - const options = { - debug: true, - showPrompt: false, - showPromptTrunc: true, - }; - - // Should not throw - provider creation should work with options - expect(() => createProvider(envConfig, options)).not.toThrow(); - }); - - it('passes debug options to OpenAI provider', () => { + it('passes debug options to provider', () => { const envConfig: EnvConfig = { LLM_PROVIDER: ProviderType.OpenAI, OPENAI_API_KEY: 'sk-test-key', OPENAI_MODEL: 'gpt-4o', - OPENAI_TEMPERATURE: 0.3, }; const options = { @@ -187,7 +122,6 @@ describe('Provider Factory', () => { showPromptTrunc: false, }; - // Should not throw - provider creation should work with options expect(() => createProvider(envConfig, options)).not.toThrow(); }); }); @@ -203,7 +137,7 @@ describe('Provider Factory', () => { it('throws descriptive error for invalid provider type', () => { const envConfig = { - LLM_PROVIDER: 'unsupported-provider', // Not supported + LLM_PROVIDER: 'unsupported-provider', } as unknown as EnvConfig; expect(() => createProvider(envConfig)).toThrow('Unsupported provider type: unsupported-provider'); @@ -224,161 +158,50 @@ describe('Provider Factory', () => { }); }); - describe('Backward Compatibility', () => { - it('maintains consistent interface for Azure OpenAI provider', () => { - const envConfig: EnvConfig = { - LLM_PROVIDER: ProviderType.AzureOpenAI, - AZURE_OPENAI_API_KEY: 'legacy-key', - AZURE_OPENAI_ENDPOINT: 'https://legacy.openai.azure.com', - AZURE_OPENAI_DEPLOYMENT_NAME: 'legacy-deployment', - AZURE_OPENAI_API_VERSION: '2024-02-15-preview', - }; - - const provider = createProvider(envConfig); - - // Should implement the LLMProvider interface - expect(provider).toHaveProperty('runPromptStructured'); - expect(typeof provider.runPromptStructured).toBe('function'); - }); - - it('maintains consistent interface for Anthropic provider', () => { - const envConfig: EnvConfig = { - LLM_PROVIDER: ProviderType.Anthropic, - ANTHROPIC_API_KEY: 'sk-ant-test-key', - ANTHROPIC_MODEL: 'claude-3-sonnet-20240229', - ANTHROPIC_MAX_TOKENS: 4096, - }; - - const provider = createProvider(envConfig); - - // Should implement the LLMProvider interface - expect(provider).toHaveProperty('runPromptStructured'); - expect(typeof provider.runPromptStructured).toBe('function'); - }); - - it('maintains consistent interface for OpenAI provider', () => { - const envConfig: EnvConfig = { - LLM_PROVIDER: ProviderType.OpenAI, - OPENAI_API_KEY: 'sk-test-key', - OPENAI_MODEL: 'gpt-4o', - }; - - const provider = createProvider(envConfig); - - // Should implement the LLMProvider interface - expect(provider).toHaveProperty('runPromptStructured'); - expect(typeof provider.runPromptStructured).toBe('function'); - }); - - it('works with existing Azure OpenAI configurations without changes', () => { - // Simulate an existing configuration that worked before Anthropic support - const existingConfig: EnvConfig = { - LLM_PROVIDER: ProviderType.AzureOpenAI, - AZURE_OPENAI_API_KEY: 'existing-api-key', - AZURE_OPENAI_ENDPOINT: 'https://existing.openai.azure.com', - AZURE_OPENAI_DEPLOYMENT_NAME: 'existing-deployment', - AZURE_OPENAI_API_VERSION: '2024-02-15-preview', - AZURE_OPENAI_TEMPERATURE: 1.0, - }; - - const provider = createProvider(existingConfig, { - debug: false, - showPrompt: false, - showPromptTrunc: false, - }); - - expect(provider).toBeInstanceOf(AzureOpenAIProvider); - }); - }); - - describe('Provider-Specific Configuration', () => { - it('handles Azure OpenAI specific fields correctly', () => { - const envConfig: EnvConfig = { - LLM_PROVIDER: ProviderType.AzureOpenAI, - AZURE_OPENAI_API_KEY: 'test-key', - AZURE_OPENAI_ENDPOINT: 'https://test.openai.azure.com', - AZURE_OPENAI_DEPLOYMENT_NAME: 'test-deployment', - AZURE_OPENAI_API_VERSION: '2024-02-15-preview', - AZURE_OPENAI_TEMPERATURE: 1.5, - }; - - // Should create provider successfully with Azure-specific config - expect(() => createProvider(envConfig)).not.toThrow(); - }); - - it('handles Anthropic specific fields correctly', () => { - const envConfig: EnvConfig = { - LLM_PROVIDER: ProviderType.Anthropic, - ANTHROPIC_API_KEY: 'sk-ant-test-key', - ANTHROPIC_MODEL: 'claude-3-opus-20240229', - ANTHROPIC_MAX_TOKENS: 8192, - ANTHROPIC_TEMPERATURE: 0.9, - }; - - // Should create provider successfully with Anthropic-specific config - expect(() => createProvider(envConfig)).not.toThrow(); - }); - - it('handles OpenAI specific fields correctly', () => { - const envConfig: EnvConfig = { - LLM_PROVIDER: ProviderType.OpenAI, - OPENAI_API_KEY: 'sk-test-key', - OPENAI_MODEL: 'gpt-4-turbo', - OPENAI_TEMPERATURE: 1.5, - }; - - // Should create provider successfully with OpenAI-specific config - expect(() => createProvider(envConfig)).not.toThrow(); - }); - - it('creates providers with different temperature ranges', () => { - // Azure OpenAI supports 0-2 temperature range + describe('Interface Consistency', () => { + it('maintains consistent LLMProvider interface for all providers', () => { const azureConfig: EnvConfig = { LLM_PROVIDER: ProviderType.AzureOpenAI, AZURE_OPENAI_API_KEY: 'test-key', AZURE_OPENAI_ENDPOINT: 'https://test.openai.azure.com', AZURE_OPENAI_DEPLOYMENT_NAME: 'test-deployment', AZURE_OPENAI_API_VERSION: '2024-02-15-preview', - AZURE_OPENAI_TEMPERATURE: 2.0, }; - // Anthropic supports 0-1 temperature range const anthropicConfig: EnvConfig = { LLM_PROVIDER: ProviderType.Anthropic, ANTHROPIC_API_KEY: 'sk-ant-test-key', ANTHROPIC_MODEL: 'claude-3-sonnet-20240229', ANTHROPIC_MAX_TOKENS: 4096, - ANTHROPIC_TEMPERATURE: 1.0, }; - // OpenAI supports 0-2 temperature range const openaiConfig: EnvConfig = { LLM_PROVIDER: ProviderType.OpenAI, OPENAI_API_KEY: 'sk-test-key', OPENAI_MODEL: 'gpt-4o', - OPENAI_TEMPERATURE: 2.0, }; - expect(() => createProvider(azureConfig)).not.toThrow(); - expect(() => createProvider(anthropicConfig)).not.toThrow(); - expect(() => createProvider(openaiConfig)).not.toThrow(); + const geminiConfig: EnvConfig = { + LLM_PROVIDER: ProviderType.Gemini, + GEMINI_API_KEY: 'test-key', + GEMINI_MODEL: 'gemini-2.5-flash', + }; + + const azureProvider = createProvider(azureConfig); + const anthropicProvider = createProvider(anthropicConfig); + const openaiProvider = createProvider(openaiConfig); + const geminiProvider = createProvider(geminiConfig); + + // All should implement the LLMProvider interface + for (const provider of [azureProvider, anthropicProvider, openaiProvider, geminiProvider]) { + expect(provider).toHaveProperty('runPromptStructured'); + expect(typeof provider.runPromptStructured).toBe('function'); + } }); }); describe('Options Handling', () => { it('works without options parameter', () => { - const envConfig: EnvConfig = { - LLM_PROVIDER: ProviderType.AzureOpenAI, - AZURE_OPENAI_API_KEY: 'test-key', - AZURE_OPENAI_ENDPOINT: 'https://test.openai.azure.com', - AZURE_OPENAI_DEPLOYMENT_NAME: 'test-deployment', - AZURE_OPENAI_API_VERSION: '2024-02-15-preview', - }; - - expect(() => createProvider(envConfig)).not.toThrow(); - }); - - it('works without options parameter for OpenAI', () => { const envConfig: EnvConfig = { LLM_PROVIDER: ProviderType.OpenAI, OPENAI_API_KEY: 'sk-test-key', @@ -399,16 +222,6 @@ describe('Provider Factory', () => { expect(() => createProvider(envConfig, {})).not.toThrow(); }); - it('works with empty options object for OpenAI', () => { - const envConfig: EnvConfig = { - LLM_PROVIDER: ProviderType.OpenAI, - OPENAI_API_KEY: 'sk-test-key', - OPENAI_MODEL: 'gpt-4o', - }; - - expect(() => createProvider(envConfig, {})).not.toThrow(); - }); - it('works with partial options', () => { const envConfig: EnvConfig = { LLM_PROVIDER: ProviderType.AzureOpenAI, @@ -422,17 +235,6 @@ describe('Provider Factory', () => { expect(() => createProvider(envConfig, { showPrompt: true })).not.toThrow(); }); - it('works with partial options for OpenAI', () => { - const envConfig: EnvConfig = { - LLM_PROVIDER: ProviderType.OpenAI, - OPENAI_API_KEY: 'sk-test-key', - OPENAI_MODEL: 'gpt-4o', - }; - - expect(() => createProvider(envConfig, { debug: true })).not.toThrow(); - expect(() => createProvider(envConfig, { showPrompt: true })).not.toThrow(); - }); - it('handles all debug options for all providers', () => { const azureConfig: EnvConfig = { LLM_PROVIDER: ProviderType.AzureOpenAI, @@ -455,6 +257,12 @@ describe('Provider Factory', () => { OPENAI_MODEL: 'gpt-4o', }; + const geminiConfig: EnvConfig = { + LLM_PROVIDER: ProviderType.Gemini, + GEMINI_API_KEY: 'test-key', + GEMINI_MODEL: 'gemini-2.5-flash', + }; + const allOptions = { debug: true, showPrompt: true, @@ -464,127 +272,70 @@ describe('Provider Factory', () => { expect(() => createProvider(azureConfig, allOptions)).not.toThrow(); expect(() => createProvider(anthropicConfig, allOptions)).not.toThrow(); expect(() => createProvider(openaiConfig, allOptions)).not.toThrow(); + expect(() => createProvider(geminiConfig, allOptions)).not.toThrow(); }); }); - describe('OpenAI Configuration Mapping Integration', () => { - it('correctly maps OpenAI environment variables to provider config', () => { - const envConfig: EnvConfig = { - LLM_PROVIDER: ProviderType.OpenAI, - OPENAI_API_KEY: 'sk-integration-test-key', - OPENAI_MODEL: 'gpt-4o-mini', - OPENAI_TEMPERATURE: 0.7, - }; - - const options = { - debug: true, - showPrompt: false, - showPromptTrunc: true, - }; - - // Should create provider without throwing - const provider = createProvider(envConfig, options); - expect(provider).toBeInstanceOf(OpenAIProvider); - }); - - it('handles OpenAI configuration with undefined optional fields', () => { + describe('Custom Request Builder', () => { + it('passes custom request builder to provider', () => { const envConfig: EnvConfig = { LLM_PROVIDER: ProviderType.OpenAI, OPENAI_API_KEY: 'sk-test-key', OPENAI_MODEL: 'gpt-4o', - // OPENAI_TEMPERATURE is undefined }; - const provider = createProvider(envConfig); - expect(provider).toBeInstanceOf(OpenAIProvider); + const customBuilder = new DefaultRequestBuilder('Custom directive'); + const provider = createProvider(envConfig, {}, customBuilder); + expect(provider).toBeInstanceOf(VercelAIProvider); }); + }); - it('passes request builder to OpenAI provider correctly', () => { + describe('Provider-Specific Configuration', () => { + it('handles Azure OpenAI specific fields correctly', () => { const envConfig: EnvConfig = { - LLM_PROVIDER: ProviderType.OpenAI, - OPENAI_API_KEY: 'sk-test-key', - OPENAI_MODEL: 'gpt-4o', + LLM_PROVIDER: ProviderType.AzureOpenAI, + AZURE_OPENAI_API_KEY: 'test-key', + AZURE_OPENAI_ENDPOINT: 'https://test.openai.azure.com', + AZURE_OPENAI_DEPLOYMENT_NAME: 'test-deployment', + AZURE_OPENAI_API_VERSION: '2024-02-15-preview', + AZURE_OPENAI_TEMPERATURE: 1.5, }; - const customBuilder = new DefaultRequestBuilder('Custom OpenAI directive'); - const provider = createProvider(envConfig, {}, customBuilder); - expect(provider).toBeInstanceOf(OpenAIProvider); + expect(() => createProvider(envConfig)).not.toThrow(); }); - it('correctly handles OpenAI temperature edge values', () => { - // Test minimum temperature (0) - const minTempConfig: EnvConfig = { - LLM_PROVIDER: ProviderType.OpenAI, - OPENAI_API_KEY: 'sk-test-key', - OPENAI_MODEL: 'gpt-4o', - OPENAI_TEMPERATURE: 0, - }; - - expect(() => createProvider(minTempConfig)).not.toThrow(); - - // Test maximum temperature (2) - const maxTempConfig: EnvConfig = { - LLM_PROVIDER: ProviderType.OpenAI, - OPENAI_API_KEY: 'sk-test-key', - OPENAI_MODEL: 'gpt-4o', - OPENAI_TEMPERATURE: 2, + it('handles Anthropic specific fields correctly', () => { + const envConfig: EnvConfig = { + LLM_PROVIDER: ProviderType.Anthropic, + ANTHROPIC_API_KEY: 'sk-ant-test-key', + ANTHROPIC_MODEL: 'claude-3-opus-20240229', + ANTHROPIC_MAX_TOKENS: 8192, + ANTHROPIC_TEMPERATURE: 0.9, }; - expect(() => createProvider(maxTempConfig)).not.toThrow(); + expect(() => createProvider(envConfig)).not.toThrow(); }); - it('creates OpenAI provider with all configuration combinations', () => { - // Test with all optional fields present - const fullConfig: EnvConfig = { + it('handles OpenAI specific fields correctly', () => { + const envConfig: EnvConfig = { LLM_PROVIDER: ProviderType.OpenAI, - OPENAI_API_KEY: 'sk-full-config-key', + OPENAI_API_KEY: 'sk-test-key', OPENAI_MODEL: 'gpt-4-turbo', - OPENAI_TEMPERATURE: 1.2, - }; - - const fullOptions = { - debug: true, - showPrompt: true, - showPromptTrunc: false, + OPENAI_TEMPERATURE: 1.5, }; - const provider = createProvider(fullConfig, fullOptions); - expect(provider).toBeInstanceOf(OpenAIProvider); + expect(() => createProvider(envConfig)).not.toThrow(); }); - it('maintains consistent option passing across provider types', () => { - const debugOptions = { - debug: true, - showPrompt: false, - showPromptTrunc: true, - }; - - // Test that the same options work for all provider types - const azureConfig: EnvConfig = { - LLM_PROVIDER: ProviderType.AzureOpenAI, - AZURE_OPENAI_API_KEY: 'test-key', - AZURE_OPENAI_ENDPOINT: 'https://test.openai.azure.com', - AZURE_OPENAI_DEPLOYMENT_NAME: 'test-deployment', - AZURE_OPENAI_API_VERSION: '2024-02-15-preview', - }; - - const anthropicConfig: EnvConfig = { - LLM_PROVIDER: ProviderType.Anthropic, - ANTHROPIC_API_KEY: 'sk-ant-test-key', - ANTHROPIC_MODEL: 'claude-3-sonnet-20240229', - ANTHROPIC_MAX_TOKENS: 4096, - }; - - const openaiConfig: EnvConfig = { - LLM_PROVIDER: ProviderType.OpenAI, - OPENAI_API_KEY: 'sk-test-key', - OPENAI_MODEL: 'gpt-4o', + it('handles Gemini specific fields correctly', () => { + const envConfig: EnvConfig = { + LLM_PROVIDER: ProviderType.Gemini, + GEMINI_API_KEY: 'test-key', + GEMINI_MODEL: 'gemini-pro', + GEMINI_TEMPERATURE: 0.5, }; - // All should create successfully with the same options - expect(() => createProvider(azureConfig, debugOptions)).not.toThrow(); - expect(() => createProvider(anthropicConfig, debugOptions)).not.toThrow(); - expect(() => createProvider(openaiConfig, debugOptions)).not.toThrow(); + expect(() => createProvider(envConfig)).not.toThrow(); }); }); -}); \ No newline at end of file +}); diff --git a/tests/vercel-ai-provider.test.ts b/tests/vercel-ai-provider.test.ts new file mode 100644 index 0000000..8e0868d --- /dev/null +++ b/tests/vercel-ai-provider.test.ts @@ -0,0 +1,493 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + + +// Mock the Vercel AI SDK — use vi.hoisted so the mock is available in the vi.mock factory +const MOCK_GENERATE_TEXT = vi.hoisted(() => vi.fn()); + +// Hoist error class for NoObjectGeneratedError +const ERROR_CLASSES = vi.hoisted(() => { + class NoObjectGeneratedError extends Error { + text: string; + constructor(message: string, text: string) { + super(message); + this.name = 'NoObjectGeneratedError'; + this.text = text; + } + static isInstance(error: unknown): error is NoObjectGeneratedError { + return error instanceof NoObjectGeneratedError; + } + } + return { NoObjectGeneratedError }; +}); + +// Mock Vercel AI SDK - must come before importing SUT +vi.mock('ai', () => { + const { NoObjectGeneratedError } = ERROR_CLASSES; + + return { + generateText: MOCK_GENERATE_TEXT, + Output: { + object: vi.fn((schema: unknown) => ({ + _outputType: 'object', + schema, + })), + }, + NoObjectGeneratedError, + }; +}); + +// Import SUT after mocks are set up +import { VercelAIProvider, type VercelAIConfig } from '../src/providers/vercel-ai-provider'; +import { DefaultRequestBuilder, type RequestBuilder } from '../src/providers/request-builder'; +import type { LanguageModel } from 'ai'; + +// Mock model stub — only stored in config and passed through to the mocked +// generateText function, so it doesn't need to implement the full interface. +const MOCK_MODEL = {} as unknown as LanguageModel; + +describe('VercelAIProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Constructor', () => { + it('creates provider with required config', () => { + const config: VercelAIConfig = { + model: MOCK_MODEL, // Mock LanguageModel + }; + + const provider = new VercelAIProvider(config); + expect(provider).toBeInstanceOf(VercelAIProvider); + }); + + it('applies default temperature when not provided', async () => { + const config: VercelAIConfig = { + model: MOCK_MODEL, + }; + + const mockResult = { experimental_output: { result: 'ok' } }; + MOCK_GENERATE_TEXT.mockResolvedValue(mockResult); + + const provider = new VercelAIProvider(config); + const schema = { + name: 'test_schema', + schema: { properties: { result: { type: 'string' } }, type: 'object' }, + }; + + await provider.runPromptStructured('content', 'prompt', schema); + + expect(MOCK_GENERATE_TEXT).toHaveBeenCalledWith( + expect.objectContaining({ temperature: 0.2 }) + ); + }); + + it('accepts custom request builder', () => { + const config: VercelAIConfig = { + model: MOCK_MODEL, + }; + const customBuilder = new DefaultRequestBuilder('custom directive'); + + expect(() => new VercelAIProvider(config, customBuilder)).not.toThrow(); + }); + + it('accepts all configuration options', () => { + const config: VercelAIConfig = { + model: MOCK_MODEL, + temperature: 0.7, + debug: true, + showPrompt: true, + showPromptTrunc: false, + }; + + expect(() => new VercelAIProvider(config)).not.toThrow(); + }); + }); + + describe('Structured Response Handling', () => { + it('successfully parses structured JSON response', async () => { + const config: VercelAIConfig = { + model: MOCK_MODEL, + }; + + const mockOutput = { + score: 85, + feedback: 'Good content', + }; + + const mockResult = { + experimental_output: mockOutput, + usage: { + promptTokens: 100, + completionTokens: 50, + totalTokens: 150, + }, + finishReason: 'stop', + }; + + MOCK_GENERATE_TEXT.mockResolvedValue(mockResult); + + const provider = new VercelAIProvider(config); + const schema = { + name: 'submit_evaluation', + schema: { + properties: { + score: { type: 'number' }, + feedback: { type: 'string' }, + }, + required: ['score', 'feedback'], + type: 'object', + }, + }; + + const result = await provider.runPromptStructured( + 'Test content', + 'Test prompt', + schema + ); + + expect(result.data).toEqual({ + score: 85, + feedback: 'Good content', + }); + + expect(result.usage).toBeDefined(); + if (result.usage) { + expect(result.usage.inputTokens).toBe(100); + expect(result.usage.outputTokens).toBe(50); + } + }); + + it('configures Vercel AI SDK with Output.object()', async () => { + const config: VercelAIConfig = { + model: MOCK_MODEL, + }; + + const mockResult = { + experimental_output: { result: 'success' }, + }; + + MOCK_GENERATE_TEXT.mockResolvedValue(mockResult); + + const provider = new VercelAIProvider(config); + const schema = { + name: 'test_schema', + schema: { + properties: { + result: { type: 'string' }, + }, + required: ['result'], + type: 'object', + }, + }; + + await provider.runPromptStructured('Test content', 'Test prompt', schema); + + expect(MOCK_GENERATE_TEXT).toHaveBeenCalledWith( + expect.objectContaining({ + system: expect.any(String) as string, + prompt: 'Input:\n\nTest content', + temperature: 0.2, + experimental_output: expect.objectContaining({ + _outputType: 'object', + }) as Record, + }) + ); + }); + + it('includes temperature in API call when configured', async () => { + const config: VercelAIConfig = { + model: MOCK_MODEL, + temperature: 0.7, + }; + + const mockResult = { experimental_output: { result: 'success' } }; + MOCK_GENERATE_TEXT.mockResolvedValue(mockResult); + + const provider = new VercelAIProvider(config); + const schema = { + name: 'test_schema', + schema: { properties: { result: { type: 'string' } }, type: 'object' }, + }; + + await provider.runPromptStructured('Test content', 'Test prompt', schema); + + expect(MOCK_GENERATE_TEXT).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.7, + }) + ); + }); + }); + + describe('Error Handling', () => { + it('handles NoObjectGeneratedError', async () => { + const config: VercelAIConfig = { + model: MOCK_MODEL, + }; + + const { NoObjectGeneratedError } = ERROR_CLASSES; + MOCK_GENERATE_TEXT.mockRejectedValue( + new NoObjectGeneratedError('Failed to generate object', 'Raw text here') + ); + + const provider = new VercelAIProvider(config); + const schema = { + name: 'test_schema', + schema: { properties: { result: { type: 'string' } }, type: 'object' }, + }; + + await expect( + provider.runPromptStructured('Test content', 'Test prompt', schema) + ).rejects.toThrow('LLM failed to generate valid structured output'); + }); + + it('handles unknown errors', async () => { + const config: VercelAIConfig = { + model: MOCK_MODEL, + }; + + MOCK_GENERATE_TEXT.mockRejectedValue(new Error('Unknown error')); + + const provider = new VercelAIProvider(config); + const schema = { + name: 'test_schema', + schema: { properties: { result: { type: 'string' } }, type: 'object' }, + }; + + await expect( + provider.runPromptStructured('Test content', 'Test prompt', schema) + ).rejects.toThrow('Vercel AI SDK call failed: Unknown error'); + }); + }); + + describe('Debugging and Logging', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { }); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it('logs debug information when debug is enabled', async () => { + const config: VercelAIConfig = { + model: MOCK_MODEL, + debug: true, + }; + + const mockResult = { + experimental_output: { result: 'success' }, + usage: { + promptTokens: 100, + completionTokens: 50, + totalTokens: 150, + }, + finishReason: 'stop', + }; + + MOCK_GENERATE_TEXT.mockResolvedValue(mockResult); + + const provider = new VercelAIProvider(config); + const schema = { + name: 'test_schema', + schema: { properties: { result: { type: 'string' } }, type: 'object' }, + }; + + await provider.runPromptStructured('Test content', 'Test prompt', schema); + + expect(consoleSpy).toHaveBeenCalledWith( + '[vectorlint] Sending request via Vercel AI SDK:', + expect.any(Object) + ); + + expect(consoleSpy).toHaveBeenCalledWith( + '[vectorlint] LLM response meta:', + expect.objectContaining({ + usage: expect.objectContaining({ + prompt_tokens: 100, + completion_tokens: 50, + total_tokens: 150, + }) as Record, + }) + ); + }); + + it('does not log when debug is disabled', async () => { + const config: VercelAIConfig = { + model: MOCK_MODEL, + debug: false, + }; + + const mockResult = { experimental_output: { result: 'success' } }; + MOCK_GENERATE_TEXT.mockResolvedValue(mockResult); + + const provider = new VercelAIProvider(config); + const schema = { + name: 'test_schema', + schema: { properties: { result: { type: 'string' } }, type: 'object' }, + }; + + await provider.runPromptStructured('Test content', 'Test prompt', schema); + + expect(consoleSpy).not.toHaveBeenCalled(); + }); + }); + + describe('Request Building', () => { + it('uses request builder to build system prompt', async () => { + const config: VercelAIConfig = { + model: MOCK_MODEL, + }; + + const mockResult = { experimental_output: { result: 'success' } }; + MOCK_GENERATE_TEXT.mockResolvedValue(mockResult); + + const buildPromptBodyForStructuredFn = vi.fn().mockReturnValue('Built system prompt'); + const mockBuilder: RequestBuilder = { + buildPromptBodyForStructured: buildPromptBodyForStructuredFn, + }; + + const provider = new VercelAIProvider(config, mockBuilder); + const schema = { + name: 'test_schema', + schema: { properties: { result: { type: 'string' } }, type: 'object' }, + }; + + await provider.runPromptStructured('Test content', 'Test prompt', schema); + + expect(buildPromptBodyForStructuredFn).toHaveBeenCalledWith('Test prompt'); + + expect(MOCK_GENERATE_TEXT).toHaveBeenCalledWith( + expect.objectContaining({ + system: 'Built system prompt', + prompt: 'Input:\n\nTest content', + }) + ); + }); + }); + + describe('JSON Schema to Zod Conversion', () => { + it('converts simple string properties', async () => { + const config: VercelAIConfig = { + model: MOCK_MODEL, + }; + + const mockResult = { experimental_output: { name: 'test' } }; + MOCK_GENERATE_TEXT.mockResolvedValue(mockResult); + + const provider = new VercelAIProvider(config); + const schema = { + name: 'test_schema', + schema: { + properties: { + name: { type: 'string' }, + }, + required: ['name'] as string[], + type: 'object', + }, + }; + + const result = await provider.runPromptStructured('Test content', 'Test prompt', schema); + expect(result.data).toEqual({ name: 'test' }); + }); + + it('converts number properties', async () => { + const config: VercelAIConfig = { + model: MOCK_MODEL, + }; + + const mockResult = { experimental_output: { score: 42 } }; + MOCK_GENERATE_TEXT.mockResolvedValue(mockResult); + + const provider = new VercelAIProvider(config); + const schema = { + name: 'test_schema', + schema: { + properties: { + score: { type: 'number' }, + }, + required: ['score'] as string[], + type: 'object', + }, + }; + + const result = await provider.runPromptStructured('Test content', 'Test prompt', schema); + expect(result.data).toEqual({ score: 42 }); + }); + + it('handles optional fields', async () => { + const config: VercelAIConfig = { + model: MOCK_MODEL, + }; + + const mockResult = { experimental_output: { requiredField: 'value' } }; + MOCK_GENERATE_TEXT.mockResolvedValue(mockResult); + + const provider = new VercelAIProvider(config); + const schema = { + name: 'test_schema', + schema: { + properties: { + requiredField: { type: 'string' }, + optionalField: { type: 'string' }, + }, + required: ['requiredField'] as string[], + type: 'object', + }, + }; + + const result = await provider.runPromptStructured('Test content', 'Test prompt', schema); + expect(result.data).toEqual({ requiredField: 'value' }); + }); + + it('converts union type arrays (e.g. [string, number])', async () => { + const config: VercelAIConfig = { + model: MOCK_MODEL, + }; + + const mockResult = { experimental_output: { value: 'hello' } }; + MOCK_GENERATE_TEXT.mockResolvedValue(mockResult); + + const provider = new VercelAIProvider(config); + const schema = { + name: 'test_schema', + schema: { + properties: { + value: { type: ['string', 'number'] }, + }, + required: ['value'] as string[], + type: 'object', + }, + }; + + const result = await provider.runPromptStructured('Test content', 'Test prompt', schema); + expect(result.data).toEqual({ value: 'hello' }); + }); + + it('handles nullable types (e.g. [null, string])', async () => { + const config: VercelAIConfig = { + model: MOCK_MODEL, + }; + + const mockResult = { experimental_output: { name: null } }; + MOCK_GENERATE_TEXT.mockResolvedValue(mockResult); + + const provider = new VercelAIProvider(config); + const schema = { + name: 'test_schema', + schema: { + properties: { + name: { type: ['null', 'string'] }, + }, + required: ['name'] as string[], + type: 'object', + }, + }; + + const result = await provider.runPromptStructured('Test content', 'Test prompt', schema); + expect(result.data).toEqual({ name: null }); + }); + }); +});