feat: Complete migration to kepler-ai-sdk

This commit is contained in:
Aunali321 2025-08-31 17:09:09 +05:30
parent 071e1016b1
commit 31d72543b3
20 changed files with 1162 additions and 550 deletions

8
.mcp.json Normal file
View file

@ -0,0 +1,8 @@
{
"mcpServers": {
"svelte-llm": {
"type": "http",
"url": "https://svelte-llm.stanislav.garden/mcp/mcp"
}
}
}

View file

@ -13,7 +13,7 @@
"@fontsource-variable/nunito-sans": "^5.2.6",
"@fontsource-variable/open-sans": "^5.2.6",
"@fontsource/instrument-serif": "^5.2.6",
"@keplersystems/kepler-ai-sdk": "^1.0.5",
"@keplersystems/kepler-ai-sdk": "file:./tmp/kepler-ai-sdk",
"better-auth": "^1.2.9",
"convex-helpers": "^0.1.94",
"hastscript": "^9.0.1",
@ -21,10 +21,13 @@
"zod": "^3.25.64",
},
"devDependencies": {
"@anthropic-ai/sdk": "^0.29.0",
"@better-auth-kit/convex": "^1.2.2",
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@google/generative-ai": "^0.21.0",
"@iconify/json": "^2.2.349",
"@mistralai/mistralai": "^1.1.0",
"@playwright/test": "^1.49.1",
"@shikijs/langs": "^3.6.0",
"@shikijs/markdown-it": "^3.6.0",
@ -38,6 +41,7 @@
"@vercel/functions": "^2.2.0",
"bits-ui": "^2.8.5",
"clsx": "^2.1.1",
"cohere-ai": "^7.14.0",
"concurrently": "^9.1.2",
"convex": "^1.24.8",
"convex-svelte": "^0.0.11",
@ -79,7 +83,7 @@
"@antfu/utils": ["@antfu/utils@8.1.1", "", {}, "sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.55.1", "", { "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-gjOMS4chmm8BxClKmCjNHmvf1FrO1Cn++CSX6K3YCZjz5JG4I9ZttQ/xEH4FBsz6HQyZvnUpiKlOAkmxaGmEaQ=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.29.2", "", { "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-5dwiOPO/AZvhY4bJIG9vjFKU9Kza3hA6VEsbIQg6L9vny2RQIpCFhV50nB9IrG2edZaHZb4HuQ9Wmsn5zgWyZg=="],
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="],
@ -259,6 +263,8 @@
"@google/genai": ["@google/genai@1.16.0", "", { "dependencies": { "google-auth-library": "^9.14.2", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.11.4" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-hdTYu39QgDFxv+FB6BK2zi4UIJGWhx2iPc0pHQ0C5Q/RCi+m+4gsryIzTGO+riqWcUA8/WGYp6hpqckdOBNysw=="],
"@google/generative-ai": ["@google/generative-ai@0.21.0", "", {}, "sha512-7XhUbtnlkSEZK15kN3t+tzIMxsbKm/dSkKBFalj+20NvPKe1kBY7mR2P7vuijEn+f06z5+A8bVGKO0v39cr6Wg=="],
"@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
@ -277,6 +283,8 @@
"@internationalized/date": ["@internationalized/date@3.9.0", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-yaN3brAnHRD+4KyyOsJyk49XUvj2wtbNACSqg0bz3u8t2VuzhC8Q5dfRnrSxjnnbDb+ienBnkn1TzQfE154vyg=="],
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
"@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
@ -291,7 +299,7 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.30", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q=="],
"@keplersystems/kepler-ai-sdk": ["@keplersystems/kepler-ai-sdk@1.0.5", "", { "dependencies": { "@anthropic-ai/sdk": "^0.55.0", "@google/genai": "^1.7.0", "@mistralai/mistralai": "^1.7.2", "cohere-ai": "^7.17.1", "exa-js": "^1.8.20", "openai": "^5.7.0" }, "peerDependencies": { "typescript": "^5" } }, "sha512-aynAClqQtS1Xkt53lZ4TdOS1YbtkfaYIrWxSmNXEV5DahLzHRi8YJ3zooAq2cmHUwjaEXmpaB2AMFdwAxWWTkQ=="],
"@keplersystems/kepler-ai-sdk": ["@keplersystems/kepler-ai-sdk@file:tmp/kepler-ai-sdk", { "dependencies": { "@anthropic-ai/sdk": "^0.55.0", "@google/genai": "^1.7.0", "@mistralai/mistralai": "^1.7.2", "cohere-ai": "^7.17.1", "exa-js": "^1.8.20", "openai": "^5.7.0" }, "devDependencies": { "@types/bun": "latest", "rimraf": "^5.0.5" }, "peerDependencies": { "typescript": "^5" } }],
"@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="],
@ -317,6 +325,8 @@
"@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.4.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.4.0", "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-F7mIZY2Eao2TaoVqigGMLv+NDdpwuBKU1fucHPONfzaBS4JXXCNCmfO0Z3dsy7JzKGqtDcYC1mr9JjaZQZNiuw=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@playwright/test": ["@playwright/test@1.55.0", "", { "dependencies": { "playwright": "1.55.0" }, "bin": { "playwright": "cli.js" } }, "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ=="],
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
@ -515,6 +525,8 @@
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
"@types/bun": ["@types/bun@1.2.21", "", { "dependencies": { "bun-types": "1.2.21" } }, "sha512-NiDnvEqmbfQ6dmZ3EeUO577s4P5bf4HCTXtI6trMc6f6RzirY5IrF3aIookuSpyslFzrnvv2lmEWv5HyC1X79A=="],
"@types/chai": ["@types/chai@5.2.2", "", { "dependencies": { "@types/deep-eql": "*" } }, "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
@ -537,6 +549,12 @@
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@18.19.123", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-K7DIaHnh0mzVxreCR9qwgNxp3MH9dltPNIEddW9MYUlcKAzm+3grKNSTe2vCJHI1FaLpvpL5JGJrz1UZDKYvDg=="],
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
"@types/react": ["@types/react@19.1.12", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
@ -591,6 +609,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=="],
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
@ -633,6 +653,8 @@
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"bun-types": ["bun-types@1.2.21", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
@ -697,6 +719,8 @@
"cssstyle": ["cssstyle@4.6.0", "", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="],
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
@ -731,6 +755,8 @@
"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=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@ -819,11 +845,13 @@
"focus-trap": ["focus-trap@7.6.5", "", { "dependencies": { "tabbable": "^6.2.0" } }, "sha512-7Ke1jyybbbPZyZXFxEftUtxFGLMpE2n6A+z//m4CRDlj0hW+o3iYSmh8nFlYMurOiJVDmJRilUQtJr08KfIxlg=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"form-data": ["form-data@4.0.4", "", { "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-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="],
"form-data-encoder": ["form-data-encoder@4.1.0", "", {}, "sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw=="],
"form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="],
"formdata-node": ["formdata-node@6.0.3", "", {}, "sha512-8e1++BCiTzUno9v5IZ2J6bv4RU+3UKDmqWUQD0MIMVCd9AdhWkO1gw57oo1mNEX1dMq2EGI+FbWz4B92pscSQg=="],
"formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
@ -839,6 +867,8 @@
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@16.3.0", "", {}, "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ=="],
@ -879,6 +909,8 @@
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
@ -911,6 +943,8 @@
"isomorphic-dompurify": ["isomorphic-dompurify@2.26.0", "", { "dependencies": { "dompurify": "^3.2.6", "jsdom": "^26.1.0" } }, "sha512-nZmoK4wKdzPs5USq4JHBiimjdKSVAOm2T1KyDoadtMPNXYHxiENd19ou4iU/V4juFM6LVgYQnpxCYmxqNP4Obw=="],
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"jest-axe": ["jest-axe@9.0.0", "", { "dependencies": { "axe-core": "4.9.1", "chalk": "4.1.2", "jest-matcher-utils": "29.2.2", "lodash.merge": "4.6.2" } }, "sha512-Xt7O0+wIpW31lv0SO1wQZUTyJE7DEmnDEZeTt9/S9L5WUywxrv8BrgvTuQEqujtfaQOcJ70p4wg7UUgK1E2F5g=="],
"jest-diff": ["jest-diff@29.7.0", "", { "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^29.6.3", "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" } }, "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw=="],
@ -1059,6 +1093,8 @@
"neverthrow": ["neverthrow@8.2.0", "", { "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "^4.24.0" } }, "sha512-kOCT/1MCPAxY5iUV3wytNFUMUolzuwd/VF/1KCx7kf6CutrOsTie+84zTGTpgQycjvfLdBBdvBvFLqFD2c0wkQ=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"nwsapi": ["nwsapi@2.2.21", "", {}, "sha512-o6nIY3qwiSXl7/LuOU0Dmuctd34Yay0yeuZRLFmDPrrdHpXKFndPj3hM+YEPVHYC5fx2otBx4Ilc/gyYSAUaIA=="],
@ -1077,6 +1113,8 @@
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
"package-manager-detector": ["package-manager-detector@1.3.0", "", {}, "sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
@ -1087,6 +1125,8 @@
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
@ -1159,6 +1199,8 @@
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="],
"rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
"rou3": ["rou3@0.5.1", "", {}, "sha512-OXMmJ3zRk2xeXFGfA3K+EOPHC5u7RDFG7lIOx0X1pdnhUkI8MdVrbV+sNsD80ElpUZ+MRHdyxPnFthq9VHs8uQ=="],
@ -1203,6 +1245,8 @@
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"sirv": ["sirv@3.0.1", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
@ -1215,12 +1259,16 @@
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
@ -1303,6 +1351,8 @@
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
"undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"unist-util-is": ["unist-util-is@6.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw=="],
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
@ -1339,6 +1389,8 @@
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
"webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
@ -1357,6 +1409,8 @@
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
@ -1371,7 +1425,7 @@
"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=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
@ -1397,6 +1451,14 @@
"@iconify/utils/globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"@keplersystems/kepler-ai-sdk/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.55.1", "", { "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-gjOMS4chmm8BxClKmCjNHmvf1FrO1Cn++CSX6K3YCZjz5JG4I9ZttQ/xEH4FBsz6HQyZvnUpiKlOAkmxaGmEaQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],
@ -1413,6 +1475,8 @@
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
"@types/node-fetch/@types/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
@ -1423,14 +1487,20 @@
"bits-ui/runed": ["runed@0.29.2", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-0cq6cA6sYGZwl/FvVqjx9YN+1xEBu9sDDyuWdDW1yWX7JF2wmvmVKfH+hVCZs+csW+P3ARH92MjI3H9QTagOQA=="],
"bun-types/@types/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="],
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"convict/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="],
"cohere-ai/form-data-encoder": ["form-data-encoder@4.1.0", "", {}, "sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw=="],
"cohere-ai/formdata-node": ["formdata-node@6.0.3", "", {}, "sha512-8e1++BCiTzUno9v5IZ2J6bv4RU+3UKDmqWUQD0MIMVCd9AdhWkO1gw57oo1mNEX1dMq2EGI+FbWz4B92pscSQg=="],
"exa-js/dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"jest-diff/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
"jest-matcher-utils/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
@ -1467,12 +1537,26 @@
"vitest/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.0", "", {}, "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg=="],
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
"@types/node-fetch/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"bun-types/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
"glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"jest-diff/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"jest-diff/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],

View file

@ -87,7 +87,7 @@
"@fontsource-variable/nunito-sans": "^5.2.6",
"@fontsource-variable/open-sans": "^5.2.6",
"@fontsource/instrument-serif": "^5.2.6",
"@keplersystems/kepler-ai-sdk": "^1.0.5",
"@keplersystems/kepler-ai-sdk": "file:./tmp/kepler-ai-sdk",
"better-auth": "^1.2.9",
"convex-helpers": "^0.1.94",
"hastscript": "^9.0.1",

View file

@ -101,16 +101,20 @@ export const set = mutation({
)
.first();
if (args.enabled && existing) return; // nothing to do here
if (existing) {
await ctx.db.delete(existing._id);
if (args.enabled) {
// Enable model: insert if not exists
if (!existing) {
await ctx.db.insert('user_enabled_models', {
...object.pick(args, ['provider', 'model_id']),
user_id: session.userId,
pinned: false,
});
}
} else {
await ctx.db.insert('user_enabled_models', {
...object.pick(args, ['provider', 'model_id']),
user_id: session.userId,
pinned: false,
});
// Disable model: delete if exists
if (existing) {
await ctx.db.delete(existing._id);
}
}
},
});

View file

@ -8,9 +8,9 @@
import { models as modelsState } from '$lib/state/models.svelte';
import { session } from '$lib/state/session.svelte';
import { settings } from '$lib/state/settings.svelte';
import { Provider } from '$lib/types';
import { Provider, PROVIDER_META } from '$lib/types';
import { fuzzysearch } from '$lib/utils/fuzzy-search';
import { supportsImages, supportsReasoning } from '$lib/utils/model-capabilities';
import { supportsImages, supportsReasoning, supportsStreaming, supportsToolCalls } from '$lib/utils/model-capabilities';
import { capitalize } from '$lib/utils/strings';
import { cn } from '$lib/utils/utils';
import { type Component } from 'svelte';
@ -57,7 +57,14 @@
session_token: session.current?.session.token ?? '',
});
const enabledArr = $derived(Object.values(enabledModelsQuery.data ?? {}));
// Get enabled models from our models state with ModelInfo data
const enabledArr = $derived.by(() => {
const enabledModelIds = Object.keys(enabledModelsQuery.data ?? {});
const enabledModels = modelsState.all().filter(model =>
enabledModelIds.some(id => id.includes(model.id))
);
return enabledModels;
});
modelsState.init();
@ -133,37 +140,38 @@
fuzzysearch({
haystack: enabledArr,
needle: search,
property: 'model_id',
property: 'id',
})
);
// Group models by company
// Group models by provider
const groupedModels = $derived.by(() => {
const groups: Record<string, typeof filteredModels> = {};
const groups: Record<Provider, typeof filteredModels> = {} as Record<Provider, typeof filteredModels>;
filteredModels.forEach((model) => {
const company = getCompanyFromModelId(model.model_id);
if (!groups[company]) {
groups[company] = [];
const provider = model.provider as Provider;
if (!groups[provider]) {
groups[provider] = [];
}
groups[company].push(model);
groups[provider].push(model);
});
// Sort companies with known icons first
const result = Object.entries(groups).sort(([a], [b]) => {
const aHasIcon = companyIcons[a] ? 0 : 1;
const bHasIcon = companyIcons[b] ? 0 : 1;
return aHasIcon - bHasIcon || a.localeCompare(b);
});
// Sort by provider order and name
const result = Object.entries(groups)
.sort(([a], [b]) => a.localeCompare(b))
.map(([provider, models]) => [
provider,
models.sort((a, b) => a.name.localeCompare(b.name))
] as [Provider, typeof models]);
return result;
});
const currentModel = $derived(enabledArr.find((m) => m.model_id === settings.modelId));
const currentModel = $derived(enabledArr.find((m) => m.id === settings.modelId));
$effect(() => {
if (!enabledArr.find((m) => m.model_id === settings.modelId) && enabledArr.length > 0) {
settings.modelId = enabledArr[0]!.model_id;
if (!enabledArr.find((m) => m.id === settings.modelId) && enabledArr.length > 0) {
settings.modelId = enabledArr[0]!.id;
}
});
@ -179,8 +187,10 @@
{ from: 'o3', to: 'o3' },
];
function formatModelName(modelId: string) {
const cleanId = modelId.replace(/^[^/]+\//, '');
function formatModelName(model: { id: string; name: string }) {
// Use the name field if available, fallback to processing ID
const displayName = model.name || model.id;
const cleanId = displayName.replace(/^[^/]+\//, '');
const parts = cleanId.split(/[-_,:]/);
const formattedParts = parts.map((part) => {
@ -228,17 +238,20 @@
const activeModelInfo = $derived.by(() => {
if (activeModel === '') return null;
const model = enabledArr.find((m) => m.model_id === activeModel);
const model = enabledArr.find((m) => m.id === activeModel);
if (!model) return null;
return {
...model,
formatted: formatModelName(activeModel),
formatted: formatModelName(model),
};
});
const pinnedModels = $derived(enabledArr.filter((m) => isPinned(m)));
// For now, we'll need to maintain pinned models using the old enabled models structure
// until we migrate the pinning system to work with the new ModelInfo structure
const enabledModelsData = $derived(Object.values(enabledModelsQuery.data ?? {}));
const pinnedModels = $derived(enabledModelsData.filter((m) => isPinned(m)));
</script>
<svelte:window
@ -259,12 +272,12 @@
)}
>
<div class="flex items-center gap-2 pr-2">
{#if currentModel && getModelIcon(currentModel.model_id)}
{@const IconComponent = getModelIcon(currentModel.model_id)}
{#if currentModel && getModelIcon(currentModel.id)}
{@const IconComponent = getModelIcon(currentModel.id)}
<IconComponent class="size-3" />
{/if}
<span class="truncate">
{currentModel ? formatModelName(currentModel.model_id).full : 'Select model'}
{currentModel ? formatModelName(currentModel).full : 'Select model'}
</span>
</div>
<ChevronDownIcon class="size-4 opacity-50" />
@ -324,12 +337,9 @@
>
{#if view === 'favorites' && pinnedModels.length > 0}
{#each pinnedModels as model (model._id)}
{@const formatted = formatModelName(model.model_id)}
{@const openRouterModel = modelsState
.from(Provider.OpenRouter)
.find((m) => m.id === model.model_id)}
{@const disabled =
onlyImageModels && openRouterModel && !supportsImages(openRouterModel)}
{@const modelInfo = enabledArr.find((m) => m.id === model.model_id)}
{@const formatted = modelInfo ? formatModelName(modelInfo) : { full: model.model_id, primary: model.model_id, secondary: '' }}
{@const disabled = onlyImageModels && modelInfo && !supportsImages(modelInfo)}
<Command.Item
value={model.model_id}
@ -354,7 +364,7 @@
</div>
<div class="flex place-items-center gap-1">
{#if openRouterModel && supportsImages(openRouterModel)}
{#if modelInfo && supportsImages(modelInfo)}
<Tooltip>
{#snippet trigger(tooltip)}
<div
@ -364,11 +374,11 @@
<EyeIcon class="size-3" />
</div>
{/snippet}
Supports image analysis
Supports vision/image analysis
</Tooltip>
{/if}
{#if openRouterModel && supportsReasoning(openRouterModel)}
{#if modelInfo && supportsReasoning(modelInfo)}
<Tooltip>
{#snippet trigger(tooltip)}
<div
@ -381,6 +391,34 @@
Supports reasoning
</Tooltip>
{/if}
{#if modelInfo && supportsStreaming(modelInfo)}
<Tooltip>
{#snippet trigger(tooltip)}
<div
{...tooltip.trigger}
class="rounded-md border-blue-500 bg-blue-500/50 p-1 text-blue-400"
>
<ZapIcon class="size-3" />
</div>
{/snippet}
Supports streaming responses
</Tooltip>
{/if}
{#if modelInfo && supportsToolCalls(modelInfo)}
<Tooltip>
{#snippet trigger(tooltip)}
<div
{...tooltip.trigger}
class="rounded-md border-orange-500 bg-orange-500/50 p-1 text-orange-400"
>
<CpuIcon class="size-3" />
</div>
{/snippet}
Supports tool/function calling
</Tooltip>
{/if}
</div>
</Command.Item>
{/each}
@ -394,22 +432,25 @@
</Command.GroupHeading>
<Command.GroupItems class="grid grid-cols-2 gap-3 px-3 pb-3 md:grid-cols-4">
{#each pinnedModels as model (model._id)}
{@render modelCard(model)}
{@const modelInfo = enabledArr.find((m) => m.id === model.model_id)}
{#if modelInfo}
{@render modelCard(modelInfo)}
{/if}
{/each}
</Command.GroupItems>
</Command.Group>
{/if}
{#each groupedModels as [company, models] (company)}
{@const filteredModels = models.filter((m) => !isPinned(m))}
{#if filteredModels.length > 0}
{#each groupedModels as [provider, models] (provider)}
{@const providerMeta = PROVIDER_META[provider]}
{#if models.length > 0}
<Command.Group class="space-y-2">
<Command.GroupHeading
class="text-heading/75 flex scroll-m-40 items-center gap-2 px-3 pt-3 pb-1 text-xs font-semibold tracking-wide capitalize"
class="text-heading/75 flex scroll-m-40 items-center gap-2 px-3 pt-3 pb-1 text-xs font-semibold tracking-wide"
>
{company}
{providerMeta.title}
</Command.GroupHeading>
<Command.GroupItems class="grid grid-cols-2 gap-3 px-3 pb-3 md:grid-cols-4">
{#each filteredModels as model (model._id)}
{#each models as model (model.id)}
{@render modelCard(model)}
{/each}
</Command.GroupItems>
@ -457,14 +498,12 @@
</Popover.Root>
{#snippet modelCard(model: (typeof enabledArr)[number])}
{@const formatted = formatModelName(model.model_id)}
{@const openRouterModel = modelsState
.from(Provider.OpenRouter)
.find((m) => m.id === model.model_id)}
{@const disabled = onlyImageModels && openRouterModel && !supportsImages(openRouterModel)}
{@const formatted = formatModelName(model)}
{@const disabled = onlyImageModels && !supportsImages(model)}
{@const enabledModelData = enabledModelsData.find(m => m.model_id === model.id)}
<Command.Item
value={model.model_id}
value={model.id}
class={cn(
'border-border bg-popover group/item flex gap-2 rounded-lg border p-2',
'relative scroll-m-36 select-none',
@ -472,11 +511,11 @@
'h-36 w-32 flex-col items-center justify-center',
disabled && 'opacity-50'
)}
onSelect={() => modelSelected(model.model_id)}
onSelect={() => modelSelected(model.id)}
>
<div class={cn('flex flex-col items-center')}>
{#if getModelIcon(model.model_id)}
{@const ModelIcon = getModelIcon(model.model_id)}
{#if getModelIcon(model.id)}
{@const ModelIcon = getModelIcon(model.id)}
<ModelIcon class="size-6 shrink-0" />
{/if}
@ -494,7 +533,7 @@
</div>
<div class="flex place-items-center gap-1">
{#if openRouterModel && supportsImages(openRouterModel)}
{#if supportsImages(model)}
<Tooltip>
{#snippet trigger(tooltip)}
<div
@ -504,11 +543,11 @@
<EyeIcon class="size-3" />
</div>
{/snippet}
Supports image analysis
Supports vision/image analysis
</Tooltip>
{/if}
{#if openRouterModel && supportsReasoning(openRouterModel)}
{#if supportsReasoning(model)}
<Tooltip>
{#snippet trigger(tooltip)}
<div
@ -521,26 +560,56 @@
Supports reasoning
</Tooltip>
{/if}
{#if supportsStreaming(model)}
<Tooltip>
{#snippet trigger(tooltip)}
<div
{...tooltip.trigger}
class="rounded-md border-blue-500 bg-blue-500/50 p-1 text-blue-400"
>
<ZapIcon class="size-3" />
</div>
{/snippet}
Supports streaming responses
</Tooltip>
{/if}
{#if supportsToolCalls(model)}
<Tooltip>
{#snippet trigger(tooltip)}
<div
{...tooltip.trigger}
class="rounded-md border-orange-500 bg-orange-500/50 p-1 text-orange-400"
>
<CpuIcon class="size-3" />
</div>
{/snippet}
Supports tool/function calling
</Tooltip>
{/if}
</div>
<div
class="bg-popover absolute top-1 right-1 scale-75 rounded-md p-1 transition-all group-hover/item:scale-100 group-hover/item:opacity-100 md:opacity-0"
>
<Button
variant="ghost"
size="icon"
class="size-7"
onclick={(e: MouseEvent) => {
e.stopPropagation();
togglePin(model._id);
}}
{#if enabledModelData}
<div
class="bg-popover absolute top-1 right-1 scale-75 rounded-md p-1 transition-all group-hover/item:scale-100 group-hover/item:opacity-100 md:opacity-0"
>
{#if isPinned(model)}
<PinOffIcon class="size-4" />
{:else}
<PinIcon class="size-4" />
{/if}
</Button>
</div>
<Button
variant="ghost"
size="icon"
class="size-7"
onclick={(e: MouseEvent) => {
e.stopPropagation();
togglePin(enabledModelData._id);
}}
>
{#if isPinned(enabledModelData)}
<PinOffIcon class="size-4" />
{:else}
<PinIcon class="size-4" />
{/if}
</Button>
</div>
{/if}
</Command.Item>
{/snippet}

View file

@ -0,0 +1,160 @@
import { ConvexHttpClient } from 'convex/browser';
import { PUBLIC_CONVEX_URL } from '$env/static/public';
import { api } from '$lib/backend/convex/_generated/api';
import { Provider } from '$lib/types';
import { createModelManager } from './model-manager';
import type { UserApiKeys } from './model-manager';
import type { ModelInfo } from '@keplersystems/kepler-ai-sdk';
import { ResultAsync } from 'neverthrow';
export interface MultiProviderModels {
[Provider.OpenAI]?: ModelInfo[];
[Provider.Anthropic]?: ModelInfo[];
[Provider.Gemini]?: ModelInfo[];
[Provider.Mistral]?: ModelInfo[];
[Provider.Cohere]?: ModelInfo[];
[Provider.OpenRouter]?: ModelInfo[];
}
const client = new ConvexHttpClient(PUBLIC_CONVEX_URL);
// Cache models for 10 minutes to avoid excessive API calls
const MODEL_CACHE_TTL = 10 * 60 * 1000;
const modelCache = new Map<string, { models: MultiProviderModels; timestamp: number }>();
/**
* Load models for a specific user based on their API keys
*/
export async function loadUserModels(sessionToken: string): Promise<MultiProviderModels> {
const cacheKey = sessionToken;
const cached = modelCache.get(cacheKey);
// Return cached models if still valid
if (cached && Date.now() - cached.timestamp < MODEL_CACHE_TTL) {
console.log('Returning cached models:', Object.keys(cached.models));
return cached.models;
}
try {
// Get user's API keys
const userApiKeys = await getUserApiKeys(sessionToken);
console.log('getUserApiKeys result:', userApiKeys);
if (!userApiKeys) {
console.log('No user API keys found');
return {};
}
// Initialize ModelManager with user's API keys
const modelManager = createModelManager();
modelManager.initializeProviders(userApiKeys);
console.log('Enabled providers:', modelManager.getEnabledProviders());
// Load models from all enabled providers
const models: MultiProviderModels = {};
const enabledProviders = modelManager.getEnabledProviders();
console.log('Loading models from providers:', enabledProviders);
const results = await Promise.allSettled(
enabledProviders.map(async (provider) => {
try {
console.log(`Loading models from ${provider}...`);
const providerModels = await modelManager.getModelsByProvider(provider);
console.log(`${provider} returned ${providerModels.length} models`);
if (providerModels.length > 0) {
models[provider] = providerModels;
}
return { provider, count: providerModels.length };
} catch (error) {
console.warn(`Failed to load models from ${provider}:`, error);
return { provider, error: error.message };
}
})
);
console.log('Model loading results:', results);
console.log('Final models object:', Object.keys(models));
// Cache the results
modelCache.set(cacheKey, {
models,
timestamp: Date.now(),
});
return models;
} catch (error) {
console.error('Failed to load user models:', error);
return {};
}
}
/**
* Load models without authentication (fallback to empty state)
*/
export function loadGuestModels(): MultiProviderModels {
return {};
}
/**
* Clear model cache for a specific user or all users
*/
export function clearModelCache(sessionToken?: string): void {
if (sessionToken) {
modelCache.delete(sessionToken);
} else {
modelCache.clear();
}
}
/**
* Get user's API keys from Convex
*/
async function getUserApiKeys(sessionToken: string): Promise<UserApiKeys | null> {
const keysResult = await ResultAsync.fromPromise(
client.query(api.user_keys.all, {
session_token: sessionToken,
}),
(e) => `Failed to get user API keys: ${e}`
);
if (keysResult.isErr()) {
console.error('Failed to get user API keys:', keysResult.error);
return null;
}
const keys = keysResult.value;
return {
openai: keys.openai,
anthropic: keys.anthropic,
google: keys.gemini,
mistral: keys.mistral,
cohere: keys.cohere,
openrouter: keys.openrouter,
};
}
/**
* Get models for a specific provider (useful for partial loading)
*/
export async function loadProviderModels(
sessionToken: string,
provider: Provider
): Promise<ModelInfo[]> {
try {
const userApiKeys = await getUserApiKeys(sessionToken);
if (!userApiKeys) {
return [];
}
const modelManager = createModelManager();
modelManager.initializeProviders(userApiKeys);
if (!modelManager.hasProviderEnabled(provider)) {
return [];
}
return await modelManager.getModelsByProvider(provider);
} catch (error) {
console.error(`Failed to load models from ${provider}:`, error);
return [];
}
}

View file

@ -19,7 +19,7 @@ export interface ProviderConfig {
export interface UserApiKeys {
openai?: string;
anthropic?: string;
gemini?: string;
google?: string;
mistral?: string;
cohere?: string;
openrouter?: string;
@ -52,9 +52,9 @@ export class ChatModelManager {
this.enabledProviders.set('anthropic', provider);
}
if (userApiKeys.gemini) {
if (userApiKeys.google) {
const provider = new GeminiProvider({
apiKey: userApiKeys.gemini,
apiKey: userApiKeys.google,
});
this.modelManager.addProvider(provider);
this.enabledProviders.set('gemini', provider);
@ -97,9 +97,19 @@ export class ChatModelManager {
return await this.modelManager.listModels();
}
async getModelsByProvider(provider: Provider): Promise<ModelInfo[]> {
if (!this.hasProviderEnabled(provider)) {
return [];
}
const allModels = await this.listAvailableModels();
return allModels.filter((model) => model.provider === provider);
}
async getModelsByCapability(capability: string): Promise<ModelInfo[]> {
const allModels = await this.listAvailableModels();
return allModels.filter(model => model.capabilities[capability as keyof typeof model.capabilities]);
return allModels.filter(
(model) => model.capabilities[capability as keyof typeof model.capabilities]
);
}
hasProviderEnabled(provider: Provider): boolean {
@ -111,10 +121,10 @@ export class ChatModelManager {
}
isModelAvailable(modelId: string): Promise<boolean> {
return this.getModel(modelId).then(model => model !== null);
return this.getModel(modelId).then((model) => model !== null);
}
}
export const createModelManager = (): ChatModelManager => {
return new ChatModelManager();
};
};

View file

@ -1,13 +1,17 @@
import { page } from '$app/state';
import { api } from '$lib/backend/convex/_generated/api';
import { getModelKey } from '$lib/backend/convex/user_enabled_models';
import type { ProviderModelMap } from '$lib/backend/models/all';
import { useCachedQuery } from '$lib/cache/cached-query.svelte';
import { createInit } from '$lib/spells/create-init.svelte';
import { Provider } from '$lib/types';
import type { ModelInfo } from '@keplersystems/kepler-ai-sdk';
import { watch } from 'runed';
import { session } from './session.svelte';
export interface ModelWithEnabledStatus extends ModelInfo {
enabled: boolean;
}
export class Models {
enabled = $state({} as Record<string, unknown>);
@ -23,14 +27,104 @@ export class Models {
);
});
from<P extends Provider>(provider: Provider) {
return page.data.models[provider].map((m: { id: string }) => {
return {
...m,
enabled: this.enabled[getModelKey({ provider, model_id: m.id })] !== undefined,
};
}) as Array<ProviderModelMap[P] & { enabled: boolean }>;
/**
* Get models from a specific provider with enabled status
*/
from(provider: Provider): ModelWithEnabledStatus[] {
const providerModels = page.data.models[provider] || [];
return providerModels.map((model: ModelInfo) => ({
...model,
enabled: this.enabled[getModelKey({ provider, model_id: model.id })] !== undefined,
}));
}
/**
* Get all models from all providers with enabled status
*/
all(): ModelWithEnabledStatus[] {
const allModels: ModelWithEnabledStatus[] = [];
const availableProviders = Object.keys(page.data.models || {}) as Provider[];
for (const provider of availableProviders) {
const providerModels = this.from(provider);
allModels.push(...providerModels);
}
return allModels;
}
/**
* Get models that support specific capabilities
*/
withCapability(capability: keyof ModelInfo['capabilities']): ModelWithEnabledStatus[] {
return this.all().filter(model => model.capabilities[capability]);
}
/**
* Get enabled models from all providers
*/
enabledModels(): ModelWithEnabledStatus[] {
return this.all().filter(model => model.enabled);
}
/**
* Get enabled models from a specific provider
*/
enabledFrom(provider: Provider): ModelWithEnabledStatus[] {
return this.from(provider).filter(model => model.enabled);
}
/**
* Get available providers (providers that have models loaded)
*/
availableProviders(): Provider[] {
const models = page.data.models || {};
return Object.keys(models).filter(provider =>
models[provider as Provider] && models[provider as Provider].length > 0
) as Provider[];
}
/**
* Check if a provider has models loaded
*/
hasProvider(provider: Provider): boolean {
const models = page.data.models[provider];
return Array.isArray(models) && models.length > 0;
}
/**
* Search models across all providers
*/
search(query: string): ModelWithEnabledStatus[] {
const lowerQuery = query.toLowerCase();
return this.all().filter(model =>
model.name.toLowerCase().includes(lowerQuery) ||
model.id.toLowerCase().includes(lowerQuery) ||
model.description?.toLowerCase().includes(lowerQuery)
);
}
/**
* Get models sorted by preference (enabled first, then by provider order)
*/
sorted(): ModelWithEnabledStatus[] {
return this.all().sort((a, b) => {
// Enabled models first
if (a.enabled && !b.enabled) return -1;
if (!a.enabled && b.enabled) return 1;
// Then by provider order
const providerOrder = Object.values(Provider);
const aProviderIndex = providerOrder.indexOf(a.provider as Provider);
const bProviderIndex = providerOrder.indexOf(b.provider as Provider);
if (aProviderIndex !== bProviderIndex) {
return aProviderIndex - bProviderIndex;
}
// Finally by name
return a.name.localeCompare(b.name);
});
}
}
export const models = new Models();
export const models = new Models();

View file

@ -69,7 +69,7 @@ export const PROVIDER_META: Record<Provider, ProviderMeta> = {
},
[Provider.Gemini]: {
title: 'Google Gemini',
link: 'https://cloud.google.com/vertex-ai',
link: 'https://ai.google.dev/docs',
description: 'Gemini models from Google',
apiKeyName: 'Google AI API Key',
placeholder: 'AIza...',

View file

@ -1,13 +1,29 @@
import type { OpenRouterModel } from '$lib/backend/models/open-router';
import type { ModelInfo } from '@keplersystems/kepler-ai-sdk';
export function supportsImages(model: OpenRouterModel): boolean {
return model.architecture.input_modalities.includes('image');
export function supportsImages(model: ModelInfo): boolean {
return model.capabilities.vision;
}
export function supportsReasoning(model: OpenRouterModel): boolean {
return model.supported_parameters.includes('reasoning');
export function supportsReasoning(model: ModelInfo): boolean {
return model.capabilities.reasoning;
}
export function getImageSupportedModels(models: OpenRouterModel[]): OpenRouterModel[] {
export function supportsStreaming(model: ModelInfo): boolean {
return model.capabilities.streaming;
}
export function supportsToolCalls(model: ModelInfo): boolean {
return model.capabilities.toolCalls;
}
export function getImageSupportedModels(models: ModelInfo[]): ModelInfo[] {
return models.filter(supportsImages);
}
export function getReasoningModels(models: ModelInfo[]): ModelInfo[] {
return models.filter(supportsReasoning);
}
export function getStreamingModels(models: ModelInfo[]): ModelInfo[] {
return models.filter(supportsStreaming);
}

View file

@ -13,25 +13,29 @@ export type ProviderApiKeyData = {
export const ProviderUtils = {
/**
* Validate an API key for a specific provider
* Validate an API key for a specific provider via server endpoint
*/
validateApiKey: async (provider: Provider, key: string): Promise<Result<ProviderApiKeyData, string>> => {
switch (provider) {
case Provider.OpenRouter:
return await validateOpenRouterKey(key);
case Provider.OpenAI:
return await validateOpenAIKey(key);
case Provider.Anthropic:
return await validateAnthropicKey(key);
case Provider.Gemini:
return await validateGeminiKey(key);
case Provider.Mistral:
return await validateMistralKey(key);
case Provider.Cohere:
return await validateCohereKey(key);
default:
return Result.err(`Validation not implemented for provider: ${provider}`);
}
return await ResultAsync.fromPromise(
(async () => {
const response = await fetch('/api/validate-key', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ provider, key }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || `HTTP ${response.status}`);
}
const result = await response.json();
return result.data;
})(),
(e) => `Failed to validate API key: ${e}`
);
},
/**
@ -54,159 +58,4 @@ export const ProviderUtils = {
getSupportedProviders: () => {
return Object.values(Provider);
},
};
// Provider-specific validation functions
async function validateOpenRouterKey(key: string): Promise<Result<ProviderApiKeyData, string>> {
return await ResultAsync.fromPromise(
(async () => {
const res = await fetch('https://openrouter.ai/api/v1/auth/key', {
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
},
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const { data } = await res.json();
if (!data) {
throw new Error('No key information returned');
}
return {
label: data.label || 'OpenRouter API Key',
usage: data.usage,
is_free_tier: data.is_free_tier,
is_provisioning_key: data.is_provisioning_key,
limit: data.limit,
limit_remaining: data.limit_remaining,
valid: true,
};
})(),
(e) => `Failed to validate OpenRouter API key: ${e}`
);
}
async function validateOpenAIKey(key: string): Promise<Result<ProviderApiKeyData, string>> {
return await ResultAsync.fromPromise(
(async () => {
const res = await fetch('https://api.openai.com/v1/models', {
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
},
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return {
label: 'OpenAI API Key',
valid: true,
};
})(),
(e) => `Failed to validate OpenAI API key: ${e}`
);
}
async function validateAnthropicKey(key: string): Promise<Result<ProviderApiKeyData, string>> {
return await ResultAsync.fromPromise(
(async () => {
// Anthropic doesn't have a simple key validation endpoint
// We'll try a minimal request to test the key
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': key,
'anthropic-version': '2023-06-01',
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'claude-3-haiku-20240307',
max_tokens: 1,
messages: [{ role: 'user', content: 'test' }],
}),
});
// Even a 400 error means the key is valid (just bad request format)
if (res.status === 401 || res.status === 403) {
throw new Error('Invalid API key');
}
return {
label: 'Anthropic API Key',
valid: true,
};
})(),
(e) => `Failed to validate Anthropic API key: ${e}`
);
}
async function validateGeminiKey(key: string): Promise<Result<ProviderApiKeyData, string>> {
return await ResultAsync.fromPromise(
(async () => {
const res = await fetch(`https://generativelanguage.googleapis.com/v1/models?key=${key}`);
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return {
label: 'Google Gemini API Key',
valid: true,
};
})(),
(e) => `Failed to validate Gemini API key: ${e}`
);
}
async function validateMistralKey(key: string): Promise<Result<ProviderApiKeyData, string>> {
return await ResultAsync.fromPromise(
(async () => {
const res = await fetch('https://api.mistral.ai/v1/models', {
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
},
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return {
label: 'Mistral API Key',
valid: true,
};
})(),
(e) => `Failed to validate Mistral API key: ${e}`
);
}
async function validateCohereKey(key: string): Promise<Result<ProviderApiKeyData, string>> {
return await ResultAsync.fromPromise(
(async () => {
const res = await fetch('https://api.cohere.ai/v1/models', {
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
},
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return {
label: 'Cohere API Key',
valid: true,
};
})(),
(e) => `Failed to validate Cohere API key: ${e}`
);
}
};

View file

@ -1,17 +1,19 @@
import { getOpenRouterModels, type OpenRouterModel } from '$lib/backend/models/open-router';
import { Provider } from '$lib/types';
import { loadUserModels, loadGuestModels } from '$lib/services/model-loader.server';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
const [session, openRouterModels] = await Promise.all([locals.auth(), getOpenRouterModels()]);
const session = await locals.auth();
// Load models based on user's API keys
const models = session?.session?.token
? await loadUserModels(session.session.token)
: loadGuestModels();
return {
session,
models: {
[Provider.OpenRouter]: openRouterModels.unwrapOr([] as OpenRouterModel[]),
},
models,
};
};
// Makes caching easier, and tbf, we don't need SSR anyways here
// Enable SSR for better performance
export const ssr = true;

View file

@ -96,7 +96,7 @@
{#if apiKeyInfoResource.loading}
<div class="bg-input h-6 w-[200px] animate-pulse rounded-md"></div>
{:else if apiKeyInfoResource.current}
{#if apiKeyInfoResource.current.usage !== undefined && apiKeyInfoResource.current.limit_remaining !== undefined}
{#if apiKeyInfoResource.current.usage !== undefined && apiKeyInfoResource.current.limit_remaining !== undefined && apiKeyInfoResource.current.usage !== null && apiKeyInfoResource.current.limit_remaining !== null}
<span class="text-muted-foreground flex h-6 place-items-center text-xs">
${apiKeyInfoResource.current.usage.toFixed(3)} used ・ ${apiKeyInfoResource.current.limit_remaining.toFixed(
3

View file

@ -5,36 +5,37 @@
import { Search } from '$lib/components/ui/search';
import { models } from '$lib/state/models.svelte';
import { session } from '$lib/state/session.svelte';
import { Provider } from '$lib/types.js';
import { page } from '$app/state';
import { Provider, PROVIDER_META } from '$lib/types.js';
import { fuzzysearch } from '$lib/utils/fuzzy-search';
import { cn } from '$lib/utils/utils';
import { Toggle } from 'melt/builders';
import PlusIcon from '~icons/lucide/plus';
import XIcon from '~icons/lucide/x';
import ModelCard from './model-card.svelte';
import { supportsImages, supportsReasoning } from '$lib/utils/model-capabilities';
import type { ModelInfo } from '@keplersystems/kepler-ai-sdk';
const openRouterKeyQuery = useCachedQuery(api.user_keys.get, {
provider: Provider.OpenRouter,
// Get all user's API keys to determine which providers are available
const userKeysQuery = useCachedQuery(api.user_keys.all, {
session_token: session.current?.session.token ?? '',
});
const hasOpenRouterKey = $derived(
openRouterKeyQuery.data !== undefined && openRouterKeyQuery.data !== ''
);
// Show providers that have API keys, regardless of whether models are loaded yet
const availableProviders = $derived.by(() => {
if (!userKeysQuery.data) {
return [];
}
return Object.entries(userKeysQuery.data)
.filter(([_, key]) => key) // Only providers with API keys
.map(([provider, _]) => provider as Provider);
});
let search = $state('');
let selectedProvider = $state<Provider | 'all'>('all');
const openRouterToggle = new Toggle({
value: true,
// TODO: enable this if and when when we use multiple providers
disabled: true,
});
const freeModelsToggle = new Toggle({
value: false,
});
// Filter toggles
const reasoningModelsToggle = new Toggle({
value: false,
});
@ -43,43 +44,47 @@
value: false,
});
let initiallyEnabled = $state<string[]>([]);
$effect(() => {
if (Object.keys(models.enabled).length && initiallyEnabled.length === 0) {
initiallyEnabled = models
.from(Provider.OpenRouter)
.filter((m) => m.enabled)
.map((m) => m.id);
}
const streamingModelsToggle = new Toggle({
value: false,
});
const openRouterModels = $derived(
fuzzysearch({
haystack: models.from(Provider.OpenRouter).filter((m) => {
if (freeModelsToggle.value) {
if (m.pricing.prompt !== '0') return false;
}
// Get models based on current filters
const filteredModels = $derived.by(() => {
let modelList = selectedProvider === 'all'
? models.all()
: models.from(selectedProvider);
if (reasoningModelsToggle.value) {
if (!supportsReasoning(m)) return false;
}
// Apply capability filters
if (reasoningModelsToggle.value) {
modelList = modelList.filter(m => m.capabilities.reasoning);
}
if (imageModelsToggle.value) {
if (!supportsImages(m)) return false;
}
if (imageModelsToggle.value) {
modelList = modelList.filter(m => m.capabilities.vision);
}
return true;
}),
needle: search,
property: 'name',
}).sort((a, b) => {
const aEnabled = initiallyEnabled.includes(a.id);
const bEnabled = initiallyEnabled.includes(b.id);
if (aEnabled && !bEnabled) return -1;
if (!aEnabled && bEnabled) return 1;
return 0;
})
);
if (streamingModelsToggle.value) {
modelList = modelList.filter(m => m.capabilities.streaming);
}
// Apply text search
if (search) {
modelList = fuzzysearch({
haystack: modelList,
needle: search,
property: 'name',
});
}
// Sort: enabled first, then by name
return modelList.sort((a, b) => {
if (a.enabled && !b.enabled) return -1;
if (!a.enabled && b.enabled) return 1;
return a.name.localeCompare(b.name);
});
});
const hasAnyApiKeys = $derived(availableProviders.length > 0);
</script>
<svelte:head>
@ -91,76 +96,125 @@
Choose which models appear in your model selector. This won't affect existing conversations.
</h2>
<div class="mt-4 flex flex-col gap-2">
<Search bind:value={search} placeholder="Search models" />
<div class="flex place-items-center gap-2">
<button
{...openRouterToggle.trigger}
aria-label="OpenRouter"
class="group text-primary-foreground bg-primary aria-[pressed=false]:border-border border-primary aria-[pressed=false]:bg-background flex place-items-center gap-1 rounded-full border px-2 py-1 text-xs transition-all disabled:cursor-not-allowed disabled:opacity-50"
>
OpenRouter
<XIcon class="inline size-3 group-aria-[pressed=false]:hidden" />
<PlusIcon class="inline size-3 group-aria-[pressed=true]:hidden" />
</button>
<button
{...freeModelsToggle.trigger}
aria-label="Free Models"
class="group text-primary-foreground bg-primary aria-[pressed=false]:border-border border-primary aria-[pressed=false]:bg-background flex place-items-center gap-1 rounded-full border px-2 py-1 text-xs transition-all disabled:cursor-not-allowed disabled:opacity-50"
>
Free
<XIcon class="inline size-3 group-aria-[pressed=false]:hidden" />
<PlusIcon class="inline size-3 group-aria-[pressed=true]:hidden" />
</button>
<button
{...reasoningModelsToggle.trigger}
aria-label="Reasoning Models"
class="group text-primary-foreground bg-primary aria-[pressed=false]:border-border border-primary aria-[pressed=false]:bg-background flex place-items-center gap-1 rounded-full border px-2 py-1 text-xs transition-all disabled:cursor-not-allowed disabled:opacity-50"
>
Reasoning
<XIcon class="inline size-3 group-aria-[pressed=false]:hidden" />
<PlusIcon class="inline size-3 group-aria-[pressed=true]:hidden" />
</button>
<button
{...imageModelsToggle.trigger}
aria-label="Image Models"
class="group text-primary-foreground bg-primary aria-[pressed=false]:border-border border-primary aria-[pressed=false]:bg-background flex place-items-center gap-1 rounded-full border px-2 py-1 text-xs transition-all disabled:cursor-not-allowed disabled:opacity-50"
>
Images
<XIcon class="inline size-3 group-aria-[pressed=false]:hidden" />
<PlusIcon class="inline size-3 group-aria-[pressed=true]:hidden" />
</button>
{#if !hasAnyApiKeys}
<div class="mt-8 rounded-lg border border-yellow-200 bg-yellow-50 p-4">
<h3 class="font-semibold text-yellow-800">No API Keys Configured</h3>
<p class="text-sm text-yellow-700 mt-1">
You need to add API keys for at least one provider to see and manage models.
<a href="/account/api-keys" class="underline hover:text-yellow-900">Go to API Keys Settings</a>
</p>
</div>
</div>
{:else}
<div class="mt-6 space-y-4">
<!-- Search -->
<Search bind:value={search} placeholder="Search models" />
{#if openRouterModels.length > 0}
<div class="mt-4 flex flex-col gap-4">
<div>
<h3 class="text-lg font-bold">OpenRouter</h3>
<p class="text-muted-foreground text-sm">Easy access to over 400 models.</p>
</div>
<div class="relative">
<div
class={cn('flex flex-col gap-4 overflow-hidden', {
'pointer-events-none max-h-96 mask-b-from-0% mask-b-to-80%': !hasOpenRouterKey,
})}
<!-- Provider and filter tabs -->
<div class="flex flex-wrap items-center gap-2">
<!-- Provider selector -->
<div class="flex items-center gap-1">
<button
onclick={() => selectedProvider = 'all'}
class={cn(
"px-3 py-1 rounded-full text-sm transition-all",
selectedProvider === 'all'
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
)}
>
All Providers ({models.all().length})
</button>
{#each availableProviders as provider}
{@const providerMeta = PROVIDER_META[provider]}
{@const providerModels = models.from(provider)}
{@const hasModels = models.hasProvider(provider)}
{#if providerMeta}
<button
onclick={() => selectedProvider = provider}
class={cn(
"px-3 py-1 rounded-full text-sm transition-all",
selectedProvider === provider
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
)}
>
{providerMeta.title} ({hasModels ? providerModels.length : 'loading...'})
</button>
{/if}
{/each}
</div>
<!-- Capability filters -->
<div class="h-4 w-px bg-border"></div>
<button
{...reasoningModelsToggle.trigger}
class={cn(
"px-3 py-1 rounded-full text-sm transition-all",
reasoningModelsToggle.value
? "bg-blue-500 text-white"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
)}
>
{#each openRouterModels as model (model.id)}
Reasoning
</button>
<button
{...imageModelsToggle.trigger}
class={cn(
"px-3 py-1 rounded-full text-sm transition-all",
imageModelsToggle.value
? "bg-green-500 text-white"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
)}
>
Vision
</button>
<button
{...streamingModelsToggle.trigger}
class={cn(
"px-3 py-1 rounded-full text-sm transition-all",
streamingModelsToggle.value
? "bg-purple-500 text-white"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
)}
>
Streaming
</button>
</div>
<!-- Models grid -->
{#if filteredModels.length === 0}
<div class="text-center py-12">
{#if selectedProvider !== 'all' && !models.hasProvider(selectedProvider)}
<h3 class="text-lg font-semibold text-muted-foreground">Loading models...</h3>
<p class="text-sm text-muted-foreground mt-2">
Models are being loaded from {PROVIDER_META[selectedProvider]?.title || selectedProvider}. Please refresh the page in a moment.
</p>
{:else}
<h3 class="text-lg font-semibold text-muted-foreground">No models found</h3>
<p class="text-sm text-muted-foreground mt-2">
{#if search}
Try adjusting your search or filters.
{:else}
No models match your current filters.
{/if}
</p>
{/if}
</div>
{:else}
<div class="grid gap-4">
{#each filteredModels as model (model.id)}
<ModelCard
provider={Provider.OpenRouter}
provider={model.provider as Provider}
{model}
enabled={model.enabled}
disabled={!hasOpenRouterKey}
disabled={false}
/>
{/each}
</div>
{#if !hasOpenRouterKey}
<div
class="absolute bottom-10 left-0 z-10 flex w-full place-items-center justify-center gap-2"
>
<Button href="/account/api-keys#openrouter" class="w-fit">Add API Key</Button>
</div>
{/if}
</div>
{/if}
</div>
{/if}
{/if}

View file

@ -1,5 +1,6 @@
<script lang="ts">
import type { Provider } from '$lib/types';
import { PROVIDER_META } from '$lib/types';
import * as Card from '$lib/components/ui/card';
import { Switch } from '$lib/components/ui/switch';
import { useConvexClient } from 'convex-svelte';
@ -7,38 +8,40 @@
import { session } from '$lib/state/session.svelte.js';
import { ResultAsync } from 'neverthrow';
import { getFirstSentence } from '$lib/utils/strings';
import { supportsImages, supportsReasoning } from '$lib/utils/model-capabilities';
import type { OpenRouterModel } from '$lib/backend/models/open-router';
import type { ModelInfo } from '@keplersystems/kepler-ai-sdk';
import Tooltip from '$lib/components/ui/tooltip.svelte';
import EyeIcon from '~icons/lucide/eye';
import BrainIcon from '~icons/lucide/brain';
type Model = {
id: string;
name: string;
description: string;
};
import ZapIcon from '~icons/lucide/zap';
import CpuIcon from '~icons/lucide/cpu';
type Props = {
provider: Provider;
model: ModelInfo;
enabled?: boolean;
disabled?: boolean;
} & {
provider: typeof Provider.OpenRouter;
model: OpenRouterModel;
};
let { provider, model, enabled = false, disabled = false }: Props = $props();
const client = useConvexClient();
const providerMeta = $derived(PROVIDER_META[provider]);
const [shortDescription, fullDescription] = $derived(getFirstSentence(model.description));
const [shortDescription, fullDescription] = $derived(
model.description ? getFirstSentence(model.description) : [null, model.name]
);
let showMore = $state(false);
async function toggleEnabled(v: boolean) {
console.log('toggleEnabled called:', { provider, model_id: model.id, enabled: v });
enabled = v; // Optimistic!
if (!session.current?.user.id) return;
if (!session.current?.user.id) {
console.log('No user session, returning early');
return;
}
console.log('Calling Convex mutation...');
const res = await ResultAsync.fromPromise(
client.mutation(api.user_enabled_models.set, {
provider,
@ -49,62 +52,138 @@
(e) => e
);
if (res.isErr()) enabled = !v; // Should have been a realist :(
if (res.isErr()) {
console.error('Mutation failed:', res.error);
enabled = !v; // Revert on error
} else {
console.log('Mutation succeeded');
}
}
// Format pricing information
const pricingInfo = $derived.by(() => {
if (!model.pricing) return null;
const { inputTokens, outputTokens } = model.pricing;
const inputPrice = inputTokens < 1 ? `$${(inputTokens * 1000).toFixed(3)}/1K` : `$${inputTokens.toFixed(3)}/1M`;
const outputPrice = outputTokens < 1 ? `$${(outputTokens * 1000).toFixed(3)}/1K` : `$${outputTokens.toFixed(3)}/1M`;
return `${inputPrice} → ${outputPrice}`;
});
// Format context window
const contextInfo = $derived.by(() => {
const contextWindow = model.contextWindow;
if (contextWindow >= 1000000) {
return `${(contextWindow / 1000000).toFixed(1)}M context`;
} else if (contextWindow >= 1000) {
return `${(contextWindow / 1000).toFixed(0)}K context`;
} else {
return `${contextWindow} tokens`;
}
});
</script>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div class="flex place-items-center gap-2">
<Card.Title>{model.name}</Card.Title>
<span class="text-muted-foreground hidden text-xs xl:block">{model.id}</span>
<div class="flex flex-col gap-1">
<div class="flex place-items-center gap-2">
<Card.Title>{model.name}</Card.Title>
<span class="px-2 py-0.5 text-xs rounded-full bg-secondary text-secondary-foreground">
{providerMeta.title}
</span>
</div>
<span class="text-muted-foreground text-xs">{model.id}</span>
</div>
<Switch bind:value={() => enabled, toggleEnabled} {disabled} />
<Switch bind:value={enabled} onValueChange={toggleEnabled} {disabled} />
</div>
<Card.Description>
{showMore ? fullDescription : (shortDescription ?? fullDescription)}
</Card.Description>
{#if shortDescription !== null}
<button
type="button"
class="text-muted-foreground w-fit text-start text-xs"
onclick={() => (showMore = !showMore)}
{disabled}
>
{showMore ? 'Show less' : 'Show more'}
</button>
{#if model.description}
<Card.Description>
{showMore ? fullDescription : (shortDescription ?? fullDescription)}
</Card.Description>
{#if shortDescription !== null}
<button
type="button"
class="text-muted-foreground w-fit text-start text-xs hover:text-foreground transition-colors"
onclick={() => (showMore = !showMore)}
{disabled}
>
{showMore ? 'Show less' : 'Show more'}
</button>
{/if}
{/if}
</Card.Header>
<Card.Content>
<div class="flex place-items-center gap-1">
{#if model && provider === 'openrouter' && supportsImages(model)}
<Tooltip>
{#snippet trigger(tooltip)}
<div
{...tooltip.trigger}
class="rounded-md border-violet-500 bg-violet-500/50 p-1 text-violet-400"
>
<EyeIcon class="size-3" />
</div>
{/snippet}
Supports image analysis
</Tooltip>
{/if}
{#if model && provider === 'openrouter' && supportsReasoning(model)}
<Tooltip>
{#snippet trigger(tooltip)}
<div
{...tooltip.trigger}
class="rounded-md border-green-500 bg-green-500/50 p-1 text-green-400"
>
<BrainIcon class="size-3" />
</div>
{/snippet}
Supports reasoning
</Tooltip>
{/if}
<Card.Content>
<div class="flex items-center justify-between">
<!-- Capabilities badges -->
<div class="flex place-items-center gap-1">
{#if model.capabilities.vision}
<Tooltip>
{#snippet trigger(tooltip)}
<div
{...tooltip.trigger}
class="rounded-md border-violet-500 bg-violet-500/50 p-1 text-violet-400"
>
<EyeIcon class="size-3" />
</div>
{/snippet}
Supports vision/image analysis
</Tooltip>
{/if}
{#if model.capabilities.reasoning}
<Tooltip>
{#snippet trigger(tooltip)}
<div
{...tooltip.trigger}
class="rounded-md border-green-500 bg-green-500/50 p-1 text-green-400"
>
<BrainIcon class="size-3" />
</div>
{/snippet}
Supports reasoning
</Tooltip>
{/if}
{#if model.capabilities.streaming}
<Tooltip>
{#snippet trigger(tooltip)}
<div
{...tooltip.trigger}
class="rounded-md border-blue-500 bg-blue-500/50 p-1 text-blue-400"
>
<ZapIcon class="size-3" />
</div>
{/snippet}
Supports streaming responses
</Tooltip>
{/if}
{#if model.capabilities.toolCalls}
<Tooltip>
{#snippet trigger(tooltip)}
<div
{...tooltip.trigger}
class="rounded-md border-orange-500 bg-orange-500/50 p-1 text-orange-400"
>
<CpuIcon class="size-3" />
</div>
{/snippet}
Supports tool/function calling
</Tooltip>
{/if}
</div>
<!-- Model info -->
<div class="flex items-center gap-3 text-xs text-muted-foreground">
{#if pricingInfo}
<span>{pricingInfo}</span>
{/if}
<span>{contextInfo}</span>
</div>
</div>
</Card.Content>
</Card.Root>
</Card.Root>

View file

@ -44,7 +44,7 @@ async function getUserApiKeys(sessionToken: string): Promise<UserApiKeys | null>
return {
openai: keys.openai,
anthropic: keys.anthropic,
gemini: keys.gemini,
gemini: keys.google,
mistral: keys.mistral,
cohere: keys.cohere,
openrouter: keys.openrouter,

View file

@ -81,7 +81,7 @@ async function getUserApiKeys(sessionToken: string): Promise<Result<UserApiKeys,
return ok({
openai: keys.openai,
anthropic: keys.anthropic,
gemini: keys.gemini,
google: keys.gemini,
mistral: keys.mistral,
cohere: keys.cohere,
openrouter: keys.openrouter,
@ -127,8 +127,8 @@ async function generateConversationTitle({
// Try to find a fast, cheap model for title generation
const availableModels = await modelManager.listAvailableModels();
const titleModel =
availableModels.find((model) => model.id.includes('kimi-k2')) ||
availableModels.find((model) => model.id.includes('gemini-2.5-flash-lite')) ||
availableModels.find((model) => model.id.includes('kimi-k2')) ||
availableModels.find((model) => model.id.includes('gpt-5-mini')) ||
availableModels[0];
@ -396,21 +396,19 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
return;
}
// Generate completion with streaming
const streamResult = await ResultAsync.fromPromise(
provider.generateCompletion({
// Generate streaming completion
let stream: AsyncIterable<any>;
try {
stream = provider.streamCompletion({
model: finalModelId,
messages: messagesToSend,
temperature: 0.7,
stream: true,
...(reasoningEffort && { reasoning_effort: reasoningEffort }),
}),
(e) => `API call failed: ${e}`
);
if (streamResult.isErr()) {
});
log('Background: Stream created successfully', startTime);
} catch (error) {
handleGenerationError({
error: `Failed to create stream: ${streamResult.error}`,
error: `Failed to create stream: API call failed: ${error}`,
conversationId,
messageId: mid,
sessionToken,
@ -419,9 +417,6 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
return;
}
const stream = streamResult.value;
log('Background: Stream created successfully', startTime);
let content = '';
let reasoning = '';
let chunkCount = 0;
@ -430,77 +425,48 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
try {
// Handle streaming response
if (stream && typeof stream[Symbol.asyncIterator] === 'function') {
for await (const chunk of stream) {
if (abortSignal?.aborted) {
log('AI response generation aborted during streaming', startTime);
break;
}
for await (const chunk of stream) {
if (abortSignal?.aborted) {
log('AI response generation aborted during streaming', startTime);
break;
}
chunkCount++;
chunkCount++;
// Extract content from chunk based on the stream format
if (chunk && typeof chunk === 'object') {
const chunkContent = chunk.content || chunk.text || '';
const chunkReasoning = chunk.reasoning || '';
const chunkAnnotations = chunk.annotations || [];
// Extract content from chunk based on the kepler-ai-sdk format
if (chunk && typeof chunk === 'object') {
const chunkContent = chunk.delta || chunk.content || chunk.text || '';
const chunkReasoning = chunk.reasoning || '';
const chunkAnnotations = chunk.annotations || [];
reasoning += chunkReasoning;
content += chunkContent;
annotations.push(...chunkAnnotations);
reasoning += chunkReasoning;
content += chunkContent;
annotations.push(...chunkAnnotations);
if (!content && !reasoning) continue;
if (!chunkContent && !chunkReasoning) continue;
generationId = chunk.id || generationId;
generationId = chunk.id || generationId;
const updateResult = await ResultAsync.fromPromise(
client.mutation(api.messages.updateContent, {
message_id: mid,
content,
reasoning: reasoning.length > 0 ? reasoning : undefined,
session_token: sessionToken,
generation_id: generationId,
annotations,
reasoning_effort: reasoningEffort,
}),
(e) => `Failed to update message content: ${e}`
const updateResult = await ResultAsync.fromPromise(
client.mutation(api.messages.updateContent, {
message_id: mid,
content,
reasoning: reasoning.length > 0 ? reasoning : undefined,
session_token: sessionToken,
generation_id: generationId,
annotations,
reasoning_effort: reasoningEffort,
}),
(e) => `Failed to update message content: ${e}`
);
if (updateResult.isErr()) {
log(
`Background message update failed on chunk ${chunkCount}: ${updateResult.error}`,
startTime
);
if (updateResult.isErr()) {
log(
`Background message update failed on chunk ${chunkCount}: ${updateResult.error}`,
startTime
);
}
}
}
} else {
// Handle non-streaming response
const response = stream as any;
content = response.content || response.text || '';
reasoning = response.reasoning || '';
generationId = response.id;
if (response.annotations) {
annotations.push(...response.annotations);
}
const updateResult = await ResultAsync.fromPromise(
client.mutation(api.messages.updateContent, {
message_id: mid,
content,
reasoning: reasoning.length > 0 ? reasoning : undefined,
session_token: sessionToken,
generation_id: generationId,
annotations,
reasoning_effort: reasoningEffort,
}),
(e) => `Failed to update message content: ${e}`
);
if (updateResult.isErr()) {
log(`Background message update failed: ${updateResult.error}`, startTime);
}
}
log(

View file

@ -0,0 +1,218 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { Provider } from '$lib/types';
import { Result, ResultAsync } from 'neverthrow';
export type ProviderApiKeyData = {
label: string;
usage?: number;
is_free_tier?: boolean;
is_provisioning_key?: boolean;
limit?: number;
limit_remaining?: number;
valid: boolean;
};
export const POST: RequestHandler = async ({ request, locals }) => {
const session = await locals.auth();
if (!session) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const { provider, key } = await request.json();
if (!provider || !key) {
return json({ error: 'Missing provider or key' }, { status: 400 });
}
if (!Object.values(Provider).includes(provider)) {
return json({ error: 'Invalid provider' }, { status: 400 });
}
const result = await validateApiKey(provider, key);
if (result.isErr()) {
return json({ error: result.error }, { status: 400 });
}
return json({ data: result.value });
} catch (error) {
console.error('API key validation error:', error);
return json({ error: 'Internal server error' }, { status: 500 });
}
};
async function validateApiKey(provider: Provider, key: string): Promise<Result<ProviderApiKeyData, string>> {
switch (provider) {
case Provider.OpenRouter:
return await validateOpenRouterKey(key);
case Provider.OpenAI:
return await validateOpenAIKey(key);
case Provider.Anthropic:
return await validateAnthropicKey(key);
case Provider.Gemini:
return await validateGeminiKey(key);
case Provider.Mistral:
return await validateMistralKey(key);
case Provider.Cohere:
return await validateCohereKey(key);
default:
return Result.err(`Validation not implemented for provider: ${provider}`);
}
}
// Provider-specific validation functions
async function validateOpenRouterKey(key: string): Promise<Result<ProviderApiKeyData, string>> {
return await ResultAsync.fromPromise(
(async () => {
const res = await fetch('https://openrouter.ai/api/v1/auth/key', {
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
},
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const { data } = await res.json();
if (!data) {
throw new Error('No key information returned');
}
return {
label: data.label || 'OpenRouter API Key',
usage: data.usage,
is_free_tier: data.is_free_tier,
is_provisioning_key: data.is_provisioning_key,
limit: data.limit,
limit_remaining: data.limit_remaining,
valid: true,
};
})(),
(e) => `Failed to validate OpenRouter API key: ${e}`
);
}
async function validateOpenAIKey(key: string): Promise<Result<ProviderApiKeyData, string>> {
return await ResultAsync.fromPromise(
(async () => {
const res = await fetch('https://api.openai.com/v1/models', {
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
},
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return {
label: 'OpenAI API Key',
valid: true,
};
})(),
(e) => `Failed to validate OpenAI API key: ${e}`
);
}
async function validateAnthropicKey(key: string): Promise<Result<ProviderApiKeyData, string>> {
return await ResultAsync.fromPromise(
(async () => {
// Anthropic doesn't have a simple key validation endpoint
// We'll try a minimal request to test the key
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': key,
'anthropic-version': '2023-06-01',
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'claude-3-haiku-20240307',
max_tokens: 1,
messages: [{ role: 'user', content: 'test' }],
}),
});
// Even a 400 error means the key is valid (just bad request format)
if (res.status === 401 || res.status === 403) {
throw new Error('Invalid API key');
}
return {
label: 'Anthropic API Key',
valid: true,
};
})(),
(e) => `Failed to validate Anthropic API key: ${e}`
);
}
async function validateGeminiKey(key: string): Promise<Result<ProviderApiKeyData, string>> {
return await ResultAsync.fromPromise(
(async () => {
const res = await fetch(`https://generativelanguage.googleapis.com/v1/models?key=${key}`);
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return {
label: 'Google Gemini API Key',
valid: true,
};
})(),
(e) => `Failed to validate Gemini API key: ${e}`
);
}
async function validateMistralKey(key: string): Promise<Result<ProviderApiKeyData, string>> {
return await ResultAsync.fromPromise(
(async () => {
const res = await fetch('https://api.mistral.ai/v1/models', {
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
},
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return {
label: 'Mistral API Key',
valid: true,
};
})(),
(e) => `Failed to validate Mistral API key: ${e}`
);
}
async function validateCohereKey(key: string): Promise<Result<ProviderApiKeyData, string>> {
return await ResultAsync.fromPromise(
(async () => {
const res = await fetch('https://api.cohere.ai/v1/models', {
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
},
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return {
label: 'Cohere API Key',
valid: true,
};
})(),
(e) => `Failed to validate Cohere API key: ${e}`
);
}

View file

@ -213,15 +213,15 @@
const currentModelSupportsImages = $derived.by(() => {
if (!settings.modelId) return false;
const openRouterModels = models.from(Provider.OpenRouter);
const currentModel = openRouterModels.find((m) => m.id === settings.modelId);
const allModels = models.all();
const currentModel = allModels.find((m) => m.id === settings.modelId);
return currentModel ? supportsImages(currentModel) : false;
});
const currentModelSupportsReasoning = $derived.by(() => {
if (!settings.modelId) return false;
const openRouterModels = models.from(Provider.OpenRouter);
const currentModel = openRouterModels.find((m) => m.id === settings.modelId);
const allModels = models.all();
const currentModel = allModels.find((m) => m.id === settings.modelId);
if (!currentModel) return false;
return supportsReasoning(currentModel);
});

View file

@ -63,8 +63,7 @@
let selectedCategory = $state<string | null>(null);
const openRouterKeyQuery = useCachedQuery(api.user_keys.get, {
provider: Provider.OpenRouter,
const userKeysQuery = useCachedQuery(api.user_keys.all, {
session_token: session.current?.session.token ?? '',
});
@ -76,7 +75,7 @@
</svelte:head>
<div class="flex h-svh flex-col items-center justify-center">
{#if prompt.current.length === 0 && openRouterKeyQuery.data}
{#if prompt.current.length === 0 && userKeysQuery.data && Object.values(userKeysQuery.data).some(key => key !== null)}
<div class="w-full p-2" in:scale={{ duration: 500, start: 0.9 }}>
<h2 class="text-left font-serif text-3xl font-semibold">
Hey there <span class={{ 'blur-sm': settings.data?.privacy_mode }}
@ -131,7 +130,7 @@
{/if}
</div>
</div>
{:else if !openRouterKeyQuery.data && !openRouterKeyQuery.isLoading}
{:else if userKeysQuery.data && !Object.values(userKeysQuery.data).some(key => key !== null) && !userKeysQuery.isLoading}
<div class="w-full p-2" in:scale={{ duration: 500, start: 0.9 }}>
<h2 class="text-left font-serif text-3xl font-semibold">
Hey there, <span class={{ 'blur-sm': settings.data?.privacy_mode }}