Merge pull request #280 from Kilo-Org/mark/implement-autocomplete

feat: transplant autocomplete module into kilo-vscode
This commit is contained in:
Mark IJbema
2026-02-12 16:37:58 +01:00
committed by GitHub
77 changed files with 1046 additions and 6628 deletions
+52 -9
View File
@@ -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({
+34 -1
View File
@@ -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"
}
}
+44
View File
@@ -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.
+4
View File
@@ -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 }),
)
}
}
@@ -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")
})
})
})
@@ -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 }> {
@@ -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)
}
@@ -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()
}
@@ -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)
}
}
}
@@ -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,
@@ -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
}
@@ -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
}
@@ -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"
@@ -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"
@@ -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"
@@ -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 {
@@ -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,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
@@ -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
}
}
}
@@ -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
@@ -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.")
}
}
@@ -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
@@ -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"
}
@@ -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
}
}
}
}
@@ -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.")
}
}
@@ -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.")
}
}
@@ -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}`)
}
}
}
@@ -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(),
}
}
}
@@ -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.")
}
}
@@ -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.")
}
}
@@ -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
}
}
@@ -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.")
}
}
@@ -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,
})
}
}
}
@@ -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",
},
]
}
}
@@ -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.")
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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
})
}
}
@@ -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.")
}
}
@@ -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.")
}
}
@@ -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.")
}
}
@@ -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[]>
}
@@ -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"
@@ -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
@@ -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,
},
}
}
@@ -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",
}
@@ -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,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"
@@ -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
// ============================================
+9 -1
View File
@@ -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