mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge pull request #280 from Kilo-Org/mark/implement-autocomplete
feat: transplant autocomplete module into kilo-vscode
This commit is contained in:
@@ -185,13 +185,26 @@
|
||||
"name": "kilo-code",
|
||||
"version": "1.0.19",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.39.0",
|
||||
"@kilocode/kilo-i18n": "workspace:*",
|
||||
"@kilocode/kilo-ui": "workspace:*",
|
||||
"@kilocode/sdk": "workspace:*",
|
||||
"diff": "^7.0.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"eventsource": "^2.0.2",
|
||||
"fastest-levenshtein": "^1.0.16",
|
||||
"ignore": "^7.0.3",
|
||||
"js-tiktoken": "^1.0.18",
|
||||
"lru-cache": "^11.0.2",
|
||||
"openai": "^4.85.4",
|
||||
"quick-lru": "^7.0.0",
|
||||
"solid-js": "^1.9.11",
|
||||
"uri-js": "^4.4.1",
|
||||
"web-tree-sitter": "^0.24.7",
|
||||
"zod": "^3.24.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/diff": "^6.0.0",
|
||||
"@types/mocha": "^10.0.10",
|
||||
"@types/node": "22.x",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
@@ -520,7 +533,7 @@
|
||||
|
||||
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
|
||||
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.71.2", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ=="],
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.39.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" } }, "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg=="],
|
||||
|
||||
"@anycable/core": ["@anycable/core@0.9.2", "", { "dependencies": { "nanoevents": "^7.0.1" } }, "sha512-x5ZXDcW/N4cxWl93CnbHs/u7qq4793jS2kNPWm+duPrXlrva+ml2ZGT7X9tuOBKzyIHf60zWCdIK7TUgMPAwXA=="],
|
||||
|
||||
@@ -1390,6 +1403,8 @@
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
||||
|
||||
"@types/diff": ["@types/diff@6.0.0", "", {}, "sha512-dhVCYGv3ZSbzmQaBSagrv1WJ6rXCdkyTcDyoNu1MD8JohI7pR7k8wdZEm+mvdxRKXyHVwckFzWU1vJc+Z29MlA=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
|
||||
@@ -1494,6 +1509,8 @@
|
||||
|
||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
|
||||
|
||||
"ai": ["ai@5.0.124", "", { "dependencies": { "@ai-sdk/gateway": "2.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Li6Jw9F9qsvFJXZPBfxj38ddP2iURCnMs96f9Q3OeQzrDVcl1hvtwSEAuxA/qmfh6SDV2ERqFUOFzigvr0697g=="],
|
||||
|
||||
"ai-gateway-provider": ["ai-gateway-provider@2.3.1", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider-utils": "^3.0.19", "ai": "^5.0.116" }, "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^3.0.71", "@ai-sdk/anthropic": "^2.0.56", "@ai-sdk/azure": "^2.0.90", "@ai-sdk/cerebras": "^1.0.33", "@ai-sdk/cohere": "^2.0.21", "@ai-sdk/deepgram": "^1.0.21", "@ai-sdk/deepseek": "^1.0.32", "@ai-sdk/elevenlabs": "^1.0.21", "@ai-sdk/fireworks": "^1.0.30", "@ai-sdk/google": "^2.0.51", "@ai-sdk/google-vertex": "3.0.90", "@ai-sdk/groq": "^2.0.33", "@ai-sdk/mistral": "^2.0.26", "@ai-sdk/openai": "^2.0.88", "@ai-sdk/perplexity": "^2.0.22", "@ai-sdk/xai": "^2.0.42", "@openrouter/ai-sdk-provider": "^1.5.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^1.0.29" } }, "sha512-PqI6TVNEDNwr7kOhy7XUGnA8XJB1SpeA9aLqGjr0CyWkKgH+y+ofPm8MZGZ74DOwVejDF+POZq0Qs9jKEKUeYg=="],
|
||||
@@ -1764,7 +1781,7 @@
|
||||
|
||||
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
||||
|
||||
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
|
||||
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
@@ -1878,6 +1895,8 @@
|
||||
|
||||
"fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="],
|
||||
|
||||
"fastest-levenshtein": ["fastest-levenshtein@1.0.16", "", {}, "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg=="],
|
||||
|
||||
"fastify": ["fastify@5.7.4", "", { "dependencies": { "@fastify/ajv-compiler": "^4.0.5", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^6.0.0", "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", "pino": "^10.1.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA=="],
|
||||
|
||||
"fastify-plugin": ["fastify-plugin@5.1.0", "", {}, "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw=="],
|
||||
@@ -1916,6 +1935,10 @@
|
||||
|
||||
"form-data": ["form-data@4.0.5", "", { "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" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||
|
||||
"form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="],
|
||||
|
||||
"formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="],
|
||||
|
||||
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
|
||||
|
||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
@@ -2026,6 +2049,8 @@
|
||||
|
||||
"human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="],
|
||||
|
||||
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
|
||||
|
||||
"husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
@@ -2156,6 +2181,8 @@
|
||||
|
||||
"jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="],
|
||||
|
||||
"js-tiktoken": ["js-tiktoken@1.0.21", "", { "dependencies": { "base64-js": "^1.5.1" } }, "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
|
||||
@@ -2242,7 +2269,7 @@
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||
"lru-cache": ["lru-cache@11.2.5", "", {}, "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw=="],
|
||||
|
||||
"lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="],
|
||||
|
||||
@@ -2380,7 +2407,7 @@
|
||||
|
||||
"open": ["open@10.1.2", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "is-wsl": "^3.1.0" } }, "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw=="],
|
||||
|
||||
"openai": ["openai@6.18.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-odLRYyz9rlzz6g8gKn61RM2oP5UUm428sE2zOxZqS9MzVfD5/XW8UoEjpnRkzTuScXP7ZbP/m7fC+bl8jCOZZw=="],
|
||||
"openai": ["openai@4.104.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" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA=="],
|
||||
|
||||
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
|
||||
|
||||
@@ -2494,7 +2521,7 @@
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
|
||||
"punycode": ["punycode@1.3.2", "", {}, "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw=="],
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
|
||||
|
||||
@@ -2506,6 +2533,8 @@
|
||||
|
||||
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
|
||||
|
||||
"quick-lru": ["quick-lru@7.3.0", "", {}, "sha512-k9lSsjl36EJdK7I06v7APZCbyGT2vMTsYSRX1Q2nbYmnkBqgUhRkAuzH08Ciotteu/PLJmIF2+tti7o3C/ts2g=="],
|
||||
|
||||
"randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="],
|
||||
|
||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
@@ -2890,7 +2919,7 @@
|
||||
|
||||
"vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="],
|
||||
|
||||
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
|
||||
|
||||
"web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="],
|
||||
|
||||
@@ -3008,6 +3037,10 @@
|
||||
|
||||
"@eslint/eslintrc/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||
|
||||
"@gitlab/gitlab-ai-provider/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.71.2", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ=="],
|
||||
|
||||
"@gitlab/gitlab-ai-provider/openai": ["openai@6.18.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-odLRYyz9rlzz6g8gKn61RM2oP5UUm428sE2zOxZqS9MzVfD5/XW8UoEjpnRkzTuScXP7ZbP/m7fC+bl8jCOZZw=="],
|
||||
|
||||
"@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@hey-api/json-schema-ref-parser/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
@@ -3178,6 +3211,8 @@
|
||||
|
||||
"c12/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||
|
||||
"c12/dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
|
||||
|
||||
"c8/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||
|
||||
"c8/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||
@@ -3194,6 +3229,8 @@
|
||||
|
||||
"eslint/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||
|
||||
"fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||
|
||||
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||
@@ -3208,12 +3245,18 @@
|
||||
|
||||
"katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
||||
|
||||
"kilo-code/diff": ["diff@7.0.0", "", {}, "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw=="],
|
||||
|
||||
"kilo-code/prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
|
||||
|
||||
"kilo-code/solid-js": ["solid-js@1.9.11", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q=="],
|
||||
|
||||
"kilo-code/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"kilo-code/web-tree-sitter": ["web-tree-sitter@0.24.7", "", {}, "sha512-CdC/TqVFbXqR+C51v38hv6wOPatKEUGxa39scAeFSm98wIhZxAYonhRQPSMmfZ2w7JDI0zQDdzdmgtNk06/krQ=="],
|
||||
|
||||
"kilo-code/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"light-my-request/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
@@ -3268,6 +3311,8 @@
|
||||
|
||||
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
|
||||
|
||||
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||
|
||||
"ora/cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
|
||||
|
||||
"ora/log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
|
||||
@@ -3276,8 +3321,6 @@
|
||||
|
||||
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
|
||||
"path-scurry/lru-cache": ["lru-cache@11.2.5", "", {}, "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw=="],
|
||||
|
||||
"pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
|
||||
|
||||
"pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="],
|
||||
@@ -3324,7 +3367,7 @@
|
||||
|
||||
"tree-sitter-bash/node-addon-api": ["node-addon-api@8.5.0", "", {}, "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A=="],
|
||||
|
||||
"uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
"url/punycode": ["punycode@1.3.2", "", {}, "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw=="],
|
||||
|
||||
"vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { fetchProfile, fetchBalance } from "../api/profile.js"
|
||||
import { fetchKilocodeNotifications, KilocodeNotificationSchema } from "../api/notifications.js"
|
||||
import { KILO_API_BASE } from "../api/constants.js"
|
||||
|
||||
// Type definitions for OpenCode dependencies (injected at runtime)
|
||||
type Hono = any
|
||||
@@ -162,6 +163,85 @@ export function createKiloRoutes(deps: KiloRoutesDeps) {
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/fim",
|
||||
describeRoute({
|
||||
summary: "FIM completion",
|
||||
description: "Proxy a Fill-in-the-Middle completion request to the Kilo Gateway",
|
||||
operationId: "kilo.fim",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Streaming FIM completion response",
|
||||
content: {
|
||||
"text/event-stream": {
|
||||
schema: resolver(z.any()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 401),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
prefix: z.string(),
|
||||
suffix: z.string(),
|
||||
model: z.string().optional(),
|
||||
maxTokens: z.number().optional(),
|
||||
temperature: z.number().optional(),
|
||||
}),
|
||||
),
|
||||
async (c: any) => {
|
||||
const auth = await Auth.get("kilo")
|
||||
|
||||
if (!auth) {
|
||||
return c.json({ error: "Not authenticated with Kilo Gateway" }, 401)
|
||||
}
|
||||
|
||||
const token = auth.type === "api" ? auth.key : auth.type === "oauth" ? auth.access : undefined
|
||||
if (!token) {
|
||||
return c.json({ error: "No valid token found" }, 401)
|
||||
}
|
||||
|
||||
const { prefix, suffix, model, maxTokens, temperature } = c.req.valid("json")
|
||||
const fimModel = model ?? "mistralai/codestral-2501"
|
||||
const fimMaxTokens = maxTokens ?? 256
|
||||
const fimTemperature = temperature ?? 0.2
|
||||
|
||||
const baseApiUrl = KILO_API_BASE + "/api/"
|
||||
const endpoint = new URL("fim/completions", baseApiUrl)
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: fimModel,
|
||||
prompt: prefix,
|
||||
suffix,
|
||||
max_tokens: fimMaxTokens,
|
||||
temperature: fimTemperature,
|
||||
stream: true,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return c.json({ error: `FIM request failed: ${response.status} ${errorText}` }, response.status as any)
|
||||
}
|
||||
|
||||
// Stream the response through
|
||||
return new Response(response.body, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/notifications",
|
||||
describeRoute({
|
||||
|
||||
@@ -68,6 +68,11 @@
|
||||
"light": "assets/icons/kilo-light.svg",
|
||||
"dark": "assets/icons/kilo-dark.svg"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "kilo-code.new.autocomplete.generateSuggestions",
|
||||
"title": "Generate Suggested Edits",
|
||||
"category": "Kilo Code"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
@@ -167,6 +172,21 @@
|
||||
"default": "kilo/auto",
|
||||
"description": "Default model ID for new sessions"
|
||||
},
|
||||
"kilo-code.new.autocomplete.enableAutoTrigger": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enable automatic inline completion suggestions"
|
||||
},
|
||||
"kilo-code.new.autocomplete.enableSmartInlineTaskKeybinding": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable smart inline task keybinding"
|
||||
},
|
||||
"kilo-code.new.autocomplete.enableChatAutocomplete": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable chat textarea autocomplete"
|
||||
},
|
||||
"kilo-code.new.browserAutomation.enabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
@@ -245,6 +265,7 @@
|
||||
"test": "vscode-test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/diff": "^6.0.0",
|
||||
"@types/mocha": "^10.0.10",
|
||||
"@types/node": "22.x",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
@@ -262,10 +283,22 @@
|
||||
"typescript-eslint": "^8.54.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.39.0",
|
||||
"@kilocode/kilo-i18n": "workspace:*",
|
||||
"@kilocode/kilo-ui": "workspace:*",
|
||||
"@kilocode/sdk": "workspace:*",
|
||||
"diff": "^7.0.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"eventsource": "^2.0.2",
|
||||
"solid-js": "^1.9.11"
|
||||
"fastest-levenshtein": "^1.0.16",
|
||||
"ignore": "^7.0.3",
|
||||
"js-tiktoken": "^1.0.18",
|
||||
"lru-cache": "^11.0.2",
|
||||
"quick-lru": "^7.0.0",
|
||||
"openai": "^4.85.4",
|
||||
"solid-js": "^1.9.11",
|
||||
"uri-js": "^4.4.1",
|
||||
"web-tree-sitter": "^0.24.7",
|
||||
"zod": "^3.24.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as vscode from "vscode"
|
||||
import { type HttpClient, type SessionInfo, type SSEEvent, type KiloConnectionService } from "./services/cli-backend"
|
||||
import { handleChatCompletionRequest } from "./services/autocomplete/chat-autocomplete/handleChatCompletionRequest"
|
||||
import { handleChatCompletionAccepted } from "./services/autocomplete/chat-autocomplete/handleChatCompletionAccepted"
|
||||
|
||||
export class KiloProvider implements vscode.WebviewViewProvider {
|
||||
public static readonly viewType = "kilo-code.new.sidebarView"
|
||||
@@ -222,6 +224,33 @@ export class KiloProvider implements vscode.WebviewViewProvider {
|
||||
.getConfiguration("kilo-code.new")
|
||||
.update("language", message.locale || undefined, vscode.ConfigurationTarget.Global)
|
||||
break
|
||||
case "requestAutocompleteSettings":
|
||||
this.sendAutocompleteSettings()
|
||||
break
|
||||
case "updateAutocompleteSetting": {
|
||||
const allowedKeys = new Set([
|
||||
"enableAutoTrigger",
|
||||
"enableSmartInlineTaskKeybinding",
|
||||
"enableChatAutocomplete",
|
||||
])
|
||||
if (allowedKeys.has(message.key)) {
|
||||
await vscode.workspace
|
||||
.getConfiguration("kilo-code.new.autocomplete")
|
||||
.update(message.key, message.value, vscode.ConfigurationTarget.Global)
|
||||
this.sendAutocompleteSettings()
|
||||
}
|
||||
break
|
||||
}
|
||||
case "requestChatCompletion":
|
||||
void handleChatCompletionRequest(
|
||||
{ type: "requestChatCompletion", text: message.text, requestId: message.requestId },
|
||||
{ postMessage: (msg) => this.postMessage(msg) },
|
||||
this.connectionService,
|
||||
)
|
||||
break
|
||||
case "chatCompletionAccepted":
|
||||
handleChatCompletionAccepted({ type: "chatCompletionAccepted", suggestionLength: message.suggestionLength })
|
||||
break
|
||||
case "deleteSession":
|
||||
await this.handleDeleteSession(message.sessionID)
|
||||
break
|
||||
@@ -1112,6 +1141,21 @@ export class KiloProvider implements vscode.WebviewViewProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read autocomplete settings from VS Code configuration and push to the webview.
|
||||
*/
|
||||
private sendAutocompleteSettings(): void {
|
||||
const config = vscode.workspace.getConfiguration("kilo-code.new.autocomplete")
|
||||
this.postMessage({
|
||||
type: "autocompleteSettingsLoaded",
|
||||
settings: {
|
||||
enableAutoTrigger: config.get<boolean>("enableAutoTrigger", true),
|
||||
enableSmartInlineTaskKeybinding: config.get<boolean>("enableSmartInlineTaskKeybinding", false),
|
||||
enableChatAutocomplete: config.get<boolean>("enableChatAutocomplete", false),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a message to the webview.
|
||||
* Public so toolbar button commands can send messages.
|
||||
|
||||
@@ -3,6 +3,7 @@ import { KiloProvider } from "./KiloProvider"
|
||||
import { AgentManagerProvider } from "./AgentManagerProvider"
|
||||
import { EXTENSION_DISPLAY_NAME } from "./constants"
|
||||
import { KiloConnectionService } from "./services/cli-backend"
|
||||
import { registerAutocompleteProvider } from "./services/autocomplete"
|
||||
import { BrowserAutomationService } from "./services/browser-automation"
|
||||
|
||||
export function activate(context: vscode.ExtensionContext) {
|
||||
@@ -57,6 +58,9 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
}),
|
||||
)
|
||||
|
||||
// Register autocomplete provider
|
||||
registerAutocompleteProvider(context, connectionService)
|
||||
|
||||
// Dispose services when extension deactivates (kills the server)
|
||||
context.subscriptions.push({
|
||||
dispose: () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as vscode from "vscode"
|
||||
import { t } from "../../i18n"
|
||||
import { t } from "./shims/i18n"
|
||||
|
||||
export class AutocompleteCodeActionProvider implements vscode.CodeActionProvider {
|
||||
public readonly providedCodeActionKinds = {
|
||||
@@ -17,7 +17,7 @@ export class AutocompleteCodeActionProvider implements vscode.CodeActionProvider
|
||||
this.providedCodeActionKinds["quickfix"],
|
||||
)
|
||||
action.command = {
|
||||
command: "kilo-code.autocomplete.generateSuggestions",
|
||||
command: "kilo-code.new.autocomplete.generateSuggestions",
|
||||
title: "",
|
||||
arguments: [document.uri, range],
|
||||
}
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
// kilocode_change - new file
|
||||
import * as vscode from "vscode"
|
||||
import { z } from "zod"
|
||||
import { AutocompleteServiceManager } from "./AutocompleteServiceManager"
|
||||
import { ClineProvider } from "../../core/webview/ClineProvider"
|
||||
import { getKiloCodeWrapperProperties } from "../../core/kilocode/wrapper"
|
||||
import { languageForFilepath } from "./continuedev/core/autocomplete/constants/AutocompleteLanguageInfo"
|
||||
import { AutocompleteContextProvider } from "./types"
|
||||
import { FimPromptBuilder } from "./classic-auto-complete/FillInTheMiddle"
|
||||
import { HoleFiller } from "./classic-auto-complete/HoleFiller"
|
||||
import { MockTextDocument } from "../mocking/MockTextDocument"
|
||||
|
||||
const GET_INLINE_COMPLETIONS_COMMAND = "kilo-code.jetbrains.getInlineCompletions"
|
||||
|
||||
// Zod schemas for validation
|
||||
const PositionSchema = z.object({
|
||||
line: z.number().int().nonnegative(),
|
||||
character: z.number().int().nonnegative(),
|
||||
})
|
||||
|
||||
const InlineCompletionArgsSchema = z.tuple([
|
||||
z.union([z.string(), z.any()]).transform((val) => String(val)), // documentUri - coerce to string
|
||||
z.union([PositionSchema, z.any()]), // position (can be object or any)
|
||||
z.union([z.string(), z.any()]).transform((val) => String(val)), // fileContent - coerce to string
|
||||
z.union([z.string(), z.any()]).transform((val) => String(val)), // languageId - coerce to string
|
||||
z.union([z.string(), z.any()]).transform((val) => String(val)), // requestId - coerce to string
|
||||
])
|
||||
|
||||
type InlineCompletionArgs = z.infer<typeof InlineCompletionArgsSchema>
|
||||
|
||||
interface DocumentParams {
|
||||
uri: string
|
||||
position: { line: number; character: number }
|
||||
content: string
|
||||
languageId: string
|
||||
requestId: string
|
||||
}
|
||||
|
||||
interface NormalizedContent {
|
||||
normalizedContent: string
|
||||
lines: string[]
|
||||
}
|
||||
|
||||
interface CompletionResult {
|
||||
requestId: string
|
||||
items: Array<{
|
||||
insertText: string
|
||||
range: {
|
||||
start: { line: number; character: number }
|
||||
end: { line: number; character: number }
|
||||
} | null
|
||||
}>
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export class AutocompleteJetbrainsBridge {
|
||||
private autocomplete: AutocompleteServiceManager
|
||||
|
||||
constructor(autocomplete: AutocompleteServiceManager) {
|
||||
this.autocomplete = autocomplete
|
||||
}
|
||||
|
||||
private determineLanguage(langId: string, uri: string): string {
|
||||
// If we have a valid language ID that's not generic, use it
|
||||
if (langId && langId !== "text" && langId !== "textmate") {
|
||||
return langId
|
||||
}
|
||||
|
||||
// Use the languageForFilepath function to get language info from file extension
|
||||
const languageInfo = languageForFilepath(uri)
|
||||
const languageName = languageInfo.name.toLowerCase()
|
||||
|
||||
// Map language names to VSCode language IDs
|
||||
const languageIdMap: { [key: string]: string } = {
|
||||
typescript: "typescript",
|
||||
javascript: "javascript",
|
||||
python: "python",
|
||||
java: "java",
|
||||
"c++": "cpp",
|
||||
"c#": "csharp",
|
||||
c: "c",
|
||||
scala: "scala",
|
||||
go: "go",
|
||||
rust: "rust",
|
||||
haskell: "haskell",
|
||||
php: "php",
|
||||
ruby: "ruby",
|
||||
"ruby on rails": "ruby",
|
||||
swift: "swift",
|
||||
kotlin: "kotlin",
|
||||
clojure: "clojure",
|
||||
julia: "julia",
|
||||
"f#": "fsharp",
|
||||
r: "r",
|
||||
dart: "dart",
|
||||
solidity: "solidity",
|
||||
yaml: "yaml",
|
||||
json: "json",
|
||||
markdown: "markdown",
|
||||
lua: "lua",
|
||||
}
|
||||
|
||||
return languageIdMap[languageName] || languageName
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate the RPC arguments using Zod schemas
|
||||
*/
|
||||
private parseAndValidateArgs(...args: any[]): DocumentParams {
|
||||
// RPC passes all arguments as a single array in args[0]
|
||||
const argsArray = Array.isArray(args[0]) ? args[0] : args
|
||||
|
||||
// Parse with Zod schema
|
||||
const parsed = InlineCompletionArgsSchema.parse(argsArray)
|
||||
const [documentUri, position, fileContent, languageId, requestId] = parsed
|
||||
|
||||
// Safely extract and normalize parameters
|
||||
const uri = typeof documentUri === "string" ? documentUri : String(documentUri)
|
||||
const pos =
|
||||
typeof position === "object" && position !== null && "line" in position && "character" in position
|
||||
? { line: position.line, character: position.character }
|
||||
: { line: 0, character: 0 }
|
||||
const content = typeof fileContent === "string" ? fileContent : String(fileContent)
|
||||
const langId = typeof languageId === "string" ? languageId : String(languageId || "")
|
||||
const reqId = typeof requestId === "string" ? requestId : String(requestId || "")
|
||||
|
||||
return {
|
||||
uri,
|
||||
position: pos,
|
||||
content,
|
||||
languageId: langId,
|
||||
requestId: reqId,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize content line endings to LF for consistent processing
|
||||
* JetBrains may send content with different line endings
|
||||
*/
|
||||
private normalizeContent(content: string): NormalizedContent {
|
||||
const normalizedContent = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
const lines = normalizedContent.split("\n")
|
||||
|
||||
return {
|
||||
normalizedContent,
|
||||
lines,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock VSCode TextDocument from the provided parameters
|
||||
*/
|
||||
private createMockDocument(uri: string, normalizedContent: string, language: string): vscode.TextDocument {
|
||||
const mockDocument = new MockTextDocument(vscode.Uri.parse(uri), normalizedContent)
|
||||
mockDocument.languageId = language
|
||||
mockDocument.fileName = uri
|
||||
return mockDocument
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock context provider that prevents workspace file access.
|
||||
* This is used for JetBrains bridge to ensure only the provided document content is used.
|
||||
*/
|
||||
private createMockContextProvider(normalizedContent: string): AutocompleteContextProvider {
|
||||
// Access the model through the inline completion provider which has access to it
|
||||
const provider = this.autocomplete.inlineCompletionProvider as any
|
||||
const model = provider.model
|
||||
|
||||
return {
|
||||
ide: {
|
||||
readFile: async () => normalizedContent,
|
||||
getWorkspaceDirs: async () => [],
|
||||
getClipboardContent: async () => ({ text: "", copiedAt: new Date().toISOString() }),
|
||||
},
|
||||
contextService: {
|
||||
initializeForFile: async () => {},
|
||||
getRootPathSnippets: async () => [],
|
||||
getSnippetsFromImportDefinitions: async () => [],
|
||||
getStaticContextSnippets: async () => [],
|
||||
},
|
||||
model,
|
||||
ignoreController: undefined,
|
||||
} as unknown as AutocompleteContextProvider
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize completion results to a format suitable for RPC response
|
||||
*/
|
||||
private serializeCompletionResult(
|
||||
completions: vscode.InlineCompletionItem[] | vscode.InlineCompletionList | undefined,
|
||||
requestId: string,
|
||||
): CompletionResult {
|
||||
const items = Array.isArray(completions) ? completions : completions?.items || []
|
||||
|
||||
return {
|
||||
requestId,
|
||||
items: items.map((item) => ({
|
||||
insertText: typeof item.insertText === "string" ? item.insertText : item.insertText.value,
|
||||
range: item.range
|
||||
? {
|
||||
start: {
|
||||
line: item.range.start.line,
|
||||
character: item.range.start.character,
|
||||
},
|
||||
end: { line: item.range.end.line, character: item.range.end.character },
|
||||
}
|
||||
: null,
|
||||
})),
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
public async getInlineCompletions(...args: any[]): Promise<CompletionResult> {
|
||||
try {
|
||||
// Parse and validate arguments
|
||||
const params = this.parseAndValidateArgs(...args)
|
||||
|
||||
// Normalize content
|
||||
const { normalizedContent, lines } = this.normalizeContent(params.content)
|
||||
|
||||
// Determine language from languageId or file extension
|
||||
const language = this.determineLanguage(params.languageId, params.uri)
|
||||
|
||||
// Create mock document
|
||||
const mockDocument = this.createMockDocument(params.uri, normalizedContent, language)
|
||||
|
||||
// Create VSCode position and context
|
||||
const vscodePosition = new vscode.Position(params.position.line, params.position.character)
|
||||
const context: vscode.InlineCompletionContext = {
|
||||
triggerKind: vscode.InlineCompletionTriggerKind.Invoke,
|
||||
selectedCompletionInfo: undefined,
|
||||
}
|
||||
const tokenSource = new vscode.CancellationTokenSource()
|
||||
|
||||
// Create mock context provider to prevent workspace file access
|
||||
const mockContextProvider = this.createMockContextProvider(normalizedContent)
|
||||
|
||||
// Save original builders
|
||||
const originalFimBuilder = this.autocomplete.inlineCompletionProvider.fimPromptBuilder
|
||||
const originalHoleFiller = this.autocomplete.inlineCompletionProvider.holeFiller
|
||||
|
||||
try {
|
||||
// Temporarily replace builders with ones using mock context
|
||||
this.autocomplete.inlineCompletionProvider.fimPromptBuilder = new FimPromptBuilder(mockContextProvider)
|
||||
this.autocomplete.inlineCompletionProvider.holeFiller = new HoleFiller(mockContextProvider)
|
||||
|
||||
// Get completions from the provider (will use mock builders internally)
|
||||
const completions = await this.autocomplete.inlineCompletionProvider.provideInlineCompletionItems(
|
||||
mockDocument,
|
||||
vscodePosition,
|
||||
context,
|
||||
tokenSource.token,
|
||||
)
|
||||
|
||||
// Serialize and return the result
|
||||
return this.serializeCompletionResult(completions, params.requestId)
|
||||
} finally {
|
||||
// Always restore original builders
|
||||
this.autocomplete.inlineCompletionProvider.fimPromptBuilder = originalFimBuilder
|
||||
this.autocomplete.inlineCompletionProvider.holeFiller = originalHoleFiller
|
||||
tokenSource.dispose()
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
requestId: "",
|
||||
items: [],
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const registerAutocompleteJetbrainsBridge = (
|
||||
context: vscode.ExtensionContext,
|
||||
_cline: ClineProvider,
|
||||
autocomplete: AutocompleteServiceManager,
|
||||
) => {
|
||||
// Check if we are running inside JetBrains IDE
|
||||
const { kiloCodeWrapped, kiloCodeWrapperJetbrains } = getKiloCodeWrapperProperties()
|
||||
if (!kiloCodeWrapped || !kiloCodeWrapperJetbrains) {
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize the JetBrains Bridge
|
||||
const bridge = new AutocompleteJetbrainsBridge(autocomplete)
|
||||
|
||||
// Register JetBrains inline completion command
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(GET_INLINE_COMPLETIONS_COMMAND, bridge.getInlineCompletions.bind(bridge)),
|
||||
)
|
||||
}
|
||||
@@ -1,219 +1,128 @@
|
||||
// kilocode_change new file
|
||||
import { modelIdKeysByProvider, ProviderName } from "@roo-code/types"
|
||||
import { ApiHandler, buildApiHandler, FimHandler } from "../../api"
|
||||
import { ProviderSettingsManager } from "../../core/config/ProviderSettingsManager"
|
||||
import { OpenRouterHandler } from "../../api/providers"
|
||||
import { CompletionUsage } from "../../api/providers/openrouter"
|
||||
import { ApiStreamChunk } from "../../api/transform/stream"
|
||||
import { AUTOCOMPLETE_PROVIDER_MODELS, checkKilocodeBalance } from "./utils/kilocode-utils"
|
||||
import { KilocodeOpenrouterHandler } from "../../api/providers/kilocode-openrouter"
|
||||
import { PROVIDERS } from "../../../webview-ui/src/components/settings/constants"
|
||||
// kilocode_change - rewritten to use CLI backend FIM endpoint
|
||||
import { ResponseMetaData } from "./types"
|
||||
import type { KiloConnectionService } from "../cli-backend"
|
||||
|
||||
function getFimHandler(handler: ApiHandler): FimHandler | undefined {
|
||||
if (typeof handler.fimSupport === "function") {
|
||||
return handler.fimSupport()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const DEFAULT_MODEL = "mistralai/codestral-2508"
|
||||
const PROVIDER_DISPLAY_NAME = "Kilo Gateway"
|
||||
|
||||
// Convert PROVIDERS array to a lookup map for display names
|
||||
const PROVIDER_DISPLAY_NAMES = Object.fromEntries(PROVIDERS.map(({ value, label }) => [value, label])) as Record<
|
||||
ProviderName,
|
||||
string
|
||||
>
|
||||
/** Chunk from an LLM streaming response */
|
||||
export type ApiStreamChunk =
|
||||
| { type: "text"; text: string }
|
||||
| {
|
||||
type: "usage"
|
||||
totalCost?: number
|
||||
inputTokens?: number
|
||||
outputTokens?: number
|
||||
cacheReadTokens?: number
|
||||
cacheWriteTokens?: number
|
||||
}
|
||||
|
||||
export class AutocompleteModel {
|
||||
private apiHandler: ApiHandler | null = null
|
||||
private connectionService: KiloConnectionService | null = null
|
||||
public profileName: string | null = null
|
||||
public profileType: string | null = null
|
||||
private currentProvider: ProviderName | null = null
|
||||
public loaded = false
|
||||
public hasKilocodeProfileWithNoBalance = false
|
||||
|
||||
constructor(apiHandler: ApiHandler | null = null) {
|
||||
if (apiHandler) {
|
||||
this.apiHandler = apiHandler
|
||||
constructor(connectionService?: KiloConnectionService) {
|
||||
if (connectionService) {
|
||||
this.connectionService = connectionService
|
||||
this.loaded = true
|
||||
}
|
||||
}
|
||||
private cleanup(): void {
|
||||
this.apiHandler = null
|
||||
this.profileName = null
|
||||
this.profileType = null
|
||||
this.currentProvider = null
|
||||
this.loaded = false
|
||||
this.hasKilocodeProfileWithNoBalance = false
|
||||
}
|
||||
|
||||
public async reload(providerSettingsManager: ProviderSettingsManager): Promise<boolean> {
|
||||
const profiles = await providerSettingsManager.listConfig()
|
||||
|
||||
this.cleanup()
|
||||
|
||||
const selectedProfile = profiles.find((x) => x.profileType === "autocomplete")
|
||||
if (selectedProfile) {
|
||||
const profile = await providerSettingsManager.getProfile({ id: selectedProfile.id })
|
||||
if (profile.apiProvider) {
|
||||
await useProfile(this, profile, profile.apiProvider)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for (const [provider, model] of AUTOCOMPLETE_PROVIDER_MODELS) {
|
||||
const selectedProfile = profiles.find((x) => x?.apiProvider === provider && !(x.profileType === "autocomplete"))
|
||||
if (!selectedProfile) continue
|
||||
const profile = await providerSettingsManager.getProfile({ id: selectedProfile.id })
|
||||
|
||||
if (provider === "kilocode") {
|
||||
// For all other providers, assume they are usable
|
||||
if (!profile.kilocodeToken) continue
|
||||
const hasBalance = await checkKilocodeBalance(profile.kilocodeToken, profile.kilocodeOrganizationId)
|
||||
if (!hasBalance) {
|
||||
// Track that we found a kilocode profile but it has no balance
|
||||
this.hasKilocodeProfileWithNoBalance = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
await useProfile(this, { ...profile, [modelIdKeysByProvider[provider]]: model }, provider)
|
||||
return true
|
||||
}
|
||||
|
||||
this.loaded = true // we loaded, and found nothing, but we do not wish to reload
|
||||
return false
|
||||
|
||||
type ProfileWithIdAndName = Awaited<ReturnType<typeof providerSettingsManager.getProfile>>
|
||||
async function useProfile(self: AutocompleteModel, profile: ProfileWithIdAndName, provider: ProviderName) {
|
||||
self.profileName = profile.name || null
|
||||
self.profileType = profile.profileType || null
|
||||
self.currentProvider = provider
|
||||
self.apiHandler = buildApiHandler(profile)
|
||||
if (self.apiHandler instanceof OpenRouterHandler) await self.apiHandler.fetchModel()
|
||||
self.loaded = true
|
||||
}
|
||||
}
|
||||
|
||||
public supportsFim(): boolean {
|
||||
if (!this.apiHandler) {
|
||||
return false
|
||||
}
|
||||
|
||||
return getFimHandler(this.apiHandler) !== undefined
|
||||
/**
|
||||
* Set the connection service (can be called after construction when service becomes available)
|
||||
*/
|
||||
public setConnectionService(service: KiloConnectionService): void {
|
||||
this.connectionService = service
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate FIM completion using the FIM API endpoint.
|
||||
* Load model configuration.
|
||||
* Returns true if the connection service is available.
|
||||
*/
|
||||
public async reload(): Promise<boolean> {
|
||||
this.loaded = true
|
||||
|
||||
if (this.connectionService) {
|
||||
const state = this.connectionService.getConnectionState()
|
||||
return state === "connected"
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
public supportsFim(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a FIM (Fill-in-the-Middle) completion via the CLI backend.
|
||||
* The CLI backend handles auth using the stored kilo OAuth token.
|
||||
*/
|
||||
public async generateFimResponse(
|
||||
prefix: string,
|
||||
suffix: string,
|
||||
onChunk: (text: string) => void,
|
||||
taskId?: string,
|
||||
_taskId?: string,
|
||||
): Promise<ResponseMetaData> {
|
||||
if (!this.apiHandler) {
|
||||
console.error("API handler is not initialized")
|
||||
throw new Error("API handler is not initialized. Please check your configuration.")
|
||||
if (!this.connectionService) {
|
||||
throw new Error("Connection service is not available")
|
||||
}
|
||||
|
||||
const fimHandler = getFimHandler(this.apiHandler)
|
||||
if (!fimHandler) {
|
||||
throw new Error("Current provider/model does not support FIM completions")
|
||||
const state = this.connectionService.getConnectionState()
|
||||
if (state !== "connected") {
|
||||
throw new Error(`CLI backend is not connected (state: ${state})`)
|
||||
}
|
||||
|
||||
console.log("USED MODEL (FIM)", fimHandler.getModel())
|
||||
const client = this.connectionService.getHttpClient()
|
||||
|
||||
let usage: CompletionUsage | undefined
|
||||
|
||||
for await (const chunk of fimHandler.streamFim(prefix, suffix, taskId, (u: CompletionUsage) => {
|
||||
usage = u
|
||||
})) {
|
||||
onChunk(chunk)
|
||||
}
|
||||
|
||||
// Calculate cost using the FimHandler's getTotalCost method
|
||||
const cost = usage ? fimHandler.getTotalCost(usage) : 0
|
||||
const inputTokens = usage?.prompt_tokens ?? 0
|
||||
const outputTokens = usage?.completion_tokens ?? 0
|
||||
const cacheReadTokens = usage?.prompt_tokens_details?.cached_tokens ?? 0
|
||||
const result = await client.fimCompletion(prefix, suffix, onChunk, {
|
||||
model: DEFAULT_MODEL,
|
||||
maxTokens: 256,
|
||||
temperature: 0.2,
|
||||
})
|
||||
|
||||
return {
|
||||
cost,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheWriteTokens: 0, // FIM doesn't support cache writes
|
||||
cacheReadTokens,
|
||||
cost: result.cost,
|
||||
inputTokens: result.inputTokens,
|
||||
outputTokens: result.outputTokens,
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate response with streaming callback support
|
||||
* Generate response via chat completions (holefiller fallback).
|
||||
* Not used when FIM is supported, but kept for compatibility.
|
||||
*/
|
||||
public async generateResponse(
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
onChunk: (chunk: ApiStreamChunk) => void,
|
||||
): Promise<ResponseMetaData> {
|
||||
if (!this.apiHandler) {
|
||||
console.error("API handler is not initialized")
|
||||
throw new Error("API handler is not initialized. Please check your configuration.")
|
||||
}
|
||||
|
||||
console.log("USED MODEL", this.apiHandler.getModel())
|
||||
|
||||
const stream = this.apiHandler.createMessage(systemPrompt, [
|
||||
{ role: "user", content: [{ type: "text", text: userPrompt }] },
|
||||
])
|
||||
|
||||
let cost = 0
|
||||
let inputTokens = 0
|
||||
let outputTokens = 0
|
||||
let cacheReadTokens = 0
|
||||
let cacheWriteTokens = 0
|
||||
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
// Call the callback with each chunk
|
||||
onChunk(chunk)
|
||||
|
||||
// Track usage information
|
||||
if (chunk.type === "usage") {
|
||||
cost = chunk.totalCost ?? 0
|
||||
cacheReadTokens = chunk.cacheReadTokens ?? 0
|
||||
cacheWriteTokens = chunk.cacheWriteTokens ?? 0
|
||||
inputTokens = chunk.inputTokens ?? 0
|
||||
outputTokens = chunk.outputTokens ?? 0
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error streaming completion:", error)
|
||||
throw error
|
||||
}
|
||||
|
||||
return {
|
||||
cost,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheWriteTokens,
|
||||
cacheReadTokens,
|
||||
}
|
||||
// FIM is the primary strategy; this method is a fallback.
|
||||
// For now, throw — callers should use generateFimResponse via supportsFim().
|
||||
throw new Error("Chat-based completions are not supported via CLI backend. Use FIM (supportsFim() returns true).")
|
||||
}
|
||||
|
||||
public getModelName(): string | undefined {
|
||||
if (!this.apiHandler) return undefined
|
||||
|
||||
return this.apiHandler.getModel().id ?? undefined
|
||||
public getModelName(): string {
|
||||
return DEFAULT_MODEL
|
||||
}
|
||||
|
||||
public getProviderDisplayName(): string | undefined {
|
||||
if (!this.currentProvider) return undefined
|
||||
return PROVIDER_DISPLAY_NAMES[this.currentProvider]
|
||||
}
|
||||
|
||||
public getRolloutHash_IfLoggedInToKilo(): number | undefined {
|
||||
return this.apiHandler instanceof KilocodeOpenrouterHandler ? this.apiHandler.getRolloutHash() : undefined
|
||||
public getProviderDisplayName(): string {
|
||||
return PROVIDER_DISPLAY_NAME
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the model has valid credentials.
|
||||
* With CLI backend, credentials are managed by the backend — we just need a connection.
|
||||
*/
|
||||
public hasValidCredentials(): boolean {
|
||||
return this.apiHandler !== null && this.loaded
|
||||
if (!this.connectionService) {
|
||||
return false
|
||||
}
|
||||
return this.connectionService.getConnectionState() === "connected"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,49 @@
|
||||
import crypto from "crypto"
|
||||
import * as vscode from "vscode"
|
||||
import { t } from "../../i18n"
|
||||
import { t } from "./shims/i18n"
|
||||
import { TelemetryStub } from "./shims/TelemetryStub"
|
||||
import { AutocompleteModel } from "./AutocompleteModel"
|
||||
import { AutocompleteStatusBar } from "./AutocompleteStatusBar"
|
||||
import { AutocompleteCodeActionProvider } from "./AutocompleteCodeActionProvider"
|
||||
import { AutocompleteInlineCompletionProvider } from "./classic-auto-complete/AutocompleteInlineCompletionProvider"
|
||||
import { AutocompleteServiceSettings, TelemetryEventName } from "@roo-code/types"
|
||||
import { ContextProxy } from "../../core/config/ContextProxy"
|
||||
import { TelemetryService } from "@roo-code/telemetry"
|
||||
import { ClineProvider } from "../../core/webview/ClineProvider"
|
||||
import { AutocompleteTelemetry } from "./classic-auto-complete/AutocompleteTelemetry"
|
||||
import { AutocompleteTelemetry, TelemetryEventName } from "./classic-auto-complete/AutocompleteTelemetry"
|
||||
import type { KiloConnectionService } from "../cli-backend"
|
||||
|
||||
const CONFIG_SECTION = "kilo-code.new.autocomplete"
|
||||
|
||||
export interface AutocompleteServiceSettings {
|
||||
enableAutoTrigger?: boolean
|
||||
enableSmartInlineTaskKeybinding?: boolean
|
||||
enableChatAutocomplete?: boolean
|
||||
provider?: string
|
||||
model?: string
|
||||
snoozeUntil?: number
|
||||
hasKilocodeProfileWithNoBalance?: boolean
|
||||
}
|
||||
|
||||
function readSettings(): AutocompleteServiceSettings {
|
||||
const config = vscode.workspace.getConfiguration(CONFIG_SECTION)
|
||||
return {
|
||||
enableAutoTrigger: config.get<boolean>("enableAutoTrigger") ?? true,
|
||||
enableSmartInlineTaskKeybinding: config.get<boolean>("enableSmartInlineTaskKeybinding") ?? true,
|
||||
enableChatAutocomplete: config.get<boolean>("enableChatAutocomplete") ?? true,
|
||||
snoozeUntil: config.get<number>("snoozeUntil"),
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSettings(patch: Partial<AutocompleteServiceSettings>): Promise<void> {
|
||||
const config = vscode.workspace.getConfiguration(CONFIG_SECTION)
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
await config.update(key, value, vscode.ConfigurationTarget.Global)
|
||||
}
|
||||
}
|
||||
|
||||
export class AutocompleteServiceManager {
|
||||
private static _instance: AutocompleteServiceManager | null = null
|
||||
|
||||
private readonly model: AutocompleteModel
|
||||
private readonly cline: ClineProvider
|
||||
private readonly context: vscode.ExtensionContext
|
||||
private readonly telemetry = new TelemetryStub()
|
||||
private settings: AutocompleteServiceSettings | null = null
|
||||
|
||||
private taskId: string | null = null
|
||||
@@ -34,7 +61,7 @@ export class AutocompleteServiceManager {
|
||||
public readonly inlineCompletionProvider: AutocompleteInlineCompletionProvider
|
||||
private inlineCompletionProviderDisposable: vscode.Disposable | null = null
|
||||
|
||||
constructor(context: vscode.ExtensionContext, cline: ClineProvider) {
|
||||
constructor(context: vscode.ExtensionContext, connectionService: KiloConnectionService) {
|
||||
if (AutocompleteServiceManager._instance) {
|
||||
throw new Error(
|
||||
"AutocompleteServiceManager is a singleton. Use AutocompleteServiceManager.getInstance() instead.",
|
||||
@@ -42,11 +69,12 @@ export class AutocompleteServiceManager {
|
||||
}
|
||||
|
||||
this.context = context
|
||||
this.cline = cline
|
||||
AutocompleteServiceManager._instance = this
|
||||
|
||||
// Register Internal Components
|
||||
this.model = new AutocompleteModel()
|
||||
this.model = new AutocompleteModel(connectionService)
|
||||
|
||||
const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? ""
|
||||
|
||||
// Register the providers
|
||||
this.codeActionProvider = new AutocompleteCodeActionProvider()
|
||||
@@ -55,7 +83,7 @@ export class AutocompleteServiceManager {
|
||||
this.model,
|
||||
this.updateCostTracking.bind(this),
|
||||
() => this.settings,
|
||||
this.cline,
|
||||
workspacePath,
|
||||
new AutocompleteTelemetry(),
|
||||
)
|
||||
|
||||
@@ -70,34 +98,13 @@ export class AutocompleteServiceManager {
|
||||
}
|
||||
|
||||
public async load() {
|
||||
await this.cline.providerSettingsManager.initialize() // avoid race condition with settings migrations
|
||||
await this.model.reload(this.cline.providerSettingsManager)
|
||||
|
||||
this.settings = ContextProxy.instance.getGlobalState("ghostServiceSettings") ?? {
|
||||
enableSmartInlineTaskKeybinding: true,
|
||||
}
|
||||
// Auto-enable autocomplete by default
|
||||
if (this.settings.enableAutoTrigger == undefined) {
|
||||
this.settings.enableAutoTrigger = true
|
||||
}
|
||||
|
||||
// Auto-enable chat autocomplete by default
|
||||
if (this.settings.enableChatAutocomplete == undefined) {
|
||||
this.settings.enableChatAutocomplete = true
|
||||
}
|
||||
await this.model.reload()
|
||||
this.settings = readSettings()
|
||||
|
||||
await this.updateGlobalContext()
|
||||
this.updateStatusBar()
|
||||
await this.updateInlineCompletionProviderRegistration()
|
||||
this.setupSnoozeTimerIfNeeded()
|
||||
const settingsWithModelInfo = {
|
||||
...this.settings,
|
||||
provider: this.getCurrentProviderName(),
|
||||
model: this.getCurrentModelName(),
|
||||
hasKilocodeProfileWithNoBalance: this.model.hasKilocodeProfileWithNoBalance,
|
||||
}
|
||||
await ContextProxy.instance.setValues({ ghostServiceSettings: settingsWithModelInfo })
|
||||
await this.cline.postStateToWebview()
|
||||
}
|
||||
|
||||
private async updateInlineCompletionProviderRegistration() {
|
||||
@@ -109,7 +116,9 @@ export class AutocompleteServiceManager {
|
||||
this.inlineCompletionProviderDisposable = null
|
||||
}
|
||||
|
||||
if (!shouldBeRegistered) return
|
||||
if (!shouldBeRegistered) {
|
||||
return
|
||||
}
|
||||
|
||||
// Register classic provider
|
||||
this.inlineCompletionProviderDisposable = vscode.languages.registerInlineCompletionItemProvider(
|
||||
@@ -120,16 +129,12 @@ export class AutocompleteServiceManager {
|
||||
}
|
||||
|
||||
public async disable() {
|
||||
const settings = ContextProxy.instance.getGlobalState("ghostServiceSettings") ?? {}
|
||||
await ContextProxy.instance.setValues({
|
||||
ghostServiceSettings: {
|
||||
...settings,
|
||||
enableAutoTrigger: false,
|
||||
enableSmartInlineTaskKeybinding: false,
|
||||
},
|
||||
await writeSettings({
|
||||
enableAutoTrigger: false,
|
||||
enableSmartInlineTaskKeybinding: false,
|
||||
})
|
||||
|
||||
TelemetryService.instance.captureEvent(TelemetryEventName.GHOST_SERVICE_DISABLED)
|
||||
this.telemetry.captureEvent(TelemetryEventName.GHOST_SERVICE_DISABLED)
|
||||
|
||||
await this.load()
|
||||
}
|
||||
@@ -139,7 +144,9 @@ export class AutocompleteServiceManager {
|
||||
*/
|
||||
public isSnoozed(): boolean {
|
||||
const snoozeUntil = this.settings?.snoozeUntil
|
||||
if (!snoozeUntil) return false
|
||||
if (!snoozeUntil) {
|
||||
return false
|
||||
}
|
||||
return Date.now() < snoozeUntil
|
||||
}
|
||||
|
||||
@@ -148,7 +155,9 @@ export class AutocompleteServiceManager {
|
||||
*/
|
||||
public getSnoozeRemainingSeconds(): number {
|
||||
const snoozeUntil = this.settings?.snoozeUntil
|
||||
if (!snoozeUntil) return 0
|
||||
if (!snoozeUntil) {
|
||||
return 0
|
||||
}
|
||||
const remaining = Math.max(0, Math.ceil((snoozeUntil - Date.now()) / 1000))
|
||||
return remaining
|
||||
}
|
||||
@@ -163,13 +172,7 @@ export class AutocompleteServiceManager {
|
||||
}
|
||||
|
||||
const snoozeUntil = Date.now() + seconds * 1000
|
||||
const settings = ContextProxy.instance.getGlobalState("ghostServiceSettings") ?? {}
|
||||
await ContextProxy.instance.setValues({
|
||||
ghostServiceSettings: {
|
||||
...settings,
|
||||
snoozeUntil,
|
||||
},
|
||||
})
|
||||
await writeSettings({ snoozeUntil })
|
||||
|
||||
this.snoozeTimer = setTimeout(() => {
|
||||
void this.unsnooze()
|
||||
@@ -187,22 +190,13 @@ export class AutocompleteServiceManager {
|
||||
this.snoozeTimer = null
|
||||
}
|
||||
|
||||
const settings = ContextProxy.instance.getGlobalState("ghostServiceSettings") ?? {}
|
||||
await ContextProxy.instance.setValues({
|
||||
ghostServiceSettings: {
|
||||
...settings,
|
||||
snoozeUntil: undefined,
|
||||
},
|
||||
})
|
||||
await writeSettings({ snoozeUntil: undefined })
|
||||
|
||||
await this.load()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a timer to auto-unsnooze if we're currently in a snoozed state.
|
||||
* This handles the case where the extension restarts while snoozed -
|
||||
* the persisted snoozeUntil timestamp keeps autocomplete disabled,
|
||||
* and this timer ensures we unsnooze at the correct time.
|
||||
*/
|
||||
private setupSnoozeTimerIfNeeded(): void {
|
||||
if (this.snoozeTimer) {
|
||||
@@ -225,7 +219,9 @@ export class AutocompleteServiceManager {
|
||||
*/
|
||||
private getSnoozeRemainingMs(): number {
|
||||
const snoozeUntil = this.settings?.snoozeUntil
|
||||
if (!snoozeUntil) return 0
|
||||
if (!snoozeUntil) {
|
||||
return 0
|
||||
}
|
||||
return Math.max(0, snoozeUntil - Date.now())
|
||||
}
|
||||
|
||||
@@ -236,7 +232,7 @@ export class AutocompleteServiceManager {
|
||||
}
|
||||
|
||||
this.taskId = crypto.randomUUID()
|
||||
TelemetryService.instance.captureEvent(TelemetryEventName.INLINE_ASSIST_AUTO_TASK, {
|
||||
this.telemetry.captureEvent(TelemetryEventName.INLINE_ASSIST_AUTO_TASK, {
|
||||
taskId: this.taskId,
|
||||
})
|
||||
|
||||
@@ -255,32 +251,27 @@ export class AutocompleteServiceManager {
|
||||
}
|
||||
const tokenSource = new vscode.CancellationTokenSource()
|
||||
|
||||
try {
|
||||
const completions = await this.inlineCompletionProvider.provideInlineCompletionItems_Internal(
|
||||
document,
|
||||
position,
|
||||
context,
|
||||
tokenSource.token,
|
||||
)
|
||||
const completions = await this.inlineCompletionProvider.provideInlineCompletionItems_Internal(
|
||||
document,
|
||||
position,
|
||||
context,
|
||||
tokenSource.token,
|
||||
)
|
||||
tokenSource.dispose()
|
||||
|
||||
// If we got completions, directly insert the first one
|
||||
if (completions && (Array.isArray(completions) ? completions.length > 0 : completions.items.length > 0)) {
|
||||
const items = Array.isArray(completions) ? completions : completions.items
|
||||
const firstCompletion = items[0]
|
||||
// If we got completions, directly insert the first one
|
||||
if (completions && (Array.isArray(completions) ? completions.length > 0 : completions.items.length > 0)) {
|
||||
const items = Array.isArray(completions) ? completions : completions.items
|
||||
const firstCompletion = items[0]
|
||||
|
||||
if (firstCompletion && firstCompletion.insertText) {
|
||||
const insertText =
|
||||
typeof firstCompletion.insertText === "string"
|
||||
? firstCompletion.insertText
|
||||
: firstCompletion.insertText.value
|
||||
if (firstCompletion?.insertText) {
|
||||
const insertText =
|
||||
typeof firstCompletion.insertText === "string" ? firstCompletion.insertText : firstCompletion.insertText.value
|
||||
|
||||
await editor.edit((editBuilder) => {
|
||||
editBuilder.insert(position, insertText)
|
||||
})
|
||||
}
|
||||
await editor.edit((editBuilder) => {
|
||||
editBuilder.insert(position, insertText)
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
tokenSource.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,12 +309,10 @@ export class AutocompleteServiceManager {
|
||||
}
|
||||
|
||||
private hasNoUsableProvider(): boolean {
|
||||
// We have no usable provider if the model is loaded but has no valid credentials
|
||||
// and it's not because of a kilocode profile with no balance (that's a different error)
|
||||
return this.model.loaded && !this.model.hasValidCredentials() && !this.model.hasKilocodeProfileWithNoBalance
|
||||
}
|
||||
|
||||
private updateCostTracking(cost: number, inputTokens: number, outputTokens: number): void {
|
||||
private updateCostTracking(cost: number, _inputTokens: number, _outputTokens: number): void {
|
||||
this.completionCount++
|
||||
this.sessionCost += cost
|
||||
this.updateStatusBar()
|
||||
@@ -355,9 +344,9 @@ export class AutocompleteServiceManager {
|
||||
const response = await vscode.window.showErrorMessage(message, disableCopilot, disableInlineAssist)
|
||||
|
||||
if (response === disableCopilot) {
|
||||
await vscode.commands.executeCommand<any>("github.copilot.completions.disable")
|
||||
await vscode.commands.executeCommand("github.copilot.completions.disable")
|
||||
} else if (response === disableInlineAssist) {
|
||||
await vscode.commands.executeCommand<any>("kilo-code.autocomplete.disable")
|
||||
await vscode.commands.executeCommand("kilo-code.new.autocomplete.disable")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,8 @@
|
||||
import * as vscode from "vscode"
|
||||
import { AUTOCOMPLETE_PROVIDER_MODELS, ProviderName } from "@roo-code/types"
|
||||
import { t } from "../../i18n"
|
||||
import { PROVIDERS } from "../../../webview-ui/src/components/settings/constants"
|
||||
import { t } from "./shims/i18n"
|
||||
import type { AutocompleteStatusBarStateProps } from "./types"
|
||||
|
||||
// Convert PROVIDERS array to a lookup map for display names
|
||||
const PROVIDER_DISPLAY_NAMES = Object.fromEntries(PROVIDERS.map(({ value, label }) => [value, label])) as Record<
|
||||
ProviderName,
|
||||
string
|
||||
>
|
||||
|
||||
/**
|
||||
* Get the display names of all supported autocomplete providers
|
||||
*/
|
||||
function getSupportedProviderDisplayNames(): string[] {
|
||||
const providerKeys = Array.from(AUTOCOMPLETE_PROVIDER_MODELS.keys())
|
||||
return providerKeys.map((key) => PROVIDER_DISPLAY_NAMES[key as ProviderName] || key)
|
||||
}
|
||||
const SUPPORTED_PROVIDER_DISPLAY_NAME = "Kilo Gateway"
|
||||
|
||||
export class AutocompleteStatusBar {
|
||||
statusBar: vscode.StatusBarItem
|
||||
@@ -55,8 +41,12 @@ export class AutocompleteStatusBar {
|
||||
|
||||
private humanFormatSessionCost(): string {
|
||||
const cost = this.props.totalSessionCost
|
||||
if (cost === 0) return t("kilocode:autocomplete.statusBar.cost.zero")
|
||||
if (cost > 0 && cost < 0.01) return t("kilocode:autocomplete.statusBar.cost.lessThanCent") // Less than one cent
|
||||
if (cost === 0) {
|
||||
return t("kilocode:autocomplete.statusBar.cost.zero")
|
||||
}
|
||||
if (cost > 0 && cost < 0.01) {
|
||||
return t("kilocode:autocomplete.statusBar.cost.lessThanCent")
|
||||
}
|
||||
return `$${cost.toFixed(2)}`
|
||||
}
|
||||
|
||||
@@ -64,7 +54,9 @@ export class AutocompleteStatusBar {
|
||||
this.props = { ...this.props, ...params }
|
||||
|
||||
this.updateVisible()
|
||||
if (this.props.enabled) this.render()
|
||||
if (this.props.enabled) {
|
||||
this.render()
|
||||
}
|
||||
}
|
||||
|
||||
private formatTime(timestamp: number): string {
|
||||
@@ -116,10 +108,8 @@ export class AutocompleteStatusBar {
|
||||
|
||||
private renderNoUsableProviderError() {
|
||||
this.statusBar.text = t("kilocode:autocomplete.statusBar.warning")
|
||||
const providers = getSupportedProviderDisplayNames()
|
||||
const providerList = providers.join(", ")
|
||||
this.statusBar.tooltip = this.createMarkdownTooltip(
|
||||
t("kilocode:autocomplete.statusBar.tooltip.noUsableProvider", { providers: providerList }),
|
||||
t("kilocode:autocomplete.statusBar.tooltip.noUsableProvider", { providers: SUPPORTED_PROVIDER_DISPLAY_NAME }),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
-359
@@ -1,359 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest"
|
||||
import * as vscode from "vscode"
|
||||
import { AutocompleteJetbrainsBridge } from "../AutocompleteJetbrainsBridge"
|
||||
import { AutocompleteServiceManager } from "../AutocompleteServiceManager"
|
||||
|
||||
// Mock vscode module
|
||||
vi.mock("vscode", () => ({
|
||||
Uri: {
|
||||
parse: vi.fn((uri: string) => ({ toString: () => uri, fsPath: uri })),
|
||||
},
|
||||
Position: class Position {
|
||||
constructor(
|
||||
public line: number,
|
||||
public character: number,
|
||||
) {}
|
||||
},
|
||||
Range: class Range {
|
||||
constructor(
|
||||
public start: any,
|
||||
public end: any,
|
||||
) {}
|
||||
},
|
||||
EndOfLine: {
|
||||
LF: 1,
|
||||
CRLF: 2,
|
||||
},
|
||||
InlineCompletionTriggerKind: {
|
||||
Invoke: 0,
|
||||
Automatic: 1,
|
||||
},
|
||||
CancellationTokenSource: class CancellationTokenSource {
|
||||
token = { isCancellationRequested: false }
|
||||
dispose = vi.fn()
|
||||
},
|
||||
}))
|
||||
|
||||
describe("AutocompleteJetbrainsBridge", () => {
|
||||
let bridge: AutocompleteJetbrainsBridge
|
||||
let mockAutocomplete: any
|
||||
|
||||
beforeEach(() => {
|
||||
mockAutocomplete = {
|
||||
inlineCompletionProvider: {
|
||||
provideInlineCompletionItems: vi.fn().mockResolvedValue([
|
||||
{
|
||||
insertText: "console.log('test')",
|
||||
range: {
|
||||
start: { line: 0, character: 0 },
|
||||
end: { line: 0, character: 10 },
|
||||
},
|
||||
},
|
||||
]),
|
||||
fimPromptBuilder: {},
|
||||
holeFiller: {},
|
||||
},
|
||||
} as any
|
||||
|
||||
bridge = new AutocompleteJetbrainsBridge(mockAutocomplete)
|
||||
})
|
||||
|
||||
describe("parseAndValidateArgs", () => {
|
||||
it("should parse arguments when passed as array", async () => {
|
||||
const args = [["file:///test.ts", { line: 5, character: 10 }, "const x = 1", "typescript", "req-123"]]
|
||||
|
||||
const result = await bridge.getInlineCompletions(...args)
|
||||
|
||||
expect(result.requestId).toBe("req-123")
|
||||
expect(result.error).toBeNull()
|
||||
})
|
||||
|
||||
it("should parse arguments when passed separately", async () => {
|
||||
const args = ["file:///test.ts", { line: 5, character: 10 }, "const x = 1", "typescript", "req-456"]
|
||||
|
||||
const result = await bridge.getInlineCompletions(...args)
|
||||
|
||||
expect(result.requestId).toBe("req-456")
|
||||
expect(result.error).toBeNull()
|
||||
})
|
||||
|
||||
it("should handle invalid position gracefully", async () => {
|
||||
const args = [["file:///test.ts", null, "const x = 1", "typescript", "req-789"]]
|
||||
|
||||
const result = await bridge.getInlineCompletions(...args)
|
||||
|
||||
// Should default to position 0,0
|
||||
expect(mockAutocomplete.inlineCompletionProvider.provideInlineCompletionItems).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ line: 0, character: 0 }),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it("should convert non-string values to strings", async () => {
|
||||
const args = [[123 as any, { line: 0, character: 0 }, 456 as any, 789 as any, 999 as any]]
|
||||
|
||||
const result = await bridge.getInlineCompletions(...args)
|
||||
|
||||
expect(result.error).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("normalizeContent", () => {
|
||||
it("should normalize CRLF line endings to LF", async () => {
|
||||
const content = "line1\r\nline2\r\nline3"
|
||||
const args = [["file:///test.ts", { line: 0, character: 0 }, content, "typescript", "req-1"]]
|
||||
|
||||
await bridge.getInlineCompletions(...args)
|
||||
|
||||
const mockDocument = mockAutocomplete.inlineCompletionProvider.provideInlineCompletionItems.mock.calls[0][0]
|
||||
expect(mockDocument.getText()).toBe("line1\nline2\nline3")
|
||||
})
|
||||
|
||||
it("should normalize CR line endings to LF", async () => {
|
||||
const content = "line1\rline2\rline3"
|
||||
const args = [["file:///test.ts", { line: 0, character: 0 }, content, "typescript", "req-2"]]
|
||||
|
||||
await bridge.getInlineCompletions(...args)
|
||||
|
||||
const mockDocument = mockAutocomplete.inlineCompletionProvider.provideInlineCompletionItems.mock.calls[0][0]
|
||||
expect(mockDocument.getText()).toBe("line1\nline2\nline3")
|
||||
})
|
||||
|
||||
it("should handle mixed line endings", async () => {
|
||||
const content = "line1\r\nline2\rline3\nline4"
|
||||
const args = [["file:///test.ts", { line: 0, character: 0 }, content, "typescript", "req-3"]]
|
||||
|
||||
await bridge.getInlineCompletions(...args)
|
||||
|
||||
const mockDocument = mockAutocomplete.inlineCompletionProvider.provideInlineCompletionItems.mock.calls[0][0]
|
||||
expect(mockDocument.getText()).toBe("line1\nline2\nline3\nline4")
|
||||
})
|
||||
})
|
||||
|
||||
describe("createMockDocument", () => {
|
||||
it("should create a valid TextDocument mock", async () => {
|
||||
const content = "line1\nline2\nline3"
|
||||
const args = [["file:///test.ts", { line: 1, character: 5 }, content, "typescript", "req-4"]]
|
||||
|
||||
await bridge.getInlineCompletions(...args)
|
||||
|
||||
const mockDocument = mockAutocomplete.inlineCompletionProvider.provideInlineCompletionItems.mock.calls[0][0]
|
||||
|
||||
expect(mockDocument.languageId).toBe("typescript")
|
||||
expect(mockDocument.lineCount).toBe(3)
|
||||
expect(mockDocument.getText()).toBe(content)
|
||||
})
|
||||
|
||||
it("should implement getText with range correctly", async () => {
|
||||
const content = "line1\nline2\nline3"
|
||||
const args = [["file:///test.ts", { line: 0, character: 0 }, content, "typescript", "req-5"]]
|
||||
|
||||
await bridge.getInlineCompletions(...args)
|
||||
|
||||
const mockDocument = mockAutocomplete.inlineCompletionProvider.provideInlineCompletionItems.mock.calls[0][0]
|
||||
|
||||
// Test single line range
|
||||
const range1 = new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 5))
|
||||
expect(mockDocument.getText(range1)).toBe("line1")
|
||||
|
||||
// Test multi-line range
|
||||
const range2 = new vscode.Range(new vscode.Position(0, 0), new vscode.Position(1, 5))
|
||||
expect(mockDocument.getText(range2)).toBe("line1\nline2")
|
||||
})
|
||||
|
||||
it("should implement lineAt correctly", async () => {
|
||||
const content = " const x = 1\nlet y = 2\n"
|
||||
const args = [["file:///test.ts", { line: 0, character: 0 }, content, "typescript", "req-6"]]
|
||||
|
||||
await bridge.getInlineCompletions(...args)
|
||||
|
||||
const mockDocument = mockAutocomplete.inlineCompletionProvider.provideInlineCompletionItems.mock.calls[0][0]
|
||||
|
||||
const line = mockDocument.lineAt(0)
|
||||
expect(line.text).toBe(" const x = 1")
|
||||
expect(line.firstNonWhitespaceCharacterIndex).toBe(2)
|
||||
expect(line.isEmptyOrWhitespace).toBe(false)
|
||||
})
|
||||
|
||||
it("should implement offsetAt correctly", async () => {
|
||||
const content = "abc\ndef\nghi"
|
||||
const args = [["file:///test.ts", { line: 0, character: 0 }, content, "typescript", "req-7"]]
|
||||
|
||||
await bridge.getInlineCompletions(...args)
|
||||
|
||||
const mockDocument = mockAutocomplete.inlineCompletionProvider.provideInlineCompletionItems.mock.calls[0][0]
|
||||
|
||||
// Position at start of line 0
|
||||
expect(mockDocument.offsetAt(new vscode.Position(0, 0))).toBe(0)
|
||||
// Position at end of line 0
|
||||
expect(mockDocument.offsetAt(new vscode.Position(0, 3))).toBe(3)
|
||||
// Position at start of line 1
|
||||
expect(mockDocument.offsetAt(new vscode.Position(1, 0))).toBe(4)
|
||||
})
|
||||
|
||||
it("should implement positionAt correctly", async () => {
|
||||
const content = "abc\ndef\nghi"
|
||||
const args = [["file:///test.ts", { line: 0, character: 0 }, content, "typescript", "req-8"]]
|
||||
|
||||
await bridge.getInlineCompletions(...args)
|
||||
|
||||
const mockDocument = mockAutocomplete.inlineCompletionProvider.provideInlineCompletionItems.mock.calls[0][0]
|
||||
|
||||
// Offset 0 should be position 0,0
|
||||
const pos1 = mockDocument.positionAt(0)
|
||||
expect(pos1.line).toBe(0)
|
||||
expect(pos1.character).toBe(0)
|
||||
|
||||
// Offset 4 should be position 1,0 (start of second line)
|
||||
const pos2 = mockDocument.positionAt(4)
|
||||
expect(pos2.line).toBe(1)
|
||||
expect(pos2.character).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("serializeCompletionResult", () => {
|
||||
it("should serialize completion items correctly", async () => {
|
||||
const args = [["file:///test.ts", { line: 0, character: 0 }, "const x = 1", "typescript", "req-9"]]
|
||||
|
||||
const result = await bridge.getInlineCompletions(...args)
|
||||
|
||||
expect(result.requestId).toBe("req-9")
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0].insertText).toBe("console.log('test')")
|
||||
expect(result.items[0].range).toEqual({
|
||||
start: { line: 0, character: 0 },
|
||||
end: { line: 0, character: 10 },
|
||||
})
|
||||
expect(result.error).toBeNull()
|
||||
})
|
||||
|
||||
it("should handle completions with string insertText", async () => {
|
||||
mockAutocomplete.inlineCompletionProvider.provideInlineCompletionItems.mockResolvedValue([
|
||||
{
|
||||
insertText: "simple string",
|
||||
range: null,
|
||||
},
|
||||
])
|
||||
|
||||
const args = [["file:///test.ts", { line: 0, character: 0 }, "const x = 1", "typescript", "req-10"]]
|
||||
|
||||
const result = await bridge.getInlineCompletions(...args)
|
||||
|
||||
expect(result.items[0].insertText).toBe("simple string")
|
||||
expect(result.items[0].range).toBeNull()
|
||||
})
|
||||
|
||||
it("should handle completions with SnippetString insertText", async () => {
|
||||
mockAutocomplete.inlineCompletionProvider.provideInlineCompletionItems.mockResolvedValue([
|
||||
{
|
||||
insertText: { value: "snippet value" },
|
||||
range: null,
|
||||
},
|
||||
])
|
||||
|
||||
const args = [["file:///test.ts", { line: 0, character: 0 }, "const x = 1", "typescript", "req-11"]]
|
||||
|
||||
const result = await bridge.getInlineCompletions(...args)
|
||||
|
||||
expect(result.items[0].insertText).toBe("snippet value")
|
||||
})
|
||||
|
||||
it("should handle InlineCompletionList format", async () => {
|
||||
mockAutocomplete.inlineCompletionProvider.provideInlineCompletionItems.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
insertText: "from list",
|
||||
range: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const args = [["file:///test.ts", { line: 0, character: 0 }, "const x = 1", "typescript", "req-12"]]
|
||||
|
||||
const result = await bridge.getInlineCompletions(...args)
|
||||
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0].insertText).toBe("from list")
|
||||
})
|
||||
|
||||
it("should handle empty completions", async () => {
|
||||
mockAutocomplete.inlineCompletionProvider.provideInlineCompletionItems.mockResolvedValue([])
|
||||
|
||||
const args = [["file:///test.ts", { line: 0, character: 0 }, "const x = 1", "typescript", "req-13"]]
|
||||
|
||||
const result = await bridge.getInlineCompletions(...args)
|
||||
|
||||
expect(result.items).toHaveLength(0)
|
||||
expect(result.error).toBeNull()
|
||||
})
|
||||
|
||||
it("should handle undefined completions", async () => {
|
||||
mockAutocomplete.inlineCompletionProvider.provideInlineCompletionItems.mockResolvedValue(undefined)
|
||||
|
||||
const args = [["file:///test.ts", { line: 0, character: 0 }, "const x = 1", "typescript", "req-14"]]
|
||||
|
||||
const result = await bridge.getInlineCompletions(...args)
|
||||
|
||||
expect(result.items).toHaveLength(0)
|
||||
expect(result.error).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should return error result when validation fails", async () => {
|
||||
const args = [[]] // Invalid args
|
||||
|
||||
const result = await bridge.getInlineCompletions(...args)
|
||||
|
||||
expect(result.requestId).toBe("")
|
||||
expect(result.items).toHaveLength(0)
|
||||
expect(result.error).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should return error result when provider throws", async () => {
|
||||
mockAutocomplete.inlineCompletionProvider.provideInlineCompletionItems.mockRejectedValue(
|
||||
new Error("Provider error"),
|
||||
)
|
||||
|
||||
const args = [["file:///test.ts", { line: 0, character: 0 }, "const x = 1", "typescript", "req-15"]]
|
||||
|
||||
const result = await bridge.getInlineCompletions(...args)
|
||||
|
||||
expect(result.requestId).toBe("")
|
||||
expect(result.items).toHaveLength(0)
|
||||
expect(result.error).toBe("Provider error")
|
||||
})
|
||||
|
||||
it("should handle non-Error exceptions", async () => {
|
||||
mockAutocomplete.inlineCompletionProvider.provideInlineCompletionItems.mockRejectedValue("String error")
|
||||
|
||||
const args = [["file:///test.ts", { line: 0, character: 0 }, "const x = 1", "typescript", "req-16"]]
|
||||
|
||||
const result = await bridge.getInlineCompletions(...args)
|
||||
|
||||
expect(result.error).toBe("String error")
|
||||
})
|
||||
})
|
||||
|
||||
describe("language determination", () => {
|
||||
it("should use provided languageId when valid", async () => {
|
||||
const args = [["file:///test.ts", { line: 0, character: 0 }, "const x = 1", "typescript", "req-17"]]
|
||||
|
||||
await bridge.getInlineCompletions(...args)
|
||||
|
||||
const mockDocument = mockAutocomplete.inlineCompletionProvider.provideInlineCompletionItems.mock.calls[0][0]
|
||||
expect(mockDocument.languageId).toBe("typescript")
|
||||
})
|
||||
|
||||
it("should determine language from file extension when languageId is generic", async () => {
|
||||
const args = [["file:///test.py", { line: 0, character: 0 }, "x = 1", "text", "req-18"]]
|
||||
|
||||
await bridge.getInlineCompletions(...args)
|
||||
|
||||
const mockDocument = mockAutocomplete.inlineCompletionProvider.provideInlineCompletionItems.mock.calls[0][0]
|
||||
expect(mockDocument.languageId).toBe("python")
|
||||
})
|
||||
})
|
||||
})
|
||||
+4
-7
@@ -1,24 +1,21 @@
|
||||
import * as vscode from "vscode"
|
||||
import { AutocompleteModel } from "../AutocompleteModel"
|
||||
import { ProviderSettingsManager } from "../../../core/config/ProviderSettingsManager"
|
||||
import { AutocompleteContext, VisibleCodeContext } from "../types"
|
||||
import { removePrefixOverlap } from "../continuedev/core/autocomplete/postprocessing/removePrefixOverlap.js"
|
||||
import { AutocompleteTelemetry } from "../classic-auto-complete/AutocompleteTelemetry"
|
||||
import { postprocessAutocompleteSuggestion } from "../classic-auto-complete/uselessSuggestionFilter"
|
||||
import type { KiloConnectionService } from "../../cli-backend"
|
||||
|
||||
export class ChatTextAreaAutocomplete {
|
||||
private model: AutocompleteModel
|
||||
private providerSettingsManager: ProviderSettingsManager
|
||||
private telemetry: AutocompleteTelemetry
|
||||
|
||||
constructor(providerSettingsManager: ProviderSettingsManager) {
|
||||
this.model = new AutocompleteModel()
|
||||
this.providerSettingsManager = providerSettingsManager
|
||||
constructor(connectionService: KiloConnectionService) {
|
||||
this.model = new AutocompleteModel(connectionService)
|
||||
this.telemetry = new AutocompleteTelemetry("chat-textarea")
|
||||
}
|
||||
|
||||
async initialize(): Promise<boolean> {
|
||||
return this.model.reload(this.providerSettingsManager)
|
||||
return this.model.reload()
|
||||
}
|
||||
|
||||
async getCompletion(userText: string, visibleCodeContext?: VisibleCodeContext): Promise<{ suggestion: string }> {
|
||||
|
||||
+6
-2
@@ -1,6 +1,10 @@
|
||||
import { WebviewMessage } from "../../../shared/WebviewMessage"
|
||||
import { AutocompleteTelemetry } from "../classic-auto-complete/AutocompleteTelemetry"
|
||||
|
||||
export interface ChatCompletionAcceptedMessage {
|
||||
type: "chatCompletionAccepted"
|
||||
suggestionLength?: number
|
||||
}
|
||||
|
||||
// Singleton telemetry instance for chat-textarea autocomplete
|
||||
// This ensures we use the same instance across requests and acceptance events
|
||||
let telemetryInstance: AutocompleteTelemetry | null = null
|
||||
@@ -19,7 +23,7 @@ export function getChatAutocompleteTelemetry(): AutocompleteTelemetry {
|
||||
* Handles a chat completion accepted event from the webview.
|
||||
* Captures telemetry when the user accepts a suggestion via Tab or ArrowRight.
|
||||
*/
|
||||
export function handleChatCompletionAccepted(message: WebviewMessage & { type: "chatCompletionAccepted" }): void {
|
||||
export function handleChatCompletionAccepted(message: ChatCompletionAcceptedMessage): void {
|
||||
const telemetry = getChatAutocompleteTelemetry()
|
||||
telemetry.captureAcceptSuggestion(message.suggestionLength)
|
||||
}
|
||||
|
||||
+29
-24
@@ -1,37 +1,42 @@
|
||||
import { ClineProvider } from "../../../core/webview/ClineProvider"
|
||||
import { WebviewMessage } from "../../../shared/WebviewMessage"
|
||||
import * as vscode from "vscode"
|
||||
import { VisibleCodeTracker } from "../context/VisibleCodeTracker"
|
||||
import { FileIgnoreController } from "../shims/FileIgnoreController"
|
||||
import { ChatTextAreaAutocomplete } from "./ChatTextAreaAutocomplete"
|
||||
import type { KiloConnectionService } from "../../cli-backend"
|
||||
|
||||
export interface ChatCompletionRequestMessage {
|
||||
type: "requestChatCompletion"
|
||||
text?: string
|
||||
requestId?: string
|
||||
}
|
||||
|
||||
export interface ChatCompletionResponseSender {
|
||||
postMessage(message: { type: "chatCompletionResult"; text: string; requestId: string }): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a chat completion request from the webview.
|
||||
* Captures visible code context and generates a FIM-based autocomplete suggestion.
|
||||
* Captures visible code context and generates an autocomplete suggestion.
|
||||
*/
|
||||
export async function handleChatCompletionRequest(
|
||||
message: WebviewMessage & { type: "requestChatCompletion" },
|
||||
provider: ClineProvider,
|
||||
getCurrentCwd: () => string,
|
||||
message: ChatCompletionRequestMessage,
|
||||
responseSender: ChatCompletionResponseSender,
|
||||
connectionService: KiloConnectionService,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userText = message.text || ""
|
||||
const requestId = message.requestId || ""
|
||||
const userText = message.text || ""
|
||||
const requestId = message.requestId || ""
|
||||
|
||||
// Pass RooIgnoreController to respect .kilocodeignore patterns
|
||||
const currentTask = provider.getCurrentTask()
|
||||
const tracker = new VisibleCodeTracker(getCurrentCwd(), currentTask?.rooIgnoreController ?? null)
|
||||
const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? ""
|
||||
const ignoreController = new FileIgnoreController()
|
||||
await ignoreController.initialize()
|
||||
|
||||
const visibleContext = await tracker.captureVisibleCode()
|
||||
const tracker = new VisibleCodeTracker(workspacePath, ignoreController)
|
||||
const visibleContext = await tracker.captureVisibleCode()
|
||||
|
||||
const autocomplete = new ChatTextAreaAutocomplete(provider.providerSettingsManager)
|
||||
const { suggestion } = await autocomplete.getCompletion(userText, visibleContext)
|
||||
const autocomplete = new ChatTextAreaAutocomplete(connectionService)
|
||||
const { suggestion } = await autocomplete.getCompletion(userText, visibleContext)
|
||||
|
||||
await provider.postMessageToWebview({ type: "chatCompletionResult", text: suggestion, requestId })
|
||||
} catch (error) {
|
||||
provider.log(`Error getting chat completion: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`)
|
||||
await provider.postMessageToWebview({
|
||||
type: "chatCompletionResult",
|
||||
text: "",
|
||||
requestId: message.requestId || "",
|
||||
})
|
||||
}
|
||||
responseSender.postMessage({ type: "chatCompletionResult", text: suggestion, requestId })
|
||||
|
||||
ignoreController.dispose()
|
||||
}
|
||||
|
||||
+9
-13
@@ -20,11 +20,10 @@ import { ContextRetrievalService } from "../continuedev/core/autocomplete/contex
|
||||
import { VsCodeIde } from "../continuedev/core/vscode-test-harness/src/VSCodeIde"
|
||||
import { RecentlyVisitedRangesService } from "../continuedev/core/vscode-test-harness/src/autocomplete/RecentlyVisitedRangesService"
|
||||
import { RecentlyEditedTracker } from "../continuedev/core/vscode-test-harness/src/autocomplete/recentlyEdited"
|
||||
import type { AutocompleteServiceSettings } from "@roo-code/types"
|
||||
import type { AutocompleteServiceSettings } from "../AutocompleteServiceManager"
|
||||
import { postprocessAutocompleteSuggestion } from "./uselessSuggestionFilter"
|
||||
import { shouldSkipAutocomplete } from "./contextualSkip"
|
||||
import { RooIgnoreController } from "../../../core/ignore/RooIgnoreController"
|
||||
import { ClineProvider } from "../../../core/webview/ClineProvider"
|
||||
import { FileIgnoreController } from "../shims/FileIgnoreController"
|
||||
import { AutocompleteTelemetry } from "./AutocompleteTelemetry"
|
||||
|
||||
const MAX_SUGGESTIONS_HISTORY = 20
|
||||
@@ -251,8 +250,8 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
public suggestionsHistory: FillInAtCursorSuggestion[] = []
|
||||
/** Tracks all pending/in-flight requests */
|
||||
private pendingRequests: PendingRequest[] = []
|
||||
public holeFiller: HoleFiller // publicly exposed for Jetbrains autocomplete code
|
||||
public fimPromptBuilder: FimPromptBuilder // publicly exposed for Jetbrains autocomplete code
|
||||
private holeFiller: HoleFiller
|
||||
private fimPromptBuilder: FimPromptBuilder
|
||||
private model: AutocompleteModel
|
||||
private costTrackingCallback: CostTrackingCallback
|
||||
private getSettings: () => AutocompleteServiceSettings | null
|
||||
@@ -260,7 +259,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
private recentlyEditedTracker: RecentlyEditedTracker
|
||||
private debounceTimer: NodeJS.Timeout | null = null
|
||||
private isFirstCall: boolean = true
|
||||
private ignoreController?: Promise<RooIgnoreController>
|
||||
private ignoreController?: Promise<FileIgnoreController>
|
||||
private acceptedCommand: vscode.Disposable | null = null
|
||||
private debounceDelayMs: number = INITIAL_DEBOUNCE_DELAY_MS
|
||||
private latencyHistory: number[] = []
|
||||
@@ -273,7 +272,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
model: AutocompleteModel,
|
||||
costTrackingCallback: CostTrackingCallback,
|
||||
getSettings: () => AutocompleteServiceSettings | null,
|
||||
cline: ClineProvider,
|
||||
workspacePath: string,
|
||||
telemetry: AutocompleteTelemetry | null = null,
|
||||
) {
|
||||
this.telemetry = telemetry
|
||||
@@ -283,7 +282,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
|
||||
// Create ignore controller internally
|
||||
this.ignoreController = (async () => {
|
||||
const ignoreController = new RooIgnoreController(cline.cwd)
|
||||
const ignoreController = new FileIgnoreController()
|
||||
await ignoreController.initialize()
|
||||
return ignoreController
|
||||
})()
|
||||
@@ -495,8 +494,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
if (!isAccessible) {
|
||||
return []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[AutocompleteInlineCompletionProvider] Error checking file access:", error)
|
||||
} catch {
|
||||
// On error, assume file is ignored
|
||||
return []
|
||||
}
|
||||
@@ -545,10 +543,9 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
}
|
||||
|
||||
return stringToInlineCompletions(cachedResult?.text ?? "", position)
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// only big catch at the top of the call-chain, if anything goes wrong at a lower level
|
||||
// do not catch, just let the error cascade
|
||||
console.error("[AutocompleteInlineCompletionProvider] Error providing inline completion:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -706,7 +703,6 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
},
|
||||
telemetryContext,
|
||||
)
|
||||
console.error("Error getting inline completion from LLM:", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
-9
@@ -1,7 +1,19 @@
|
||||
import { TelemetryService } from "@roo-code/telemetry"
|
||||
import { TelemetryEventName } from "@roo-code/types"
|
||||
import { TelemetryStub, type ITelemetryClient } from "../shims/TelemetryStub"
|
||||
import type { AutocompleteContext, CacheMatchType, FillInAtCursorSuggestion } from "../types"
|
||||
|
||||
export const TelemetryEventName = {
|
||||
AUTOCOMPLETE_SUGGESTION_REQUESTED: "Autocomplete Suggestion Requested",
|
||||
AUTOCOMPLETE_LLM_REQUEST_COMPLETED: "Autocomplete LLM Request Completed",
|
||||
AUTOCOMPLETE_LLM_REQUEST_FAILED: "Autocomplete LLM Request Failed",
|
||||
AUTOCOMPLETE_LLM_SUGGESTION_RETURNED: "Autocomplete LLM Suggestion Returned",
|
||||
AUTOCOMPLETE_SUGGESTION_CACHE_HIT: "Autocomplete Suggestion Cache Hit",
|
||||
AUTOCOMPLETE_ACCEPT_SUGGESTION: "Autocomplete Accept Suggestion",
|
||||
AUTOCOMPLETE_SUGGESTION_FILTERED: "Autocomplete Suggestion Filtered",
|
||||
AUTOCOMPLETE_UNIQUE_SUGGESTION_SHOWN: "Autocomplete Unique Suggestion Shown",
|
||||
INLINE_ASSIST_AUTO_TASK: "Inline Assist Auto Task",
|
||||
GHOST_SERVICE_DISABLED: "Ghost Service Disabled",
|
||||
} as const
|
||||
|
||||
export type { AutocompleteContext, CacheMatchType, FillInAtCursorSuggestion }
|
||||
|
||||
/**
|
||||
@@ -83,14 +95,17 @@ export class AutocompleteTelemetry {
|
||||
this.autocompleteType = autocompleteType
|
||||
}
|
||||
|
||||
private captureEvent(event: TelemetryEventName, properties?: Record<string, unknown>): void {
|
||||
if (TelemetryService.hasInstance()) {
|
||||
const propsWithType = {
|
||||
...properties,
|
||||
autocompleteType: this.autocompleteType,
|
||||
}
|
||||
TelemetryService.instance.captureEvent(event, propsWithType)
|
||||
private telemetryClient: ITelemetryClient = new TelemetryStub()
|
||||
|
||||
private captureEvent(
|
||||
event: (typeof TelemetryEventName)[keyof typeof TelemetryEventName],
|
||||
properties?: Record<string, unknown>,
|
||||
): void {
|
||||
const propsWithType = {
|
||||
...properties,
|
||||
autocompleteType: this.autocompleteType,
|
||||
}
|
||||
this.telemetryClient.captureEvent(event, propsWithType)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,8 +7,7 @@ import {
|
||||
} from "../types"
|
||||
import { getProcessedSnippets } from "./getProcessedSnippets"
|
||||
import { formatSnippets } from "../continuedev/core/autocomplete/templating/formatting"
|
||||
import { AutocompleteModel } from "../AutocompleteModel"
|
||||
import { ApiStreamChunk } from "../../../api/transform/stream"
|
||||
import { AutocompleteModel, ApiStreamChunk } from "../AutocompleteModel"
|
||||
|
||||
export type { HoleFillerAutocompletePrompt, FillInAtCursorSuggestion, ChatCompletionResult }
|
||||
|
||||
@@ -189,29 +188,23 @@ Return the COMPLETION tags`
|
||||
): Promise<ChatCompletionResult> {
|
||||
const { systemPrompt, userPrompt } = prompt
|
||||
let response = ""
|
||||
let chunkCount = 0
|
||||
|
||||
const onChunk = (chunk: ApiStreamChunk) => {
|
||||
if (chunk.type === "text") {
|
||||
response += chunk.text
|
||||
chunkCount++
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[HoleFiller] userPrompt:", userPrompt)
|
||||
|
||||
const usageInfo = await model.generateResponse(systemPrompt, userPrompt, onChunk)
|
||||
|
||||
console.log("response", response)
|
||||
|
||||
// Extract just the text from the response - prefix/suffix are handled by the caller
|
||||
const completionMatch = response.match(/<COMPLETION>([\s\S]*?)<\/COMPLETION>/i)
|
||||
const suggestionText = completionMatch ? (completionMatch[1] || "").replace(/<\/?COMPLETION>/gi, "") : ""
|
||||
|
||||
const fillInAtCursorSuggestion = processSuggestion(suggestionText)
|
||||
|
||||
if (fillInAtCursorSuggestion.text) {
|
||||
console.info("Final suggestion:", fillInAtCursorSuggestion)
|
||||
}
|
||||
|
||||
return {
|
||||
suggestion: fillInAtCursorSuggestion,
|
||||
cost: usageInfo.cost,
|
||||
|
||||
+3
-3
@@ -8,7 +8,7 @@ import { getDefinitionsFromLsp } from "../continuedev/core/vscode-test-harness/s
|
||||
import { DEFAULT_AUTOCOMPLETE_OPTS } from "../continuedev/core/util/parameters"
|
||||
import { getSnippets } from "../continuedev/core/autocomplete/templating/filtering"
|
||||
import { AutocompleteModel } from "../AutocompleteModel"
|
||||
import { RooIgnoreController } from "../../../core/ignore/RooIgnoreController"
|
||||
import { FileIgnoreController } from "../shims/FileIgnoreController"
|
||||
import { AutocompleteSnippet, AutocompleteSnippetType } from "../continuedev/core/autocomplete/types"
|
||||
|
||||
function uriToFsPath(filepath: string): string {
|
||||
@@ -24,7 +24,7 @@ function hasFilepath(snippet: AutocompleteSnippet): snippet is AutocompleteSnipp
|
||||
|
||||
async function filterSnippetsByAccess(
|
||||
snippets: AutocompleteSnippet[],
|
||||
ignoreController?: Promise<RooIgnoreController>,
|
||||
ignoreController?: Promise<FileIgnoreController>,
|
||||
): Promise<AutocompleteSnippet[]> {
|
||||
if (!ignoreController) {
|
||||
return snippets
|
||||
@@ -70,7 +70,7 @@ export async function getProcessedSnippets(
|
||||
contextService: ContextRetrievalService,
|
||||
model: AutocompleteModel,
|
||||
ide: VsCodeIde,
|
||||
ignoreController?: Promise<RooIgnoreController>,
|
||||
ignoreController?: Promise<FileIgnoreController>,
|
||||
): Promise<{
|
||||
filepathUri: string
|
||||
helper: any
|
||||
|
||||
@@ -10,9 +10,12 @@
|
||||
|
||||
import * as vscode from "vscode"
|
||||
|
||||
import { toRelativePath } from "../../../utils/path"
|
||||
import { isSecurityConcern } from "../continuedev/core/indexing/ignore"
|
||||
import type { RooIgnoreController } from "../../../core/ignore/RooIgnoreController"
|
||||
import type { FileIgnoreController } from "../shims/FileIgnoreController"
|
||||
|
||||
function toRelativePath(absolutePath: string, workspacePath: string): string {
|
||||
return vscode.workspace.asRelativePath(absolutePath, false) || absolutePath.replace(workspacePath + "/", "")
|
||||
}
|
||||
|
||||
import { VisibleCodeContext, VisibleEditorInfo, VisibleRange, DiffInfo } from "../types"
|
||||
|
||||
@@ -24,7 +27,7 @@ export class VisibleCodeTracker {
|
||||
|
||||
constructor(
|
||||
private workspacePath: string,
|
||||
private rooIgnoreController: RooIgnoreController | null = null,
|
||||
private ignoreController: FileIgnoreController | null = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -56,7 +59,7 @@ export class VisibleCodeTracker {
|
||||
console.log(`[VisibleCodeTracker] Filtered (security): ${relativePath}`)
|
||||
continue
|
||||
}
|
||||
if (this.rooIgnoreController && !this.rooIgnoreController.validateAccess(relativePath)) {
|
||||
if (this.ignoreController && !this.ignoreController.validateAccess(relativePath)) {
|
||||
console.log(`[VisibleCodeTracker] Filtered (.kilocodeignore): ${relativePath}`)
|
||||
continue
|
||||
}
|
||||
|
||||
-5
@@ -1,6 +1,5 @@
|
||||
import { MinimalConfigProvider } from "./MinimalConfig.js"
|
||||
import { IDE, ILLM } from "../index.js"
|
||||
import { OpenAI } from "../llm/llms/OpenAI.js"
|
||||
import { DEFAULT_AUTOCOMPLETE_OPTS } from "../util/parameters.js"
|
||||
import { shouldCompleteMultiline } from "./classification/shouldCompleteMultiline.js"
|
||||
import { ContextRetrievalService } from "./context/ContextRetrievalService.js"
|
||||
@@ -69,10 +68,6 @@ export class CompletionProvider {
|
||||
llm.completionOptions.temperature = 0.01
|
||||
}
|
||||
|
||||
if (llm instanceof OpenAI) {
|
||||
llm.useLegacyCompletionsEndpoint = true
|
||||
}
|
||||
|
||||
return llm
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -1,7 +1,10 @@
|
||||
import { createHash } from "crypto"
|
||||
|
||||
import { LRUCache } from "lru-cache"
|
||||
import { Node as SyntaxNode, Query, Point } from "web-tree-sitter"
|
||||
import type Parser from "web-tree-sitter"
|
||||
type SyntaxNode = Parser.SyntaxNode
|
||||
type Query = Parser.Query
|
||||
type Point = Parser.Point
|
||||
|
||||
import { IDE } from "../../.."
|
||||
import { getFullLanguageName, getQueryForFile, IGNORE_PATH_PATTERNS, LanguageName } from "../../../util/treeSitter"
|
||||
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
import * as fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Node as SyntaxNode } from "web-tree-sitter"
|
||||
import type Parser from "web-tree-sitter"
|
||||
type SyntaxNode = Parser.SyntaxNode
|
||||
import { IDE, Position } from "../../.."
|
||||
import { localPathOrUriToPath } from "../../../util/pathToUri"
|
||||
import { getFullLanguageName, getQueryForFile } from "../../../util/treeSitter"
|
||||
|
||||
+4
-1
@@ -1,5 +1,8 @@
|
||||
import * as fs from "fs/promises"
|
||||
import { Node as SyntaxNode, QueryMatch, Tree } from "web-tree-sitter"
|
||||
import type Parser from "web-tree-sitter"
|
||||
type SyntaxNode = Parser.SyntaxNode
|
||||
type QueryMatch = Parser.QueryMatch
|
||||
type Tree = Parser.Tree
|
||||
import { getFullLanguageName, getQueryForFile } from "../../../util/treeSitter"
|
||||
import { getAst } from "../../util/ast"
|
||||
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
import { Tree } from "web-tree-sitter"
|
||||
import type Parser from "web-tree-sitter"
|
||||
type Tree = Parser.Tree
|
||||
import { Range } from "../../.."
|
||||
|
||||
export interface TypeSpanAndSourceFile {
|
||||
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
import { Node as SyntaxNode, Tree } from "web-tree-sitter"
|
||||
import type Parser from "web-tree-sitter"
|
||||
type SyntaxNode = Parser.SyntaxNode
|
||||
type Tree = Parser.Tree
|
||||
|
||||
import { getParserForFile } from "../../util/treeSitter"
|
||||
|
||||
|
||||
+1
@@ -1,3 +1,4 @@
|
||||
// @ts-expect-error quick-lru is ESM-only; esbuild handles the import at build time
|
||||
import QuickLRU from "quick-lru"
|
||||
|
||||
// maximum number of open files that can be cached
|
||||
|
||||
+12
-2
@@ -4,6 +4,16 @@ import Parser from "web-tree-sitter"
|
||||
export type ModelRole = "chat" | "edit" | "autocomplete" | "apply" | "repoMapFileSelection"
|
||||
export type PromptTemplates = Record<string, any>
|
||||
|
||||
// Stub types referenced but not defined in upstream continuedev
|
||||
export type MockMessage = ChatMessage
|
||||
export interface IFimProvider {
|
||||
streamFim(prefix: string, suffix: string, signal: AbortSignal): AsyncGenerator<string>
|
||||
}
|
||||
export interface ParameterInformation {
|
||||
label: string | [number, number]
|
||||
documentation?: string
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
ide?: "vscode"
|
||||
@@ -311,13 +321,13 @@ export interface CustomLLMWithOptionals {
|
||||
prompt: string,
|
||||
signal: AbortSignal,
|
||||
options: CompletionOptions,
|
||||
fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>,
|
||||
fetch: (input: string | URL, init?: RequestInit) => Promise<Response>,
|
||||
) => AsyncGenerator<string>
|
||||
streamChat?: (
|
||||
messages: ChatMessage[],
|
||||
signal: AbortSignal,
|
||||
options: CompletionOptions,
|
||||
fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>,
|
||||
fetch: (input: string | URL, init?: RequestInit) => Promise<Response>,
|
||||
) => AsyncGenerator<ChatMessage | string>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-expect-error js-tiktoken is ESM-only; esbuild handles the import at build time
|
||||
import { Tiktoken, encodingForModel as _encodingForModel } from "js-tiktoken"
|
||||
import { ChatMessage, CompiledMessagesResult, MessageContent } from "../index.js"
|
||||
import { addSpaceToAnyEmptyMessages, chatMessageIsEmpty } from "./messages.js"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { findLlmInfo } from "./model-info"
|
||||
import { BaseLlmApi, constructLlmApi } from "./openai-adapters"
|
||||
import { ChatCompletionCreateParams } from "openai/resources/index"
|
||||
|
||||
import {
|
||||
@@ -93,7 +92,7 @@ export abstract class BaseLLM implements ILLM {
|
||||
|
||||
private _llmOptions: LLMOptions
|
||||
|
||||
protected openaiAdapter?: BaseLlmApi
|
||||
protected openaiAdapter?: any
|
||||
|
||||
constructor(_options: LLMOptions) {
|
||||
this._llmOptions = _options
|
||||
@@ -137,26 +136,13 @@ export abstract class BaseLLM implements ILLM {
|
||||
}
|
||||
this.capabilities = options.capabilities
|
||||
|
||||
this.openaiAdapter = this.createOpenAiAdapter()
|
||||
|
||||
this.autocompleteOptions = options.autocompleteOptions
|
||||
|
||||
// openaiAdapter is initialized above
|
||||
}
|
||||
|
||||
get contextLength() {
|
||||
return this._contextLength ?? DEFAULT_CONTEXT_LENGTH
|
||||
}
|
||||
|
||||
protected createOpenAiAdapter() {
|
||||
return constructLlmApi({
|
||||
provider: this.providerName as any,
|
||||
apiKey: this.apiKey ?? "",
|
||||
apiBase: this.apiBase,
|
||||
env: this._llmOptions.env,
|
||||
})
|
||||
}
|
||||
|
||||
private _templatePromptLikeMessages(prompt: string): string {
|
||||
if (!this.templateMessages) {
|
||||
return prompt
|
||||
@@ -629,7 +615,9 @@ export abstract class BaseLLM implements ILLM {
|
||||
|
||||
// Standard OpenAI format
|
||||
if (results.data && Array.isArray(results.data)) {
|
||||
return results.data.sort((a, b) => a.index - b.index).map((result) => result.relevance_score)
|
||||
return results.data
|
||||
.sort((a: { index: number }, b: { index: number }) => a.index - b.index)
|
||||
.map((result: { relevance_score: number }) => result.relevance_score)
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import { ChatMessage, CompletionOptions, LLMOptions } from "../../index.js"
|
||||
import { X_KILOCODE_VERSION } from "../../../../../../shared/kilocode/headers"
|
||||
import { Package } from "../../../../../../shared/package"
|
||||
import OpenRouter from "./OpenRouter"
|
||||
import { IFimProvider } from "../../../../../../api/providers/kilocode/IFimProvider"
|
||||
import { getKiloUrlFromToken } from "@roo-code/types"
|
||||
|
||||
/**
|
||||
* Extended CompletionOptions to include KiloCode-specific per-request metadata
|
||||
*/
|
||||
export interface KiloCodeCompletionOptions extends CompletionOptions {
|
||||
kilocodeTaskId?: string
|
||||
kilocodeProjectId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* KiloCode LLM provider that extends OpenRouter with KiloCode-specific features:
|
||||
* - Custom base URL using getKiloUrlFromToken()
|
||||
* - KiloCode-specific headers (organizationId, taskId, projectId, version, tester)
|
||||
* - Support for both static (organizationId) and per-request (taskId, projectId) metadata
|
||||
*
|
||||
* This provider maintains API parity with the kilocode-openrouter API provider
|
||||
* while working within the continuedev LLM architecture.
|
||||
*/
|
||||
class KiloCode extends OpenRouter {
|
||||
static override providerName = "kilocode"
|
||||
|
||||
// Instance variables to store per-request metadata
|
||||
private currentTaskId?: string
|
||||
private currentProjectId?: string
|
||||
public fimProvider?: IFimProvider
|
||||
|
||||
constructor(options: LLMOptions) {
|
||||
// Extract KiloCode-specific config from env
|
||||
const kilocodeToken = options.apiKey ?? ""
|
||||
|
||||
// Extract fimProvider before passing to parent
|
||||
const { fimProvider, ...parentOptions } = options
|
||||
|
||||
// Transform apiBase to use KiloCode backend
|
||||
const transformedOptions = {
|
||||
...parentOptions,
|
||||
apiBase: getKiloUrlFromToken("https://api.kilo.ai/api/openrouter/v1/", kilocodeToken),
|
||||
}
|
||||
|
||||
super(transformedOptions)
|
||||
|
||||
// Use provided handler or create a new one if not provided
|
||||
this.fimProvider = fimProvider
|
||||
}
|
||||
|
||||
/**
|
||||
* Override _streamChat to extract per-request metadata from options
|
||||
* This allows dynamic taskId and projectId per request
|
||||
*/
|
||||
protected override async *_streamChat(
|
||||
messages: ChatMessage[],
|
||||
signal: AbortSignal,
|
||||
options: CompletionOptions,
|
||||
): AsyncGenerator<ChatMessage> {
|
||||
// Extract KiloCode metadata from options if available
|
||||
const kilocodeOptions = options as KiloCodeCompletionOptions
|
||||
this.currentTaskId = kilocodeOptions.kilocodeTaskId
|
||||
this.currentProjectId = kilocodeOptions.kilocodeProjectId
|
||||
|
||||
try {
|
||||
// Call parent implementation
|
||||
yield* super._streamChat(messages, signal, options)
|
||||
} finally {
|
||||
// Clear per-request metadata after stream completes
|
||||
this.currentTaskId = undefined
|
||||
this.currentProjectId = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override _streamComplete to support per-request metadata
|
||||
*/
|
||||
protected override async *_streamComplete(
|
||||
prompt: string,
|
||||
signal: AbortSignal,
|
||||
options: CompletionOptions,
|
||||
): AsyncGenerator<string> {
|
||||
// Extract metadata (same pattern as _streamChat)
|
||||
const kilocodeOptions = options as KiloCodeCompletionOptions
|
||||
this.currentTaskId = kilocodeOptions.kilocodeTaskId
|
||||
this.currentProjectId = kilocodeOptions.kilocodeProjectId
|
||||
|
||||
try {
|
||||
yield* super._streamComplete(prompt, signal, options)
|
||||
} finally {
|
||||
// Clear metadata
|
||||
this.currentTaskId = undefined
|
||||
this.currentProjectId = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override _streamFim to delegate to IFimProvider
|
||||
* This reuses the FIM implementation from the API handler
|
||||
*/
|
||||
protected override async *_streamFim(
|
||||
prefix: string,
|
||||
suffix: string,
|
||||
signal: AbortSignal,
|
||||
options: CompletionOptions,
|
||||
): AsyncGenerator<string> {
|
||||
if (!this.fimProvider) {
|
||||
throw new Error("FIM provider not initialized")
|
||||
}
|
||||
|
||||
// Extract metadata (same pattern as _streamChat)
|
||||
const kilocodeOptions = options as KiloCodeCompletionOptions
|
||||
this.currentTaskId = kilocodeOptions.kilocodeTaskId
|
||||
this.currentProjectId = kilocodeOptions.kilocodeProjectId
|
||||
|
||||
try {
|
||||
// Delegate to FIM provider's streamFim method
|
||||
yield* this.fimProvider.streamFim(prefix, suffix, this.currentTaskId)
|
||||
} finally {
|
||||
// Clear metadata
|
||||
this.currentTaskId = undefined
|
||||
this.currentProjectId = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override _getHeaders to inject KiloCode-specific headers
|
||||
* Delegates to FIM provider's customRequestOptions() for consistency
|
||||
*/
|
||||
protected override _getHeaders() {
|
||||
const baseHeaders = super._getHeaders()
|
||||
|
||||
// Always add version header
|
||||
const kilocodeHeaders: Record<string, string> = {
|
||||
[X_KILOCODE_VERSION]: Package.version,
|
||||
}
|
||||
|
||||
// Delegate to FIM provider's customRequestOptions for other KiloCode headers
|
||||
// Only call if we have a taskId (required by the metadata interface)
|
||||
if (this.fimProvider && this.currentTaskId) {
|
||||
const customOptions = this.fimProvider.customRequestOptions({
|
||||
taskId: this.currentTaskId,
|
||||
projectId: this.currentProjectId,
|
||||
mode: "code", // Default mode for LLM operations
|
||||
})
|
||||
|
||||
if (customOptions?.headers) {
|
||||
Object.assign(kilocodeHeaders, customOptions.headers)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...baseHeaders,
|
||||
...kilocodeHeaders,
|
||||
}
|
||||
}
|
||||
|
||||
override supportsFim(): boolean {
|
||||
return this.fimProvider?.supportsFim() || false
|
||||
}
|
||||
}
|
||||
|
||||
export default KiloCode
|
||||
@@ -1,78 +0,0 @@
|
||||
import { ChatMessage, LLMOptions } from "../../index.js"
|
||||
|
||||
import { OpenAI } from "./OpenAI"
|
||||
|
||||
type MistralApiKeyType = "mistral" | "codestral"
|
||||
|
||||
class Mistral extends OpenAI {
|
||||
static override providerName = "mistral"
|
||||
static defaultOptions: Partial<LLMOptions> = {
|
||||
apiBase: "https://api.mistral.ai/v1/",
|
||||
model: "codestral-latest",
|
||||
}
|
||||
|
||||
private async autodetectApiKeyType(): Promise<MistralApiKeyType> {
|
||||
const mistralResp = await fetch("https://api.mistral.ai/v1/models", {
|
||||
method: "GET",
|
||||
headers: this._getHeaders(),
|
||||
})
|
||||
if (mistralResp.status === 401) {
|
||||
return "codestral"
|
||||
}
|
||||
return "mistral"
|
||||
}
|
||||
|
||||
constructor(options: LLMOptions) {
|
||||
super(options)
|
||||
if (options.model.includes("codestral") && !options.model.includes("mamba")) {
|
||||
this.apiBase = options.apiBase ?? "https://codestral.mistral.ai/v1/"
|
||||
}
|
||||
|
||||
if (!this.apiBase?.endsWith("/")) {
|
||||
this.apiBase += "/"
|
||||
}
|
||||
|
||||
// Unless the user explicitly specifies, we will autodetect the API key type and adjust the API base accordingly
|
||||
if (!options.apiBase) {
|
||||
this.autodetectApiKeyType()
|
||||
.then((keyType) => {
|
||||
switch (keyType) {
|
||||
case "codestral":
|
||||
this.apiBase = "https://codestral.mistral.ai/v1/"
|
||||
break
|
||||
case "mistral":
|
||||
this.apiBase = "https://api.mistral.ai/v1/"
|
||||
break
|
||||
}
|
||||
|
||||
this.openaiAdapter = this.createOpenAiAdapter()
|
||||
})
|
||||
.catch((err: any) => {})
|
||||
}
|
||||
}
|
||||
|
||||
private static modelConversion: { [key: string]: string } = {
|
||||
"mistral-7b": "open-mistral-7b",
|
||||
"mistral-8x7b": "open-mixtral-8x7b",
|
||||
}
|
||||
protected override _convertModelName(model: string): string {
|
||||
return Mistral.modelConversion[model] ?? model
|
||||
}
|
||||
|
||||
override _convertArgs(options: any, messages: ChatMessage[]) {
|
||||
const finalOptions = super._convertArgs(options, messages)
|
||||
|
||||
const lastMessage = finalOptions.messages[finalOptions.messages.length - 1]
|
||||
if (lastMessage?.role === "assistant") {
|
||||
;(lastMessage as any).prefix = true
|
||||
}
|
||||
|
||||
return finalOptions
|
||||
}
|
||||
|
||||
override supportsFim(): boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export default Mistral
|
||||
@@ -1,273 +0,0 @@
|
||||
import { ChatCompletionCreateParams, ChatCompletionMessageParam } from "openai/resources/index"
|
||||
|
||||
import { streamSse } from "../../fetch/stream.js"
|
||||
import { ChatMessage, CompletionOptions, LLMOptions } from "../../index.js"
|
||||
import { renderChatMessage } from "../../util/messageContent.js"
|
||||
import { BaseLLM } from "../index.js"
|
||||
import { fromChatCompletionChunk, LlmApiRequestType, toChatBody } from "../openaiTypeConverters.js"
|
||||
|
||||
const NON_CHAT_MODELS = [
|
||||
"text-davinci-002",
|
||||
"text-davinci-003",
|
||||
"code-davinci-002",
|
||||
"text-ada-001",
|
||||
"text-babbage-001",
|
||||
"text-curie-001",
|
||||
"davinci",
|
||||
"curie",
|
||||
"babbage",
|
||||
"ada",
|
||||
]
|
||||
|
||||
function isChatOnlyModel(model: string): boolean {
|
||||
// gpt and o-series models
|
||||
return model.startsWith("gpt") || model.startsWith("o")
|
||||
}
|
||||
|
||||
const formatMessageForO1OrGpt5 = (messages: ChatCompletionMessageParam[]) => {
|
||||
return messages?.map((message: any) => {
|
||||
if (message?.role === "system") {
|
||||
return {
|
||||
...message,
|
||||
role: "developer",
|
||||
}
|
||||
}
|
||||
|
||||
return message
|
||||
})
|
||||
}
|
||||
|
||||
export class OpenAI extends BaseLLM {
|
||||
public useLegacyCompletionsEndpoint: boolean | undefined = undefined
|
||||
|
||||
constructor(options: LLMOptions) {
|
||||
super({
|
||||
apiBase: "https://api.openai.com/v1/",
|
||||
...options,
|
||||
})
|
||||
this.useLegacyCompletionsEndpoint = options.useLegacyCompletionsEndpoint
|
||||
// Azure apiVersion removed from narrowed LLMOptions; not used
|
||||
}
|
||||
|
||||
static override providerName = "openai"
|
||||
|
||||
protected override useOpenAIAdapterFor: (LlmApiRequestType | "*")[] = ["chat", "rerank", "streamChat", "streamFim"]
|
||||
|
||||
protected _convertModelName(model: string): string {
|
||||
return model
|
||||
}
|
||||
|
||||
public isOSeriesOrGpt5Model(model?: string): boolean {
|
||||
return !!model && (!!model.match(/^o[0-9]+/) || model.includes("gpt-5"))
|
||||
}
|
||||
|
||||
protected extraBodyProperties(): Record<string, any> {
|
||||
return {}
|
||||
}
|
||||
|
||||
protected getMaxStopWords(): number {
|
||||
const url = new URL(this.apiBase!)
|
||||
|
||||
if (this.maxStopWords !== undefined) {
|
||||
return this.maxStopWords
|
||||
} else if (url.host === "api.deepseek.com") {
|
||||
return 16
|
||||
} else if (url.port === "1337" || url.host === "api.openai.com" || url.host === "api.groq.com") {
|
||||
return 4
|
||||
} else {
|
||||
return Infinity
|
||||
}
|
||||
}
|
||||
|
||||
protected _convertArgs(options: CompletionOptions, messages: ChatMessage[]): ChatCompletionCreateParams {
|
||||
const finalOptions = toChatBody(messages, options)
|
||||
|
||||
finalOptions.stop = options.stop?.slice(0, this.getMaxStopWords())
|
||||
|
||||
// OpenAI o1-preview and o1-mini or o3-mini:
|
||||
if (this.isOSeriesOrGpt5Model(options.model)) {
|
||||
// a) use max_completion_tokens instead of max_tokens
|
||||
finalOptions.max_completion_tokens = options.maxTokens
|
||||
finalOptions.max_tokens = undefined
|
||||
|
||||
// b) don't support system message
|
||||
finalOptions.messages = formatMessageForO1OrGpt5(finalOptions.messages)
|
||||
}
|
||||
|
||||
if (options.model === "o1") {
|
||||
finalOptions.stream = false
|
||||
}
|
||||
|
||||
return finalOptions
|
||||
}
|
||||
|
||||
protected _getHeaders() {
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
...(this.apiKey && { Authorization: `Bearer ${this.apiKey}` }),
|
||||
"api-key": this.apiKey ?? "", // For Azure
|
||||
}
|
||||
}
|
||||
|
||||
protected override async _complete(prompt: string, signal: AbortSignal, options: CompletionOptions): Promise<string> {
|
||||
let completion = ""
|
||||
for await (const chunk of this._streamChat([{ role: "user", content: prompt }], signal, options)) {
|
||||
completion += chunk.content
|
||||
}
|
||||
|
||||
return completion
|
||||
}
|
||||
|
||||
protected _getEndpoint(endpoint: "chat/completions" | "completions" | "models") {
|
||||
if (!this.apiBase) {
|
||||
throw new Error("No API base URL provided. Please set the 'apiBase' option in config.json")
|
||||
}
|
||||
return new URL(endpoint, this.apiBase)
|
||||
}
|
||||
|
||||
protected override async *_streamComplete(
|
||||
prompt: string,
|
||||
signal: AbortSignal,
|
||||
options: CompletionOptions,
|
||||
): AsyncGenerator<string> {
|
||||
for await (const chunk of this._streamChat([{ role: "user", content: prompt }], signal, options)) {
|
||||
yield renderChatMessage(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
protected override modifyChatBody(body: ChatCompletionCreateParams): ChatCompletionCreateParams {
|
||||
body.stop = body.stop?.slice(0, this.getMaxStopWords())
|
||||
|
||||
// OpenAI o1-preview and o1-mini or o3-mini:
|
||||
if (this.isOSeriesOrGpt5Model(body.model)) {
|
||||
// a) use max_completion_tokens instead of max_tokens
|
||||
body.max_completion_tokens = body.max_tokens
|
||||
body.max_tokens = undefined
|
||||
|
||||
// b) don't support system message
|
||||
body.messages = formatMessageForO1OrGpt5(body.messages)
|
||||
}
|
||||
|
||||
if (body.model === "o1") {
|
||||
// o1 doesn't support streaming
|
||||
body.stream = false
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
protected async *_legacystreamComplete(
|
||||
prompt: string,
|
||||
signal: AbortSignal,
|
||||
options: CompletionOptions,
|
||||
): AsyncGenerator<string> {
|
||||
const args: any = this._convertArgs(options, [])
|
||||
args.prompt = prompt
|
||||
args.messages = undefined
|
||||
|
||||
const response = await fetch(this._getEndpoint("completions"), {
|
||||
method: "POST",
|
||||
headers: this._getHeaders(),
|
||||
body: JSON.stringify({
|
||||
...args,
|
||||
stream: true,
|
||||
...this.extraBodyProperties(),
|
||||
}),
|
||||
signal,
|
||||
})
|
||||
|
||||
for await (const value of streamSse(response)) {
|
||||
if (value.choices?.[0]?.text && value.finish_reason !== "eos") {
|
||||
yield value.choices[0].text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override async *_streamChat(
|
||||
messages: ChatMessage[],
|
||||
signal: AbortSignal,
|
||||
options: CompletionOptions,
|
||||
): AsyncGenerator<ChatMessage> {
|
||||
if (
|
||||
!isChatOnlyModel(options.model) &&
|
||||
this.supportsCompletions() &&
|
||||
(NON_CHAT_MODELS.includes(options.model) || this.useLegacyCompletionsEndpoint || options.raw)
|
||||
) {
|
||||
for await (const content of this._legacystreamComplete(
|
||||
renderChatMessage(messages[messages.length - 1]),
|
||||
signal,
|
||||
options,
|
||||
)) {
|
||||
yield {
|
||||
role: "assistant",
|
||||
content,
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const body = this._convertArgs(options, messages)
|
||||
|
||||
const response = await fetch(this._getEndpoint("chat/completions"), {
|
||||
method: "POST",
|
||||
headers: this._getHeaders(),
|
||||
body: JSON.stringify({
|
||||
...body,
|
||||
...this.extraBodyProperties(),
|
||||
}),
|
||||
signal,
|
||||
})
|
||||
|
||||
// Handle non-streaming response
|
||||
if (body.stream === false) {
|
||||
if (response.status === 499) {
|
||||
return // Aborted by user
|
||||
}
|
||||
const data = await response.json()
|
||||
yield data.choices[0].message
|
||||
return
|
||||
}
|
||||
|
||||
for await (const value of streamSse(response)) {
|
||||
const chunk = fromChatCompletionChunk(value)
|
||||
if (chunk) {
|
||||
yield chunk
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override async *_streamFim(
|
||||
prefix: string,
|
||||
suffix: string,
|
||||
signal: AbortSignal,
|
||||
options: CompletionOptions,
|
||||
): AsyncGenerator<string> {
|
||||
const endpoint = new URL("fim/completions", this.apiBase)
|
||||
const resp = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
model: options.model,
|
||||
prompt: prefix,
|
||||
suffix,
|
||||
max_tokens: options.maxTokens,
|
||||
temperature: options.temperature,
|
||||
top_p: options.topP,
|
||||
frequency_penalty: options.frequencyPenalty,
|
||||
presence_penalty: options.presencePenalty,
|
||||
stop: options.stop,
|
||||
stream: true,
|
||||
...this.extraBodyProperties(),
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"x-api-key": this.apiKey ?? "",
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
for await (const chunk of streamSse(resp)) {
|
||||
yield chunk.choices[0].delta.content
|
||||
}
|
||||
}
|
||||
}
|
||||
-141
@@ -1,141 +0,0 @@
|
||||
import { ChatCompletionCreateParams } from "openai/resources/index"
|
||||
import { LLMOptions } from "../../index.js"
|
||||
import { osModelsEditPrompt } from "../templates/edit.js"
|
||||
import { OpenAI } from "./OpenAI"
|
||||
|
||||
class OpenRouter extends OpenAI {
|
||||
static override providerName = "openrouter"
|
||||
protected supportsReasoningField = true
|
||||
protected supportsReasoningDetailsField = true
|
||||
static defaultOptions: Partial<LLMOptions> = {
|
||||
apiBase: "https://openrouter.ai/api/v1/",
|
||||
model: "gpt-4o-mini",
|
||||
promptTemplates: {
|
||||
edit: osModelsEditPrompt,
|
||||
},
|
||||
useLegacyCompletionsEndpoint: false,
|
||||
}
|
||||
|
||||
protected override extraBodyProperties(): Record<string, any> {
|
||||
if (this.completionOptions.reasoning === false) {
|
||||
return {
|
||||
reasoning: {
|
||||
effort: "minimal",
|
||||
},
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if the model is an Anthropic/Claude model
|
||||
*/
|
||||
private isAnthropicModel(model?: string): boolean {
|
||||
if (!model) return false
|
||||
const modelLower = model.toLowerCase()
|
||||
return modelLower.includes("claude")
|
||||
}
|
||||
|
||||
/**
|
||||
* Add cache_control to message content for Anthropic models
|
||||
*/
|
||||
private addCacheControlToContent(content: any, addCaching: boolean): any {
|
||||
if (!addCaching) return content
|
||||
|
||||
if (typeof content === "string") {
|
||||
return [
|
||||
{
|
||||
type: "text",
|
||||
text: content,
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
// For array content, add cache_control to the last text item
|
||||
return content.map((part, idx) => {
|
||||
if (part.type === "text" && idx === content.length - 1) {
|
||||
return {
|
||||
...part,
|
||||
cache_control: { type: "ephemeral" },
|
||||
}
|
||||
}
|
||||
return part
|
||||
})
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
/**
|
||||
* Override modifyChatBody to add Anthropic caching when appropriate
|
||||
*/
|
||||
protected override modifyChatBody(body: ChatCompletionCreateParams): ChatCompletionCreateParams {
|
||||
// First apply parent modifications
|
||||
body = super.modifyChatBody(body)
|
||||
|
||||
// Check if we should apply Anthropic caching
|
||||
if (!this.isAnthropicModel(body.model) || (!this.cacheBehavior && !this.completionOptions.promptCaching)) {
|
||||
return body
|
||||
}
|
||||
|
||||
const shouldCacheConversation = this.cacheBehavior?.cacheConversation || this.completionOptions.promptCaching
|
||||
const shouldCacheSystemMessage = this.cacheBehavior?.cacheSystemMessage || this.completionOptions.promptCaching
|
||||
|
||||
if (!shouldCacheConversation && !shouldCacheSystemMessage) {
|
||||
return body
|
||||
}
|
||||
|
||||
// Follow the same logic as Anthropic.ts: filter out system messages first
|
||||
const filteredMessages = body.messages.filter((m: any) => m.role !== "system" && !!m.content)
|
||||
|
||||
// Find the last two user message indices from the filtered array
|
||||
const lastTwoUserMsgIndices = filteredMessages
|
||||
.map((msg: any, index: number) => (msg.role === "user" ? index : -1))
|
||||
.filter((index: number) => index !== -1)
|
||||
.slice(-2)
|
||||
|
||||
// Create a mapping from filtered indices to original indices
|
||||
let filteredIndex = 0
|
||||
const filteredToOriginalIndexMap: number[] = []
|
||||
body.messages.forEach((msg: any, originalIndex: number) => {
|
||||
if (msg.role !== "system" && !!msg.content) {
|
||||
filteredToOriginalIndexMap[filteredIndex] = originalIndex
|
||||
filteredIndex++
|
||||
}
|
||||
})
|
||||
|
||||
// Modify messages to add cache_control
|
||||
body.messages = body.messages.map((message: any, idx) => {
|
||||
// Handle system message caching
|
||||
if (message.role === "system" && shouldCacheSystemMessage) {
|
||||
return {
|
||||
...message,
|
||||
content: this.addCacheControlToContent(message.content, true),
|
||||
}
|
||||
}
|
||||
|
||||
// Handle conversation caching for last two user messages
|
||||
// Check if this message's index (in filtered array) is one of the last two user messages
|
||||
const filteredIdx = filteredToOriginalIndexMap.indexOf(idx)
|
||||
if (
|
||||
message.role === "user" &&
|
||||
shouldCacheConversation &&
|
||||
filteredIdx !== -1 &&
|
||||
lastTwoUserMsgIndices.includes(filteredIdx)
|
||||
) {
|
||||
return {
|
||||
...message,
|
||||
content: this.addCacheControlToContent(message.content, true),
|
||||
}
|
||||
}
|
||||
|
||||
return message
|
||||
})
|
||||
|
||||
return body
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenRouter
|
||||
-347
@@ -1,347 +0,0 @@
|
||||
import {
|
||||
ContentBlockParam,
|
||||
MessageCreateParams,
|
||||
MessageParam,
|
||||
RawContentBlockDeltaEvent,
|
||||
RawMessageDeltaEvent,
|
||||
RawMessageStartEvent,
|
||||
RawMessageStreamEvent,
|
||||
Tool,
|
||||
} from "@anthropic-ai/sdk/resources"
|
||||
import { streamSse } from "../../../fetch/stream.js"
|
||||
import { OpenAI } from "openai/index"
|
||||
import {
|
||||
ChatCompletion,
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionContentPartRefusal,
|
||||
ChatCompletionContentPartText,
|
||||
ChatCompletionCreateParamsNonStreaming,
|
||||
ChatCompletionCreateParamsStreaming,
|
||||
Completion,
|
||||
CompletionCreateParamsNonStreaming,
|
||||
CompletionCreateParamsStreaming,
|
||||
CompletionUsage,
|
||||
} from "openai/resources/index"
|
||||
import { ChatCompletionCreateParams } from "openai/resources/index.js"
|
||||
import { AnthropicConfig } from "../types.js"
|
||||
import { chatChunk, usageChatChunk } from "../util.js"
|
||||
import { EMPTY_CHAT_COMPLETION } from "../util/emptyChatCompletion.js"
|
||||
import { CACHING_STRATEGIES, CachingStrategyName } from "./AnthropicCachingStrategies.js"
|
||||
import {
|
||||
getAnthropicHeaders,
|
||||
getAnthropicMediaTypeFromDataUrl,
|
||||
openAiToolChoiceToAnthropicToolChoice,
|
||||
openaiToolToAnthropicTool,
|
||||
} from "./AnthropicUtils.js"
|
||||
import { BaseLlmApi, CreateRerankResponse, FimCreateParamsStreaming, RerankCreateParams } from "./base.js"
|
||||
|
||||
export class AnthropicApi implements BaseLlmApi {
|
||||
apiBase: string = "https://api.anthropic.com/v1/"
|
||||
|
||||
constructor(
|
||||
protected config: AnthropicConfig & {
|
||||
cachingStrategy?: CachingStrategyName
|
||||
},
|
||||
) {
|
||||
this.apiBase = config.apiBase ?? this.apiBase
|
||||
if (!this.apiBase.endsWith("/")) {
|
||||
this.apiBase += "/"
|
||||
}
|
||||
}
|
||||
|
||||
private _convertBody(oaiBody: ChatCompletionCreateParams) {
|
||||
// Step 1: Convert to clean Anthropic body (no caching)
|
||||
const cleanBody = this._convertToCleanAnthropicBody(oaiBody)
|
||||
|
||||
// Step 2: Apply caching strategy
|
||||
const cachingStrategy = CACHING_STRATEGIES[this.config.cachingStrategy ?? "systemAndTools"]
|
||||
return cachingStrategy(cleanBody)
|
||||
}
|
||||
|
||||
private maxTokensForModel(model: string): number {
|
||||
if (model.includes("haiku")) {
|
||||
return 8192
|
||||
}
|
||||
return 32_000
|
||||
}
|
||||
|
||||
public _convertToCleanAnthropicBody(oaiBody: ChatCompletionCreateParams): MessageCreateParams {
|
||||
let stop = undefined
|
||||
if (oaiBody.stop && Array.isArray(oaiBody.stop)) {
|
||||
stop = oaiBody.stop.filter((x) => x.trim() !== "")
|
||||
} else if (typeof oaiBody.stop === "string" && oaiBody.stop.trim() !== "") {
|
||||
stop = [oaiBody.stop]
|
||||
}
|
||||
|
||||
const systemMessage = oaiBody.messages.find((msg) => msg.role === "system")?.content
|
||||
|
||||
// TODO support custom tools
|
||||
const functionTools = oaiBody.tools?.filter((t) => t.type === "function")
|
||||
let tools: Tool[] | undefined = undefined
|
||||
|
||||
if (oaiBody.tool_choice !== "none" && functionTools && functionTools.length > 0) {
|
||||
if (typeof oaiBody.tool_choice !== "string" && oaiBody.tool_choice?.type === "allowed_tools") {
|
||||
const allowedToolNames = new Set(oaiBody.tool_choice?.allowed_tools.tools.map((tool) => tool["name"]) ?? [])
|
||||
const allowedTools = functionTools.filter((t) => allowedToolNames.has(t.function.name))
|
||||
tools = allowedTools.map(openaiToolToAnthropicTool)
|
||||
} else {
|
||||
tools = functionTools.map(openaiToolToAnthropicTool)
|
||||
}
|
||||
}
|
||||
|
||||
const anthropicBody: MessageCreateParams = {
|
||||
messages: this._convertMessages(oaiBody.messages.filter((msg) => msg.role !== "system")),
|
||||
system:
|
||||
typeof systemMessage === "string"
|
||||
? [
|
||||
{
|
||||
type: "text",
|
||||
text: systemMessage,
|
||||
},
|
||||
]
|
||||
: systemMessage,
|
||||
top_p: oaiBody.top_p ?? undefined,
|
||||
temperature: oaiBody.temperature ?? undefined,
|
||||
max_tokens: oaiBody.max_tokens ?? this.maxTokensForModel(oaiBody.model), // max_tokens is required
|
||||
model: oaiBody.model,
|
||||
stop_sequences: stop,
|
||||
stream: oaiBody.stream ?? undefined,
|
||||
tools,
|
||||
tool_choice: openAiToolChoiceToAnthropicToolChoice(oaiBody.tool_choice),
|
||||
}
|
||||
|
||||
return anthropicBody
|
||||
}
|
||||
|
||||
// 1. ignores empty content
|
||||
// 2. converts string content to text parts
|
||||
// 3. converts text and refusal parts to text blocks
|
||||
// 4. converts image parts to image blocks
|
||||
private convertMessageContentToBlocks(
|
||||
content:
|
||||
| string
|
||||
| OpenAI.Chat.Completions.ChatCompletionContentPart[]
|
||||
| (ChatCompletionContentPartText | ChatCompletionContentPartRefusal)[],
|
||||
): ContentBlockParam[] {
|
||||
const blocks: ContentBlockParam[] = []
|
||||
if (typeof content === "string") {
|
||||
if (content) {
|
||||
blocks.push({
|
||||
type: "text",
|
||||
text: content,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const supportedParts = content.filter((p) => p.type === "text" || p.type === "image_url" || p.type === "refusal")
|
||||
for (const part of supportedParts) {
|
||||
if (part.type === "image_url") {
|
||||
const dataUrl = part.image_url.url
|
||||
if (dataUrl?.startsWith("data:")) {
|
||||
blocks.push({
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: getAnthropicMediaTypeFromDataUrl(dataUrl),
|
||||
data: dataUrl.split(",")[1],
|
||||
},
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const text = part.type === "text" ? part.text : part.refusal
|
||||
if (text) {
|
||||
blocks.push({
|
||||
type: "text",
|
||||
text,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
private getContentBlocksFromChatMessage(
|
||||
message: OpenAI.Chat.Completions.ChatCompletionMessageParam,
|
||||
): ContentBlockParam[] {
|
||||
switch (message.role) {
|
||||
// One tool message = one tool_result block
|
||||
case "user":
|
||||
return this.convertMessageContentToBlocks(message.content)
|
||||
case "assistant": {
|
||||
const blocks: ContentBlockParam[] = message.content ? this.convertMessageContentToBlocks(message.content) : []
|
||||
return blocks
|
||||
}
|
||||
// system, etc.
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private _convertMessages(msgs: OpenAI.Chat.Completions.ChatCompletionMessageParam[]): MessageParam[] {
|
||||
const nonSystemMessages = msgs.filter((m) => m.role !== "system")
|
||||
|
||||
const convertedMessages: MessageParam[] = []
|
||||
let currentRole: "user" | "assistant" | undefined = undefined
|
||||
let currentParts: ContentBlockParam[] = []
|
||||
|
||||
const flushCurrentMessage = () => {
|
||||
if (currentRole && currentParts.length > 0) {
|
||||
convertedMessages.push({
|
||||
role: currentRole,
|
||||
content: currentParts,
|
||||
})
|
||||
currentParts = []
|
||||
}
|
||||
}
|
||||
|
||||
for (const message of nonSystemMessages) {
|
||||
const newRole = message.role === "user" || message.role === "tool" ? "user" : "assistant"
|
||||
if (currentRole !== newRole) {
|
||||
flushCurrentMessage()
|
||||
currentRole = newRole
|
||||
}
|
||||
currentParts.push(...this.getContentBlocksFromChatMessage(message))
|
||||
}
|
||||
flushCurrentMessage()
|
||||
|
||||
return convertedMessages
|
||||
}
|
||||
|
||||
async chatCompletionNonStream(
|
||||
body: ChatCompletionCreateParamsNonStreaming,
|
||||
signal: AbortSignal,
|
||||
): Promise<ChatCompletion> {
|
||||
const response = await fetch(new URL("messages", this.apiBase), {
|
||||
method: "POST",
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(this._convertBody(body)),
|
||||
signal,
|
||||
})
|
||||
|
||||
if (response.status === 499) {
|
||||
return EMPTY_CHAT_COMPLETION
|
||||
}
|
||||
|
||||
const completion = await response.json()
|
||||
|
||||
const usage: Record<string, number> | undefined = completion.usage
|
||||
return {
|
||||
id: completion.id,
|
||||
object: "chat.completion",
|
||||
model: body.model,
|
||||
created: Date.now(),
|
||||
usage: {
|
||||
total_tokens: (usage?.input_tokens ?? 0) + (usage?.output_tokens ?? 0),
|
||||
completion_tokens: usage?.output_tokens ?? 0,
|
||||
prompt_tokens: usage?.input_tokens ?? 0,
|
||||
prompt_tokens_details: {
|
||||
cached_tokens: usage?.cache_read_input_tokens ?? 0,
|
||||
},
|
||||
},
|
||||
choices: [
|
||||
{
|
||||
logprobs: null,
|
||||
finish_reason: "stop",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: completion.content[0].text,
|
||||
refusal: null,
|
||||
},
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// This is split off so e.g. VertexAI can use it
|
||||
async *handleStreamResponse(response: any, model: string) {
|
||||
const usage: CompletionUsage = {
|
||||
completion_tokens: 0,
|
||||
prompt_tokens: 0,
|
||||
total_tokens: 0,
|
||||
}
|
||||
for await (const event of streamSse(response)) {
|
||||
// https://docs.anthropic.com/en/api/messages-streaming#event-types
|
||||
const rawEvent = event as RawMessageStreamEvent
|
||||
switch (rawEvent.type) {
|
||||
case "message_start": {
|
||||
const startEvent = rawEvent as RawMessageStartEvent
|
||||
usage.prompt_tokens = startEvent.message.usage?.input_tokens ?? 0
|
||||
usage.prompt_tokens_details = {
|
||||
cached_tokens: startEvent.message.usage?.cache_read_input_tokens ?? 0,
|
||||
}
|
||||
break
|
||||
}
|
||||
case "message_delta": {
|
||||
const deltaEvent = rawEvent as RawMessageDeltaEvent
|
||||
usage.completion_tokens = deltaEvent.usage?.output_tokens ?? 0
|
||||
break
|
||||
}
|
||||
case "content_block_delta": {
|
||||
// https://docs.anthropic.com/en/api/messages-streaming#delta-types
|
||||
const blockDeltaEvent = rawEvent as RawContentBlockDeltaEvent
|
||||
switch (blockDeltaEvent.delta.type) {
|
||||
case "text_delta":
|
||||
yield chatChunk({
|
||||
content: blockDeltaEvent.delta.text,
|
||||
model,
|
||||
})
|
||||
break
|
||||
case "input_json_delta":
|
||||
// Skip tool use delta
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
yield usageChatChunk({
|
||||
model,
|
||||
usage: {
|
||||
...usage,
|
||||
total_tokens: usage.completion_tokens + usage.prompt_tokens,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async *chatCompletionStream(
|
||||
body: ChatCompletionCreateParamsStreaming,
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator<ChatCompletionChunk> {
|
||||
const response = await fetch(new URL("messages", this.apiBase), {
|
||||
method: "POST",
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(this._convertBody(body)),
|
||||
signal,
|
||||
})
|
||||
yield* this.handleStreamResponse(response, body.model)
|
||||
}
|
||||
|
||||
private getHeaders(): Record<string, string> {
|
||||
const enableCaching = this.config?.cachingStrategy !== "none"
|
||||
return getAnthropicHeaders(this.config.apiKey, enableCaching)
|
||||
}
|
||||
|
||||
async completionNonStream(_body: CompletionCreateParamsNonStreaming, _signal: AbortSignal): Promise<Completion> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
// eslint-disable-next-line require-yield
|
||||
async *completionStream(_body: CompletionCreateParamsStreaming, _signal: AbortSignal): AsyncGenerator<Completion> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
// eslint-disable-next-line require-yield
|
||||
async *fimStream(_body: FimCreateParamsStreaming, _signal: AbortSignal): AsyncGenerator<ChatCompletionChunk> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
|
||||
async rerank(_body: RerankCreateParams): Promise<CreateRerankResponse> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
|
||||
list(): Promise<OpenAI.Models.Model[]> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
}
|
||||
-160
@@ -1,160 +0,0 @@
|
||||
import { MessageCreateParams } from "@anthropic-ai/sdk/resources"
|
||||
|
||||
const MAX_CACHING_MESSAGES = 4
|
||||
|
||||
// Caching strategy type - transforms a clean Anthropic body by adding cache_control
|
||||
type CachingStrategy = (anthropicBody: MessageCreateParams) => MessageCreateParams
|
||||
|
||||
// Utility function to estimate token count
|
||||
const estimateTokenCount = (text: string): number => Math.ceil(text.length / 4)
|
||||
|
||||
// Strategy 1: No Caching
|
||||
const noCachingStrategy: CachingStrategy = (body) => body
|
||||
|
||||
// Strategy 2: System Messages Only
|
||||
const systemOnlyStrategy: CachingStrategy = (body) => {
|
||||
let availableCacheMessages = MAX_CACHING_MESSAGES
|
||||
if (body.system && Array.isArray(body.system)) {
|
||||
return {
|
||||
...body,
|
||||
system: body.system.map((item) => {
|
||||
if (availableCacheMessages > 0) {
|
||||
availableCacheMessages -= 1
|
||||
return {
|
||||
...item,
|
||||
cache_control: { type: "ephemeral" },
|
||||
}
|
||||
}
|
||||
return item
|
||||
}),
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// Strategy 3: System and Tools (High Impact)
|
||||
const systemAndToolsStrategy: CachingStrategy = (body) => {
|
||||
const result = { ...body }
|
||||
let availableCacheMessages = MAX_CACHING_MESSAGES
|
||||
|
||||
// Cache system messages
|
||||
if (result.system && Array.isArray(result.system)) {
|
||||
result.system = result.system.map((item) => {
|
||||
if (availableCacheMessages > 0) {
|
||||
availableCacheMessages -= 1
|
||||
return {
|
||||
...item,
|
||||
cache_control: { type: "ephemeral" },
|
||||
}
|
||||
}
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
// Cache tool definitions
|
||||
if (result.tools && Array.isArray(result.tools) && result.tools.length > 0) {
|
||||
result.tools = result.tools.map((tool, index: number) => {
|
||||
if (index === result.tools!.length - 1 && availableCacheMessages > 0) {
|
||||
availableCacheMessages -= 1
|
||||
return {
|
||||
...tool,
|
||||
cache_control: { type: "ephemeral" },
|
||||
}
|
||||
}
|
||||
return tool
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Strategy 4: Optimized (Intelligent Caching)
|
||||
const optimizedStrategy: CachingStrategy = (body) => {
|
||||
const result = { ...body }
|
||||
let availableCacheMessages = MAX_CACHING_MESSAGES
|
||||
|
||||
// Always cache system messages
|
||||
if (result.system && Array.isArray(result.system)) {
|
||||
result.system = result.system.map((item) => {
|
||||
if (availableCacheMessages > 0) {
|
||||
availableCacheMessages -= 1
|
||||
return {
|
||||
...item,
|
||||
cache_control: { type: "ephemeral" },
|
||||
}
|
||||
}
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
// Cache tool definitions
|
||||
if (result.tools && Array.isArray(result.tools) && result.tools.length > 0) {
|
||||
result.tools = result.tools.map((tool, index: number) => {
|
||||
if (index === result.tools!.length - 1 && availableCacheMessages > 0) {
|
||||
availableCacheMessages -= 1
|
||||
return {
|
||||
...tool,
|
||||
cache_control: { type: "ephemeral" },
|
||||
}
|
||||
}
|
||||
return tool
|
||||
})
|
||||
}
|
||||
|
||||
// Cache large messages (>500 tokens)
|
||||
if (result.messages && Array.isArray(result.messages)) {
|
||||
result.messages = result.messages.map((message) => {
|
||||
if (message.content && typeof message.content === "string") {
|
||||
const tokens = estimateTokenCount(message.content)
|
||||
if (tokens > 500 && availableCacheMessages > 0) {
|
||||
availableCacheMessages -= 1
|
||||
return {
|
||||
...message,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: message.content,
|
||||
cache_control: { type: "ephemeral" as const },
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
} else if (message.content && Array.isArray(message.content)) {
|
||||
// Only add one cache control per message with array content
|
||||
let addedCacheControl = false
|
||||
const updatedContent = message.content.map((item) => {
|
||||
if (item.type === "text" && item.text) {
|
||||
const tokens = estimateTokenCount(item.text)
|
||||
if (tokens > 500 && availableCacheMessages > 0 && !addedCacheControl) {
|
||||
availableCacheMessages -= 1
|
||||
addedCacheControl = true
|
||||
return {
|
||||
...item,
|
||||
cache_control: { type: "ephemeral" as const },
|
||||
}
|
||||
}
|
||||
}
|
||||
return item
|
||||
})
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: updatedContent,
|
||||
}
|
||||
}
|
||||
return message
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Available caching strategies
|
||||
export const CACHING_STRATEGIES = {
|
||||
none: noCachingStrategy,
|
||||
systemOnly: systemOnlyStrategy,
|
||||
systemAndTools: systemAndToolsStrategy,
|
||||
optimized: optimizedStrategy,
|
||||
} as const
|
||||
|
||||
export type CachingStrategyName = keyof typeof CACHING_STRATEGIES
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
import { Base64ImageSource, MessageParam, Tool, ToolChoice } from "@anthropic-ai/sdk/resources"
|
||||
import { ChatCompletionTool, ChatCompletionToolChoiceOption } from "openai/resources"
|
||||
|
||||
export function getAnthropicHeaders(apiKey: string, enableCaching: boolean): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"anthropic-version": "2023-06-01",
|
||||
"x-api-key": apiKey,
|
||||
}
|
||||
|
||||
if (enableCaching) {
|
||||
headers["anthropic-beta"] = "prompt-caching-2024-07-31"
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
export function addCacheControlToLastTwoUserMessages(messages: MessageParam[]) {
|
||||
let userMessages = 0
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i]
|
||||
if (msg.role === "user") {
|
||||
userMessages++
|
||||
if (typeof msg.content === "string") {
|
||||
continue
|
||||
}
|
||||
for (let j = msg.content.length - 1; j >= 0; j--) {
|
||||
const part = msg.content[j]
|
||||
if (part.type === "text") {
|
||||
part.cache_control = { type: "ephemeral" }
|
||||
break
|
||||
}
|
||||
}
|
||||
if (userMessages >= 2) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function openAiToolChoiceToAnthropicToolChoice(
|
||||
toolChoice: ChatCompletionToolChoiceOption | undefined,
|
||||
): ToolChoice | undefined {
|
||||
if (!toolChoice) {
|
||||
return undefined
|
||||
}
|
||||
switch (toolChoice) {
|
||||
case "none":
|
||||
// "none" is handled by excluding tools
|
||||
return undefined
|
||||
case "required":
|
||||
return {
|
||||
type: "any",
|
||||
}
|
||||
case "auto":
|
||||
return {
|
||||
type: "auto",
|
||||
}
|
||||
default:
|
||||
switch (toolChoice.type) {
|
||||
case "allowed_tools":
|
||||
// Filtering of tools is handled elsewhere
|
||||
return {
|
||||
type: toolChoice.allowed_tools.mode === "auto" ? "auto" : "any",
|
||||
}
|
||||
case "custom":
|
||||
return undefined // TODO not supported yet
|
||||
case "function":
|
||||
return {
|
||||
type: "tool",
|
||||
name: toolChoice.function.name,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function openaiToolToAnthropicTool(tool: ChatCompletionTool): Tool {
|
||||
if (tool.type === "function" && "function" in tool) {
|
||||
return {
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
input_schema: tool.function.parameters as Tool.InputSchema, // TODO unsafe cast, may be differences between openai tool schema and anthropic tool schema,
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Unsupported tool type in Anthropic: ${tool.type}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract media type from data URL (ex. "data:image/png;base64,..." -> "image/png")
|
||||
export function getAnthropicMediaTypeFromDataUrl(dataUrl: string): Base64ImageSource["media_type"] {
|
||||
const match = dataUrl.match(/^data:([^;]+);base64,/)
|
||||
if (match) {
|
||||
switch (match[1]) {
|
||||
case "image/png":
|
||||
case "image/gif":
|
||||
case "image/webp":
|
||||
return match[1]
|
||||
}
|
||||
}
|
||||
return "image/jpeg"
|
||||
}
|
||||
-118
@@ -1,118 +0,0 @@
|
||||
import { OpenAI } from "openai/index"
|
||||
import {
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionCreateParams,
|
||||
ChatCompletionCreateParamsStreaming,
|
||||
} from "openai/resources/index"
|
||||
import { z } from "zod"
|
||||
import { AzureConfigSchema } from "../types.js"
|
||||
import { OpenAIApi } from "./OpenAI.js"
|
||||
|
||||
export class AzureApi extends OpenAIApi {
|
||||
constructor(azureConfig: z.infer<typeof AzureConfigSchema>) {
|
||||
super({
|
||||
...azureConfig,
|
||||
provider: "openai",
|
||||
})
|
||||
|
||||
const { baseURL, defaultQuery } = this._getAzureBaseURL(azureConfig)
|
||||
|
||||
this.openai = new OpenAI({
|
||||
apiKey: azureConfig.apiKey,
|
||||
baseURL,
|
||||
defaultQuery,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Default is `azure-openai`, but previously was `azure`
|
||||
* @param apiType
|
||||
* @returns
|
||||
*/
|
||||
private _isAzureOpenAI(apiType?: string): boolean {
|
||||
return apiType === "azure-openai" || apiType === "azure"
|
||||
}
|
||||
|
||||
private _getAzureBaseURL(config: z.infer<typeof AzureConfigSchema>): {
|
||||
baseURL: string
|
||||
defaultQuery: Record<string, string>
|
||||
} {
|
||||
const url = new URL(this.apiBase)
|
||||
|
||||
// Copy search params to separate object for OpenAI
|
||||
const queryParams: Record<string, string> = {}
|
||||
for (const [key, value] of url.searchParams.entries()) {
|
||||
queryParams[key] = value
|
||||
}
|
||||
|
||||
url.pathname = url.pathname.replace(/\/$/, "") // Remove trailing slash if present
|
||||
url.search = "" // Clear original search params
|
||||
|
||||
// Default is `azure-openai` in docs, but previously was `azure`
|
||||
if (this._isAzureOpenAI(config.env?.apiType)) {
|
||||
if (!config.env?.deployment) {
|
||||
throw new Error("`env.deployment` is a required configuration property for Azure OpenAI")
|
||||
}
|
||||
|
||||
if (!config.env?.apiVersion) {
|
||||
throw new Error("`env.apiVersion` is a required configuration property for Azure OpenAI")
|
||||
}
|
||||
|
||||
const basePathname = `openai/deployments/${config.env.deployment}`
|
||||
|
||||
url.pathname = url.pathname === "/" ? basePathname : `${url.pathname}/${basePathname}`
|
||||
|
||||
queryParams["api-version"] = config.env.apiVersion
|
||||
}
|
||||
|
||||
return {
|
||||
baseURL: url.toString(),
|
||||
defaultQuery: queryParams,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out empty text content parts from messages.
|
||||
*
|
||||
* Azure models may not support empty content parts, which can cause issues.
|
||||
* This function removes any text content parts that are empty or contain only whitespace.
|
||||
*/
|
||||
private _filterEmptyContentParts<T extends ChatCompletionCreateParams>(body: T): T {
|
||||
const result = { ...body }
|
||||
|
||||
result.messages = result.messages.map((message: any) => {
|
||||
if (Array.isArray(message.content)) {
|
||||
const filteredContent = message.content.filter((part: any) => {
|
||||
return !(part.type === "text" && (!part.text || part.text.trim() === ""))
|
||||
})
|
||||
return {
|
||||
...message,
|
||||
content: filteredContent.length > 0 ? filteredContent : message.content,
|
||||
}
|
||||
}
|
||||
return message
|
||||
}) as any
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
override modifyChatBody<T extends ChatCompletionCreateParams>(body: T): T {
|
||||
let modifiedBody = super.modifyChatBody(body)
|
||||
modifiedBody = this._filterEmptyContentParts(modifiedBody)
|
||||
return modifiedBody
|
||||
}
|
||||
|
||||
override async *chatCompletionStream(
|
||||
body: ChatCompletionCreateParamsStreaming,
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator<ChatCompletionChunk, any, unknown> {
|
||||
const response = await this.openai.chat.completions.create(this.modifyChatBody(body), { signal })
|
||||
|
||||
for await (const result of response) {
|
||||
// Skip chunks with no choices (common with Azure content filtering)
|
||||
if (result.choices && result.choices.length > 0) {
|
||||
yield result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-504
@@ -1,504 +0,0 @@
|
||||
import {
|
||||
BedrockRuntimeClient,
|
||||
ContentBlock,
|
||||
ContentBlockStart,
|
||||
ConversationRole,
|
||||
ConverseStreamCommand,
|
||||
ConverseStreamCommandInput,
|
||||
ImageFormat,
|
||||
InvokeModelCommand,
|
||||
Message,
|
||||
ToolConfiguration,
|
||||
} from "@aws-sdk/client-bedrock-runtime"
|
||||
import { OpenAI } from "openai/index"
|
||||
import { randomUUID } from "node:crypto"
|
||||
|
||||
import {
|
||||
ChatCompletion,
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionContentPartImage,
|
||||
ChatCompletionCreateParams,
|
||||
ChatCompletionCreateParamsNonStreaming,
|
||||
ChatCompletionCreateParamsStreaming,
|
||||
Completion,
|
||||
CompletionCreateParamsNonStreaming,
|
||||
CompletionCreateParamsStreaming,
|
||||
Model,
|
||||
} from "openai/resources/index"
|
||||
|
||||
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
|
||||
import { BedrockConfig } from "../types.js"
|
||||
import { chatChunk, rerank } from "../util.js"
|
||||
import { BaseLlmApi, CreateRerankResponse, FimCreateParamsStreaming, RerankCreateParams } from "./base.js"
|
||||
|
||||
export class BedrockApi implements BaseLlmApi {
|
||||
constructor(protected config: BedrockConfig) {
|
||||
if (config.env?.accessKeyId || config?.env?.secretAccessKey) {
|
||||
if (!config.env?.accessKeyId) {
|
||||
throw new Error("accessKeyId is required for Bedrock API. Only found secretAccessKey")
|
||||
}
|
||||
if (!config.env?.secretAccessKey) {
|
||||
throw new Error("secretAccessKey is required for Bedrock API. Only found accessKeyId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getCreds() {
|
||||
if (this.config?.env?.accessKeyId && this.config?.env?.secretAccessKey) {
|
||||
return {
|
||||
accessKeyId: this.config.env.accessKeyId,
|
||||
secretAccessKey: this.config.env.secretAccessKey,
|
||||
}
|
||||
}
|
||||
const profile = this.config.env?.profile ?? "bedrock"
|
||||
try {
|
||||
return await fromNodeProviderChain({
|
||||
profile: profile,
|
||||
ignoreCache: true,
|
||||
})()
|
||||
} catch {
|
||||
console.warn(`AWS profile with name ${profile} not found in ~/.aws/credentials, using default profile`)
|
||||
}
|
||||
return await fromNodeProviderChain()()
|
||||
}
|
||||
async getClient(): Promise<BedrockRuntimeClient> {
|
||||
const region = this.config.env?.region
|
||||
|
||||
// If apiKey is provided, use bearer token authentication
|
||||
if (this.config.apiKey) {
|
||||
return new BedrockRuntimeClient({
|
||||
region,
|
||||
credentials: {
|
||||
accessKeyId: this.config.apiKey,
|
||||
secretAccessKey: this.config.apiKey,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Otherwise use IAM credentials (existing behavior)
|
||||
const creds = await this.getCreds()
|
||||
return new BedrockRuntimeClient({
|
||||
region,
|
||||
credentials: creds,
|
||||
})
|
||||
}
|
||||
|
||||
private _oaiPartToBedrockPart(
|
||||
part: OpenAI.Chat.Completions.ChatCompletionContentPart | OpenAI.Chat.Completions.ChatCompletionContentPartRefusal,
|
||||
): ContentBlock {
|
||||
switch (part.type) {
|
||||
case "refusal":
|
||||
return {
|
||||
text: part.refusal,
|
||||
}
|
||||
case "text":
|
||||
return {
|
||||
text: part.text,
|
||||
}
|
||||
case "input_audio":
|
||||
throw new Error("Unsupported part type: input_audio")
|
||||
case "image_url":
|
||||
default:
|
||||
try {
|
||||
const [mimeType, base64Data] = (part as ChatCompletionContentPartImage).image_url.url.split(",")
|
||||
const format = mimeType.split("/")[1]?.split(";")[0] || "jpeg"
|
||||
if (
|
||||
format === ImageFormat.JPEG ||
|
||||
format === ImageFormat.PNG ||
|
||||
format === ImageFormat.WEBP ||
|
||||
format === ImageFormat.GIF
|
||||
) {
|
||||
return {
|
||||
image: {
|
||||
format,
|
||||
source: {
|
||||
bytes: Uint8Array.from(Buffer.from(base64Data, "base64")),
|
||||
},
|
||||
},
|
||||
}
|
||||
} else {
|
||||
console.warn(`Bedrock: skipping unsupported image part format: ${format}`)
|
||||
return { text: "[Unsupported image format]" }
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Bedrock: failed to process image part", error)
|
||||
return { text: "[Failed to process image]" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _convertMessages(
|
||||
oaiMessages: OpenAI.Chat.Completions.ChatCompletionMessageParam[],
|
||||
_availableTools: Set<string>,
|
||||
): Message[] {
|
||||
let currentRole: "user" | "assistant" = "user"
|
||||
let currentBlocks: ContentBlock[] = []
|
||||
const converted: Message[] = []
|
||||
|
||||
const pushCurrentMessage = () => {
|
||||
if (currentBlocks.length > 0) {
|
||||
converted.push({
|
||||
role: currentRole,
|
||||
content: currentBlocks,
|
||||
})
|
||||
currentBlocks = []
|
||||
}
|
||||
}
|
||||
|
||||
const nonSystemMessages = oaiMessages.filter((m) => m.role !== "system")
|
||||
|
||||
for (let idx = 0; idx < nonSystemMessages.length; idx++) {
|
||||
const message = nonSystemMessages[idx]
|
||||
|
||||
if (message.role === "user" || message.role === "tool") {
|
||||
// Detect conversational turn change
|
||||
if (currentRole !== ConversationRole.USER) {
|
||||
pushCurrentMessage()
|
||||
currentRole = ConversationRole.USER
|
||||
}
|
||||
|
||||
// USER messages
|
||||
if (message.role === "user") {
|
||||
const content = message.content
|
||||
if (content) {
|
||||
if (typeof content === "string") {
|
||||
currentBlocks.push({ text: content })
|
||||
} else {
|
||||
content.forEach((part) => {
|
||||
currentBlocks.push(this._oaiPartToBedrockPart(part))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// TOOL messages - no longer supported, skip
|
||||
} else if (message.role === "assistant") {
|
||||
// Detect conversational turn change
|
||||
if (currentRole !== ConversationRole.ASSISTANT) {
|
||||
pushCurrentMessage()
|
||||
currentRole = ConversationRole.ASSISTANT
|
||||
}
|
||||
|
||||
// ASSISTANT messages
|
||||
if (typeof message.content === "string") {
|
||||
const trimmedText = message.content.trim()
|
||||
if (trimmedText) {
|
||||
currentBlocks.push({ text: trimmedText })
|
||||
}
|
||||
} else {
|
||||
message.content?.forEach((part) => {
|
||||
const text = part.type === "text" ? part.text : part.refusal
|
||||
const trimmedText = text.trim()
|
||||
if (trimmedText) {
|
||||
currentBlocks.push({ text: trimmedText })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentBlocks.length > 0) {
|
||||
pushCurrentMessage()
|
||||
}
|
||||
|
||||
// If caching is enabled, add cache points
|
||||
// if (this.config.cacheBehavior?.cacheConversation) {
|
||||
// this._addCachingToLastTwoUserMessages(converted);
|
||||
// }
|
||||
|
||||
return converted
|
||||
}
|
||||
|
||||
private _convertBody(oaiBody: ChatCompletionCreateParams): ConverseStreamCommandInput {
|
||||
// Extract system message
|
||||
const systemMessage = oaiBody.messages.find((msg) => msg.role === "system")?.content || ""
|
||||
|
||||
const systemMessageText =
|
||||
typeof systemMessage === "string"
|
||||
? systemMessage
|
||||
: systemMessage.map((part) => (part.type === "text" ? part.text : "[Non-text content]")).join(" ")
|
||||
|
||||
// Check for tools
|
||||
const availableTools = new Set<string>()
|
||||
let toolConfig: ToolConfiguration | undefined = undefined
|
||||
|
||||
if (oaiBody.tools && oaiBody.tools.length > 0) {
|
||||
toolConfig = {
|
||||
tools: oaiBody.tools.map((tool) => {
|
||||
// Type guard for function tools
|
||||
if (tool.type === "function" && "function" in tool) {
|
||||
return {
|
||||
toolSpec: {
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
inputSchema: {
|
||||
json: tool.function.parameters,
|
||||
},
|
||||
},
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Unsupported tool type in Bedrock: ${tool.type}`)
|
||||
}
|
||||
}),
|
||||
} as ToolConfiguration
|
||||
|
||||
// Add cache point if needed
|
||||
// if (this.config.cacheBehavior?.cacheSystemMessage) {
|
||||
// toolConfig!.tools!.push({ cachePoint: { type: "default" } });
|
||||
// }
|
||||
|
||||
oaiBody.tools.forEach((tool) => {
|
||||
if (tool.type === "function" && "function" in tool) {
|
||||
availableTools.add(tool.function.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Convert messages
|
||||
const convertedMessages = this._convertMessages(oaiBody.messages, availableTools)
|
||||
|
||||
// Build final request body
|
||||
const body: {
|
||||
modelId: string
|
||||
messages: Message[]
|
||||
inferenceConfig: {
|
||||
temperature?: number
|
||||
topP?: number
|
||||
maxTokens?: number
|
||||
stopSequences?: string[] | undefined
|
||||
}
|
||||
system?: Array<{ text: string } | { cachePoint: { type: "default" } }>
|
||||
toolConfig?: ToolConfiguration
|
||||
} = {
|
||||
modelId: oaiBody.model,
|
||||
messages: convertedMessages,
|
||||
inferenceConfig: {
|
||||
temperature: oaiBody.temperature ?? undefined,
|
||||
topP: oaiBody.top_p ?? undefined,
|
||||
maxTokens: oaiBody.max_tokens ?? undefined,
|
||||
stopSequences: Array.isArray(oaiBody.stop)
|
||||
? oaiBody.stop.filter((s) => s.trim() !== "").slice(0, 4)
|
||||
: oaiBody.stop
|
||||
? [oaiBody.stop].filter((s) => s.trim() !== "")
|
||||
: undefined,
|
||||
},
|
||||
}
|
||||
|
||||
// Add system message if present
|
||||
if (systemMessageText) {
|
||||
body.system = [{ text: systemMessageText }]
|
||||
}
|
||||
|
||||
// Add tool config if present
|
||||
if (toolConfig) {
|
||||
body.toolConfig = toolConfig
|
||||
}
|
||||
|
||||
// Add reasoning if needed
|
||||
// TODO REASONING
|
||||
// if (this.c) {
|
||||
// body.additionalModelRequestFields = {
|
||||
// thinking: {
|
||||
// type: "enabled",
|
||||
// budget_tokens:
|
||||
// oaiBody.additionalModelRequestFields.reasoningBudgetTokens,
|
||||
// },
|
||||
// };
|
||||
// }
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
async chatCompletionNonStream(
|
||||
body: ChatCompletionCreateParamsNonStreaming,
|
||||
signal: AbortSignal,
|
||||
): Promise<ChatCompletion> {
|
||||
let completion = ""
|
||||
|
||||
for await (const chunk of this.chatCompletionStream(
|
||||
{
|
||||
...body,
|
||||
stream: true,
|
||||
},
|
||||
signal,
|
||||
)) {
|
||||
if (chunk.choices[0].delta.content) {
|
||||
completion += chunk.choices[0].delta.content
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: randomUUID(),
|
||||
object: "chat.completion",
|
||||
model: body.model,
|
||||
created: Date.now(),
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
logprobs: null,
|
||||
finish_reason: "stop",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: completion,
|
||||
refusal: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
usage: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async *chatCompletionStream(
|
||||
body: ChatCompletionCreateParamsStreaming,
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator<ChatCompletionChunk> {
|
||||
const requestBody = this._convertBody(body)
|
||||
|
||||
try {
|
||||
const command = new ConverseStreamCommand({
|
||||
...requestBody,
|
||||
})
|
||||
|
||||
const client = await this.getClient()
|
||||
const response = await client.send(command, { abortSignal: signal })
|
||||
|
||||
if (!response?.stream) {
|
||||
throw new Error("No stream received from Bedrock API")
|
||||
}
|
||||
|
||||
for await (const chunk of response.stream) {
|
||||
if (chunk.contentBlockDelta?.delta) {
|
||||
type DeltaBlock = {
|
||||
text?: string
|
||||
reasoningContent?: { text?: string }
|
||||
toolUse?: { toolUseId: string; name: string; input: string }
|
||||
}
|
||||
const delta = chunk.contentBlockDelta.delta as unknown as DeltaBlock
|
||||
|
||||
// Handle text content
|
||||
if (delta.text) {
|
||||
yield chatChunk({
|
||||
content: delta.text,
|
||||
model: body.model,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle thinking content (if reasoning enabled)
|
||||
if (delta.reasoningContent?.text) {
|
||||
// TODO reasoning
|
||||
// Reasoning is not directly supported in OpenAI format,
|
||||
// but we could add it as a special message
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip tool use blocks
|
||||
}
|
||||
|
||||
if (chunk.contentBlockStart?.start) {
|
||||
const start: ContentBlockStart = chunk.contentBlockStart.start
|
||||
|
||||
if (start.toolUse) {
|
||||
// Skip tool use
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if ("code" in error) {
|
||||
const code = (error as { code?: string }).code
|
||||
throw new Error(`AWS Bedrock stream error (${code ?? "UNKNOWN"}): ${error.message}`)
|
||||
}
|
||||
throw new Error(`Error processing Bedrock stream: ${error.message}`)
|
||||
}
|
||||
throw new Error("Error processing Bedrock stream: Unknown error occurred")
|
||||
}
|
||||
}
|
||||
|
||||
completionNonStream(_body: CompletionCreateParamsNonStreaming): Promise<Completion> {
|
||||
throw new Error("Bedrock does not support completions API")
|
||||
}
|
||||
|
||||
completionStream(_body: CompletionCreateParamsStreaming): AsyncGenerator<Completion> {
|
||||
throw new Error("Bedrock does not support completions API")
|
||||
}
|
||||
|
||||
fimStream(_body: FimCreateParamsStreaming): AsyncGenerator<ChatCompletionChunk> {
|
||||
throw new Error("Bedrock does not support FIM directly")
|
||||
}
|
||||
|
||||
private async getInvokeModelResponseBody(model: string, jsonBody: object) {
|
||||
const payload = {
|
||||
body: JSON.stringify(jsonBody),
|
||||
modelId: model,
|
||||
accept: "*/*",
|
||||
contentType: "application/json",
|
||||
}
|
||||
const command = new InvokeModelCommand(payload)
|
||||
const client = await this.getClient()
|
||||
const response = await client.send(command)
|
||||
if (!response.body) {
|
||||
throw new Error("No response body")
|
||||
}
|
||||
const decoder = new TextDecoder()
|
||||
const decoded = decoder.decode(response.body)
|
||||
return JSON.parse(decoded)
|
||||
}
|
||||
|
||||
async rerank(body: RerankCreateParams): Promise<CreateRerankResponse> {
|
||||
if (!body.query || !body.documents.length) {
|
||||
throw new Error("Query and chunks must not be empty")
|
||||
}
|
||||
|
||||
// Base payload for both models
|
||||
const payload: {
|
||||
query: string
|
||||
documents: string[]
|
||||
top_n: number
|
||||
api_version?: number
|
||||
} = {
|
||||
query: body.query,
|
||||
documents: body.documents,
|
||||
top_n: body.top_k ?? body.documents.length,
|
||||
}
|
||||
|
||||
// Add api_version for Cohere model
|
||||
if (body.model.startsWith("cohere.rerank")) {
|
||||
payload.api_version = 2
|
||||
}
|
||||
|
||||
try {
|
||||
const responseBody = await this.getInvokeModelResponseBody(body.model, payload)
|
||||
const scores = (
|
||||
responseBody.results as Array<{
|
||||
index: number
|
||||
relevance_score: number
|
||||
}>
|
||||
)
|
||||
.sort((a, b) => a.index - b.index)
|
||||
.map((result) => result.relevance_score)
|
||||
|
||||
return rerank({
|
||||
model: body.model,
|
||||
usage: {
|
||||
total_tokens: 0,
|
||||
},
|
||||
data: scores,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if ("code" in error) {
|
||||
// AWS SDK specific errors
|
||||
const code = (error as { code?: string }).code
|
||||
throw new Error(`AWS Bedrock rerank error (${code ?? "UNKNOWN"}): ${error.message}`)
|
||||
}
|
||||
throw new Error(`Error in BedrockReranker.rerank: ${error.message}`)
|
||||
}
|
||||
throw new Error("Error in BedrockReranker.rerank: Unknown error occurred")
|
||||
}
|
||||
}
|
||||
|
||||
list(): Promise<Model[]> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
}
|
||||
-162
@@ -1,162 +0,0 @@
|
||||
import { streamJSON } from "../../../fetch/stream.js"
|
||||
import { OpenAI } from "openai/index"
|
||||
import {
|
||||
ChatCompletion,
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionCreateParams,
|
||||
ChatCompletionCreateParamsNonStreaming,
|
||||
ChatCompletionCreateParamsStreaming,
|
||||
Completion,
|
||||
CompletionCreateParamsNonStreaming,
|
||||
CompletionCreateParamsStreaming,
|
||||
Model,
|
||||
} from "openai/resources/index"
|
||||
import { CohereConfig } from "../types.js"
|
||||
import { chatCompletion } from "../util.js"
|
||||
import { EMPTY_CHAT_COMPLETION } from "../util/emptyChatCompletion.js"
|
||||
import { BaseLlmApi, CreateRerankResponse, FimCreateParamsStreaming, RerankCreateParams } from "./base.js"
|
||||
|
||||
export class CohereApi implements BaseLlmApi {
|
||||
apiBase: string = "https://api.cohere.com/v1"
|
||||
|
||||
static maxStopSequences = 5
|
||||
|
||||
constructor(protected config: CohereConfig) {
|
||||
this.apiBase = config.apiBase ?? this.apiBase
|
||||
}
|
||||
|
||||
private _convertMessages(msgs: OpenAI.Chat.Completions.ChatCompletionMessageParam[]): any[] {
|
||||
return msgs.map((m) => ({
|
||||
role: m.role === "assistant" ? "CHATBOT" : "USER",
|
||||
message: m.content,
|
||||
}))
|
||||
}
|
||||
|
||||
private _convertBody(oaiBody: ChatCompletionCreateParams) {
|
||||
return {
|
||||
message: oaiBody.messages.pop()?.content,
|
||||
chat_history: this._convertMessages(oaiBody.messages.filter((msg) => msg.role !== "system")),
|
||||
preamble: oaiBody.messages.find((msg) => msg.role === "system")?.content,
|
||||
model: oaiBody.model,
|
||||
stream: oaiBody.stream,
|
||||
temperature: oaiBody.temperature,
|
||||
max_tokens: oaiBody.max_tokens,
|
||||
p: oaiBody.top_p,
|
||||
stop_sequences: oaiBody.stop?.slice(0, CohereApi.maxStopSequences),
|
||||
frequency_penalty: oaiBody.frequency_penalty,
|
||||
presence_penalty: oaiBody.presence_penalty,
|
||||
}
|
||||
}
|
||||
|
||||
async chatCompletionNonStream(
|
||||
body: ChatCompletionCreateParamsNonStreaming,
|
||||
signal: AbortSignal,
|
||||
): Promise<ChatCompletion> {
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
}
|
||||
|
||||
const resp = await fetch(new URL("chat", this.apiBase), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(this._convertBody(body)),
|
||||
signal,
|
||||
})
|
||||
|
||||
if (resp.status === 499) {
|
||||
return EMPTY_CHAT_COMPLETION
|
||||
}
|
||||
|
||||
const data = (await resp.json()) as any
|
||||
const { input_tokens, output_tokens } = data.meta.tokens
|
||||
return chatCompletion({
|
||||
model: body.model,
|
||||
id: data.id,
|
||||
content: data.text,
|
||||
usage: {
|
||||
total_tokens: input_tokens + output_tokens,
|
||||
completion_tokens: output_tokens,
|
||||
prompt_tokens: input_tokens,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async *chatCompletionStream(
|
||||
body: ChatCompletionCreateParamsStreaming,
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator<ChatCompletionChunk> {
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
}
|
||||
|
||||
const resp = await fetch(new URL("chat", this.apiBase), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(this._convertBody(body)),
|
||||
signal,
|
||||
})
|
||||
|
||||
for await (const value of streamJSON(resp as any)) {
|
||||
if (value.event_type === "text-generation") {
|
||||
yield {
|
||||
id: value.id,
|
||||
object: "chat.completion.chunk",
|
||||
model: body.model,
|
||||
created: Date.now(),
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
logprobs: undefined,
|
||||
finish_reason: null,
|
||||
delta: {
|
||||
role: "assistant",
|
||||
content: value.text,
|
||||
},
|
||||
},
|
||||
],
|
||||
usage: undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
completionNonStream(_body: CompletionCreateParamsNonStreaming): Promise<Completion> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
completionStream(_body: CompletionCreateParamsStreaming): AsyncGenerator<Completion> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
fimStream(_body: FimCreateParamsStreaming): AsyncGenerator<ChatCompletionChunk> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
async rerank(body: RerankCreateParams): Promise<CreateRerankResponse> {
|
||||
const endpoint = new URL("rerank", this.apiBase)
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"x-api-key": this.config.apiKey ?? "",
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
},
|
||||
})
|
||||
const data = (await response.json()) as any
|
||||
return {
|
||||
object: "list",
|
||||
data: data.results.map((result: any) => ({
|
||||
index: result.index,
|
||||
relevance_score: result.relevance_score,
|
||||
})),
|
||||
model: body.model,
|
||||
usage: {
|
||||
total_tokens: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
list(): Promise<Model[]> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
}
|
||||
-100
@@ -1,100 +0,0 @@
|
||||
import {
|
||||
ChatCompletion,
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionCreateParamsNonStreaming,
|
||||
ChatCompletionCreateParamsStreaming,
|
||||
Completion,
|
||||
CompletionCreateParamsNonStreaming,
|
||||
CompletionCreateParamsStreaming,
|
||||
Model,
|
||||
} from "openai/resources/index"
|
||||
import { CometAPIConfig } from "../types.js"
|
||||
import { BaseLlmApi, CreateRerankResponse, FimCreateParamsStreaming, RerankCreateParams } from "./base.js"
|
||||
import { OpenAIApi } from "./OpenAI.js"
|
||||
|
||||
/**
|
||||
* CometAPI adapter - extends OpenAI adapter since CometAPI is OpenAI-compatible
|
||||
*
|
||||
* CometAPI provides access to multiple LLM providers (GPT, Claude, Gemini, etc.)
|
||||
* through a unified OpenAI-compatible API interface.
|
||||
*/
|
||||
export class CometAPIApi extends OpenAIApi implements BaseLlmApi {
|
||||
// Store the original CometAPI config separately
|
||||
|
||||
constructor(config: CometAPIConfig) {
|
||||
// CometAPI uses OpenAI-compatible API, so we can reuse OpenAI adapter
|
||||
// Convert CometAPI config to OpenAI-compatible config for the base class
|
||||
const openAICompatibleConfig = {
|
||||
...config,
|
||||
provider: "openai" as const,
|
||||
apiBase: config.apiBase ?? "https://api.cometapi.com/v1/",
|
||||
}
|
||||
super(openAICompatibleConfig)
|
||||
}
|
||||
|
||||
/**
|
||||
* Override list method to handle CometAPI-specific model filtering
|
||||
* The core filtering logic is handled in the CometAPI provider class
|
||||
*/
|
||||
override async list(): Promise<Model[]> {
|
||||
try {
|
||||
return await super.list()
|
||||
} catch (error) {
|
||||
// Fallback to empty list if model listing fails
|
||||
console.warn("CometAPI: Failed to fetch model list", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat completion - uses OpenAI-compatible format
|
||||
*/
|
||||
override async chatCompletionNonStream(
|
||||
body: ChatCompletionCreateParamsNonStreaming,
|
||||
signal: AbortSignal,
|
||||
): Promise<ChatCompletion> {
|
||||
return super.chatCompletionNonStream(body, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming chat completion - uses OpenAI-compatible format
|
||||
*/
|
||||
override async *chatCompletionStream(
|
||||
body: ChatCompletionCreateParamsStreaming,
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator<ChatCompletionChunk> {
|
||||
yield* super.chatCompletionStream(body, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy completion endpoint support
|
||||
*/
|
||||
override completionNonStream(body: CompletionCreateParamsNonStreaming, signal: AbortSignal): Promise<Completion> {
|
||||
return super.completionNonStream(body, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy streaming completion endpoint support
|
||||
*/
|
||||
override completionStream(body: CompletionCreateParamsStreaming, signal: AbortSignal): AsyncGenerator<Completion> {
|
||||
return super.completionStream(body, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill-in-the-middle completion support
|
||||
*/
|
||||
override fimStream(body: FimCreateParamsStreaming, signal: AbortSignal): AsyncGenerator<ChatCompletionChunk> {
|
||||
return super.fimStream(body, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reranking support (if available through CometAPI)
|
||||
*/
|
||||
override async rerank(body: RerankCreateParams): Promise<CreateRerankResponse> {
|
||||
try {
|
||||
return await super.rerank(body)
|
||||
} catch (error) {
|
||||
throw new Error(`CometAPI reranking not supported: ${error}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
import {
|
||||
ChatCompletionCreateParams,
|
||||
CompletionCreateParamsNonStreaming,
|
||||
CompletionCreateParamsStreaming,
|
||||
} from "openai/resources/index"
|
||||
import { z } from "zod"
|
||||
import { ContinueProxyConfigSchema } from "../types.js"
|
||||
import { FimCreateParamsStreaming, RerankCreateParams } from "./base.js"
|
||||
import { OpenAIApi } from "./OpenAI.js"
|
||||
|
||||
interface ContinueProperties {
|
||||
apiKeyLocation?: string
|
||||
envSecretLocations?: Record<string, string>
|
||||
apiBase?: string
|
||||
orgScopeId?: string | null
|
||||
env?: Record<string, any>
|
||||
}
|
||||
|
||||
export class ContinueProxyApi extends OpenAIApi {
|
||||
// The apiKey and apiBase are set to the values for the proxy,
|
||||
// but we need to keep track of the actual values that the proxy will use
|
||||
// to call whatever LLM API is chosen
|
||||
private actualApiBase?: string
|
||||
|
||||
// Contains extra properties that we pass along to the proxy. Originally from `env` property on LLMOptions
|
||||
private configEnv?: Record<string, any>
|
||||
|
||||
// Store the continue proxy config separately
|
||||
private continueProxyConfig: z.infer<typeof ContinueProxyConfigSchema>
|
||||
|
||||
constructor(config: z.infer<typeof ContinueProxyConfigSchema>) {
|
||||
// Convert ContinueProxyConfigSchema to OpenAIConfigSchema format
|
||||
const openaiConfig = {
|
||||
provider: "openai" as const,
|
||||
apiKey: config.apiKey,
|
||||
apiBase: config.env?.proxyUrl
|
||||
? new URL("model-proxy/v1/", config.env?.proxyUrl).toString()
|
||||
: "https://api.continue.dev/model-proxy/v1/",
|
||||
}
|
||||
|
||||
super(openaiConfig)
|
||||
|
||||
this.continueProxyConfig = config
|
||||
this.configEnv = config.env
|
||||
this.actualApiBase = config.apiBase
|
||||
}
|
||||
|
||||
protected extraBodyProperties(): Record<string, any> {
|
||||
const continueProperties: ContinueProperties = {
|
||||
apiKeyLocation: this.continueProxyConfig.env?.apiKeyLocation,
|
||||
envSecretLocations: this.continueProxyConfig.env?.envSecretLocations,
|
||||
apiBase: this.actualApiBase,
|
||||
orgScopeId: this.continueProxyConfig.env?.orgScopeId ?? null,
|
||||
env: this.configEnv,
|
||||
}
|
||||
return {
|
||||
continueProperties,
|
||||
}
|
||||
}
|
||||
|
||||
private modifyBodyWithContinueProperties<T extends Record<string, any>>(body: T): T {
|
||||
return {
|
||||
...body,
|
||||
...this.extraBodyProperties(),
|
||||
}
|
||||
}
|
||||
|
||||
override modifyChatBody<T extends ChatCompletionCreateParams>(body: T): T {
|
||||
// First apply OpenAI-specific modifications
|
||||
const modifiedBody = super.modifyChatBody(body)
|
||||
// Then add Continue properties
|
||||
return this.modifyBodyWithContinueProperties(modifiedBody)
|
||||
}
|
||||
|
||||
override modifyCompletionBody<T extends CompletionCreateParamsNonStreaming | CompletionCreateParamsStreaming>(
|
||||
body: T,
|
||||
): T {
|
||||
return this.modifyBodyWithContinueProperties(body)
|
||||
}
|
||||
|
||||
override modifyFimBody<T extends FimCreateParamsStreaming>(body: T): T {
|
||||
const modifiedBody = super.modifyFimBody(body)
|
||||
return this.modifyBodyWithContinueProperties(modifiedBody)
|
||||
}
|
||||
|
||||
protected override getHeaders(): Record<string, string> {
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"x-api-key": this.continueProxyConfig.apiKey ?? "",
|
||||
Authorization: `Bearer ${this.continueProxyConfig.apiKey}`,
|
||||
}
|
||||
}
|
||||
|
||||
override modifyRerankBody<T extends RerankCreateParams>(body: T): T {
|
||||
return {
|
||||
...body,
|
||||
...this.extraBodyProperties(),
|
||||
}
|
||||
}
|
||||
}
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
import { streamSse } from "../../../fetch/stream.js"
|
||||
import { ChatCompletionChunk, Model } from "openai/resources/index"
|
||||
import { DeepseekConfig } from "../types.js"
|
||||
import { chatChunk } from "../util.js"
|
||||
import { OpenAIApi } from "./OpenAI.js"
|
||||
import { FimCreateParamsStreaming } from "./base.js"
|
||||
|
||||
const DEEPSEEK_API_BASE = "https://api.deepseek.com/"
|
||||
export class DeepSeekApi extends OpenAIApi {
|
||||
constructor(config: DeepseekConfig) {
|
||||
super({
|
||||
...config,
|
||||
provider: "openai",
|
||||
apiBase: config.apiBase ?? DEEPSEEK_API_BASE,
|
||||
})
|
||||
}
|
||||
|
||||
override async *fimStream(
|
||||
body: FimCreateParamsStreaming,
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator<ChatCompletionChunk, any, unknown> {
|
||||
const endpoint = new URL("beta/completions", this.apiBase)
|
||||
const resp = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
model: body.model,
|
||||
prompt: body.prompt,
|
||||
suffix: body.suffix,
|
||||
max_tokens: body.max_tokens,
|
||||
temperature: body.temperature,
|
||||
top_p: body.top_p,
|
||||
frequency_penalty: body.frequency_penalty,
|
||||
presence_penalty: body.presence_penalty,
|
||||
stop: body.stop,
|
||||
stream: true,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
for await (const chunk of streamSse(resp as any)) {
|
||||
yield chatChunk({
|
||||
content: chunk.choices[0].text,
|
||||
finish_reason: chunk.finish_reason,
|
||||
model: body.model,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override list(): Promise<Model[]> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
}
|
||||
-316
@@ -1,316 +0,0 @@
|
||||
import { OpenAI } from "openai/index"
|
||||
import {
|
||||
ChatCompletion,
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionContentPartImage,
|
||||
ChatCompletionCreateParams,
|
||||
ChatCompletionCreateParamsNonStreaming,
|
||||
ChatCompletionCreateParamsStreaming,
|
||||
Completion,
|
||||
CompletionCreateParamsNonStreaming,
|
||||
CompletionCreateParamsStreaming,
|
||||
CompletionUsage,
|
||||
Model,
|
||||
} from "openai/resources/index"
|
||||
|
||||
import { streamResponse } from "../../../fetch/stream.js"
|
||||
import { GeminiConfig } from "../types.js"
|
||||
import { chatChunk, usageChatChunk } from "../util.js"
|
||||
import {
|
||||
convertOpenAIToolToGeminiFunction,
|
||||
GeminiChatContent,
|
||||
GeminiChatContentPart,
|
||||
GeminiToolFunctionDeclaration,
|
||||
} from "../util/gemini-types.js"
|
||||
import { BaseLlmApi, CreateRerankResponse, FimCreateParamsStreaming, RerankCreateParams } from "./base.js"
|
||||
|
||||
type UsageInfo = Pick<CompletionUsage, "total_tokens" | "completion_tokens" | "prompt_tokens">
|
||||
|
||||
export class GeminiApi implements BaseLlmApi {
|
||||
apiBase: string = "https://generativelanguage.googleapis.com/v1beta/"
|
||||
|
||||
constructor(protected config: GeminiConfig) {
|
||||
this.apiBase = config.apiBase ?? this.apiBase
|
||||
}
|
||||
|
||||
private _oaiPartToGeminiPart(
|
||||
part: OpenAI.Chat.Completions.ChatCompletionContentPart | OpenAI.Chat.Completions.ChatCompletionContentPartRefusal,
|
||||
): GeminiChatContentPart {
|
||||
switch (part.type) {
|
||||
case "refusal":
|
||||
return {
|
||||
text: part.refusal,
|
||||
}
|
||||
case "text":
|
||||
return {
|
||||
text: part.text,
|
||||
}
|
||||
case "input_audio":
|
||||
throw new Error("Unsupported part type: input_audio")
|
||||
case "image_url":
|
||||
default:
|
||||
return {
|
||||
inlineData: {
|
||||
mimeType: "image/jpeg",
|
||||
data: (part as ChatCompletionContentPartImage).image_url?.url.split(",")[1],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public _convertBody(oaiBody: ChatCompletionCreateParams, url: string) {
|
||||
const generationConfig: any = {}
|
||||
|
||||
if (oaiBody.top_p) {
|
||||
generationConfig.topP = oaiBody.top_p
|
||||
}
|
||||
if (oaiBody.temperature !== undefined && oaiBody.temperature !== null) {
|
||||
generationConfig.temperature = oaiBody.temperature
|
||||
}
|
||||
if (oaiBody.max_tokens) {
|
||||
generationConfig.maxOutputTokens = oaiBody.max_tokens
|
||||
}
|
||||
if (oaiBody.stop) {
|
||||
const stop = Array.isArray(oaiBody.stop) ? oaiBody.stop : [oaiBody.stop]
|
||||
generationConfig.stopSequences = stop.filter((x) => x.trim() !== "")
|
||||
}
|
||||
|
||||
const isV1API = url.includes("/v1/")
|
||||
|
||||
const contents: (GeminiChatContent | null)[] = oaiBody.messages
|
||||
.map((msg) => {
|
||||
if (msg.role === "system" && !isV1API) {
|
||||
return null // Don't include system message in contents
|
||||
}
|
||||
|
||||
if (msg.role === "assistant") {
|
||||
return {
|
||||
role: "model" as const,
|
||||
parts: [
|
||||
{
|
||||
text:
|
||||
typeof msg.content === "string"
|
||||
? msg.content
|
||||
: (msg.content ?? [])
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join(""),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.role === "user") {
|
||||
return {
|
||||
role: "user" as const,
|
||||
parts: [
|
||||
{
|
||||
text:
|
||||
typeof msg.content === "string"
|
||||
? msg.content
|
||||
: msg.content
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join(""),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (!msg.content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
role: "user" as const,
|
||||
parts: typeof msg.content === "string" ? [{ text: msg.content }] : msg.content.map(this._oaiPartToGeminiPart),
|
||||
}
|
||||
})
|
||||
.filter((c) => c !== null)
|
||||
|
||||
const sysMsg = oaiBody.messages.find((msg) => msg.role === "system")
|
||||
const finalBody: any = {
|
||||
generationConfig,
|
||||
contents,
|
||||
// if there is a system message, reformat it for Gemini API
|
||||
...(sysMsg &&
|
||||
!isV1API && {
|
||||
systemInstruction: { parts: [{ text: sysMsg.content }] },
|
||||
}),
|
||||
}
|
||||
|
||||
if (!isV1API) {
|
||||
// Convert and add tools if present
|
||||
if (oaiBody.tools?.length) {
|
||||
// Choosing to map all tools to the functionDeclarations of one tool
|
||||
// Rather than map each tool to its own tool + functionDeclaration
|
||||
// Same difference
|
||||
const functions: GeminiToolFunctionDeclaration[] = []
|
||||
oaiBody.tools.forEach((tool) => {
|
||||
try {
|
||||
functions.push(convertOpenAIToolToGeminiFunction(tool))
|
||||
} catch {
|
||||
console.warn(
|
||||
`Failed to convert tool to gemini function definition. Skipping: ${JSON.stringify(tool, null, 2)}`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
if (functions.length) {
|
||||
finalBody.tools = [
|
||||
{
|
||||
functionDeclarations: functions,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return finalBody
|
||||
}
|
||||
|
||||
async chatCompletionNonStream(
|
||||
body: ChatCompletionCreateParamsNonStreaming,
|
||||
signal: AbortSignal,
|
||||
): Promise<ChatCompletion> {
|
||||
let completion = ""
|
||||
let usage: UsageInfo | undefined = undefined
|
||||
for await (const chunk of this.chatCompletionStream(
|
||||
{
|
||||
...body,
|
||||
stream: true,
|
||||
},
|
||||
signal,
|
||||
)) {
|
||||
if (chunk.choices.length > 0) {
|
||||
completion += chunk.choices[0].delta.content || ""
|
||||
}
|
||||
if (chunk.usage) {
|
||||
usage = chunk.usage
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: "",
|
||||
object: "chat.completion",
|
||||
model: body.model,
|
||||
created: Date.now(),
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
logprobs: null,
|
||||
finish_reason: "stop",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: completion,
|
||||
refusal: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
usage,
|
||||
}
|
||||
}
|
||||
|
||||
async *handleStreamResponse(response: Response, model: string) {
|
||||
let buffer = ""
|
||||
let usage: UsageInfo | undefined = undefined
|
||||
for await (const chunk of streamResponse(response)) {
|
||||
buffer += chunk
|
||||
if (buffer.startsWith("[")) {
|
||||
buffer = buffer.slice(1)
|
||||
}
|
||||
if (buffer.endsWith("]")) {
|
||||
buffer = buffer.slice(0, -1)
|
||||
}
|
||||
if (buffer.startsWith(",")) {
|
||||
buffer = buffer.slice(1)
|
||||
}
|
||||
|
||||
const parts = buffer.split("\n,")
|
||||
|
||||
let foundIncomplete = false
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i]
|
||||
let data
|
||||
try {
|
||||
data = JSON.parse(part)
|
||||
} catch {
|
||||
foundIncomplete = true
|
||||
continue // yo!
|
||||
}
|
||||
if (data.error) {
|
||||
throw new Error(data.error.message)
|
||||
}
|
||||
|
||||
// Check for usage metadata
|
||||
if (data.usageMetadata) {
|
||||
usage = {
|
||||
prompt_tokens: data.usageMetadata.promptTokenCount || 0,
|
||||
completion_tokens: data.usageMetadata.candidatesTokenCount || 0,
|
||||
total_tokens: data.usageMetadata.totalTokenCount || 0,
|
||||
}
|
||||
}
|
||||
|
||||
// In case of max tokens reached, gemini will sometimes return content with no parts, even though that doesn't match the API spec
|
||||
const contentParts = data?.candidates?.[0]?.content?.parts
|
||||
if (contentParts) {
|
||||
for (const part of contentParts) {
|
||||
if ("text" in part) {
|
||||
yield chatChunk({
|
||||
content: part.text,
|
||||
model,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn("Unexpected response format:", data)
|
||||
}
|
||||
}
|
||||
if (foundIncomplete) {
|
||||
buffer = parts[parts.length - 1]
|
||||
} else {
|
||||
buffer = ""
|
||||
}
|
||||
}
|
||||
|
||||
// Emit usage at the end if we have it
|
||||
if (usage) {
|
||||
yield usageChatChunk({
|
||||
model,
|
||||
usage,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async *chatCompletionStream(
|
||||
body: ChatCompletionCreateParamsStreaming,
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator<ChatCompletionChunk> {
|
||||
const apiURL = new URL(
|
||||
`models/${body.model}:streamGenerateContent?key=${this.config.apiKey}`,
|
||||
this.apiBase,
|
||||
).toString()
|
||||
const convertedBody = this._convertBody(body, apiURL)
|
||||
const resp = await fetch(apiURL, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(convertedBody),
|
||||
signal,
|
||||
})
|
||||
yield* this.handleStreamResponse(resp, body.model)
|
||||
}
|
||||
completionNonStream(_body: CompletionCreateParamsNonStreaming): Promise<Completion> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
completionStream(_body: CompletionCreateParamsStreaming): AsyncGenerator<Completion> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
fimStream(_body: FimCreateParamsStreaming): AsyncGenerator<ChatCompletionChunk> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
async rerank(_body: RerankCreateParams): Promise<CreateRerankResponse> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
|
||||
list(): Promise<Model[]> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
}
|
||||
-237
@@ -1,237 +0,0 @@
|
||||
import { streamSse } from "../../../fetch/stream.js"
|
||||
import {
|
||||
ChatCompletion,
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionCreateParamsNonStreaming,
|
||||
ChatCompletionCreateParamsStreaming,
|
||||
ChatCompletionMessageParam,
|
||||
Model,
|
||||
} from "openai/resources/index"
|
||||
import { InceptionConfig } from "../types.js"
|
||||
import { chatChunk } from "../util.js"
|
||||
import { OpenAIApi } from "./OpenAI.js"
|
||||
import { FimCreateParamsStreaming } from "./base.js"
|
||||
|
||||
// export type InceptionChatCompletionCreateParamsStreaming =
|
||||
// ChatCompletionCreateParamsStreaming & {
|
||||
// nextEdit?: boolean;
|
||||
// };
|
||||
|
||||
// export type InceptionChatCompletionCreateParamsNonStreaming =
|
||||
// ChatCompletionCreateParamsNonStreaming & {
|
||||
// nextEdit?: boolean;
|
||||
// };
|
||||
|
||||
const UNIQUE_TOKEN = "<|!@#IS_NEXT_EDIT!@#|>"
|
||||
const APPLY_UNIQUE_TOKEN = "<|!@#IS_APPLY!@#|>"
|
||||
const INCEPTION_API_BASE = "https://api.inceptionlabs.ai/v1/"
|
||||
export class InceptionApi extends OpenAIApi {
|
||||
constructor(config: InceptionConfig) {
|
||||
super({
|
||||
...config,
|
||||
provider: "openai",
|
||||
apiBase: config.apiBase ?? INCEPTION_API_BASE,
|
||||
})
|
||||
}
|
||||
|
||||
// Add custom edit completions method.
|
||||
async *editCompletionStream(
|
||||
body: ChatCompletionCreateParamsStreaming,
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator<ChatCompletionChunk, any, unknown> {
|
||||
yield* this.streamCustomEndpoint("edit/completions", body, signal)
|
||||
}
|
||||
|
||||
// Add custom edit completions method (non-streaming).
|
||||
async editCompletionNonStream(
|
||||
body: ChatCompletionCreateParamsNonStreaming,
|
||||
signal: AbortSignal,
|
||||
): Promise<ChatCompletion> {
|
||||
return this.nonStreamCustomEndpoint("edit/completions", body, signal)
|
||||
}
|
||||
|
||||
// Override the regular chat stream method to route to edit endpoint for next edit requests.
|
||||
override async *chatCompletionStream(
|
||||
body: ChatCompletionCreateParamsStreaming,
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator<ChatCompletionChunk, any, unknown> {
|
||||
if (this.isNextEdit(body.messages)) {
|
||||
body.messages = this.removeToken(body.messages, UNIQUE_TOKEN)
|
||||
yield* this.editCompletionStream(body, signal)
|
||||
} else if (this.isApply(body.messages)) {
|
||||
body.messages = this.removeToken(body.messages, APPLY_UNIQUE_TOKEN)
|
||||
yield* this.applyCompletionStream(body, signal)
|
||||
} else {
|
||||
yield* super.chatCompletionStream(body, signal)
|
||||
}
|
||||
}
|
||||
|
||||
// Override the regular chat non stream method to route to edit endpoint for next edit requests.
|
||||
override async chatCompletionNonStream(
|
||||
body: ChatCompletionCreateParamsNonStreaming,
|
||||
signal: AbortSignal,
|
||||
): Promise<ChatCompletion> {
|
||||
if (this.isNextEdit(body.messages)) {
|
||||
body.messages = this.removeToken(body.messages, UNIQUE_TOKEN)
|
||||
return this.editCompletionNonStream(body, signal)
|
||||
} else if (this.isApply(body.messages)) {
|
||||
body.messages = this.removeToken(body.messages, APPLY_UNIQUE_TOKEN)
|
||||
return this.applyCompletionNonStream(body, signal)
|
||||
} else {
|
||||
return super.chatCompletionNonStream(body, signal)
|
||||
}
|
||||
}
|
||||
|
||||
async *applyCompletionStream(
|
||||
body: ChatCompletionCreateParamsStreaming,
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator<ChatCompletionChunk, any, unknown> {
|
||||
yield* this.streamCustomEndpoint("apply/completions", body, signal)
|
||||
}
|
||||
|
||||
async applyCompletionNonStream(
|
||||
body: ChatCompletionCreateParamsNonStreaming,
|
||||
signal: AbortSignal,
|
||||
): Promise<ChatCompletion> {
|
||||
return this.nonStreamCustomEndpoint("apply/completions", body, signal)
|
||||
}
|
||||
|
||||
override async *fimStream(
|
||||
body: FimCreateParamsStreaming,
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator<ChatCompletionChunk, any, unknown> {
|
||||
const endpoint = new URL("completions", this.apiBase)
|
||||
const resp = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
model: body.model,
|
||||
prompt: body.prompt,
|
||||
suffix: body.suffix.trim() === "" ? "<|endoftext|>" : body.suffix,
|
||||
max_tokens: body.max_tokens ?? 150, // Only want this for /fim, not chat
|
||||
temperature: body.temperature,
|
||||
top_p: body.top_p,
|
||||
frequency_penalty: body.frequency_penalty,
|
||||
presence_penalty: body.presence_penalty,
|
||||
stop: [...(body.stop ?? []), "\n\n", "\n \n"],
|
||||
stream: true,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
for await (const chunk of streamSse(resp as any)) {
|
||||
if (!chunk.choices[0]) {
|
||||
continue
|
||||
}
|
||||
yield chatChunk({
|
||||
content: chunk.choices[0].text,
|
||||
finish_reason: null,
|
||||
model: body.model,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override list(): Promise<Model[]> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
|
||||
// Check if any message contains the unique next edit token.
|
||||
private isNextEdit(messages: ChatCompletionMessageParam[]): boolean {
|
||||
return messages.some((message) => typeof message.content === "string" && message.content.endsWith(UNIQUE_TOKEN))
|
||||
}
|
||||
|
||||
private isApply(messages: ChatCompletionMessageParam[]): boolean {
|
||||
return messages.some(
|
||||
(message) => typeof message.content === "string" && message.content.endsWith(APPLY_UNIQUE_TOKEN),
|
||||
)
|
||||
}
|
||||
|
||||
// Remove the unique token from messages.
|
||||
private removeToken(messages: ChatCompletionMessageParam[], token: string): ChatCompletionMessageParam[] {
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
|
||||
if (typeof lastMessage?.content === "string" && lastMessage.content.endsWith(token)) {
|
||||
const cleanedMessages = [...messages]
|
||||
cleanedMessages[cleanedMessages.length - 1] = {
|
||||
...lastMessage,
|
||||
content: lastMessage.content.slice(0, -token.length),
|
||||
}
|
||||
return cleanedMessages
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
private async *streamCustomEndpoint(
|
||||
path: string,
|
||||
body: ChatCompletionCreateParamsStreaming,
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator<ChatCompletionChunk, any, unknown> {
|
||||
const endpoint = new URL(path, this.apiBase)
|
||||
const resp = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
model: body.model,
|
||||
messages: body.messages,
|
||||
max_tokens: body.max_tokens,
|
||||
temperature: body.temperature,
|
||||
top_p: body.top_p,
|
||||
frequency_penalty: body.frequency_penalty,
|
||||
presence_penalty: body.presence_penalty,
|
||||
stop: body.stop,
|
||||
stream: true,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
|
||||
for await (const chunk of streamSse(resp as any)) {
|
||||
const deltaContent = chunk.choices?.[0]?.delta?.content
|
||||
if (deltaContent) {
|
||||
yield chatChunk({
|
||||
content: deltaContent,
|
||||
finish_reason: chunk.choices[0].finish_reason || null,
|
||||
model: body.model,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async nonStreamCustomEndpoint(
|
||||
path: string,
|
||||
body: ChatCompletionCreateParamsNonStreaming,
|
||||
signal: AbortSignal,
|
||||
): Promise<ChatCompletion> {
|
||||
const endpoint = new URL(path, this.apiBase)
|
||||
const resp = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
model: body.model,
|
||||
messages: body.messages,
|
||||
max_tokens: body.max_tokens,
|
||||
temperature: body.temperature,
|
||||
top_p: body.top_p,
|
||||
frequency_penalty: body.frequency_penalty,
|
||||
presence_penalty: body.presence_penalty,
|
||||
stop: body.stop,
|
||||
stream: false,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
|
||||
const data = await resp.json()
|
||||
return data as ChatCompletion
|
||||
}
|
||||
}
|
||||
-70
@@ -1,70 +0,0 @@
|
||||
import {
|
||||
ChatCompletion,
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionCreateParamsNonStreaming,
|
||||
ChatCompletionCreateParamsStreaming,
|
||||
Completion,
|
||||
CompletionCreateParamsNonStreaming,
|
||||
CompletionCreateParamsStreaming,
|
||||
Model,
|
||||
} from "openai/resources/index"
|
||||
import { JinaConfig } from "../types.js"
|
||||
import { rerank } from "../util.js"
|
||||
import { BaseLlmApi, CreateRerankResponse, FimCreateParamsStreaming, RerankCreateParams } from "./base.js"
|
||||
|
||||
export class JinaApi implements BaseLlmApi {
|
||||
apiBase: string = "https://api.jina.ai/v1/"
|
||||
|
||||
constructor(protected config: JinaConfig) {
|
||||
this.apiBase = config.apiBase ?? this.apiBase
|
||||
}
|
||||
|
||||
async chatCompletionNonStream(_body: ChatCompletionCreateParamsNonStreaming): Promise<ChatCompletion> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
|
||||
// eslint-disable-next-line require-yield
|
||||
async *chatCompletionStream(
|
||||
_body: ChatCompletionCreateParamsStreaming,
|
||||
): AsyncGenerator<ChatCompletionChunk, any, unknown> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
async completionNonStream(_body: CompletionCreateParamsNonStreaming): Promise<Completion> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
// eslint-disable-next-line require-yield
|
||||
async *completionStream(_body: CompletionCreateParamsStreaming): AsyncGenerator<Completion, any, unknown> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
// eslint-disable-next-line require-yield
|
||||
async *fimStream(_body: FimCreateParamsStreaming): AsyncGenerator<ChatCompletionChunk, any, unknown> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
|
||||
async rerank(body: RerankCreateParams): Promise<CreateRerankResponse> {
|
||||
const endpoint = new URL("rerank", this.apiBase)
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"x-api-key": this.config.apiKey ?? "",
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
},
|
||||
})
|
||||
const data = (await response.json()) as any
|
||||
|
||||
return rerank({
|
||||
model: body.model,
|
||||
usage: {
|
||||
total_tokens: 0,
|
||||
},
|
||||
data: data.results.map((result: any) => result.relevance_score),
|
||||
})
|
||||
}
|
||||
|
||||
list(): Promise<Model[]> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
}
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
import { streamSse } from "../../../fetch/stream.js"
|
||||
import { ChatCompletionChunk } from "openai/resources/index"
|
||||
import { LlamastackConfig } from "../types.js"
|
||||
import { chatChunk } from "../util.js"
|
||||
import { OpenAIApi } from "./OpenAI.js"
|
||||
import { FimCreateParamsStreaming } from "./base.js"
|
||||
|
||||
const Llamastack_API_BASE = "http://localhost:8321/v1/openai/v1/"
|
||||
export class LlamastackApi extends OpenAIApi {
|
||||
constructor(config: LlamastackConfig) {
|
||||
super({
|
||||
...config,
|
||||
provider: "openai",
|
||||
apiBase: config.apiBase ?? Llamastack_API_BASE,
|
||||
apiKey: config.apiKey ?? "dummy",
|
||||
})
|
||||
}
|
||||
|
||||
override async *fimStream(
|
||||
body: FimCreateParamsStreaming,
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator<ChatCompletionChunk, any, unknown> {
|
||||
const endpoint = new URL("completions", this.apiBase)
|
||||
const resp = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
model: body.model,
|
||||
prompt: body.prompt,
|
||||
suffix: body.suffix,
|
||||
max_tokens: body.max_tokens,
|
||||
max_completion_tokens: (body as any).max_completion_tokens,
|
||||
temperature: body.temperature,
|
||||
top_p: body.top_p,
|
||||
frequency_penalty: body.frequency_penalty,
|
||||
presence_penalty: body.presence_penalty,
|
||||
stop: body.stop,
|
||||
stream: true,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"x-api-key": this.config.apiKey ?? "",
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
for await (const chunk of streamSse(resp as any)) {
|
||||
yield chatChunk({
|
||||
content: chunk.choices[0].text,
|
||||
finish_reason: chunk.finish_reason,
|
||||
model: body.model,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
-127
@@ -1,127 +0,0 @@
|
||||
import {
|
||||
ChatCompletion,
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionCreateParamsNonStreaming,
|
||||
ChatCompletionCreateParamsStreaming,
|
||||
Completion,
|
||||
CompletionCreateParamsNonStreaming,
|
||||
CompletionCreateParamsStreaming,
|
||||
Model,
|
||||
} from "openai/resources/index"
|
||||
import { chatChunk, chatCompletion } from "../util.js"
|
||||
import { BaseLlmApi, CreateRerankResponse, FimCreateParamsStreaming, RerankCreateParams } from "./base.js"
|
||||
|
||||
const MOCK_RESPONSE =
|
||||
"This is a mock response from the OpenAI API. It can be returned all at once or streamed chunk by chunk."
|
||||
|
||||
export class MockApi implements BaseLlmApi {
|
||||
async chatCompletionNonStream(
|
||||
body: ChatCompletionCreateParamsNonStreaming,
|
||||
_signal: AbortSignal,
|
||||
): Promise<ChatCompletion> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
const lastMessage = body.messages[body.messages.length - 1].content
|
||||
const content = !lastMessage
|
||||
? MOCK_RESPONSE
|
||||
: typeof lastMessage === "string"
|
||||
? lastMessage
|
||||
: lastMessage[0].type === "text"
|
||||
? lastMessage[0].text
|
||||
: MOCK_RESPONSE
|
||||
return chatCompletion({
|
||||
content,
|
||||
model: body.model,
|
||||
})
|
||||
}
|
||||
|
||||
async *chatCompletionStream(
|
||||
body: ChatCompletionCreateParamsStreaming,
|
||||
_signal: AbortSignal,
|
||||
): AsyncGenerator<ChatCompletionChunk, any, unknown> {
|
||||
const lastMessage = body.messages[body.messages.length - 1].content
|
||||
const content = !lastMessage
|
||||
? MOCK_RESPONSE
|
||||
: typeof lastMessage === "string"
|
||||
? lastMessage
|
||||
: lastMessage[0].type === "text"
|
||||
? lastMessage[0].text
|
||||
: MOCK_RESPONSE
|
||||
const chunks = content.split(" ")
|
||||
for (const chunk of chunks) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
yield chatChunk({
|
||||
content: chunk + " ",
|
||||
model: body.model,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async completionNonStream(body: CompletionCreateParamsNonStreaming, _signal: AbortSignal): Promise<Completion> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
return {
|
||||
id: "mock-completion",
|
||||
object: "text_completion",
|
||||
created: Date.now(),
|
||||
model: body.model,
|
||||
choices: [
|
||||
{
|
||||
text: body.prompt as string,
|
||||
index: 0,
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
} as Completion
|
||||
}
|
||||
|
||||
async *completionStream(
|
||||
body: CompletionCreateParamsStreaming,
|
||||
_signal: AbortSignal,
|
||||
): AsyncGenerator<Completion, any, unknown> {
|
||||
const chunks = (body.prompt as string).split(" ")
|
||||
for (const chunk of chunks) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
yield {
|
||||
id: "mock-chunk",
|
||||
object: "text_completion",
|
||||
created: Date.now(),
|
||||
model: body.model,
|
||||
choices: [
|
||||
{
|
||||
text: chunk + " ",
|
||||
index: 0,
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
} as Completion
|
||||
}
|
||||
}
|
||||
|
||||
async *fimStream(
|
||||
body: FimCreateParamsStreaming,
|
||||
_signal: AbortSignal,
|
||||
): AsyncGenerator<ChatCompletionChunk, any, unknown> {
|
||||
const chunks = (body.prompt as string).split(" ")
|
||||
for (const chunk of chunks) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
yield chatChunk({
|
||||
content: chunk + " ",
|
||||
model: body.model,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async rerank(_body: RerankCreateParams): Promise<CreateRerankResponse> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
|
||||
async list(): Promise<Model[]> {
|
||||
return [
|
||||
{
|
||||
id: "mock-model",
|
||||
created: Date.now(),
|
||||
object: "model",
|
||||
owned_by: "mock",
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
import { streamSse } from "../../../fetch/stream.js"
|
||||
import { ChatCompletionChunk, Model } from "openai/resources/index"
|
||||
import { MoonshotConfig } from "../types.js"
|
||||
import { chatChunk } from "../util.js"
|
||||
import { OpenAIApi } from "./OpenAI.js"
|
||||
import { FimCreateParamsStreaming } from "./base.js"
|
||||
|
||||
export class MoonshotApi extends OpenAIApi {
|
||||
override apiBase: string = "https://api.moonshot.cn/"
|
||||
constructor(config: MoonshotConfig) {
|
||||
super({
|
||||
...config,
|
||||
provider: "openai",
|
||||
})
|
||||
}
|
||||
|
||||
override async *fimStream(
|
||||
body: FimCreateParamsStreaming,
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator<ChatCompletionChunk, any, unknown> {
|
||||
const endpoint = new URL("v1/chat/completions", this.apiBase)
|
||||
const resp = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
model: body.model,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: body.prompt + "[fill]" + body.suffix,
|
||||
},
|
||||
],
|
||||
max_tokens: body.max_tokens,
|
||||
temperature: body.temperature,
|
||||
top_p: body.top_p,
|
||||
frequency_penalty: body.frequency_penalty,
|
||||
presence_penalty: body.presence_penalty,
|
||||
stop: body.stop,
|
||||
stream: true,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
|
||||
for await (const chunk of streamSse(resp as any)) {
|
||||
yield chatChunk({
|
||||
content: chunk.choices[0].delta.content,
|
||||
finish_reason: chunk.finish_reason,
|
||||
model: body.model,
|
||||
})
|
||||
}
|
||||
}
|
||||
override list(): Promise<Model[]> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
}
|
||||
-157
@@ -1,157 +0,0 @@
|
||||
import { streamSse } from "../../../fetch/stream.js"
|
||||
import { OpenAI } from "openai/index"
|
||||
import {
|
||||
ChatCompletion,
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionCreateParams,
|
||||
ChatCompletionCreateParamsNonStreaming,
|
||||
ChatCompletionCreateParamsStreaming,
|
||||
Completion,
|
||||
CompletionCreateParamsNonStreaming,
|
||||
CompletionCreateParamsStreaming,
|
||||
Model,
|
||||
} from "openai/resources/index"
|
||||
import { z } from "zod"
|
||||
import { OpenAIConfigSchema } from "../types.js"
|
||||
import { BaseLlmApi, CreateRerankResponse, FimCreateParamsStreaming, RerankCreateParams } from "./base.js"
|
||||
|
||||
export class OpenAIApi implements BaseLlmApi {
|
||||
openai: OpenAI
|
||||
apiBase: string = "https://api.openai.com/v1/"
|
||||
|
||||
constructor(protected config: z.infer<typeof OpenAIConfigSchema>) {
|
||||
this.apiBase = config.apiBase ?? this.apiBase
|
||||
this.openai = new OpenAI({
|
||||
// Necessary because `new OpenAI()` will throw an error if there is no API Key
|
||||
apiKey: config.apiKey ?? "",
|
||||
baseURL: this.apiBase,
|
||||
})
|
||||
}
|
||||
modifyChatBody<T extends ChatCompletionCreateParams>(body: T): T {
|
||||
// Add stream_options to include usage in streaming responses
|
||||
if (body.stream) {
|
||||
;(body as any).stream_options = { include_usage: true }
|
||||
}
|
||||
|
||||
// o-series models - only apply for official OpenAI API
|
||||
const isOfficialOpenAIAPI = this.apiBase === "https://api.openai.com/v1/"
|
||||
if (isOfficialOpenAIAPI) {
|
||||
if (body.model.startsWith("o") || body.model.includes("gpt-5")) {
|
||||
// a) use max_completion_tokens instead of max_tokens
|
||||
body.max_completion_tokens = body.max_tokens
|
||||
body.max_tokens = undefined
|
||||
|
||||
// b) use "developer" message role rather than "system"
|
||||
body.messages = body.messages.map((message) => {
|
||||
if (message.role === "system") {
|
||||
return { ...message, role: "developer" } as any
|
||||
}
|
||||
return message
|
||||
})
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
modifyCompletionBody<T extends CompletionCreateParamsNonStreaming | CompletionCreateParamsStreaming>(body: T): T {
|
||||
return body
|
||||
}
|
||||
|
||||
modifyFimBody<T extends FimCreateParamsStreaming>(body: T): T {
|
||||
return body
|
||||
}
|
||||
|
||||
modifyRerankBody<T extends RerankCreateParams>(body: T): T {
|
||||
return body
|
||||
}
|
||||
|
||||
protected getHeaders(): Record<string, string> {
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"x-api-key": this.config.apiKey ?? "",
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
}
|
||||
}
|
||||
|
||||
async chatCompletionNonStream(
|
||||
body: ChatCompletionCreateParamsNonStreaming,
|
||||
signal: AbortSignal,
|
||||
): Promise<ChatCompletion> {
|
||||
const response = await this.openai.chat.completions.create(this.modifyChatBody(body), {
|
||||
signal,
|
||||
})
|
||||
return response
|
||||
}
|
||||
|
||||
async *chatCompletionStream(
|
||||
body: ChatCompletionCreateParamsStreaming,
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator<ChatCompletionChunk, any, unknown> {
|
||||
const response = await this.openai.chat.completions.create(this.modifyChatBody(body), {
|
||||
signal,
|
||||
})
|
||||
for await (const result of response) {
|
||||
yield result
|
||||
}
|
||||
}
|
||||
async completionNonStream(body: CompletionCreateParamsNonStreaming, signal: AbortSignal): Promise<Completion> {
|
||||
const response = await this.openai.completions.create(this.modifyCompletionBody(body), { signal })
|
||||
return response
|
||||
}
|
||||
async *completionStream(
|
||||
body: CompletionCreateParamsStreaming,
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator<Completion, any, unknown> {
|
||||
const response = await this.openai.completions.create(this.modifyCompletionBody(body), { signal })
|
||||
for await (const result of response) {
|
||||
yield result
|
||||
}
|
||||
}
|
||||
async *fimStream(
|
||||
body: FimCreateParamsStreaming,
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator<ChatCompletionChunk, any, unknown> {
|
||||
const endpoint = new URL("fim/completions", this.apiBase)
|
||||
const modifiedBody = this.modifyFimBody(body)
|
||||
const resp = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
model: modifiedBody.model,
|
||||
prompt: modifiedBody.prompt,
|
||||
suffix: modifiedBody.suffix,
|
||||
max_tokens: modifiedBody.max_tokens,
|
||||
max_completion_tokens: (modifiedBody as any).max_completion_tokens,
|
||||
temperature: modifiedBody.temperature,
|
||||
top_p: modifiedBody.top_p,
|
||||
frequency_penalty: modifiedBody.frequency_penalty,
|
||||
presence_penalty: modifiedBody.presence_penalty,
|
||||
stop: modifiedBody.stop,
|
||||
stream: true,
|
||||
}),
|
||||
headers: this.getHeaders(),
|
||||
signal,
|
||||
})
|
||||
for await (const chunk of streamSse(resp as any)) {
|
||||
if (chunk.choices && chunk.choices.length > 0) {
|
||||
yield chunk
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async rerank(body: RerankCreateParams): Promise<CreateRerankResponse> {
|
||||
const endpoint = new URL("rerank", this.apiBase)
|
||||
const modifiedBody = this.modifyRerankBody(body)
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(modifiedBody),
|
||||
headers: this.getHeaders(),
|
||||
})
|
||||
const data = await response.json()
|
||||
return data as any
|
||||
}
|
||||
|
||||
async list(): Promise<Model[]> {
|
||||
return (await this.openai.models.list()).data
|
||||
}
|
||||
}
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
import { ChatCompletionCreateParams } from "openai/resources/index"
|
||||
|
||||
import { OpenAIConfig } from "../types.js"
|
||||
import { OpenAIApi } from "./OpenAI.js"
|
||||
import { applyAnthropicCachingToOpenRouterBody } from "./OpenRouterCaching.js"
|
||||
|
||||
interface OpenRouterConfig extends OpenAIConfig {
|
||||
cachingStrategy?: import("./AnthropicCachingStrategies.js").CachingStrategyName
|
||||
}
|
||||
|
||||
export class OpenRouterApi extends OpenAIApi {
|
||||
constructor(config: OpenRouterConfig) {
|
||||
super({
|
||||
...config,
|
||||
apiBase: config.apiBase ?? "https://openrouter.ai/api/v1/",
|
||||
})
|
||||
}
|
||||
|
||||
private isAnthropicModel(model?: string): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
}
|
||||
const modelLower = model.toLowerCase()
|
||||
return modelLower.includes("claude")
|
||||
}
|
||||
|
||||
override modifyChatBody<T extends ChatCompletionCreateParams>(body: T): T {
|
||||
const modifiedBody = super.modifyChatBody(body)
|
||||
|
||||
if (!this.isAnthropicModel(modifiedBody.model)) {
|
||||
return modifiedBody
|
||||
}
|
||||
|
||||
applyAnthropicCachingToOpenRouterBody(
|
||||
modifiedBody as unknown as ChatCompletionCreateParams,
|
||||
(this.config as OpenRouterConfig).cachingStrategy ?? "systemAndTools",
|
||||
)
|
||||
|
||||
return modifiedBody
|
||||
}
|
||||
}
|
||||
-258
@@ -1,258 +0,0 @@
|
||||
import { ContentBlockParam, MessageCreateParams, MessageParam } from "@anthropic-ai/sdk/resources"
|
||||
import { ChatCompletionCreateParams, ChatCompletionMessageParam } from "openai/resources/index"
|
||||
|
||||
import { CACHING_STRATEGIES, CachingStrategyName } from "./AnthropicCachingStrategies.js"
|
||||
import { addCacheControlToLastTwoUserMessages, openaiToolToAnthropicTool } from "./AnthropicUtils.js"
|
||||
|
||||
interface SystemMapping {
|
||||
openaiIndex: number
|
||||
start: number
|
||||
length: number
|
||||
wasString: boolean
|
||||
originalContent: ChatCompletionMessageParam["content"]
|
||||
textPartIndices: (number | null)[]
|
||||
}
|
||||
|
||||
interface MessageMapping {
|
||||
openaiIndex: number
|
||||
anthropicIndex: number
|
||||
role: string
|
||||
wasString: boolean
|
||||
originalContent: ChatCompletionMessageParam["content"]
|
||||
textPartIndices: (number | null)[]
|
||||
}
|
||||
|
||||
interface ConversionResult {
|
||||
anthropicBody: MessageCreateParams
|
||||
systemMappings: SystemMapping[]
|
||||
messageMappings: MessageMapping[]
|
||||
}
|
||||
|
||||
const convertContentToBlocks = (
|
||||
content: ChatCompletionMessageParam["content"],
|
||||
): {
|
||||
blocks: ContentBlockParam[]
|
||||
textPartIndices: (number | null)[]
|
||||
wasString: boolean
|
||||
} => {
|
||||
if (typeof content === "string" || typeof content === "number") {
|
||||
const text = String(content)
|
||||
return {
|
||||
blocks: [
|
||||
{
|
||||
type: "text",
|
||||
text,
|
||||
} as ContentBlockParam,
|
||||
],
|
||||
textPartIndices: [null],
|
||||
wasString: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return {
|
||||
blocks: [],
|
||||
textPartIndices: [],
|
||||
wasString: false,
|
||||
}
|
||||
}
|
||||
|
||||
const blocks: ContentBlockParam[] = []
|
||||
const textPartIndices: (number | null)[] = []
|
||||
|
||||
content.forEach((part: any, idx: number) => {
|
||||
if (part?.type === "text") {
|
||||
blocks.push({
|
||||
type: "text",
|
||||
text: part.text ?? "",
|
||||
} as ContentBlockParam)
|
||||
textPartIndices.push(idx)
|
||||
} else {
|
||||
blocks.push({ ...(part ?? {}) } as any)
|
||||
textPartIndices.push(null)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
blocks,
|
||||
textPartIndices,
|
||||
wasString: false,
|
||||
}
|
||||
}
|
||||
|
||||
const convertToAnthropic = (body: ChatCompletionCreateParams): ConversionResult => {
|
||||
const systemBlocks: ContentBlockParam[] = []
|
||||
const systemMappings: SystemMapping[] = []
|
||||
const messages: MessageParam[] = []
|
||||
const messageMappings: MessageMapping[] = []
|
||||
|
||||
let systemOffset = 0
|
||||
|
||||
body.messages.forEach((message, index) => {
|
||||
const { blocks, textPartIndices, wasString } = convertContentToBlocks(message.content)
|
||||
|
||||
if (message.role === "system") {
|
||||
const length = blocks.length
|
||||
systemMappings.push({
|
||||
openaiIndex: index,
|
||||
start: systemOffset,
|
||||
length,
|
||||
wasString,
|
||||
originalContent: message.content,
|
||||
textPartIndices,
|
||||
})
|
||||
systemBlocks.push(...blocks)
|
||||
systemOffset += length
|
||||
} else {
|
||||
messages.push({
|
||||
role: message.role as MessageParam["role"],
|
||||
content: blocks as any,
|
||||
})
|
||||
messageMappings.push({
|
||||
openaiIndex: index,
|
||||
anthropicIndex: messages.length - 1,
|
||||
role: message.role,
|
||||
wasString,
|
||||
originalContent: message.content,
|
||||
textPartIndices,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const tools = body.tools?.filter((tool) => tool.type === "function").map((tool) => openaiToolToAnthropicTool(tool))
|
||||
|
||||
const anthropicBody: MessageCreateParams = {
|
||||
model: body.model,
|
||||
messages,
|
||||
max_tokens: body.max_tokens ?? 1,
|
||||
system: systemBlocks.length > 0 ? (systemBlocks as any) : undefined,
|
||||
tools,
|
||||
}
|
||||
|
||||
return { anthropicBody, systemMappings, messageMappings }
|
||||
}
|
||||
|
||||
export const applyAnthropicCachingToOpenRouterBody = (
|
||||
body: ChatCompletionCreateParams,
|
||||
strategy: CachingStrategyName,
|
||||
): void => {
|
||||
const { anthropicBody, systemMappings, messageMappings } = convertToAnthropic(body)
|
||||
|
||||
const cachingStrategy = CACHING_STRATEGIES[strategy] ?? CACHING_STRATEGIES.systemAndTools
|
||||
const cachedBody = cachingStrategy({ ...anthropicBody })
|
||||
|
||||
cachedBody.messages = cachedBody.messages ?? []
|
||||
addCacheControlToLastTwoUserMessages(cachedBody.messages)
|
||||
|
||||
const cachedSystem = Array.isArray(cachedBody.system) ? cachedBody.system : []
|
||||
|
||||
systemMappings.forEach((mapping) => {
|
||||
const openaiMessage = body.messages[mapping.openaiIndex] as any
|
||||
if (!openaiMessage) {
|
||||
return
|
||||
}
|
||||
|
||||
const slice = cachedSystem.slice(mapping.start, mapping.start + mapping.length)
|
||||
const hasCache = slice.some((block: any) => block?.cache_control)
|
||||
|
||||
if (!hasCache) {
|
||||
openaiMessage.content = mapping.originalContent
|
||||
return
|
||||
}
|
||||
|
||||
if (mapping.wasString) {
|
||||
openaiMessage.content = slice.map((block: any) => ({
|
||||
type: "text",
|
||||
text: block?.text ?? "",
|
||||
...(block?.cache_control ? { cache_control: block.cache_control } : {}),
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (Array.isArray(mapping.originalContent)) {
|
||||
const newParts = mapping.originalContent.map((part: any) => ({
|
||||
...part,
|
||||
}))
|
||||
|
||||
slice.forEach((block: any, idx: number) => {
|
||||
const originalIndex = mapping.textPartIndices[idx]
|
||||
if (originalIndex === null || originalIndex === undefined || !block?.cache_control) {
|
||||
return
|
||||
}
|
||||
|
||||
newParts[originalIndex] = {
|
||||
...newParts[originalIndex],
|
||||
cache_control: block.cache_control,
|
||||
...(block.text !== undefined ? { text: block.text } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
openaiMessage.content = newParts
|
||||
}
|
||||
})
|
||||
|
||||
const cachedMessages = cachedBody.messages ?? []
|
||||
messageMappings.forEach((mapping) => {
|
||||
const openaiMessage = body.messages[mapping.openaiIndex] as any
|
||||
const cachedMessage = cachedMessages[mapping.anthropicIndex] as any
|
||||
if (!openaiMessage || !cachedMessage) {
|
||||
return
|
||||
}
|
||||
|
||||
if (cachedMessage.role !== "user") {
|
||||
openaiMessage.content = mapping.originalContent
|
||||
return
|
||||
}
|
||||
|
||||
const contentArray = Array.isArray(cachedMessage.content) ? cachedMessage.content : []
|
||||
const hasCache = contentArray.some((block: any) => block?.cache_control)
|
||||
|
||||
if (!hasCache) {
|
||||
openaiMessage.content = mapping.originalContent
|
||||
return
|
||||
}
|
||||
|
||||
if (mapping.wasString) {
|
||||
openaiMessage.content = contentArray.map((block: any) => ({
|
||||
type: "text",
|
||||
text: block?.text ?? "",
|
||||
...(block?.cache_control ? { cache_control: block.cache_control } : {}),
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (Array.isArray(mapping.originalContent)) {
|
||||
const newParts = mapping.originalContent.map((part: any) => ({
|
||||
...part,
|
||||
}))
|
||||
|
||||
contentArray.forEach((block: any, idx: number) => {
|
||||
const originalIndex = mapping.textPartIndices[idx]
|
||||
if (originalIndex === null || originalIndex === undefined || !block?.cache_control) {
|
||||
return
|
||||
}
|
||||
|
||||
newParts[originalIndex] = {
|
||||
...newParts[originalIndex],
|
||||
cache_control: block.cache_control,
|
||||
...(block.text !== undefined ? { text: block.text } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
openaiMessage.content = newParts
|
||||
}
|
||||
})
|
||||
|
||||
if (body.tools?.length && cachedBody.tools?.length) {
|
||||
body.tools = body.tools.map((tool, idx) => {
|
||||
const cachedTool = (cachedBody.tools ?? [])[idx] as any
|
||||
if (!cachedTool?.cache_control) {
|
||||
return tool
|
||||
}
|
||||
return {
|
||||
...tool,
|
||||
cache_control: cachedTool.cache_control,
|
||||
} as any
|
||||
})
|
||||
}
|
||||
}
|
||||
-134
@@ -1,134 +0,0 @@
|
||||
import { Completion, CompletionUsage } from "openai/resources/completions.mjs"
|
||||
import {
|
||||
ChatCompletion,
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionCreateParamsNonStreaming,
|
||||
ChatCompletionCreateParamsStreaming,
|
||||
} from "openai/resources/index.mjs"
|
||||
import { Model } from "openai/resources/models.mjs"
|
||||
import { z } from "zod"
|
||||
import { OpenAIConfigSchema } from "../types.js"
|
||||
import { chatChunk, chatCompletion, usageChatChunk } from "../util.js"
|
||||
import { BaseLlmApi, CreateRerankResponse } from "./base.js"
|
||||
|
||||
type UsageInfo = Pick<CompletionUsage, "total_tokens" | "completion_tokens" | "prompt_tokens">
|
||||
|
||||
// Relace only supports apply through a /v1/apply endpoint
|
||||
export class RelaceApi implements BaseLlmApi {
|
||||
private apiBase = "https://instantapply.endpoint.relace.run/v1/"
|
||||
|
||||
constructor(private readonly config: z.infer<typeof OpenAIConfigSchema>) {
|
||||
this.apiBase = config.apiBase ?? this.apiBase
|
||||
if (!this.apiBase.endsWith("/")) {
|
||||
this.apiBase += "/"
|
||||
}
|
||||
this.config = config
|
||||
}
|
||||
|
||||
async chatCompletionNonStream(
|
||||
body: ChatCompletionCreateParamsNonStreaming,
|
||||
signal: AbortSignal,
|
||||
): Promise<ChatCompletion> {
|
||||
let content = ""
|
||||
let usage: UsageInfo | undefined = undefined
|
||||
|
||||
// Convert the non-streaming params to streaming params
|
||||
const streamingBody: ChatCompletionCreateParamsStreaming = {
|
||||
...body,
|
||||
stream: true,
|
||||
}
|
||||
|
||||
for await (const chunk of this.chatCompletionStream(streamingBody, signal)) {
|
||||
if (chunk.choices.length > 0) {
|
||||
content += chunk.choices[0]?.delta?.content || ""
|
||||
}
|
||||
if (chunk.usage) {
|
||||
usage = chunk.usage
|
||||
}
|
||||
}
|
||||
|
||||
return chatCompletion({
|
||||
content,
|
||||
model: body.model,
|
||||
usage,
|
||||
})
|
||||
}
|
||||
|
||||
// We convert from what would be sent to OpenAI (a prediction for the existing code and a user message with the new code)
|
||||
// to Relace's format
|
||||
async *chatCompletionStream(
|
||||
body: ChatCompletionCreateParamsStreaming,
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator<ChatCompletionChunk> {
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
}
|
||||
|
||||
const prediction = body.prediction?.content ?? ""
|
||||
const initialCode = typeof prediction === "string" ? prediction : prediction.map((p) => p.text).join("")
|
||||
|
||||
const userContent = body.messages.find((m) => m.role === "user")?.content
|
||||
if (!userContent) {
|
||||
throw new Error("No edit snippet provided.")
|
||||
}
|
||||
|
||||
const editSnippet =
|
||||
typeof userContent === "string"
|
||||
? userContent
|
||||
: userContent
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("")
|
||||
|
||||
const data = {
|
||||
initialCode,
|
||||
editSnippet,
|
||||
}
|
||||
|
||||
const url = this.apiBase + "code/apply"
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(data),
|
||||
signal,
|
||||
})
|
||||
|
||||
if (response.status === 499) {
|
||||
return // Aborted by user
|
||||
}
|
||||
|
||||
const result = (await response.json()) as any
|
||||
const mergedCode = result.mergedCode
|
||||
|
||||
yield chatChunk({
|
||||
content: mergedCode,
|
||||
model: body.model,
|
||||
})
|
||||
|
||||
yield usageChatChunk({
|
||||
model: body.model,
|
||||
usage: {
|
||||
prompt_tokens: result.usage.prompt_tokens || 0,
|
||||
completion_tokens: result.usage.completion_tokens || 0,
|
||||
total_tokens: result.usage.total_tokens,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
completionNonStream(): Promise<Completion> {
|
||||
throw new Error("Relace provider does not support non-streaming completion.")
|
||||
}
|
||||
completionStream(): AsyncGenerator<Completion> {
|
||||
throw new Error("Relace provider does not support streaming completion.")
|
||||
}
|
||||
fimStream(): AsyncGenerator<ChatCompletionChunk> {
|
||||
throw new Error("Relace provider does not support streaming FIM completion.")
|
||||
}
|
||||
rerank(): Promise<CreateRerankResponse> {
|
||||
throw new Error("Relace provider does not support reranking.")
|
||||
}
|
||||
list(): Promise<Model[]> {
|
||||
throw new Error("Relace provider does not support model listing.")
|
||||
}
|
||||
}
|
||||
-439
@@ -1,439 +0,0 @@
|
||||
import { streamSse } from "../../../fetch/stream.js"
|
||||
import { AuthClient, GoogleAuth, JWT, auth } from "google-auth-library"
|
||||
import {
|
||||
ChatCompletion,
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionCreateParams,
|
||||
ChatCompletionCreateParamsNonStreaming,
|
||||
ChatCompletionCreateParamsStreaming,
|
||||
Completion,
|
||||
CompletionCreateParamsNonStreaming,
|
||||
CompletionCreateParamsStreaming,
|
||||
Model,
|
||||
} from "openai/resources/index"
|
||||
import { VertexAIConfig } from "../types.js"
|
||||
import { chatChunk, chatCompletion } from "../util.js"
|
||||
import { AnthropicApi } from "./Anthropic.js"
|
||||
import { BaseLlmApi, CreateRerankResponse, FimCreateParamsStreaming } from "./base.js"
|
||||
import { GeminiApi } from "./Gemini.js"
|
||||
import { OpenAIApi } from "./OpenAI.js"
|
||||
|
||||
export class VertexAIApi implements BaseLlmApi {
|
||||
anthropicInstance: AnthropicApi
|
||||
geminiInstance: GeminiApi
|
||||
mistralInstance: OpenAIApi
|
||||
private clientPromise?: Promise<AuthClient | void>
|
||||
static AUTH_SCOPES = "https://www.googleapis.com/auth/cloud-platform"
|
||||
|
||||
constructor(protected config: VertexAIConfig) {
|
||||
this.setupAuthentication()
|
||||
|
||||
// These sub-instances are only used to convert and handle responses,
|
||||
// So do not need apiKey, etc
|
||||
this.anthropicInstance = new AnthropicApi({
|
||||
provider: "anthropic",
|
||||
apiKey: "dud",
|
||||
})
|
||||
this.geminiInstance = new GeminiApi({
|
||||
provider: "gemini",
|
||||
apiKey: "dud",
|
||||
})
|
||||
this.mistralInstance = new OpenAIApi({
|
||||
provider: "mistral",
|
||||
apiKey: "dud",
|
||||
})
|
||||
}
|
||||
|
||||
private setupAuthentication(): void {
|
||||
const { apiKey, env } = this.config
|
||||
const { region, projectId, keyFile, keyJson } = env || {}
|
||||
|
||||
// Validate authentication configuration
|
||||
if (apiKey) {
|
||||
// Express mode validation
|
||||
if (region || projectId || keyFile || keyJson) {
|
||||
throw new Error(
|
||||
"VertexAI in express mode (apiKey only) cannot be configured with region, projectId, keyFile, or keyJson",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Standard mode validation
|
||||
if (!region || !projectId) {
|
||||
throw new Error("region and projectId are required for VertexAI (when not using express/apiKey mode)")
|
||||
}
|
||||
if (keyFile && keyJson) {
|
||||
throw new Error("VertexAI credentials can be configured with either keyFile or keyJson but not both")
|
||||
}
|
||||
}
|
||||
|
||||
// Set up authentication client
|
||||
if (keyJson) {
|
||||
try {
|
||||
const parsed = JSON.parse(keyJson)
|
||||
if (!parsed?.private_key) {
|
||||
throw new Error("VertexAI: keyJson must contain a valid private key")
|
||||
}
|
||||
parsed.private_key = parsed.private_key.replace(/\\n/g, "\n")
|
||||
const jsonClient = auth.fromJSON(parsed)
|
||||
if (jsonClient instanceof JWT) {
|
||||
jsonClient.scopes = [VertexAIApi.AUTH_SCOPES]
|
||||
} else {
|
||||
throw new Error("VertexAI: keyJson must be a valid JWT")
|
||||
}
|
||||
this.clientPromise = Promise.resolve(jsonClient)
|
||||
} catch {
|
||||
throw new Error("VertexAI: Failed to parse keyJson")
|
||||
}
|
||||
} else if (keyFile) {
|
||||
if (typeof keyFile !== "string") {
|
||||
throw new Error("VertexAI: keyFile must be a string")
|
||||
}
|
||||
this.clientPromise = new GoogleAuth({
|
||||
scopes: VertexAIApi.AUTH_SCOPES,
|
||||
keyFile,
|
||||
})
|
||||
.getClient()
|
||||
.catch((e: Error) => {
|
||||
console.warn(`Failed to load credentials for Vertex AI: ${e.message}`)
|
||||
})
|
||||
} else if (!apiKey) {
|
||||
// Application Default Credentials
|
||||
this.clientPromise = new GoogleAuth({
|
||||
scopes: VertexAIApi.AUTH_SCOPES,
|
||||
})
|
||||
.getClient()
|
||||
.catch((e: Error) => {
|
||||
console.warn(`Failed to load credentials for Vertex AI: ${e.message}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private getApiBase(): string {
|
||||
const { apiKey, env } = this.config
|
||||
|
||||
if (this.config.apiBase) {
|
||||
return this.config.apiBase
|
||||
}
|
||||
|
||||
if (apiKey) {
|
||||
// Express mode
|
||||
return "https://aiplatform.googleapis.com/v1/"
|
||||
} else {
|
||||
// Standard mode
|
||||
const { region, projectId } = env!
|
||||
return `https://${region}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/`
|
||||
}
|
||||
}
|
||||
|
||||
private determineVertexProvider(model: string): "mistral" | "anthropic" | "gemini" | "unknown" {
|
||||
if (model.includes("mistral") || model.includes("codestral") || model.includes("mixtral")) {
|
||||
return "mistral"
|
||||
} else if (model.includes("claude")) {
|
||||
return "anthropic"
|
||||
} else if (model.includes("gemini")) {
|
||||
return "gemini"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
private async getAuthHeaders(): Promise<Record<string, string>> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
// Accept: "application/json"
|
||||
}
|
||||
|
||||
// TODO - support anthropic prompt caching with "anthropic-beta" header
|
||||
|
||||
if (this.config.apiKey) {
|
||||
// Express mode - no Authorization header needed, API key is in URL
|
||||
return headers
|
||||
} else {
|
||||
// Standard mode - use OAuth token
|
||||
const client = await this.clientPromise
|
||||
const result = await client?.getAccessToken()
|
||||
if (!result?.token) {
|
||||
throw new Error("Could not get an access token. Set up your Google Application Default Credentials.")
|
||||
}
|
||||
headers.Authorization = `Bearer ${result.token}`
|
||||
return headers
|
||||
}
|
||||
}
|
||||
|
||||
private buildUrl(endpoint: string): URL {
|
||||
const apiBase = this.getApiBase()
|
||||
const url = new URL(endpoint, apiBase)
|
||||
|
||||
if (this.config.apiKey) {
|
||||
url.searchParams.set("key", this.config.apiKey)
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
private convertAnthropicBody(oaiBody: ChatCompletionCreateParams): object {
|
||||
const body = this.anthropicInstance._convertToCleanAnthropicBody(oaiBody)
|
||||
const { model: _model, ...exceptModel } = body
|
||||
return {
|
||||
...exceptModel,
|
||||
anthropic_version: "vertex-2023-10-16",
|
||||
}
|
||||
}
|
||||
|
||||
private convertGeminiBody(oaiBody: ChatCompletionCreateParams, url: URL): object {
|
||||
return this.geminiInstance._convertBody(oaiBody, url.toString())
|
||||
}
|
||||
|
||||
async chatCompletionNonStream(
|
||||
body: ChatCompletionCreateParamsNonStreaming,
|
||||
signal: AbortSignal,
|
||||
): Promise<ChatCompletion> {
|
||||
const vertexProvider = this.determineVertexProvider(body.model)
|
||||
|
||||
if (this.config.apiKey && vertexProvider !== "gemini") {
|
||||
throw new Error("VertexAI: only gemini models are supported in express (apiKey) mode")
|
||||
}
|
||||
|
||||
const headers = await this.getAuthHeaders()
|
||||
let url: URL
|
||||
let requestBody: any
|
||||
|
||||
switch (vertexProvider) {
|
||||
case "anthropic":
|
||||
url = this.buildUrl(`publishers/anthropic/models/${body.model}:rawPredict`)
|
||||
requestBody = this.convertAnthropicBody(body)
|
||||
break
|
||||
case "gemini":
|
||||
url = this.buildUrl(`publishers/google/models/${body.model}:generateContent`)
|
||||
requestBody = this.convertGeminiBody(body, url)
|
||||
break
|
||||
case "mistral":
|
||||
url = this.buildUrl(`publishers/mistralai/models/${body.model}:rawPredict`)
|
||||
requestBody = body
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported model: ${body.model}`)
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(requestBody),
|
||||
signal,
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`VertexAI API error: ${response.status} ${response.statusText}\n${JSON.stringify(data)}`)
|
||||
}
|
||||
|
||||
// Convert response to OpenAI format
|
||||
switch (vertexProvider) {
|
||||
case "anthropic":
|
||||
return chatCompletion({
|
||||
content: data.content?.[0]?.text || "",
|
||||
model: body.model,
|
||||
})
|
||||
case "gemini":
|
||||
return chatCompletion({
|
||||
content: data.candidates?.[0]?.content?.parts?.[0]?.text || "",
|
||||
model: body.model,
|
||||
})
|
||||
case "mistral":
|
||||
return chatCompletion({
|
||||
content: data.choices?.[0]?.message?.content || "",
|
||||
model: body.model,
|
||||
})
|
||||
default:
|
||||
throw new Error(`Unsupported provider: ${vertexProvider}`)
|
||||
}
|
||||
}
|
||||
|
||||
async *chatCompletionStream(
|
||||
body: ChatCompletionCreateParamsStreaming,
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator<ChatCompletionChunk> {
|
||||
const vertexProvider = this.determineVertexProvider(body.model)
|
||||
|
||||
if (this.config.apiKey && vertexProvider !== "gemini") {
|
||||
throw new Error("VertexAI: only gemini models are supported in express (apiKey) mode")
|
||||
}
|
||||
|
||||
const headers = await this.getAuthHeaders()
|
||||
let url: URL
|
||||
let requestBody: any
|
||||
|
||||
switch (vertexProvider) {
|
||||
case "anthropic":
|
||||
url = this.buildUrl(`publishers/anthropic/models/${body.model}:streamRawPredict`)
|
||||
requestBody = this.convertAnthropicBody(body)
|
||||
break
|
||||
case "gemini":
|
||||
url = this.buildUrl(`publishers/google/models/${body.model}:streamGenerateContent`)
|
||||
requestBody = this.convertGeminiBody(body, url)
|
||||
break
|
||||
case "mistral":
|
||||
url = this.buildUrl(`publishers/mistralai/models/${body.model}:streamRawPredict`)
|
||||
requestBody = body
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported model: ${body.model}`)
|
||||
}
|
||||
|
||||
switch (vertexProvider) {
|
||||
case "mistral": {
|
||||
const mistralResponse = await this.mistralInstance.openai.chat.completions.create(
|
||||
this.mistralInstance.modifyChatBody(body),
|
||||
{
|
||||
signal,
|
||||
headers,
|
||||
},
|
||||
)
|
||||
for await (const result of mistralResponse) {
|
||||
yield result
|
||||
}
|
||||
break
|
||||
}
|
||||
case "anthropic":
|
||||
case "gemini": {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(requestBody),
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new Error(`VertexAI API error: ${response.status} ${response.statusText}\n${JSON.stringify(data)}`)
|
||||
}
|
||||
|
||||
if (response.status === 499) {
|
||||
return // Aborted by user
|
||||
}
|
||||
if (vertexProvider === "gemini") {
|
||||
yield* this.geminiInstance.handleStreamResponse(response, body.model)
|
||||
} else {
|
||||
yield* this.anthropicInstance.handleStreamResponse(response, body.model)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async completionNonStream(body: CompletionCreateParamsNonStreaming, signal: AbortSignal): Promise<Completion> {
|
||||
// Convert completion to chat completion and back
|
||||
const promptText =
|
||||
typeof body.prompt === "string" ? body.prompt : Array.isArray(body.prompt) ? body.prompt.join("") : ""
|
||||
|
||||
const chatBody: ChatCompletionCreateParamsNonStreaming = {
|
||||
model: body.model,
|
||||
messages: [{ role: "user", content: promptText }],
|
||||
max_tokens: body.max_tokens,
|
||||
temperature: body.temperature,
|
||||
top_p: body.top_p,
|
||||
stop: body.stop,
|
||||
stream: false,
|
||||
}
|
||||
|
||||
const chatResponse = await this.chatCompletionNonStream(chatBody, signal)
|
||||
|
||||
return {
|
||||
id: chatResponse.id,
|
||||
object: "text_completion",
|
||||
created: chatResponse.created,
|
||||
model: chatResponse.model,
|
||||
choices: [
|
||||
{
|
||||
text: chatResponse.choices[0]?.message?.content || "",
|
||||
index: 0,
|
||||
logprobs: null,
|
||||
finish_reason: chatResponse.choices[0]?.finish_reason || null,
|
||||
},
|
||||
],
|
||||
usage: chatResponse.usage,
|
||||
} as Completion
|
||||
}
|
||||
|
||||
async *completionStream(body: CompletionCreateParamsStreaming, signal: AbortSignal): AsyncGenerator<Completion> {
|
||||
// Convert completion to chat completion and back
|
||||
const promptText =
|
||||
typeof body.prompt === "string" ? body.prompt : Array.isArray(body.prompt) ? body.prompt.join("") : ""
|
||||
|
||||
const chatBody: ChatCompletionCreateParamsStreaming = {
|
||||
model: body.model,
|
||||
messages: [{ role: "user", content: promptText }],
|
||||
max_tokens: body.max_tokens,
|
||||
temperature: body.temperature,
|
||||
top_p: body.top_p,
|
||||
stop: body.stop,
|
||||
stream: true,
|
||||
}
|
||||
|
||||
for await (const chatChunk of this.chatCompletionStream(chatBody, signal)) {
|
||||
yield {
|
||||
id: chatChunk.id,
|
||||
object: "text_completion",
|
||||
created: chatChunk.created,
|
||||
model: chatChunk.model,
|
||||
choices: [
|
||||
{
|
||||
text: chatChunk.choices[0]?.delta?.content || "",
|
||||
index: 0,
|
||||
logprobs: null,
|
||||
finish_reason: chatChunk.choices[0]?.finish_reason || null,
|
||||
},
|
||||
],
|
||||
} as Completion
|
||||
}
|
||||
}
|
||||
|
||||
async *fimStream(body: FimCreateParamsStreaming, signal: AbortSignal): AsyncGenerator<ChatCompletionChunk> {
|
||||
// Only Codestral (Mistral) supports FIM in VertexAI
|
||||
if (!body.model.includes("codestral")) {
|
||||
throw new Error(`FIM is only supported for Codestral models, got: ${body.model}`)
|
||||
}
|
||||
|
||||
const headers = await this.getAuthHeaders()
|
||||
const url = this.buildUrl(`publishers/mistralai/models/${body.model}:streamRawPredict`)
|
||||
|
||||
const requestBody = {
|
||||
model: body.model,
|
||||
max_tokens: body.max_tokens,
|
||||
temperature: body.temperature,
|
||||
top_p: body.top_p,
|
||||
stream: body.stream ?? true,
|
||||
stop: body.stop,
|
||||
prompt: body.prompt,
|
||||
suffix: body.suffix,
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(requestBody),
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`VertexAI API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
for await (const chunk of streamSse(response)) {
|
||||
if (chunk.choices?.[0]?.delta?.content) {
|
||||
yield chatChunk({
|
||||
content: chunk.choices[0].delta.content,
|
||||
model: body.model,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async rerank(): Promise<CreateRerankResponse> {
|
||||
throw new Error("Reranking is not supported by VertexAI")
|
||||
}
|
||||
|
||||
async list(): Promise<Model[]> {
|
||||
throw new Error("VertexAI provider does not support model listing.")
|
||||
}
|
||||
}
|
||||
-322
@@ -1,322 +0,0 @@
|
||||
import { streamSse } from "../../../fetch/stream.js"
|
||||
import { OpenAI } from "openai/index"
|
||||
import {
|
||||
ChatCompletion,
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionCreateParamsNonStreaming,
|
||||
ChatCompletionCreateParamsStreaming,
|
||||
Completion,
|
||||
CompletionCreateParamsStreaming,
|
||||
} from "openai/resources/index"
|
||||
import { ChatCompletionCreateParams } from "openai/resources/index.js"
|
||||
import { WatsonXConfig } from "../types.js"
|
||||
import { chatCompletion } from "../util.js"
|
||||
import { BaseLlmApi, CreateRerankResponse } from "./base.js"
|
||||
|
||||
export class WatsonXApi implements BaseLlmApi {
|
||||
apiBase: string
|
||||
apiVersion: string = "2023-05-29"
|
||||
projectId?: string
|
||||
deploymentId?: string
|
||||
|
||||
constructor(protected config: WatsonXConfig) {
|
||||
this.apiBase = config.apiBase ?? "https://us-south.ml.cloud.ibm.com"
|
||||
if (!this.apiBase.endsWith("/")) {
|
||||
this.apiBase += "/"
|
||||
}
|
||||
this.apiVersion = config.env.apiVersion ?? this.apiVersion
|
||||
this.projectId = config.env.projectId
|
||||
this.deploymentId = config.env.deploymentId
|
||||
}
|
||||
|
||||
async getBearerToken(): Promise<{ token: string; expiration: number }> {
|
||||
if (this.apiBase?.includes("cloud.ibm.com")) {
|
||||
// watsonx SaaS
|
||||
const wxToken = (await (
|
||||
await fetch(
|
||||
`https://iam.cloud.ibm.com/identity/token?apikey=${this.config.apiKey}&grant_type=urn:ibm:params:oauth:grant-type:apikey`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
},
|
||||
)
|
||||
).json()) as any
|
||||
return {
|
||||
token: wxToken["access_token"],
|
||||
expiration: wxToken["expiration"],
|
||||
}
|
||||
} else {
|
||||
// watsonx Software
|
||||
// if (this.config.env.bearerTokenRequired) {
|
||||
// In certain WatsonX environments, ZenApiKey authentication is disabled,
|
||||
// and it's necessary to call this endpoint with username+api_key to get a bearer token.
|
||||
// See the docs: https://www.ibm.com/docs/en/watsonx/w-and-w/2.1.0?topic=keys-generating-bearer-token
|
||||
// Ask @sestinj why the rest is commented out.
|
||||
const base64Decoded = Buffer.from(this.config.apiKey ?? "", "base64").toString()
|
||||
const [username, api_key] = base64Decoded.split(":")
|
||||
|
||||
const wxToken = (await (
|
||||
await fetch(new URL("icp4d-api/v1/authorize", this.apiBase), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: username?.trim(),
|
||||
api_key: api_key?.trim(),
|
||||
}),
|
||||
})
|
||||
).json()) as any
|
||||
|
||||
return {
|
||||
token: wxToken["access_token"] ?? wxToken["token"],
|
||||
expiration: 0,
|
||||
}
|
||||
// } else if (!this.config.apiKey?.includes(":")) {
|
||||
// // Using ZenApiKey auth
|
||||
// return {
|
||||
// token: this.config.apiKey ?? "",
|
||||
// expiration: -1,
|
||||
// };
|
||||
// } else {
|
||||
// // Using username/password auth
|
||||
// const userPass = this.config.apiKey?.split(":");
|
||||
// const wxToken = (await (
|
||||
// await fetch(
|
||||
// `${this.apiBase}/icp4d-api/v1/authorize`,
|
||||
// {
|
||||
// method: "POST",
|
||||
// headers: {
|
||||
// "Content-Type": "application/json",
|
||||
// Accept: "application/json",
|
||||
// },
|
||||
// body: JSON.stringify({
|
||||
// username: userPass[0],
|
||||
// password: userPass[1],
|
||||
// }),
|
||||
// },
|
||||
// )
|
||||
// ).json()) as any;
|
||||
// const wxTokenExpiry = (await (
|
||||
// await fetch(
|
||||
// `${this.apiBase}/usermgmt/v1/user/tokenExpiry`,
|
||||
// {
|
||||
// method: "GET",
|
||||
// headers: {
|
||||
// Accept: "application/json",
|
||||
// Authorization: `Bearer ${wxToken["token"]}`,
|
||||
// },
|
||||
// },
|
||||
// )
|
||||
// ).json()) as any;
|
||||
// return {
|
||||
// token: wxToken["token"],
|
||||
// expiration: wxTokenExpiry["exp"],
|
||||
// };
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
private getEndpoint(endpoint: string): string {
|
||||
return `${this.apiBase}ml/v1/${this.deploymentId ? `deployments/${this.deploymentId}/` : ""}text/${endpoint}_stream?version=${this.apiVersion}`
|
||||
}
|
||||
|
||||
private _convertBody(oaiBody: ChatCompletionCreateParams) {
|
||||
const stopSequences = oaiBody.stop
|
||||
? Array.isArray(oaiBody.stop)
|
||||
? oaiBody.stop.filter((s) => s.trim() !== "")
|
||||
: [oaiBody.stop]
|
||||
: undefined
|
||||
|
||||
const payload: any = {
|
||||
messages: oaiBody.messages,
|
||||
max_tokens: oaiBody.max_tokens ?? 1024,
|
||||
stop: stopSequences,
|
||||
frequency_penalty: oaiBody.frequency_penalty,
|
||||
presence_penalty: oaiBody.presence_penalty,
|
||||
}
|
||||
|
||||
if (!this.deploymentId) {
|
||||
payload.model_id = oaiBody.model
|
||||
payload.project_id = this.projectId
|
||||
}
|
||||
|
||||
if (oaiBody.temperature !== undefined) {
|
||||
payload.temperature = oaiBody.temperature
|
||||
}
|
||||
|
||||
if (oaiBody.top_p !== undefined) {
|
||||
payload.top_p = oaiBody.top_p
|
||||
}
|
||||
|
||||
if (oaiBody.tools) {
|
||||
payload.tools = oaiBody.tools
|
||||
if (oaiBody.tool_choice) {
|
||||
payload.tool_choice = oaiBody.tool_choice
|
||||
} else {
|
||||
payload.tool_choice_option = "auto"
|
||||
}
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
private async getHeaders(): Promise<Record<string, string>> {
|
||||
const bearer = await this.getBearerToken()
|
||||
// const isZenApiKey = bearer.expiration === -1;
|
||||
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
// Authorization: `${isZenApiKey ? "ZenApiKey" : "Bearer"} ${bearer.token}`,
|
||||
Authorization: `Bearer ${bearer.token}`,
|
||||
}
|
||||
}
|
||||
|
||||
async chatCompletionNonStream(
|
||||
body: ChatCompletionCreateParamsNonStreaming,
|
||||
signal: AbortSignal,
|
||||
): Promise<ChatCompletion> {
|
||||
const generator = this.chatCompletionStream(
|
||||
{
|
||||
...body,
|
||||
stream: true,
|
||||
},
|
||||
signal,
|
||||
)
|
||||
|
||||
let content = ""
|
||||
for await (const chunk of generator) {
|
||||
content += chunk.choices[0].delta.content ?? ""
|
||||
}
|
||||
return chatCompletion({
|
||||
content,
|
||||
model: body.model,
|
||||
})
|
||||
}
|
||||
|
||||
async *chatCompletionStream(
|
||||
body: ChatCompletionCreateParamsStreaming,
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator<ChatCompletionChunk, any, unknown> {
|
||||
const url = this.getEndpoint("chat")
|
||||
const headers = await this.getHeaders()
|
||||
const stringifiedBody = JSON.stringify({
|
||||
time_limit: 8000,
|
||||
...this._convertBody(body),
|
||||
stream: true,
|
||||
})
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: stringifiedBody,
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Failed to stream chat completion: ${await response.text()}`)
|
||||
}
|
||||
|
||||
for await (const value of streamSse(response as any)) {
|
||||
if (!value.choices?.[0]) {
|
||||
continue
|
||||
}
|
||||
yield value
|
||||
}
|
||||
}
|
||||
|
||||
async completionNonStream(): Promise<Completion> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
|
||||
async *completionStream(
|
||||
body: CompletionCreateParamsStreaming,
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator<Completion, any, unknown> {
|
||||
const params = {
|
||||
decoding_method: body.temperature ? "sample" : "greedy",
|
||||
max_new_tokens: body.max_tokens ?? 1024,
|
||||
min_new_tokens: 1,
|
||||
stop_sequences: body.stop ? (Array.isArray(body.stop) ? body.stop : [body.stop]) : [],
|
||||
include_stop_sequence: false,
|
||||
repetition_penalty: body.frequency_penalty || 1,
|
||||
temperature: body.temperature,
|
||||
top_p: body.top_p,
|
||||
top_k: 100,
|
||||
}
|
||||
|
||||
const payload: any = {
|
||||
input: body.prompt,
|
||||
parameters: params,
|
||||
}
|
||||
|
||||
if (!this.deploymentId) {
|
||||
payload.model_id = body.model
|
||||
payload.project_id = this.projectId
|
||||
}
|
||||
|
||||
const url = this.getEndpoint("generation")
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: await this.getHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Failed to stream completion: ${await response.text()}`)
|
||||
}
|
||||
|
||||
for await (const value of streamSse(response as any)) {
|
||||
const lines = value.toString().split("\n")
|
||||
let generatedChunk = ""
|
||||
|
||||
lines.forEach((line: string) => {
|
||||
if (line.startsWith("data:")) {
|
||||
const dataStr = line.replace(/^data:\s+/, "")
|
||||
try {
|
||||
const data = JSON.parse(dataStr)
|
||||
data.results.forEach((result: any) => {
|
||||
generatedChunk += result.generated_text || ""
|
||||
})
|
||||
} catch {
|
||||
// parsing error is expected with streaming response
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (generatedChunk) {
|
||||
yield {
|
||||
id: `watsonx-${Date.now()}`,
|
||||
object: "text_completion",
|
||||
created: Date.now(),
|
||||
model: body.model,
|
||||
choices: [
|
||||
{
|
||||
text: generatedChunk,
|
||||
index: 0,
|
||||
logprobs: null,
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line require-yield
|
||||
async *fimStream(): AsyncGenerator<ChatCompletionChunk, any, unknown> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
|
||||
async rerank(): Promise<CreateRerankResponse> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
|
||||
async list(): Promise<OpenAI.Models.Model[]> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
}
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
import {
|
||||
ChatCompletion,
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionCreateParamsNonStreaming,
|
||||
ChatCompletionCreateParamsStreaming,
|
||||
Completion,
|
||||
CompletionCreateParamsNonStreaming,
|
||||
CompletionCreateParamsStreaming,
|
||||
Model,
|
||||
} from "openai/resources/index"
|
||||
|
||||
export interface FimCreateParamsStreaming extends CompletionCreateParamsStreaming {
|
||||
suffix: string
|
||||
}
|
||||
|
||||
export interface RerankCreateParams {
|
||||
query: string
|
||||
documents: string[]
|
||||
model: string
|
||||
top_k?: number
|
||||
}
|
||||
|
||||
interface CreateRerankItem {
|
||||
relevance_score: number
|
||||
index: number
|
||||
}
|
||||
|
||||
export interface CreateRerankResponse {
|
||||
object: "list"
|
||||
data: CreateRerankItem[]
|
||||
model: string
|
||||
usage: {
|
||||
total_tokens: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface BaseLlmApi {
|
||||
// Chat, no stream
|
||||
chatCompletionNonStream(body: ChatCompletionCreateParamsNonStreaming, signal: AbortSignal): Promise<ChatCompletion>
|
||||
|
||||
// Chat, stream
|
||||
chatCompletionStream(
|
||||
body: ChatCompletionCreateParamsStreaming,
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator<ChatCompletionChunk>
|
||||
|
||||
// Completion, no stream
|
||||
completionNonStream(body: CompletionCreateParamsNonStreaming, signal: AbortSignal): Promise<Completion>
|
||||
|
||||
// Completion, stream
|
||||
completionStream(body: CompletionCreateParamsStreaming, signal: AbortSignal): AsyncGenerator<Completion>
|
||||
|
||||
// FIM, stream
|
||||
fimStream(body: FimCreateParamsStreaming, signal: AbortSignal): AsyncGenerator<ChatCompletionChunk>
|
||||
|
||||
// Reranking
|
||||
rerank(body: RerankCreateParams): Promise<CreateRerankResponse>
|
||||
|
||||
// List Models
|
||||
list(): Promise<Model[]>
|
||||
}
|
||||
-162
@@ -1,162 +0,0 @@
|
||||
import dotenv from "dotenv"
|
||||
import { z } from "zod"
|
||||
import { AnthropicApi } from "./apis/Anthropic.js"
|
||||
import { AzureApi } from "./apis/Azure.js"
|
||||
import { BedrockApi } from "./apis/Bedrock.js"
|
||||
import { CohereApi } from "./apis/Cohere.js"
|
||||
import { CometAPIApi } from "./apis/CometAPI.js"
|
||||
import { ContinueProxyApi } from "./apis/ContinueProxy.js"
|
||||
import { DeepSeekApi } from "./apis/DeepSeek.js"
|
||||
import { GeminiApi } from "./apis/Gemini.js"
|
||||
import { InceptionApi } from "./apis/Inception.js"
|
||||
import { JinaApi } from "./apis/Jina.js"
|
||||
import { LlamastackApi } from "./apis/LlamaStack.js"
|
||||
import { MockApi } from "./apis/Mock.js"
|
||||
import { MoonshotApi } from "./apis/Moonshot.js"
|
||||
import { OpenAIApi } from "./apis/OpenAI.js"
|
||||
import { OpenRouterApi } from "./apis/OpenRouter.js"
|
||||
import { RelaceApi } from "./apis/Relace.js"
|
||||
import { VertexAIApi } from "./apis/VertexAI.js"
|
||||
import { WatsonXApi } from "./apis/WatsonX.js"
|
||||
import { BaseLlmApi } from "./apis/base.js"
|
||||
import { LLMConfig, OpenAIConfigSchema } from "./types.js"
|
||||
|
||||
dotenv.config()
|
||||
|
||||
function openAICompatible(apiBase: string, config: z.infer<typeof OpenAIConfigSchema>): OpenAIApi {
|
||||
return new OpenAIApi({
|
||||
...config,
|
||||
apiBase: config.apiBase ?? apiBase,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if a HuggingFace API URL is using an OpenAI-compatible router
|
||||
* @param url The URL to check
|
||||
* @returns true if the URL appears to be using an OpenAI-compatible router
|
||||
*/
|
||||
function isHuggingFaceOpenAICompatible(url: string): boolean {
|
||||
if (!url) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Normalize the URL to lowercase for case-insensitive matching
|
||||
const normalizedUrl = url.toLowerCase()
|
||||
|
||||
// Check for common OpenAI-compatible patterns
|
||||
const openAIPatterns = [
|
||||
"/v1/", // Standard OpenAI v1 API pattern
|
||||
"/openai/", // Explicit OpenAI compatibility path
|
||||
"/v1/chat/completions", // Specific OpenAI chat completions endpoint
|
||||
"/v1/completions", // OpenAI completions endpoint
|
||||
"/v1/embeddings", // OpenAI embeddings endpoint
|
||||
"/v1/models", // OpenAI models endpoint
|
||||
]
|
||||
|
||||
// Check if the URL contains any of the OpenAI-compatible patterns
|
||||
return openAIPatterns.some((pattern) => normalizedUrl.includes(pattern))
|
||||
}
|
||||
|
||||
export function constructLlmApi(config: LLMConfig): BaseLlmApi | undefined {
|
||||
switch (config.provider) {
|
||||
case "openai":
|
||||
return new OpenAIApi(config)
|
||||
case "azure":
|
||||
return new AzureApi(config)
|
||||
case "bedrock":
|
||||
return new BedrockApi(config)
|
||||
case "cohere":
|
||||
return new CohereApi(config)
|
||||
case "cometapi":
|
||||
return new CometAPIApi(config)
|
||||
case "anthropic":
|
||||
return new AnthropicApi(config)
|
||||
case "gemini":
|
||||
return new GeminiApi(config)
|
||||
case "jina":
|
||||
return new JinaApi(config)
|
||||
case "deepseek":
|
||||
return new DeepSeekApi(config)
|
||||
case "moonshot":
|
||||
return new MoonshotApi(config)
|
||||
case "relace":
|
||||
return new RelaceApi(config)
|
||||
case "inception":
|
||||
return new InceptionApi(config)
|
||||
case "watsonx":
|
||||
return new WatsonXApi(config)
|
||||
case "vertexai":
|
||||
return new VertexAIApi(config)
|
||||
case "llamastack":
|
||||
return new LlamastackApi(config)
|
||||
case "continue-proxy":
|
||||
return new ContinueProxyApi(config)
|
||||
case "xAI":
|
||||
return openAICompatible("https://api.x.ai/v1/", config)
|
||||
case "voyage":
|
||||
return openAICompatible("https://api.voyageai.com/v1/", config)
|
||||
case "mistral":
|
||||
return openAICompatible("https://api.mistral.ai/v1/", config)
|
||||
case "deepinfra":
|
||||
return openAICompatible("https://api.deepinfra.com/v1/openai/", config)
|
||||
case "vllm":
|
||||
return openAICompatible("http://localhost:8000/v1/", config)
|
||||
case "groq":
|
||||
return openAICompatible("https://api.groq.com/openai/v1/", config)
|
||||
case "sambanova":
|
||||
return openAICompatible("https://api.sambanova.ai/v1/", config)
|
||||
case "text-gen-webui":
|
||||
return openAICompatible("http://127.0.0.1:5000/v1/", config)
|
||||
case "cerebras":
|
||||
return openAICompatible("https://api.cerebras.ai/v1/", config)
|
||||
case "kindo":
|
||||
return openAICompatible("https://llm.kindo.ai/v1/", config)
|
||||
case "msty":
|
||||
return openAICompatible("http://localhost:10000", config)
|
||||
case "nvidia":
|
||||
return openAICompatible("https://integrate.api.nvidia.com/v1/", config)
|
||||
case "ovhcloud":
|
||||
return openAICompatible("https://oai.endpoints.kepler.ai.cloud.ovh.net/v1/", config)
|
||||
case "scaleway":
|
||||
return openAICompatible("https://api.scaleway.ai/v1/", config)
|
||||
case "fireworks":
|
||||
return openAICompatible("https://api.fireworks.ai/inference/v1/", config)
|
||||
case "together":
|
||||
return openAICompatible("https://api.together.xyz/v1/", config)
|
||||
case "ncompass":
|
||||
return openAICompatible("https://api.ncompass.tech/v1", config)
|
||||
case "novita":
|
||||
return openAICompatible("https://api.novita.ai/v3/openai", config)
|
||||
case "nebius":
|
||||
return openAICompatible("https://api.studio.nebius.ai/v1/", config)
|
||||
case "function-network":
|
||||
return openAICompatible("https://api.function.network/v1/", config)
|
||||
case "openrouter":
|
||||
return new OpenRouterApi(config)
|
||||
case "llama.cpp":
|
||||
case "llamafile":
|
||||
return openAICompatible("http://localhost:8000/", config)
|
||||
case "lmstudio":
|
||||
return openAICompatible("http://localhost:1234/", config)
|
||||
case "ollama":
|
||||
// for openai compaitability, we need to add /v1 to the end of the url
|
||||
// this is required for cli (for core, endpoints are overriden by core/llm/llms/Ollama.ts)
|
||||
config.apiBase = config.apiBase && new URL("v1/", config.apiBase).toString()
|
||||
return openAICompatible("http://localhost:11434/v1/", config)
|
||||
case "mock":
|
||||
return new MockApi()
|
||||
case "huggingface-inference-api":
|
||||
// Check if it's an OpenAI-compatible router
|
||||
if (config.apiBase && isHuggingFaceOpenAICompatible(config.apiBase)) {
|
||||
return openAICompatible(config.apiBase, config)
|
||||
}
|
||||
// Return undefined for native HuggingFace endpoints
|
||||
// (handled by HuggingFaceInferenceAPI class in core)
|
||||
return undefined
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// export
|
||||
export type { BaseLlmApi } from "./apis/base.js"
|
||||
-185
@@ -1,185 +0,0 @@
|
||||
import * as z from "zod"
|
||||
|
||||
// Base config objects
|
||||
const BaseConfig = z.object({
|
||||
provider: z.string(),
|
||||
})
|
||||
|
||||
const BasePlusConfig = BaseConfig.extend({
|
||||
apiBase: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
})
|
||||
|
||||
// OpenAI and compatible
|
||||
export const OpenAIConfigSchema = BasePlusConfig.extend({
|
||||
provider: z.union([
|
||||
z.literal("openai"),
|
||||
z.literal("mistral"),
|
||||
z.literal("voyage"),
|
||||
z.literal("deepinfra"),
|
||||
z.literal("groq"),
|
||||
z.literal("nvidia"),
|
||||
z.literal("ovhcloud"),
|
||||
z.literal("fireworks"),
|
||||
z.literal("together"),
|
||||
z.literal("novita"),
|
||||
z.literal("nebius"),
|
||||
z.literal("function-network"),
|
||||
z.literal("llama.cpp"),
|
||||
z.literal("llamafile"),
|
||||
z.literal("lmstudio"),
|
||||
z.literal("ollama"),
|
||||
z.literal("cerebras"),
|
||||
z.literal("kindo"),
|
||||
z.literal("msty"),
|
||||
z.literal("openrouter"),
|
||||
z.literal("sambanova"),
|
||||
z.literal("text-gen-webui"),
|
||||
z.literal("vllm"),
|
||||
z.literal("xAI"),
|
||||
z.literal("scaleway"),
|
||||
z.literal("ncompass"),
|
||||
z.literal("relace"),
|
||||
z.literal("huggingface-inference-api"),
|
||||
]),
|
||||
})
|
||||
export type OpenAIConfig = z.infer<typeof OpenAIConfigSchema>
|
||||
|
||||
const _MoonshotConfigSchema = OpenAIConfigSchema.extend({
|
||||
provider: z.literal("moonshot"),
|
||||
})
|
||||
export type MoonshotConfig = z.infer<typeof _MoonshotConfigSchema>
|
||||
|
||||
const _DeepseekConfigSchema = OpenAIConfigSchema.extend({
|
||||
provider: z.literal("deepseek"),
|
||||
})
|
||||
export type DeepseekConfig = z.infer<typeof _DeepseekConfigSchema>
|
||||
|
||||
const _BedrockConfigSchema = OpenAIConfigSchema.extend({
|
||||
provider: z.literal("bedrock"),
|
||||
// cacheBehavior: z.object({
|
||||
// cacheSystemMessage: z.boolean().optional(),
|
||||
// cacheConversation: z.boolean().optional(),
|
||||
// }).optional(),
|
||||
env: z
|
||||
.object({
|
||||
region: z.string().optional(),
|
||||
accessKeyId: z.string().optional(),
|
||||
secretAccessKey: z.string().optional(),
|
||||
profile: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
export type BedrockConfig = z.infer<typeof _BedrockConfigSchema>
|
||||
|
||||
const _LlamastackConfigSchema = OpenAIConfigSchema.extend({
|
||||
provider: z.literal("llamastack"),
|
||||
})
|
||||
export type LlamastackConfig = z.infer<typeof _LlamastackConfigSchema>
|
||||
|
||||
export const ContinueProxyConfigSchema = BasePlusConfig.extend({
|
||||
provider: z.literal("continue-proxy"),
|
||||
env: z.object({
|
||||
apiKeyLocation: z.string().optional(),
|
||||
envSecretLocations: z.record(z.string(), z.string()).optional(),
|
||||
orgScopeId: z.string().nullable(),
|
||||
proxyUrl: z.string().optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
const _MockConfigSchema = BasePlusConfig.extend({
|
||||
provider: z.literal("mock"),
|
||||
})
|
||||
|
||||
// Other APIs
|
||||
const _CohereConfigSchema = OpenAIConfigSchema.extend({
|
||||
provider: z.literal("cohere"),
|
||||
})
|
||||
export type CohereConfig = z.infer<typeof _CohereConfigSchema>
|
||||
|
||||
const _CometAPIConfigSchema = OpenAIConfigSchema.extend({
|
||||
provider: z.literal("cometapi"),
|
||||
})
|
||||
export type CometAPIConfig = z.infer<typeof _CometAPIConfigSchema>
|
||||
|
||||
export const AzureConfigSchema = OpenAIConfigSchema.extend({
|
||||
provider: z.literal("azure"),
|
||||
env: z
|
||||
.object({
|
||||
apiVersion: z.string().optional(),
|
||||
apiType: z
|
||||
.union([
|
||||
z.literal("azure-foundry"),
|
||||
z.literal("azure-openai"),
|
||||
z.literal("azure"), // Legacy
|
||||
])
|
||||
.optional(),
|
||||
deployment: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
const _GeminiConfigSchema = OpenAIConfigSchema.extend({
|
||||
provider: z.literal("gemini"),
|
||||
apiKey: z.string(),
|
||||
})
|
||||
export type GeminiConfig = z.infer<typeof _GeminiConfigSchema>
|
||||
|
||||
const _AnthropicConfigSchema = OpenAIConfigSchema.extend({
|
||||
provider: z.literal("anthropic"),
|
||||
apiKey: z.string(),
|
||||
})
|
||||
export type AnthropicConfig = z.infer<typeof _AnthropicConfigSchema>
|
||||
|
||||
const _WatsonXConfigSchema = BasePlusConfig.extend({
|
||||
provider: z.literal("watsonx"),
|
||||
apiKey: z.string(),
|
||||
env: z.object({
|
||||
apiVersion: z.string().optional(),
|
||||
projectId: z.string().optional(),
|
||||
deploymentId: z.string().optional(),
|
||||
}),
|
||||
})
|
||||
export type WatsonXConfig = z.infer<typeof _WatsonXConfigSchema>
|
||||
|
||||
const _JinaConfigSchema = OpenAIConfigSchema.extend({
|
||||
provider: z.literal("jina"),
|
||||
})
|
||||
export type JinaConfig = z.infer<typeof _JinaConfigSchema>
|
||||
|
||||
const _InceptionConfigSchema = OpenAIConfigSchema.extend({
|
||||
provider: z.literal("inception"),
|
||||
})
|
||||
export type InceptionConfig = z.infer<typeof _InceptionConfigSchema>
|
||||
|
||||
const _VertexAIConfigSchema = BasePlusConfig.extend({
|
||||
provider: z.literal("vertexai"),
|
||||
env: z
|
||||
.object({
|
||||
region: z.string().optional(),
|
||||
projectId: z.string().optional(),
|
||||
keyFile: z.string().optional(),
|
||||
keyJson: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
export type VertexAIConfig = z.infer<typeof _VertexAIConfigSchema>
|
||||
|
||||
// Discriminated union
|
||||
export type LLMConfig =
|
||||
| OpenAIConfig
|
||||
| BedrockConfig
|
||||
| MoonshotConfig
|
||||
| DeepseekConfig
|
||||
| CohereConfig
|
||||
| z.infer<typeof AzureConfigSchema>
|
||||
| GeminiConfig
|
||||
| AnthropicConfig
|
||||
| WatsonXConfig
|
||||
| JinaConfig
|
||||
| z.infer<typeof _MockConfigSchema>
|
||||
| InceptionConfig
|
||||
| VertexAIConfig
|
||||
| LlamastackConfig
|
||||
| z.infer<typeof ContinueProxyConfigSchema>
|
||||
| CometAPIConfig
|
||||
-93
@@ -1,93 +0,0 @@
|
||||
import { ChatCompletionChunk, CompletionUsage } from "openai/resources/index"
|
||||
|
||||
import { ChatCompletion } from "openai/resources/index.js"
|
||||
import { CreateRerankResponse } from "./apis/base.js"
|
||||
|
||||
export function chatChunk(options: {
|
||||
content: string | null | undefined
|
||||
model: string
|
||||
finish_reason?: ChatCompletionChunk.Choice["finish_reason"]
|
||||
id?: string | null
|
||||
usage?: CompletionUsage
|
||||
}): ChatCompletionChunk {
|
||||
return {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
content: options.content,
|
||||
role: "assistant",
|
||||
},
|
||||
finish_reason: options.finish_reason ?? "stop",
|
||||
index: 0,
|
||||
logprobs: null,
|
||||
},
|
||||
],
|
||||
usage: options.usage,
|
||||
created: Date.now(),
|
||||
id: options.id ?? "",
|
||||
model: options.model,
|
||||
object: "chat.completion.chunk",
|
||||
}
|
||||
}
|
||||
|
||||
export function usageChatChunk(options: {
|
||||
model: string
|
||||
id?: string | null
|
||||
usage?: CompletionUsage
|
||||
}): ChatCompletionChunk {
|
||||
return {
|
||||
choices: [],
|
||||
usage: options.usage,
|
||||
created: Date.now(),
|
||||
id: options.id ?? "",
|
||||
model: options.model,
|
||||
object: "chat.completion.chunk",
|
||||
}
|
||||
}
|
||||
|
||||
export function chatCompletion(options: {
|
||||
content: string | null | undefined
|
||||
model: string
|
||||
finish_reason?: ChatCompletion.Choice["finish_reason"]
|
||||
id?: string | null
|
||||
usage?: CompletionUsage
|
||||
index?: number | null
|
||||
}): ChatCompletion {
|
||||
return {
|
||||
choices: [
|
||||
{
|
||||
finish_reason: options.finish_reason ?? "stop",
|
||||
index: options.index ?? 0,
|
||||
logprobs: null,
|
||||
message: {
|
||||
content: options.content ?? null,
|
||||
role: "assistant",
|
||||
refusal: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
usage: options.usage,
|
||||
created: Date.now(),
|
||||
id: options.id ?? "",
|
||||
model: options.model,
|
||||
object: "chat.completion",
|
||||
}
|
||||
}
|
||||
|
||||
export function rerank(options: {
|
||||
model: string
|
||||
data: number[]
|
||||
usage?: CreateRerankResponse["usage"]
|
||||
}): CreateRerankResponse {
|
||||
return {
|
||||
data: options.data.map((score, index) => ({
|
||||
index,
|
||||
relevance_score: score,
|
||||
})),
|
||||
model: options.model,
|
||||
object: "list" as const,
|
||||
usage: options.usage ?? {
|
||||
total_tokens: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
import { ChatCompletion } from "openai/resources/index"
|
||||
|
||||
export const EMPTY_CHAT_COMPLETION: ChatCompletion = {
|
||||
choices: [
|
||||
{
|
||||
finish_reason: "stop",
|
||||
index: 0,
|
||||
logprobs: null,
|
||||
message: {
|
||||
content: null,
|
||||
role: "assistant",
|
||||
refusal: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
usage: undefined,
|
||||
created: Date.now(),
|
||||
id: "",
|
||||
model: "UNSPECIFIED",
|
||||
object: "chat.completion",
|
||||
}
|
||||
-333
@@ -1,333 +0,0 @@
|
||||
import { ChatCompletionTool } from "openai/resources/index.mjs"
|
||||
|
||||
type GeminiObjectSchemaType = "TYPE_UNSPECIFIED" | "STRING" | "NUMBER" | "INTEGER" | "BOOLEAN" | "ARRAY" | "OBJECT"
|
||||
|
||||
interface GeminiObjectSchema {
|
||||
type: GeminiObjectSchemaType
|
||||
format?: string
|
||||
title?: string
|
||||
description?: string
|
||||
nullable?: boolean
|
||||
enum?: string[]
|
||||
maxItems?: string
|
||||
minItems?: string
|
||||
properties?: Record<string, GeminiObjectSchema>
|
||||
required?: string[]
|
||||
anyOf?: GeminiObjectSchema[]
|
||||
propertyOrdering?: string[]
|
||||
items?: GeminiObjectSchema
|
||||
minimum?: number
|
||||
maximum?: number
|
||||
}
|
||||
|
||||
const jsonSchemaTypeToGeminiType = (jsonSchemaType: string): GeminiObjectSchemaType => {
|
||||
switch (jsonSchemaType.toLowerCase()) {
|
||||
case "string":
|
||||
return "STRING"
|
||||
case "object":
|
||||
return "OBJECT"
|
||||
case "number":
|
||||
return "NUMBER"
|
||||
case "integer":
|
||||
return "INTEGER"
|
||||
case "array":
|
||||
return "ARRAY"
|
||||
case "boolean":
|
||||
return "BOOLEAN"
|
||||
default:
|
||||
return "TYPE_UNSPECIFIED"
|
||||
}
|
||||
}
|
||||
|
||||
function convertJsonSchemaToGeminiSchema(jsonSchema: any): GeminiObjectSchema {
|
||||
const jsonSchemaType = jsonSchema["type"]
|
||||
if (!jsonSchemaType || typeof jsonSchema.type !== "string") {
|
||||
throw new Error(`Invalid type property in function declaration\n${JSON.stringify(jsonSchema, null, 2)}`)
|
||||
}
|
||||
const geminiSchema: GeminiObjectSchema = {
|
||||
type: jsonSchemaTypeToGeminiType(jsonSchemaType),
|
||||
}
|
||||
|
||||
// if (jsonSchema.format) geminiSchema.format = jsonSchema.format;
|
||||
if (jsonSchema.title) geminiSchema.title = jsonSchema.title
|
||||
if (jsonSchema.description) geminiSchema.description = jsonSchema.description
|
||||
|
||||
// Handle nullable
|
||||
if (jsonSchemaType === "null" || jsonSchema.nullable) {
|
||||
geminiSchema.nullable = true
|
||||
}
|
||||
|
||||
// Handle enum values
|
||||
if (Array.isArray(jsonSchema.enum)) {
|
||||
geminiSchema.enum = jsonSchema.enum.map(String)
|
||||
}
|
||||
|
||||
// Handle array constraints
|
||||
if (jsonSchemaType === "array") {
|
||||
if (typeof jsonSchema.maxItems !== "undefined") {
|
||||
geminiSchema.maxItems = String(jsonSchema.maxItems)
|
||||
}
|
||||
if (typeof jsonSchema.minItems !== "undefined") {
|
||||
geminiSchema.minItems = String(jsonSchema.minItems)
|
||||
}
|
||||
// Handle array items
|
||||
if (jsonSchema.items) {
|
||||
geminiSchema.items = convertJsonSchemaToGeminiSchema(jsonSchema.items)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle numeric constraints
|
||||
if (typeof jsonSchema.minimum !== "undefined") {
|
||||
geminiSchema.minimum = Number(jsonSchema.minimum)
|
||||
}
|
||||
if (typeof jsonSchema.maximum !== "undefined") {
|
||||
geminiSchema.maximum = Number(jsonSchema.maximum)
|
||||
}
|
||||
|
||||
// Handle properties for objects
|
||||
if (jsonSchema.properties) {
|
||||
geminiSchema.properties = {}
|
||||
for (const [key, value] of Object.entries(jsonSchema.properties)) {
|
||||
geminiSchema.properties[key] = convertJsonSchemaToGeminiSchema(value)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle required properties
|
||||
if (Array.isArray(jsonSchema.required)) {
|
||||
geminiSchema.required = jsonSchema.required
|
||||
}
|
||||
|
||||
// Handle anyOf
|
||||
if (Array.isArray(jsonSchema.anyOf)) {
|
||||
geminiSchema.anyOf = jsonSchema.anyOf.map(convertJsonSchemaToGeminiSchema)
|
||||
}
|
||||
|
||||
// TODO/UNSUPPORTED:
|
||||
// format
|
||||
// property ordering:
|
||||
// if (Array.isArray(jsonSchema.propertyOrdering)) {
|
||||
// geminiSchema.propertyOrdering = jsonSchema.propertyOrdering;
|
||||
// }
|
||||
|
||||
return geminiSchema
|
||||
}
|
||||
|
||||
// https://ai.google.dev/api/caching#FunctionDeclaration
|
||||
// Note "reponse" field (schema showing function output structure) is not supported at the moment
|
||||
export function convertOpenAIToolToGeminiFunction(tool: ChatCompletionTool): GeminiToolFunctionDeclaration {
|
||||
// Type guard for function tools
|
||||
if (tool.type !== "function" || !tool.function) {
|
||||
throw new Error(`Unsupported tool type: ${tool.type}`)
|
||||
}
|
||||
|
||||
if (!tool.function.name) {
|
||||
throw new Error("Function name required")
|
||||
}
|
||||
const description = tool.function.description ?? ""
|
||||
const name = tool.function.name
|
||||
|
||||
const fn: GeminiToolFunctionDeclaration = {
|
||||
description,
|
||||
name,
|
||||
}
|
||||
|
||||
if (
|
||||
tool.function.parameters &&
|
||||
"type" in tool.function.parameters &&
|
||||
typeof tool.function.parameters.type === "string"
|
||||
) {
|
||||
// Gemini can't take an empty object
|
||||
// So if empty object param is present just don't add parameters
|
||||
if (tool.function.parameters.type === "object") {
|
||||
if (JSON.stringify(tool.function.parameters.properties) === "{}") {
|
||||
return fn
|
||||
}
|
||||
}
|
||||
|
||||
fn.parameters = convertJsonSchemaToGeminiSchema(tool.function.parameters)
|
||||
}
|
||||
|
||||
return fn
|
||||
}
|
||||
|
||||
type GeminiTextContentPart = {
|
||||
text: string
|
||||
}
|
||||
|
||||
type GeminiInlineDataContentPart = {
|
||||
inlineData: {
|
||||
mimeType: string
|
||||
data: string
|
||||
}
|
||||
}
|
||||
|
||||
type GeminiFunctionCallContentPart = {
|
||||
functionCall: {
|
||||
id?: string
|
||||
name: string
|
||||
args: unknown
|
||||
}
|
||||
}
|
||||
|
||||
type GeminiFunctionResponseContentPart = {
|
||||
functionResponse: {
|
||||
id?: string
|
||||
name: string
|
||||
response: unknown
|
||||
}
|
||||
}
|
||||
|
||||
type GeminiFileDataContentPart = {
|
||||
fileData: {
|
||||
fileUri: string
|
||||
mimeType: string // See possible values here: https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#filedata
|
||||
}
|
||||
}
|
||||
|
||||
type GeminiExecutableCodeContentPart = {
|
||||
executableCode: {
|
||||
language: "PYTHON" | "LANGUAGE_UNSPECIFIED"
|
||||
code: string
|
||||
}
|
||||
}
|
||||
|
||||
type GeminiCodeExecutionResultContentPart = {
|
||||
codeExecutionResult: {
|
||||
outcome: "OUTCOME_UNSPECIFIED" | "OUTCOME_OK" | "OUTCOME_FAILED" | "OUTCOME_DEADLINE_EXCEEDED"
|
||||
output: string
|
||||
}
|
||||
}
|
||||
|
||||
export type GeminiChatContentPart =
|
||||
| GeminiTextContentPart
|
||||
| GeminiInlineDataContentPart
|
||||
| GeminiFunctionCallContentPart
|
||||
| GeminiFunctionResponseContentPart
|
||||
| GeminiFileDataContentPart
|
||||
| GeminiExecutableCodeContentPart
|
||||
| GeminiCodeExecutionResultContentPart
|
||||
|
||||
export interface GeminiChatContent {
|
||||
role?: "user" | "model"
|
||||
parts: GeminiChatContentPart[]
|
||||
}
|
||||
|
||||
export interface GeminiToolFunctionDeclaration {
|
||||
name: string
|
||||
description: string
|
||||
parameters?: GeminiObjectSchema
|
||||
response?: GeminiObjectSchema
|
||||
}
|
||||
|
||||
interface SafetyRating {
|
||||
harmCategory: HarmCategory
|
||||
harmProbability: HarmProbability
|
||||
blocked: boolean
|
||||
}
|
||||
|
||||
enum HarmCategory {
|
||||
HARM_CATEGORY_UNSPECIFIED = "HARM_CATEGORY_UNSPECIFIED",
|
||||
HARM_CATEGORY_DEROGATORY = "HARM_CATEGORY_DEROGATORY",
|
||||
HARM_CATEGORY_TOXICITY = "HARM_CATEGORY_TOXICITY",
|
||||
HARM_CATEGORY_VIOLENCE = "HARM_CATEGORY_VIOLENCE",
|
||||
HARM_CATEGORY_SEXUAL = "HARM_CATEGORY_SEXUAL",
|
||||
HARM_CATEGORY_MEDICAL = "HARM_CATEGORY_MEDICAL",
|
||||
HARM_CATEGORY_DANGEROUS = "HARM_CATEGORY_DANGEROUS",
|
||||
HARM_CATEGORY_HARASSMENT = "HARM_CATEGORY_HARASSMENT",
|
||||
HARM_CATEGORY_HATE_SPEECH = "HARM_CATEGORY_HATE_SPEECH",
|
||||
HARM_CATEGORY_SEXUALLY_EXPLICIT = "HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
||||
HARM_CATEGORY_DANGEROUS_CONTENT = "HARM_CATEGORY_DANGEROUS_CONTENT",
|
||||
HARM_CATEGORY_CIVIC_INTEGRITY = "HARM_CATEGORY_CIVIC_INTEGRITY",
|
||||
}
|
||||
|
||||
enum HarmProbability {
|
||||
HARM_PROBABILITY_UNSPECIFIED = "HARM_PROBABILITY_UNSPECIFIED",
|
||||
NEGLIGIBLE = "NEGLIGIBLE",
|
||||
LOW = "LOW",
|
||||
MEDIUM = "MEDIUM",
|
||||
HIGH = "HIGH",
|
||||
}
|
||||
|
||||
interface Candidate {
|
||||
content: GeminiChatContent
|
||||
finishReason: FinishReason
|
||||
safetyRatings: SafetyRating[]
|
||||
tokenCount: number
|
||||
groundingAttribution?: GroundingAttribution
|
||||
groundingMetadata?: GroundingMetadata
|
||||
avgLogprobs?: number
|
||||
logprobs?: LogprobsResult
|
||||
index: number
|
||||
}
|
||||
|
||||
enum FinishReason {
|
||||
FINISH_REASON_UNSPECIFIED = "FINISH_REASON_UNSPECIFIED",
|
||||
STOP = "STOP",
|
||||
MAX_TOKENS = "MAX_TOKENS",
|
||||
SAFETY = "SAFETY",
|
||||
RECITATION = "RECITATION",
|
||||
LANGUAGE = "LANGUAGE",
|
||||
OTHER = "OTHER",
|
||||
BLOCKLIST = "BLOCKLIST",
|
||||
PROHIBITED_CONTENT = "PROHIBITED_CONTENT",
|
||||
SPII = "SPII",
|
||||
MALFORMED_FUNCTION_CALL = "MALFORMED_FUNCTION_CALL",
|
||||
}
|
||||
|
||||
interface GroundingAttribution {
|
||||
attributionSourceId: AttributionSourceId
|
||||
groundingSourceContent: string
|
||||
}
|
||||
|
||||
interface AttributionSourceId {
|
||||
groundingPassage?: GroundingPassageId
|
||||
semanticRetrieverChunk?: SemanticRetrieverChunk
|
||||
}
|
||||
|
||||
interface GroundingPassageId {
|
||||
passageId: string
|
||||
partIndex: number
|
||||
}
|
||||
|
||||
interface SemanticRetrieverChunk {
|
||||
source: string
|
||||
chunk: string
|
||||
}
|
||||
|
||||
interface GroundingMetadata {
|
||||
groundingSupport?: GroundingSupport[]
|
||||
webSearchQueries?: string[]
|
||||
searchEntryPoint?: SearchEntryPoint
|
||||
retrievalMetadata?: RetrievalMetadata
|
||||
}
|
||||
|
||||
interface SearchEntryPoint {
|
||||
renderedContent?: string
|
||||
sdkBlob?: string
|
||||
}
|
||||
|
||||
interface RetrievalMetadata {
|
||||
googleSearchDynamicRetrievalScore?: number
|
||||
}
|
||||
|
||||
interface GroundingSupport {
|
||||
groundingChunkIndices: number[]
|
||||
confidenceScores: number[]
|
||||
segment: Segment
|
||||
}
|
||||
|
||||
interface Segment {
|
||||
partIndex: number
|
||||
startIndex: number
|
||||
endIndex: number
|
||||
text: string
|
||||
}
|
||||
|
||||
interface LogprobsResult {
|
||||
topCandidates: TopCandidates[]
|
||||
chosenCandidates: Candidate[]
|
||||
}
|
||||
|
||||
interface TopCandidates {
|
||||
candidates: Candidate[]
|
||||
}
|
||||
+1
-2
@@ -1,4 +1,3 @@
|
||||
import { FimCreateParamsStreaming } from "./openai-adapters/apis/base"
|
||||
import {
|
||||
ChatCompletion,
|
||||
ChatCompletionChunk,
|
||||
@@ -69,7 +68,7 @@ export function toCompleteBody(prompt: string, options: CompletionOptions): Comp
|
||||
}
|
||||
}
|
||||
|
||||
export function toFimBody(prefix: string, suffix: string, options: CompletionOptions): FimCreateParamsStreaming {
|
||||
export function toFimBody(prefix: string, suffix: string, options: CompletionOptions): Record<string, unknown> {
|
||||
return {
|
||||
model: options.model,
|
||||
prompt: prefix,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import fs from "node:fs"
|
||||
import path from "path"
|
||||
|
||||
import type { Language, Node as SyntaxNode, Query, Tree } from "web-tree-sitter"
|
||||
import type Parser from "web-tree-sitter"
|
||||
type Language = Parser.Language
|
||||
type SyntaxNode = Parser.SyntaxNode
|
||||
type Query = Parser.Query
|
||||
type Tree = Parser.Tree
|
||||
import { SymbolWithRange } from ".."
|
||||
import { getUriFileExtension } from "./uri"
|
||||
|
||||
|
||||
+2
-1
@@ -7,7 +7,8 @@ import { intersection } from "../../../util/ranges"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
import type { DocumentSymbol, IDE, Range, RangeInFile, RangeInFileWithContents, SignatureHelp } from "../../../"
|
||||
import type { Node as SyntaxNode } from "web-tree-sitter"
|
||||
import type Parser from "web-tree-sitter"
|
||||
type SyntaxNode = Parser.SyntaxNode
|
||||
const FUNCTION_BLOCK_NODE_TYPES = ["block", "statement_block"]
|
||||
const FUNCTION_DECLARATION_NODE_TYPEs = [
|
||||
"method_definition",
|
||||
|
||||
@@ -702,3 +702,60 @@ Use this as the practical step-by-step transplant procedure.
|
||||
3. (Optional) Webview:
|
||||
- confirm settings update triggers reload
|
||||
- confirm chat textarea completion roundtrip works
|
||||
|
||||
## 11. Clarifications
|
||||
|
||||
These decisions were made before implementation began and override any conflicting guidance in the sections above.
|
||||
|
||||
### 11.1 LLM Provider Architecture
|
||||
|
||||
**Decision: Option A — Via CLI backend (Kilo Gateway).**
|
||||
|
||||
FIM is postponed; only the **holefiller** (chat-completion-based) strategy will be used initially. The LLM provider will route completions through the Kilo Gateway backend. The only supported model for now is `mistralai/codestral-2508`.
|
||||
|
||||
### 11.2 Provider & Model Selection
|
||||
|
||||
**Decision: Hardcoded to Kilo Gateway + `mistralai/codestral-2508`.**
|
||||
|
||||
No profile resolver is needed in phase 1. The provider and model are fixed.
|
||||
|
||||
### 11.3 Feature Scope
|
||||
|
||||
| Feature | Decision |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| JetBrains bridge | **Exclude permanently** — delete `AutocompleteJetbrainsBridge.ts` and its tests. This will never be implemented this way. |
|
||||
| Chat textarea autocomplete | **Include** |
|
||||
| Code actions | **Include** |
|
||||
| Continuedev LLM adapters | **Strip to minimum** — remove all adapters not needed for the Kilo Gateway / holefiller path. The module uses `AutocompleteModel`, not the continuedev adapters directly, so most can be removed. |
|
||||
|
||||
### 11.4 Settings Storage
|
||||
|
||||
**Decision: VS Code settings** via `contributes.configuration` in `package.json`.
|
||||
|
||||
### 11.5 Telemetry
|
||||
|
||||
**Decision: Console logging only.** The extension has no telemetry system yet. Keep console logs but do not send actual telemetry events. Implement a no-op `ITelemetryClient`.
|
||||
|
||||
### 11.6 i18n
|
||||
|
||||
**Decision: Wire into `@kilocode/kilo-i18n`.** Use the translations from `src/services/autocomplete/i18n/`. Keys may differ from what `kilo-i18n` uses, so mapping is required. Discard translations for locales not present in `kilo-i18n`.
|
||||
|
||||
### 11.7 File Ignore / Access Control
|
||||
|
||||
**Decision: Dummy `RooIgnoreController`** that allows everything except `.env` files (and similar sensitive defaults). Include a `TODO` comment for proper implementation later.
|
||||
|
||||
### 11.8 Command Prefix
|
||||
|
||||
**Decision: Use `kilo-code.new.autocomplete.*`** to be consistent with the existing extension naming convention.
|
||||
|
||||
### 11.9 NPM Dependencies
|
||||
|
||||
**Decision: Strip unused continuedev code first**, then install only what is actually needed.
|
||||
|
||||
### 11.10 Tree-Sitter WASM Bundling
|
||||
|
||||
**Decision: Bundle in `dist/`** via esbuild copy plugin.
|
||||
|
||||
### 11.11 Singleton vs Dependency Injection
|
||||
|
||||
**Decision: Deferred** — will be determined during implementation based on what works best with the existing `KiloConnectionService` / `KiloProvider` architecture.
|
||||
|
||||
@@ -1,39 +1,38 @@
|
||||
// kilocode_change - new file
|
||||
import * as vscode from "vscode"
|
||||
import { AutocompleteServiceManager } from "./AutocompleteServiceManager"
|
||||
import { ClineProvider } from "../../core/webview/ClineProvider"
|
||||
import { registerAutocompleteJetbrainsBridge } from "./AutocompleteJetbrainsBridge"
|
||||
import type { KiloConnectionService } from "../cli-backend"
|
||||
|
||||
export const registerAutocompleteProvider = (context: vscode.ExtensionContext, cline: ClineProvider) => {
|
||||
const autocompleteManager = new AutocompleteServiceManager(context, cline)
|
||||
export const registerAutocompleteProvider = (
|
||||
context: vscode.ExtensionContext,
|
||||
connectionService: KiloConnectionService,
|
||||
) => {
|
||||
const autocompleteManager = new AutocompleteServiceManager(context, connectionService)
|
||||
context.subscriptions.push(autocompleteManager)
|
||||
|
||||
// Register JetBrains Bridge if applicable
|
||||
registerAutocompleteJetbrainsBridge(context, cline, autocompleteManager)
|
||||
|
||||
// Register AutocompleteServiceManager Commands
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("kilo-code.autocomplete.reload", async () => {
|
||||
vscode.commands.registerCommand("kilo-code.new.autocomplete.reload", async () => {
|
||||
await autocompleteManager.load()
|
||||
}),
|
||||
)
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("kilo-code.autocomplete.codeActionQuickFix", async () => {
|
||||
vscode.commands.registerCommand("kilo-code.new.autocomplete.codeActionQuickFix", async () => {
|
||||
return
|
||||
}),
|
||||
)
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("kilo-code.autocomplete.generateSuggestions", async () => {
|
||||
vscode.commands.registerCommand("kilo-code.new.autocomplete.generateSuggestions", async () => {
|
||||
autocompleteManager.codeSuggestion()
|
||||
}),
|
||||
)
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("kilo-code.autocomplete.showIncompatibilityExtensionPopup", async () => {
|
||||
vscode.commands.registerCommand("kilo-code.new.autocomplete.showIncompatibilityExtensionPopup", async () => {
|
||||
await autocompleteManager.showIncompatibilityExtensionPopup()
|
||||
}),
|
||||
)
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("kilo-code.autocomplete.disable", async () => {
|
||||
vscode.commands.registerCommand("kilo-code.new.autocomplete.disable", async () => {
|
||||
await autocompleteManager.disable()
|
||||
}),
|
||||
)
|
||||
@@ -44,4 +43,13 @@ export const registerAutocompleteProvider = (context: vscode.ExtensionContext, c
|
||||
providedCodeActionKinds: Object.values(autocompleteManager.codeActionProvider.providedCodeActionKinds),
|
||||
}),
|
||||
)
|
||||
|
||||
// Re-load when autocomplete settings change (e.g. toggled from webview or VS Code settings UI)
|
||||
context.subscriptions.push(
|
||||
vscode.workspace.onDidChangeConfiguration((e) => {
|
||||
if (e.affectsConfiguration("kilo-code.new.autocomplete")) {
|
||||
void autocompleteManager.load()
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Dummy file ignore controller for autocomplete context filtering.
|
||||
*
|
||||
* TODO: Implement proper file ignore logic based on .gitignore, .kilocodeignore,
|
||||
* or CLI backend access control APIs. For now, this allows everything except
|
||||
* known sensitive file patterns.
|
||||
*/
|
||||
|
||||
const SENSITIVE_PATTERNS = [".env", ".env.local", ".env.production", ".env.staging", ".env.development", ".env.*"]
|
||||
|
||||
function isSensitiveFile(filePath: string): boolean {
|
||||
const basename = filePath.split("/").pop() ?? ""
|
||||
return SENSITIVE_PATTERNS.some((pattern) => {
|
||||
if (pattern.includes("*")) {
|
||||
const prefix = pattern.split("*")[0]
|
||||
return basename.startsWith(prefix)
|
||||
}
|
||||
return basename === pattern
|
||||
})
|
||||
}
|
||||
|
||||
export class FileIgnoreController {
|
||||
async initialize(): Promise<void> {
|
||||
// No-op for now
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the file can be read/used as autocomplete context.
|
||||
*/
|
||||
validateAccess(filePath: string): boolean {
|
||||
return !isSensitiveFile(filePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter a list of candidate paths to those allowed.
|
||||
*/
|
||||
filterPaths(paths: string[]): string[] {
|
||||
return paths.filter((p) => this.validateAccess(p))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns user-facing instructions explaining why access is restricted.
|
||||
*/
|
||||
getInstructions(): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
// No-op
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* No-op telemetry client for autocomplete.
|
||||
*
|
||||
* The extension has no telemetry system yet. This stub logs events
|
||||
* to the console for debugging but does not send them anywhere.
|
||||
*/
|
||||
|
||||
export type TelemetryEventName = string
|
||||
|
||||
export interface ITelemetryClient {
|
||||
captureEvent(event: TelemetryEventName, properties?: Record<string, unknown>): void
|
||||
}
|
||||
|
||||
export class TelemetryStub implements ITelemetryClient {
|
||||
captureEvent(event: TelemetryEventName, properties?: Record<string, unknown>): void {
|
||||
console.log("[Kilo New] [Telemetry]", event, properties ?? "")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// kilocode_change - new file
|
||||
// i18n bridge for autocomplete module
|
||||
// Provides a t() function using the autocomplete English dictionary as fallback.
|
||||
// Can be wired to locale detection later.
|
||||
|
||||
import { dict as enDict } from "../i18n/en"
|
||||
|
||||
const translations: Record<string, string> = { ...enDict }
|
||||
|
||||
export function t(key: string, vars?: Record<string, string | number>): string {
|
||||
let text = translations[key] ?? key
|
||||
if (vars) {
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
text = text.replaceAll(`{{${k}}}`, String(v))
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
RangeInFile,
|
||||
TabAutocompleteOptions as CoreTabAutocompleteOptions,
|
||||
} from "./continuedev/core"
|
||||
import { RooIgnoreController } from "../../core/ignore/RooIgnoreController"
|
||||
import { FileIgnoreController } from "./shims/FileIgnoreController"
|
||||
import { ContextRetrievalService } from "./continuedev/core/autocomplete/context/ContextRetrievalService"
|
||||
import { VsCodeIde } from "./continuedev/core/vscode-test-harness/src/VSCodeIde"
|
||||
import { AutocompleteModel } from "./AutocompleteModel"
|
||||
@@ -288,5 +288,5 @@ export interface AutocompleteContextProvider {
|
||||
contextService: ContextRetrievalService
|
||||
ide: VsCodeIde
|
||||
model: AutocompleteModel
|
||||
ignoreController?: Promise<RooIgnoreController>
|
||||
ignoreController?: Promise<FileIgnoreController>
|
||||
}
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { checkKilocodeBalance } from "./kilocode-utils"
|
||||
|
||||
describe("checkKilocodeBalance", () => {
|
||||
const mockToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbnYiOiJwcm9kdWN0aW9uIn0.test"
|
||||
const mockOrgId = "org-123"
|
||||
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("should return true when balance is positive", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ balance: 100 }),
|
||||
} as Response)
|
||||
|
||||
const result = await checkKilocodeBalance(mockToken)
|
||||
expect(result).toBe(true)
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://api.kilo.ai/api/profile/balance",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: `Bearer ${mockToken}`,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should return false when balance is zero", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ balance: 0 }),
|
||||
} as Response)
|
||||
|
||||
const result = await checkKilocodeBalance(mockToken)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when balance is negative", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ balance: -10 }),
|
||||
} as Response)
|
||||
|
||||
const result = await checkKilocodeBalance(mockToken)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should include organization ID in headers when provided", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ balance: 100 }),
|
||||
} as Response)
|
||||
|
||||
const result = await checkKilocodeBalance(mockToken, mockOrgId)
|
||||
expect(result).toBe(true)
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://api.kilo.ai/api/profile/balance",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: `Bearer ${mockToken}`,
|
||||
"X-KiloCode-OrganizationId": mockOrgId,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should not include organization ID in headers when not provided", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ balance: 100 }),
|
||||
} as Response)
|
||||
|
||||
await checkKilocodeBalance(mockToken)
|
||||
|
||||
const fetchCall = vi.mocked(global.fetch).mock.calls[0]
|
||||
expect(fetchCall).toBeDefined()
|
||||
const headers = (fetchCall![1] as RequestInit)?.headers as Record<string, string>
|
||||
|
||||
expect(headers).toHaveProperty("Authorization")
|
||||
expect(headers).not.toHaveProperty("X-KiloCode-OrganizationId")
|
||||
})
|
||||
|
||||
it("should return false when API request fails", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
} as Response)
|
||||
|
||||
const result = await checkKilocodeBalance(mockToken)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when fetch throws an error", async () => {
|
||||
vi.mocked(global.fetch).mockRejectedValueOnce(new Error("Network error"))
|
||||
|
||||
const result = await checkKilocodeBalance(mockToken)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should handle missing balance field in response", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
} as Response)
|
||||
|
||||
const result = await checkKilocodeBalance(mockToken)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
import { getKiloBaseUriFromToken, AUTOCOMPLETE_PROVIDER_MODELS, AutocompleteProviderKey } from "@roo-code/types"
|
||||
|
||||
export { AUTOCOMPLETE_PROVIDER_MODELS }
|
||||
export type { AutocompleteProviderKey }
|
||||
|
||||
/**
|
||||
* Check if the Kilocode account has a positive balance
|
||||
* @param kilocodeToken - The Kilocode JWT token
|
||||
* @param kilocodeOrganizationId - Optional organization ID to include in headers
|
||||
* @returns Promise<boolean> - True if balance > 0, false otherwise
|
||||
*/
|
||||
export async function checkKilocodeBalance(kilocodeToken: string, kilocodeOrganizationId?: string): Promise<boolean> {
|
||||
try {
|
||||
const baseUrl = getKiloBaseUriFromToken(kilocodeToken)
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${kilocodeToken}`,
|
||||
}
|
||||
|
||||
if (kilocodeOrganizationId) {
|
||||
headers["X-KiloCode-OrganizationId"] = kilocodeOrganizationId
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/profile/balance`, {
|
||||
headers,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return false
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const balance = data.balance ?? 0
|
||||
return balance > 0
|
||||
} catch (error) {
|
||||
console.error("Error checking kilocode balance:", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -313,6 +313,112 @@ export class HttpClient {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// FIM Completion Methods
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Stream a FIM (Fill-in-the-Middle) completion from the Kilo Gateway via the CLI backend.
|
||||
* The CLI backend handles auth — no API key needed in the extension.
|
||||
*
|
||||
* @param prefix - Code before the cursor
|
||||
* @param suffix - Code after the cursor
|
||||
* @param onChunk - Callback for each text chunk
|
||||
* @param options - Optional model, maxTokens, temperature
|
||||
* @returns Usage metadata (cost, tokens)
|
||||
*/
|
||||
async fimCompletion(
|
||||
prefix: string,
|
||||
suffix: string,
|
||||
onChunk: (text: string) => void,
|
||||
options?: { model?: string; maxTokens?: number; temperature?: number },
|
||||
): Promise<{ cost: number; inputTokens: number; outputTokens: number }> {
|
||||
const url = `${this.baseUrl}/kilo/fim`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: this.authHeader,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prefix,
|
||||
suffix,
|
||||
model: options?.model,
|
||||
maxTokens: options?.maxTokens,
|
||||
temperature: options?.temperature,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`FIM request failed: ${response.status} ${errorText}`)
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("FIM response has no body")
|
||||
}
|
||||
|
||||
let cost = 0
|
||||
let inputTokens = 0
|
||||
let outputTokens = 0
|
||||
|
||||
// Parse SSE stream
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
break
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
// Process complete SSE lines
|
||||
const lines = buffer.split("\n")
|
||||
buffer = lines.pop() ?? "" // Keep incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) {
|
||||
continue
|
||||
}
|
||||
|
||||
const data = line.slice(6).trim()
|
||||
if (data === "[DONE]") {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data) as {
|
||||
choices?: Array<{ delta?: { content?: string } }>
|
||||
usage?: { prompt_tokens?: number; completion_tokens?: number }
|
||||
cost?: number
|
||||
}
|
||||
|
||||
const content = parsed.choices?.[0]?.delta?.content
|
||||
if (content) {
|
||||
onChunk(content)
|
||||
}
|
||||
|
||||
if (parsed.usage) {
|
||||
inputTokens = parsed.usage.prompt_tokens ?? 0
|
||||
outputTokens = parsed.usage.completion_tokens ?? 0
|
||||
}
|
||||
|
||||
if (parsed.cost !== undefined) {
|
||||
cost = parsed.cost
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed JSON lines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { cost, inputTokens, outputTokens }
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Auth Methods
|
||||
// ============================================
|
||||
|
||||
@@ -13,5 +13,13 @@
|
||||
// "noUnusedParameters": true, /* Report errors on unused parameters. */
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["webview-ui"]
|
||||
"exclude": [
|
||||
"webview-ui",
|
||||
"src/**/__tests__/**",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/test/**",
|
||||
"src/**/vitest.setup.ts",
|
||||
"src/**/testUtils.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,29 +1,100 @@
|
||||
/**
|
||||
* PromptInput component
|
||||
* Text input with send/abort buttons for the chat interface
|
||||
* Text input with send/abort buttons and ghost-text autocomplete for the chat interface
|
||||
*/
|
||||
|
||||
import { Component, createSignal, Show } from "solid-js"
|
||||
import { Component, createSignal, onCleanup, Show } from "solid-js"
|
||||
import { Button } from "@kilocode/kilo-ui/button"
|
||||
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
|
||||
import { useSession } from "../../context/session"
|
||||
import { useServer } from "../../context/server"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { useVSCode } from "../../context/vscode"
|
||||
import { ModelSelector } from "./ModelSelector"
|
||||
import { ModeSwitcher } from "./ModeSwitcher"
|
||||
|
||||
const AUTOCOMPLETE_DEBOUNCE_MS = 500
|
||||
const MIN_TEXT_LENGTH = 3
|
||||
|
||||
export const PromptInput: Component = () => {
|
||||
const session = useSession()
|
||||
const server = useServer()
|
||||
const language = useLanguage()
|
||||
const vscode = useVSCode()
|
||||
|
||||
const [text, setText] = createSignal("")
|
||||
const [ghostText, setGhostText] = createSignal("")
|
||||
let textareaRef: HTMLTextAreaElement | undefined
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let requestCounter = 0
|
||||
|
||||
const isBusy = () => session.status() === "busy"
|
||||
const isDisabled = () => !server.isConnected()
|
||||
const canSend = () => text().trim().length > 0 && !isBusy() && !isDisabled()
|
||||
|
||||
// Listen for chat completion results from the extension
|
||||
const unsubscribe = vscode.onMessage((message) => {
|
||||
if (message.type === "chatCompletionResult") {
|
||||
const result = message as { type: "chatCompletionResult"; text: string; requestId: string }
|
||||
// Only apply if the requestId matches the latest request
|
||||
const expectedId = `chat-ac-${requestCounter}`
|
||||
if (result.requestId === expectedId && result.text) {
|
||||
setGhostText(result.text)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
unsubscribe()
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
}
|
||||
})
|
||||
|
||||
// Request autocomplete from the extension
|
||||
const requestAutocomplete = (currentText: string) => {
|
||||
if (currentText.length < MIN_TEXT_LENGTH || isDisabled()) {
|
||||
setGhostText("")
|
||||
return
|
||||
}
|
||||
|
||||
requestCounter++
|
||||
const requestId = `chat-ac-${requestCounter}`
|
||||
|
||||
vscode.postMessage({
|
||||
type: "requestChatCompletion",
|
||||
text: currentText,
|
||||
requestId,
|
||||
})
|
||||
}
|
||||
|
||||
// Accept the ghost text suggestion
|
||||
const acceptSuggestion = () => {
|
||||
const suggestion = ghostText()
|
||||
if (!suggestion) return
|
||||
|
||||
const newText = text() + suggestion
|
||||
setText(newText)
|
||||
setGhostText("")
|
||||
|
||||
// Notify extension of acceptance for telemetry
|
||||
vscode.postMessage({
|
||||
type: "chatCompletionAccepted",
|
||||
suggestionLength: suggestion.length,
|
||||
})
|
||||
|
||||
// Update textarea
|
||||
if (textareaRef) {
|
||||
textareaRef.value = newText
|
||||
adjustHeight()
|
||||
}
|
||||
}
|
||||
|
||||
// Dismiss the ghost text
|
||||
const dismissSuggestion = () => {
|
||||
setGhostText("")
|
||||
}
|
||||
|
||||
// Auto-resize textarea
|
||||
const adjustHeight = () => {
|
||||
if (!textareaRef) return
|
||||
@@ -35,12 +106,38 @@ export const PromptInput: Component = () => {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
setText(target.value)
|
||||
adjustHeight()
|
||||
|
||||
// Clear existing ghost text on new input
|
||||
setGhostText("")
|
||||
|
||||
// Debounce autocomplete request
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
}
|
||||
debounceTimer = setTimeout(() => {
|
||||
requestAutocomplete(target.value)
|
||||
}, AUTOCOMPLETE_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Tab or ArrowRight to accept ghost text
|
||||
if ((e.key === "Tab" || e.key === "ArrowRight") && ghostText()) {
|
||||
e.preventDefault()
|
||||
acceptSuggestion()
|
||||
return
|
||||
}
|
||||
|
||||
// Escape to dismiss ghost text
|
||||
if (e.key === "Escape" && ghostText()) {
|
||||
e.preventDefault()
|
||||
dismissSuggestion()
|
||||
return
|
||||
}
|
||||
|
||||
// Enter to send (without shift)
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
dismissSuggestion()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
@@ -52,6 +149,7 @@ export const PromptInput: Component = () => {
|
||||
const sel = session.selected()
|
||||
session.sendMessage(message, sel?.providerID, sel?.modelID)
|
||||
setText("")
|
||||
setGhostText("")
|
||||
|
||||
// Reset textarea height
|
||||
if (textareaRef) {
|
||||
@@ -66,18 +164,26 @@ export const PromptInput: Component = () => {
|
||||
return (
|
||||
<div class="prompt-input-container">
|
||||
<div class="prompt-input-wrapper">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
class="prompt-input"
|
||||
placeholder={
|
||||
isDisabled() ? language.t("prompt.placeholder.connecting") : language.t("prompt.placeholder.default")
|
||||
}
|
||||
value={text()}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isDisabled()}
|
||||
rows={1}
|
||||
/>
|
||||
<div class="prompt-input-ghost-wrapper">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
class="prompt-input"
|
||||
placeholder={
|
||||
isDisabled() ? language.t("prompt.placeholder.connecting") : language.t("prompt.placeholder.default")
|
||||
}
|
||||
value={text()}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isDisabled()}
|
||||
rows={1}
|
||||
/>
|
||||
<Show when={ghostText()}>
|
||||
<div class="prompt-input-ghost-overlay" aria-hidden="true">
|
||||
<span class="prompt-input-ghost-text-hidden">{text()}</span>
|
||||
<span class="prompt-input-ghost-text">{ghostText()}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="prompt-input-actions">
|
||||
<Show
|
||||
when={isBusy()}
|
||||
|
||||
@@ -1,30 +1,60 @@
|
||||
import { Component } from "solid-js"
|
||||
import { Component, createSignal, onCleanup } from "solid-js"
|
||||
import { Switch } from "@kilocode/kilo-ui/switch"
|
||||
import { useVSCode } from "../../context/vscode"
|
||||
import type { ExtensionMessage } from "../../types/messages"
|
||||
|
||||
const AutocompleteTab: Component = () => {
|
||||
const vscode = useVSCode()
|
||||
|
||||
const [enableAutoTrigger, setEnableAutoTrigger] = createSignal(true)
|
||||
const [enableSmartInlineTaskKeybinding, setEnableSmartInlineTaskKeybinding] = createSignal(false)
|
||||
const [enableChatAutocomplete, setEnableChatAutocomplete] = createSignal(false)
|
||||
|
||||
const unsubscribe = vscode.onMessage((message: ExtensionMessage) => {
|
||||
if (message.type !== "autocompleteSettingsLoaded") {
|
||||
return
|
||||
}
|
||||
setEnableAutoTrigger(message.settings.enableAutoTrigger)
|
||||
setEnableSmartInlineTaskKeybinding(message.settings.enableSmartInlineTaskKeybinding)
|
||||
setEnableChatAutocomplete(message.settings.enableChatAutocomplete)
|
||||
})
|
||||
|
||||
onCleanup(unsubscribe)
|
||||
|
||||
vscode.postMessage({ type: "requestAutocompleteSettings" })
|
||||
|
||||
const updateSetting = (
|
||||
key: "enableAutoTrigger" | "enableSmartInlineTaskKeybinding" | "enableChatAutocomplete",
|
||||
value: boolean,
|
||||
) => {
|
||||
vscode.postMessage({ type: "updateAutocompleteSetting", key, value })
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
background: "var(--vscode-editor-background)",
|
||||
border: "1px solid var(--vscode-panel-border)",
|
||||
"border-radius": "4px",
|
||||
padding: "16px",
|
||||
}}
|
||||
<div data-component="autocomplete-settings" style={{ display: "flex", "flex-direction": "column", gap: "12px" }}>
|
||||
<Switch
|
||||
checked={enableAutoTrigger()}
|
||||
onChange={(checked) => updateSetting("enableAutoTrigger", checked)}
|
||||
description="Automatically show inline completion suggestions as you type"
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
"font-size": "12px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
margin: 0,
|
||||
"line-height": "1.5",
|
||||
}}
|
||||
>
|
||||
<strong style={{ color: "var(--vscode-foreground)" }}>This section is not implemented yet.</strong> It will
|
||||
contain configuration options and explanatory text related to the selected settings category. During
|
||||
reimplementation, use this space to validate layout, spacing, scrolling behavior, and navigation state before
|
||||
wiring up real controls.
|
||||
</p>
|
||||
</div>
|
||||
Enable automatic inline completions
|
||||
</Switch>
|
||||
|
||||
<Switch
|
||||
checked={enableSmartInlineTaskKeybinding()}
|
||||
onChange={(checked) => updateSetting("enableSmartInlineTaskKeybinding", checked)}
|
||||
description="Use a smart keybinding for triggering inline tasks"
|
||||
>
|
||||
Enable smart inline task keybinding
|
||||
</Switch>
|
||||
|
||||
<Switch
|
||||
checked={enableChatAutocomplete()}
|
||||
onChange={(checked) => updateSetting("enableChatAutocomplete", checked)}
|
||||
description="Show autocomplete suggestions in the chat textarea"
|
||||
>
|
||||
Enable chat textarea autocomplete
|
||||
</Switch>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -181,6 +181,44 @@
|
||||
color: var(--vscode-input-placeholderForeground);
|
||||
}
|
||||
|
||||
/* Ghost text autocomplete overlay */
|
||||
.prompt-input-ghost-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.prompt-input-ghost-wrapper .prompt-input {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.prompt-input-ghost-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
padding: 8px 12px;
|
||||
font-family: var(--vscode-font-family);
|
||||
font-size: var(--vscode-font-size, 13px);
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.prompt-input-ghost-text-hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.prompt-input-ghost-text {
|
||||
color: var(--vscode-editorGhostText-foreground, rgba(255, 255, 255, 0.35));
|
||||
}
|
||||
|
||||
.prompt-input-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
@@ -414,6 +414,21 @@ export interface AgentsLoadedMessage {
|
||||
defaultAgent: string
|
||||
}
|
||||
|
||||
export interface AutocompleteSettingsLoadedMessage {
|
||||
type: "autocompleteSettingsLoaded"
|
||||
settings: {
|
||||
enableAutoTrigger: boolean
|
||||
enableSmartInlineTaskKeybinding: boolean
|
||||
enableChatAutocomplete: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface ChatCompletionResultMessage {
|
||||
type: "chatCompletionResult"
|
||||
text: string
|
||||
requestId: string
|
||||
}
|
||||
|
||||
export interface QuestionRequestMessage {
|
||||
type: "questionRequest"
|
||||
question: QuestionRequest
|
||||
@@ -484,6 +499,8 @@ export type ExtensionMessage =
|
||||
| DeviceAuthCancelledMessage
|
||||
| ProvidersLoadedMessage
|
||||
| AgentsLoadedMessage
|
||||
| AutocompleteSettingsLoadedMessage
|
||||
| ChatCompletionResultMessage
|
||||
| QuestionRequestMessage
|
||||
| QuestionResolvedMessage
|
||||
| QuestionErrorMessage
|
||||
@@ -601,6 +618,26 @@ export interface RenameSessionRequest {
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface RequestAutocompleteSettingsMessage {
|
||||
type: "requestAutocompleteSettings"
|
||||
}
|
||||
|
||||
export interface UpdateAutocompleteSettingMessage {
|
||||
type: "updateAutocompleteSetting"
|
||||
key: "enableAutoTrigger" | "enableSmartInlineTaskKeybinding" | "enableChatAutocomplete"
|
||||
value: boolean
|
||||
}
|
||||
|
||||
export interface RequestChatCompletionMessage {
|
||||
type: "requestChatCompletion"
|
||||
text: string
|
||||
requestId: string
|
||||
}
|
||||
|
||||
export interface ChatCompletionAcceptedMessage {
|
||||
type: "chatCompletionAccepted"
|
||||
suggestionLength?: number
|
||||
}
|
||||
export interface UpdateSettingRequest {
|
||||
type: "updateSetting"
|
||||
key: string
|
||||
@@ -646,6 +683,10 @@ export type WebviewMessage =
|
||||
| QuestionRejectRequest
|
||||
| DeleteSessionRequest
|
||||
| RenameSessionRequest
|
||||
| RequestAutocompleteSettingsMessage
|
||||
| UpdateAutocompleteSettingMessage
|
||||
| RequestChatCompletionMessage
|
||||
| ChatCompletionAcceptedMessage
|
||||
| UpdateSettingRequest
|
||||
| RequestBrowserSettingsMessage
|
||||
| RequestConfigMessage
|
||||
|
||||
Reference in New Issue
Block a user