mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 05:19:33 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3230516ad | ||
|
|
0280d849e9 | ||
|
|
8355b4f23c | ||
|
|
b78f75b06c | ||
|
|
188d6507c2 | ||
|
|
c3718ac3a4 |
No files matched your search
@@ -5,6 +5,7 @@ node_modules
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
/.zero-build
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
@@ -25,6 +26,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
.zero-test-cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
@@ -36,3 +38,5 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
.cursor
|
||||
|
||||
zero
|
||||
zero.exe
|
||||
.*.bun-build
|
||||
@@ -9,7 +9,82 @@ bun install
|
||||
To run:
|
||||
|
||||
```bash
|
||||
bun run index.ts
|
||||
bun run dev
|
||||
```
|
||||
|
||||
To list built-in provider catalog entries:
|
||||
|
||||
```bash
|
||||
bun run src/index.ts providers catalog
|
||||
```
|
||||
|
||||
## Provider catalog
|
||||
|
||||
Provider, gateway, and local endpoint catalog entries live in
|
||||
`src/providers/catalog/definitions`. Global model metadata lives in
|
||||
`src/providers/catalog/models`.
|
||||
|
||||
Top-level providers are services that self-publish model families, such as
|
||||
OpenAI with GPT/o-series, Anthropic with Claude/Opus, Google with Gemini, and
|
||||
DeepSeek with DeepSeek models. Gateways should usually reference those global
|
||||
models instead of redefining them.
|
||||
|
||||
Minimal gateway example:
|
||||
|
||||
```toml
|
||||
id = "my-gateway"
|
||||
name = "My Gateway"
|
||||
kind = "gateway"
|
||||
description = "OpenAI-compatible gateway"
|
||||
category = "aggregating"
|
||||
baseURL = "https://api.example.com/v1"
|
||||
defaultModel = "vendor/model-name"
|
||||
apiKeyRequired = true
|
||||
credentialEnvVars = ["MY_GATEWAY_API_KEY"]
|
||||
costCurrency = "USD"
|
||||
|
||||
[setup]
|
||||
requiresAuth = true
|
||||
authMode = "api-key"
|
||||
|
||||
[transportConfig]
|
||||
kind = "openai-compatible"
|
||||
maxTokensField = "max_completion_tokens"
|
||||
removeBodyFields = ["store", "stream_options"]
|
||||
authHeader.name = "authorization"
|
||||
authHeader.scheme = "bearer"
|
||||
headers.X-Trace-Source = "zero"
|
||||
headers.X-Tenant = "$MY_GATEWAY_TENANT"
|
||||
|
||||
[catalog]
|
||||
source = "hybrid"
|
||||
discoveryCacheTtl = "1h"
|
||||
discoveryRefreshMode = "manual"
|
||||
discovery.kind = "openai-compatible"
|
||||
discovery.requiresAuth = true
|
||||
discovery.path = "/models"
|
||||
|
||||
[[catalog.models]]
|
||||
globalModelId = "gpt-4o"
|
||||
apiName = "openai/gpt-4o"
|
||||
contextWindow = 32000
|
||||
maxOutputTokens = 4096
|
||||
cost.inputPerMillion = 2.50
|
||||
cost.outputPerMillion = 10.00
|
||||
|
||||
[[catalog.models]]
|
||||
globalModelId = "claude-sonnet-4"
|
||||
apiName = "anthropic/claude-sonnet-4"
|
||||
contextWindow = 200000
|
||||
maxOutputTokens = 64000
|
||||
cost.inputPerMillion = 3.00
|
||||
cost.outputPerMillion = 15.00
|
||||
```
|
||||
|
||||
Detailed docs:
|
||||
|
||||
- [Provider definitions](docs/provider-catalog/providers.md)
|
||||
- [Model catalog entries](docs/provider-catalog/models.md)
|
||||
- [Examples](docs/provider-catalog/examples.md)
|
||||
|
||||
This project was created using `bun init` in bun v1.3.11. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
||||
@@ -5,17 +5,21 @@
|
||||
"": {
|
||||
"name": "zero",
|
||||
"dependencies": {
|
||||
"@opentui/core": "^0.2.16",
|
||||
"@opentui/solid": "^0.2.16",
|
||||
"commander": "^15.0.0",
|
||||
"execa": "^9.6.1",
|
||||
"ink": "^7.0.4",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"ink-text-input": "^6.0.0",
|
||||
"openai": "^6.39.1",
|
||||
"react": "^19.2.6",
|
||||
"react-devtools-core": "^7.0.1",
|
||||
"shiki": "^4.1.0",
|
||||
"solid-js": "1.9.12",
|
||||
"smol-toml": "^1.6.1",
|
||||
"zod": "^4.4.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/react": "^19.2.15",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
@@ -23,87 +27,7 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@ampproject/remapping": ["@ampproject/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/[email protected]", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
|
||||
|
||||
"@babel/core": ["@babel/[email protected]", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
|
||||
|
||||
"@babel/generator": ["@babel/[email protected]", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="],
|
||||
|
||||
"@babel/helper-annotate-as-pure": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="],
|
||||
|
||||
"@babel/helper-compilation-targets": ["@babel/[email protected]", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
|
||||
|
||||
"@babel/helper-create-class-features-plugin": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/[email protected]", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
|
||||
|
||||
"@babel/helper-member-expression-to-functions": ["@babel/[email protected]", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg=="],
|
||||
|
||||
"@babel/helper-module-imports": ["@babel/[email protected]", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
|
||||
|
||||
"@babel/helper-module-transforms": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
|
||||
|
||||
"@babel/helper-optimise-call-expression": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong=="],
|
||||
|
||||
"@babel/helper-plugin-utils": ["@babel/[email protected]", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
|
||||
|
||||
"@babel/helper-replace-supers": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ=="],
|
||||
|
||||
"@babel/helper-skip-transparent-expression-wrappers": ["@babel/[email protected]", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/[email protected]", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/[email protected]", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/[email protected]", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
|
||||
|
||||
"@babel/helpers": ["@babel/[email protected]", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
|
||||
|
||||
"@babel/parser": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
|
||||
|
||||
"@babel/plugin-syntax-jsx": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="],
|
||||
|
||||
"@babel/plugin-syntax-typescript": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA=="],
|
||||
|
||||
"@babel/plugin-transform-modules-commonjs": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ=="],
|
||||
|
||||
"@babel/plugin-transform-typescript": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/plugin-syntax-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw=="],
|
||||
|
||||
"@babel/preset-typescript": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ=="],
|
||||
|
||||
"@babel/template": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
|
||||
|
||||
"@babel/traverse": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="],
|
||||
|
||||
"@babel/types": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/[email protected]", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/[email protected]", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@opentui/core": ["@opentui/[email protected]", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.16", "@opentui/core-darwin-x64": "0.2.16", "@opentui/core-linux-arm64": "0.2.16", "@opentui/core-linux-x64": "0.2.16", "@opentui/core-win32-arm64": "0.2.16", "@opentui/core-win32-x64": "0.2.16" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-4vWN15Zc3nsXJlOiHhhpqkBXD+wrNFKxCPtiTiillZYDRre+XsZogVTOOGUDwaBIC23OSxq7imezLmmtShVBEA=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aFb2Yp+oqDu3h6VCWi7xpQ9yjpKSQcROzGGfHgqC6Nd3U+uiLfPJBkmiI87iK0opCggCFj5TkKI004050DmGjg=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-KimiHE0j7EsTB5P8doW0lr1eH5iZKLPKWQO+tmy1VcdYr/TzqhdHSvGuJXrZvfTFi9/rV57Eq0d7964Ri9O0vQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-4fCwRCfTtUgS/5QcSEkSuBjgQymSOUWXgrXG2ycrf3Swi0QhKDA/pVjwLrUJ6eF+/8mQyQSEV72T8MxMO3M2qg=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-KgQBGjiucw4e7gM+R8qOzHWBFhjCY1IfCrGjW3Wzxv2hKUlL+mPhelaeJwnEqtNxMUdVTYjlwlu3IHxslXMJWQ=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-C6WqEI3VkXatXraMgSFXZjEXq0pzURGjRpFAJZYmuVDmpqE57o7E80Np2UkdZ6m5kpJDt4mRyu3krc/P825iNQ=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-kCX3CMTns6DMCFDNTDV4sjmBKyA/iEvzaVhl/jYi4JRIVT2zcy1lo+lhXT5mPgYHmJZu8Uye6j3Zi3c7Z2Me5A=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/[email protected]", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.16", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-2Q+v1PPpXXr+sALi9Aj6I5Jvo7xDfbmstYjRLL7lW3Hghh9i7ONQKpt/gyDDRbhSsYrhxKYTNenF9OxgoXkTHg=="],
|
||||
"@alcalzone/ansi-tokenize": ["@alcalzone/[email protected]", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA=="],
|
||||
|
||||
"@sec-ant/readable-stream": ["@sec-ant/[email protected]", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
|
||||
|
||||
@@ -133,99 +57,87 @@
|
||||
|
||||
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
|
||||
|
||||
"@types/react": ["@types/[email protected]", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="],
|
||||
|
||||
"@types/unist": ["@types/[email protected]", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/[email protected]", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="],
|
||||
|
||||
"ansi-escapes": ["[email protected]", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
|
||||
|
||||
"ansi-regex": ["[email protected]", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"babel-plugin-jsx-dom-expressions": ["[email protected]", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-/O6JWUmjv03OI9lL2ry9bUjpD5S3PclM55RRJEyCdcFZ5W2SEA/59d+l2hNsk3gI6kiWRdRPdOtqZmsQzFN1pQ=="],
|
||||
"ansi-styles": ["[email protected]", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"babel-plugin-module-resolver": ["babel-plugin-module-resolver@5.0.2", "", { "dependencies": { "find-babel-config": "^2.1.1", "glob": "^9.3.3", "pkg-up": "^3.1.0", "reselect": "^4.1.7", "resolve": "^1.22.8" } }, "sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg=="],
|
||||
|
||||
"babel-preset-solid": ["[email protected]", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="],
|
||||
|
||||
"balanced-match": ["[email protected]", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"baseline-browser-mapping": ["[email protected]", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw=="],
|
||||
|
||||
"brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
|
||||
|
||||
"browserslist": ["[email protected]", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
|
||||
|
||||
"bun-ffi-structs": ["[email protected]", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="],
|
||||
"auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="],
|
||||
|
||||
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"caniuse-lite": ["[email protected]", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="],
|
||||
|
||||
"ccount": ["[email protected]", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
"chalk": ["[email protected]", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"character-entities-html4": ["[email protected]", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
|
||||
|
||||
"character-entities-legacy": ["[email protected]", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
|
||||
|
||||
"cli-boxes": ["[email protected]", "", {}, "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw=="],
|
||||
|
||||
"cli-cursor": ["[email protected]", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="],
|
||||
|
||||
"cli-spinners": ["[email protected]", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
|
||||
|
||||
"cli-truncate": ["[email protected]", "", { "dependencies": { "slice-ansi": "^9.0.0", "string-width": "^8.2.0" } }, "sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA=="],
|
||||
|
||||
"code-excerpt": ["[email protected]", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="],
|
||||
|
||||
"comma-separated-tokens": ["[email protected]", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
|
||||
|
||||
"commander": ["[email protected]", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
"convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="],
|
||||
|
||||
"cross-spawn": ["[email protected]", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"dequal": ["[email protected]", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"devlop": ["[email protected]", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
"diff": ["[email protected]", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
|
||||
"environment": ["[email protected]", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
|
||||
|
||||
"electron-to-chromium": ["[email protected]", "", {}, "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw=="],
|
||||
"es-toolkit": ["[email protected]", "", {}, "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||
|
||||
"entities": ["[email protected]", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"es-errors": ["[email protected]", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"escalade": ["[email protected]", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
"escape-string-regexp": ["escape-string-regex[email protected].0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
|
||||
|
||||
"execa": ["[email protected]", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
|
||||
|
||||
"figures": ["[email protected]", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
|
||||
|
||||
"find-babel-config": ["[email protected]", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
|
||||
|
||||
"find-up": ["[email protected]", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="],
|
||||
|
||||
"fs.realpath": ["[email protected]", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
||||
|
||||
"function-bind": ["[email protected]", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"gensync": ["[email protected]", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"get-east-asian-width": ["[email protected]", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
|
||||
|
||||
"get-stream": ["[email protected]", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
|
||||
|
||||
"glob": ["[email protected]", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
|
||||
|
||||
"hasown": ["[email protected]", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
|
||||
|
||||
"hast-util-to-html": ["[email protected]", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
|
||||
|
||||
"hast-util-whitespace": ["[email protected]", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
|
||||
|
||||
"html-entities": ["[email protected]", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
|
||||
|
||||
"html-void-elements": ["[email protected]", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
|
||||
|
||||
"human-signals": ["[email protected]", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
|
||||
|
||||
"is-core-module": ["[email protected]", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="],
|
||||
"indent-string": ["[email protected]", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="],
|
||||
|
||||
"ink": ["[email protected]", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.3.0", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.3", "auto-bind": "^5.0.1", "chalk": "^5.6.2", "cli-boxes": "^4.0.1", "cli-cursor": "^4.0.0", "cli-truncate": "^6.0.0", "code-excerpt": "^4.0.0", "es-toolkit": "^1.45.1", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^9.0.0", "stack-utils": "^2.0.6", "string-width": "^8.2.0", "terminal-size": "^4.0.1", "type-fest": "^5.5.0", "widest-line": "^6.0.0", "wrap-ansi": "^10.0.0", "ws": "^8.20.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.2.0", "react": ">=19.2.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-4wsM/gMKOT2ZANNTJibI6I9IcwBfobqv/CgaDcwvOaCREZIQxo3iGQS7qPHa2hmA67NYltZWCMtBDELB/mcbJQ=="],
|
||||
|
||||
"ink-spinner": ["[email protected]", "", { "dependencies": { "cli-spinners": "^2.7.0" }, "peerDependencies": { "ink": ">=4.0.0", "react": ">=18.0.0" } }, "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA=="],
|
||||
|
||||
"ink-text-input": ["[email protected]", "", { "dependencies": { "chalk": "^5.3.0", "type-fest": "^4.18.2" }, "peerDependencies": { "ink": ">=5", "react": ">=18" } }, "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw=="],
|
||||
|
||||
"is-fullwidth-code-point": ["[email protected]", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
|
||||
|
||||
"is-in-ci": ["[email protected]", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="],
|
||||
|
||||
"is-plain-obj": ["[email protected]", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
@@ -235,18 +147,6 @@
|
||||
|
||||
"isexe": ["[email protected]", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsesc": ["[email protected]", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json5": ["[email protected]", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"locate-path": ["[email protected]", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="],
|
||||
|
||||
"lru-cache": ["[email protected]", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"marked": ["[email protected]", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
|
||||
|
||||
"mdast-util-to-hast": ["[email protected]", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
|
||||
|
||||
"micromark-util-character": ["[email protected]", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
|
||||
@@ -259,90 +159,78 @@
|
||||
|
||||
"micromark-util-types": ["[email protected]", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
||||
|
||||
"minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="],
|
||||
|
||||
"minipass": ["[email protected]", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="],
|
||||
|
||||
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"node-releases": ["[email protected]", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="],
|
||||
"mimic-fn": ["[email protected]", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
|
||||
|
||||
"npm-run-path": ["[email protected]", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
|
||||
|
||||
"onetime": ["[email protected]", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||
|
||||
"oniguruma-parser": ["[email protected]", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
|
||||
|
||||
"oniguruma-to-es": ["[email protected]", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="],
|
||||
|
||||
"openai": ["[email protected]", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-z3dO9fEWOXBzlXynVb/xZ/tujzUjFWQWn3C0n0mw6Vo0zJTbEkaN4b2cLWjhJ6haJQx8LlREoafHRl+Gu/Hl+A=="],
|
||||
|
||||
"p-limit": ["[email protected]", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
||||
|
||||
"p-locate": ["[email protected]", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
|
||||
|
||||
"p-try": ["[email protected]", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
||||
|
||||
"parse-ms": ["[email protected]", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
|
||||
|
||||
"parse5": ["[email protected]", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"path-exists": ["[email protected]", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="],
|
||||
"patch-console": ["[email protected]", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="],
|
||||
|
||||
"path-key": ["[email protected]", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-parse": ["[email protected]", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||
|
||||
"path-scurry": ["[email protected]", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||
|
||||
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"pkg-up": ["[email protected]", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="],
|
||||
|
||||
"pretty-ms": ["[email protected]", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
|
||||
|
||||
"property-information": ["[email protected]", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
|
||||
|
||||
"react": ["[email protected]", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
|
||||
|
||||
"react-devtools-core": ["[email protected]", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="],
|
||||
|
||||
"react-reconciler": ["[email protected]", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="],
|
||||
|
||||
"regex": ["[email protected]", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
|
||||
|
||||
"regex-recursion": ["[email protected]", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
|
||||
|
||||
"regex-utilities": ["[email protected]", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
|
||||
|
||||
"reselect": ["[email protected]", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="],
|
||||
"restore-cursor": ["[email protected]", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="],
|
||||
|
||||
"resolve": ["[email protected]", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
|
||||
|
||||
"s-js": ["[email protected]", "", {}, "sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ=="],
|
||||
|
||||
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"seroval": ["[email protected]", "", {}, "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw=="],
|
||||
|
||||
"seroval-plugins": ["[email protected]", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw=="],
|
||||
"scheduler": ["[email protected].0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"shebang-command": ["[email protected]", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["[email protected]", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"shell-quote": ["[email protected]", "", {}, "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ=="],
|
||||
|
||||
"shiki": ["[email protected]", "", { "dependencies": { "@shikijs/core": "4.1.0", "@shikijs/engine-javascript": "4.1.0", "@shikijs/engine-oniguruma": "4.1.0", "@shikijs/langs": "4.1.0", "@shikijs/themes": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q=="],
|
||||
|
||||
"signal-exit": ["[email protected]", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"solid-js": ["soli[email protected]", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw=="],
|
||||
"slice-ansi": ["sli[email protected]", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA=="],
|
||||
|
||||
"smol-toml": ["[email protected]", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="],
|
||||
|
||||
"space-separated-tokens": ["[email protected]", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||
|
||||
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||
"stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
|
||||
|
||||
"string-width": ["[email protected]", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="],
|
||||
|
||||
"stringify-entities": ["[email protected]", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
||||
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||
|
||||
"strip-final-newline": ["[email protected]", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
|
||||
|
||||
"supports-preserve-symlinks-flag": ["[email protected]", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
"tagged-tag": ["[email protected]", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||
|
||||
"terminal-size": ["[email protected]", "", {}, "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ=="],
|
||||
|
||||
"trim-lines": ["[email protected]", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||
|
||||
"type-fest": ["[email protected]", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="],
|
||||
|
||||
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["[email protected]", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||
@@ -359,19 +247,17 @@
|
||||
|
||||
"unist-util-visit-parents": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
||||
|
||||
"update-browserslist-db": ["[email protected]", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"vfile": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
||||
"vfile-message": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"web-tree-sitter": ["[email protected]", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="],
|
||||
|
||||
"which": ["[email protected]", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"ws": ["[email protected].0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||
"widest-line": ["[email protected].0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="],
|
||||
|
||||
"yallist": ["[email protected]", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
"wrap-ansi": ["[email protected]", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="],
|
||||
|
||||
"ws": ["[email protected]", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||
|
||||
"yoctocolors": ["[email protected]", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
|
||||
|
||||
@@ -381,16 +267,14 @@
|
||||
|
||||
"zwitch": ["[email protected]", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||
|
||||
"babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
|
||||
"ink/signal-exit": ["[email protected]", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"ink-text-input/type-fest": ["[email protected]", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||
|
||||
"npm-run-path/path-key": ["[email protected]", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
|
||||
"parse5/entities": ["[email protected]", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
"react-devtools-core/ws": ["[email protected]", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA=="],
|
||||
|
||||
"path-scurry/lru-cache": ["[email protected]", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"path-scurry/minipass": ["[email protected]", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||
|
||||
"string-width/strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||
"restore-cursor/signal-exit": ["[email protected]", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
# Provider catalog examples
|
||||
|
||||
Top-level providers self-publish model families, such as OpenAI with
|
||||
GPT/o-series, Anthropic with Claude/Opus, Google with Gemini, and DeepSeek with
|
||||
DeepSeek models. Gateways and local endpoints normally reference those global
|
||||
models with `globalModelId`.
|
||||
|
||||
## Gateway with discovery and curated models
|
||||
|
||||
```toml
|
||||
id = "my-gateway"
|
||||
name = "My Gateway"
|
||||
kind = "gateway"
|
||||
description = "OpenAI-compatible model gateway"
|
||||
category = "aggregating"
|
||||
baseURL = "https://api.example.com/v1"
|
||||
defaultModel = "vendor/model-name"
|
||||
supportsModelRouting = true
|
||||
apiKeyLabel = "My Gateway API key"
|
||||
apiKeyPlaceholder = "gw_live_..."
|
||||
apiKeyRequired = true
|
||||
credentialEnvVars = ["MY_GATEWAY_API_KEY"]
|
||||
costCurrency = "USD"
|
||||
|
||||
[setup]
|
||||
requiresAuth = true
|
||||
authMode = "api-key"
|
||||
|
||||
[validation]
|
||||
kind = "credential-env"
|
||||
missingCredentialMessage = "An API key is required."
|
||||
matchBaseUrlHosts = ["api.example.com"]
|
||||
|
||||
[transportConfig]
|
||||
kind = "openai-compatible"
|
||||
maxTokensField = "max_completion_tokens"
|
||||
removeBodyFields = ["store", "stream_options"]
|
||||
authHeader.name = "authorization"
|
||||
authHeader.scheme = "bearer"
|
||||
headers.X-Trace-Source = "zero"
|
||||
headers.X-Tenant = "$MY_GATEWAY_TENANT"
|
||||
|
||||
[catalog]
|
||||
source = "hybrid"
|
||||
discoveryCacheTtl = "1h"
|
||||
discoveryRefreshMode = "manual"
|
||||
allowManualRefresh = true
|
||||
discovery.kind = "openai-compatible"
|
||||
discovery.requiresAuth = true
|
||||
discovery.path = "/models"
|
||||
|
||||
[[catalog.models]]
|
||||
globalModelId = "gpt-4o"
|
||||
apiName = "openai/gpt-4o"
|
||||
contextWindow = 32000
|
||||
maxOutputTokens = 4096
|
||||
cost.inputPerMillion = 2.50
|
||||
cost.outputPerMillion = 10.00
|
||||
|
||||
[[catalog.models]]
|
||||
globalModelId = "claude-sonnet-4"
|
||||
apiName = "anthropic/claude-sonnet-4"
|
||||
contextWindow = 200000
|
||||
maxOutputTokens = 64000
|
||||
cost.inputPerMillion = 3.00
|
||||
cost.outputPerMillion = 15.00
|
||||
|
||||
[preset]
|
||||
id = "my-gateway"
|
||||
description = "My Gateway hosted models"
|
||||
fallbackBaseUrl = "https://api.example.com/v1"
|
||||
fallbackModel = "vendor/model-name"
|
||||
badge.text = "HOSTED"
|
||||
badge.color = "success"
|
||||
```
|
||||
|
||||
## Local Ollama-style provider
|
||||
|
||||
```toml
|
||||
id = "ollama"
|
||||
name = "Ollama"
|
||||
kind = "localhost"
|
||||
description = "Local models via Ollama"
|
||||
category = "local"
|
||||
baseURL = "http://localhost:11434/v1"
|
||||
defaultModel = "llama3.2"
|
||||
apiKeyRequired = false
|
||||
|
||||
[setup]
|
||||
requiresAuth = false
|
||||
authMode = "none"
|
||||
|
||||
[startup]
|
||||
autoDetectable = true
|
||||
probeReadiness = "openai-compatible-models"
|
||||
|
||||
[transportConfig]
|
||||
kind = "openai-compatible"
|
||||
|
||||
[catalog]
|
||||
source = "dynamic"
|
||||
discoveryCacheTtl = "5m"
|
||||
discoveryRefreshMode = "on-open"
|
||||
allowManualRefresh = true
|
||||
discovery.kind = "ollama"
|
||||
discovery.requiresAuth = false
|
||||
|
||||
[[catalog.models]]
|
||||
id = "llama3.2"
|
||||
name = "Llama 3.2"
|
||||
tier = "local"
|
||||
|
||||
[[catalog.models]]
|
||||
id = "mistral"
|
||||
name = "Mistral"
|
||||
tier = "local"
|
||||
```
|
||||
|
||||
## First-party provider
|
||||
|
||||
```toml
|
||||
id = "openai"
|
||||
name = "OpenAI"
|
||||
kind = "provider"
|
||||
description = "OpenAI direct API"
|
||||
category = "hosted"
|
||||
baseURL = "https://api.openai.com/v1"
|
||||
defaultModel = "gpt-4o"
|
||||
isFirstParty = true
|
||||
apiKeyRequired = true
|
||||
credentialEnvVars = ["OPENAI_API_KEY"]
|
||||
costCurrency = "USD"
|
||||
|
||||
[setup]
|
||||
requiresAuth = true
|
||||
authMode = "api-key"
|
||||
|
||||
[transportConfig]
|
||||
kind = "openai-compatible"
|
||||
|
||||
[catalog]
|
||||
source = "hybrid"
|
||||
discoveryCacheTtl = "1h"
|
||||
discoveryRefreshMode = "manual"
|
||||
allowManualRefresh = true
|
||||
discovery.kind = "openai-compatible"
|
||||
discovery.requiresAuth = true
|
||||
discovery.path = "/models"
|
||||
|
||||
[[catalog.models]]
|
||||
globalModelId = "gpt-4o"
|
||||
cost.inputPerMillion = 2.50
|
||||
cost.outputPerMillion = 10.00
|
||||
|
||||
[[catalog.models]]
|
||||
globalModelId = "gpt-4o-mini"
|
||||
cost.inputPerMillion = 0.15
|
||||
cost.outputPerMillion = 0.60
|
||||
```
|
||||
@@ -0,0 +1,129 @@
|
||||
# Provider model catalog
|
||||
|
||||
Global model metadata lives in `src/providers/catalog/models`. Files use TOML
|
||||
`[[model]]` entries. Global models describe identity, ownership, limits, and
|
||||
capabilities only; all pricing belongs on provider `[[catalog.models]]` route
|
||||
entries.
|
||||
|
||||
Only top-level providers should add global model metadata. A top-level provider
|
||||
self-publishes the model family, such as OpenAI with GPT/o-series, Anthropic
|
||||
with Claude/Opus, Google with Gemini, or DeepSeek with DeepSeek models.
|
||||
|
||||
Each model entry must stay as one TOML block. Nested model subtables such as
|
||||
`[model.cost]`, `[model.capabilities]`, or `[catalog.models.cost]` are not
|
||||
allowed; use dotted keys inside the `[[model]]` or `[[catalog.models]]` block.
|
||||
|
||||
## Global model example
|
||||
|
||||
Use one `[[model]]` block per global model owned by a top-level provider. Add
|
||||
additional models by adding additional `[[model]]` blocks in the same family
|
||||
file.
|
||||
|
||||
```toml
|
||||
[[model]]
|
||||
id = "gpt-4o"
|
||||
name = "GPT-4o"
|
||||
apiName = "gpt-4o"
|
||||
ownerProviderId = "openai"
|
||||
tier = "first-party"
|
||||
description = "OpenAI flagship multimodal model"
|
||||
classification = ["chat", "vision", "coding"]
|
||||
contextWindow = 128000
|
||||
maxOutputTokens = 16384
|
||||
defaultTemperature = 0.7
|
||||
temperatureRange.min = 0
|
||||
temperatureRange.max = 2
|
||||
capabilities.supportsVision = true
|
||||
capabilities.supportsStreaming = true
|
||||
capabilities.supportsFunctionCalling = true
|
||||
capabilities.supportsJsonMode = true
|
||||
capabilities.supportsReasoning = false
|
||||
capabilities.supportsPreciseTokenCount = true
|
||||
capabilities.supportsEmbeddings = false
|
||||
capabilities.supportsTemperature = true
|
||||
notes = "Optional maintainer note."
|
||||
|
||||
[[model]]
|
||||
id = "gpt-4o-mini"
|
||||
name = "GPT-4o Mini"
|
||||
apiName = "gpt-4o-mini"
|
||||
ownerProviderId = "openai"
|
||||
tier = "first-party"
|
||||
description = "OpenAI small multimodal model"
|
||||
classification = ["chat", "vision"]
|
||||
contextWindow = 128000
|
||||
maxOutputTokens = 16384
|
||||
capabilities.supportsVision = true
|
||||
capabilities.supportsStreaming = true
|
||||
capabilities.supportsFunctionCalling = true
|
||||
capabilities.supportsJsonMode = true
|
||||
capabilities.supportsReasoning = false
|
||||
```
|
||||
|
||||
## Model fields
|
||||
|
||||
Global `[[model]]` entries and provider `[[catalog.models]]` entries share the
|
||||
identity, capability, and limit fields below. Provider entries may also define
|
||||
route-specific pricing with `cost.*`.
|
||||
|
||||
| Field | Required | Accepted values or behavior |
|
||||
|-------|----------|-----------------------------|
|
||||
| `id` | Yes for global models; no for provider entries with `globalModelId` | Stable catalog ID. |
|
||||
| `apiName` | No | Exact model name sent to the API. Defaults to `id`; with `globalModelId`, defaults to the global model ID unless overridden. |
|
||||
| `globalModelId` | No | Inherits metadata from a global model, then applies provider-entry overrides. |
|
||||
| `ownerProviderId` | No | First-party provider that owns a global model. Used by `isFirstParty` providers. |
|
||||
| `name` / `label` | No | Display metadata. |
|
||||
| `default` / `hidden` | No | Optional selection metadata. |
|
||||
| `classification` | No | Any of `chat`, `reasoning`, `vision`, and `coding`. |
|
||||
| `tier` | No | `first-party`, `hosted`, `local`, or `community`. |
|
||||
| `contextWindow` | No | Input/context token limit for this model or route. |
|
||||
| `maxOutputTokens` | No | Output token limit for this model or route. |
|
||||
| `defaultTemperature` | No | Suggested temperature default. |
|
||||
| `temperatureRange.min` / `temperatureRange.max` | No | Supported temperature bounds. |
|
||||
| `capabilities.*` | No | Feature flags listed below. |
|
||||
| `transportOverrides.*` | No | Per-model transport overrides merged over provider `transportConfig`. |
|
||||
| `notes` | No | Free-form maintainer note. |
|
||||
|
||||
## Provider model pricing and overrides
|
||||
|
||||
When a provider or gateway exposes a global model under a provider-specific
|
||||
name, limit, or price, keep those route-specific values in that provider's
|
||||
single `[[catalog.models]]` block. Put the shared pricing currency once at the
|
||||
provider top level with `costCurrency`.
|
||||
|
||||
```toml
|
||||
costCurrency = "USD"
|
||||
|
||||
[[catalog.models]]
|
||||
globalModelId = "claude-sonnet-4"
|
||||
apiName = "anthropic/claude-sonnet-4"
|
||||
contextWindow = 200000
|
||||
maxOutputTokens = 64000
|
||||
capabilities.supportsReasoning = true
|
||||
cost.inputPerMillion = 3.00
|
||||
cost.outputPerMillion = 15.00
|
||||
cost.cachePerMillion = 0.30
|
||||
transportOverrides.maxTokensField = "max_tokens"
|
||||
transportOverrides.removeBodyFields = ["store"]
|
||||
|
||||
[[catalog.models]]
|
||||
globalModelId = "deepseek-v3"
|
||||
apiName = "deepseek/deepseek-chat"
|
||||
contextWindow = 65536
|
||||
maxOutputTokens = 8192
|
||||
cost.inputPerMillion = 0.14
|
||||
cost.outputPerMillion = 0.28
|
||||
```
|
||||
|
||||
`capabilities` flags are `supportsVision`, `supportsStreaming`,
|
||||
`supportsFunctionCalling`, `supportsJsonMode`, `supportsReasoning`,
|
||||
`supportsPreciseTokenCount`, `supportsEmbeddings`, and
|
||||
`supportsTemperature`.
|
||||
|
||||
Provider model `cost` requires `inputPerMillion` and `outputPerMillion` when
|
||||
present. `cachePerMillion` is optional. Do not put `cost` or `costCurrency` in
|
||||
global model files.
|
||||
|
||||
`transportOverrides` accepts the same fields as `transportConfig`. Header
|
||||
overrides are merged with provider headers, and `removeBodyFields` is unioned
|
||||
with provider-level removals.
|
||||
@@ -0,0 +1,213 @@
|
||||
# Provider catalog definitions
|
||||
|
||||
Provider catalog entries are TOML files in `src/providers/catalog/definitions`.
|
||||
Use one definition file for each API provider, gateway, or local endpoint.
|
||||
|
||||
A top-level provider is the company or service that self-publishes a model
|
||||
family. Examples: OpenAI publishes GPT and o-series models, Anthropic publishes
|
||||
Claude and Opus/Sonnet models, Google publishes Gemini models, and DeepSeek
|
||||
publishes DeepSeek models.
|
||||
|
||||
Gateways and hosted proxies do not own those global model families. They expose
|
||||
routes to models owned by top-level providers, so their catalog entries should
|
||||
usually reference global models with `globalModelId`.
|
||||
|
||||
## Pick The Right File
|
||||
|
||||
| What you're adding | Files to touch | Brand/provider examples |
|
||||
|--------------------|----------------|-------------------------|
|
||||
| Top-level provider with no new global models | `definitions/<provider>.toml` | OpenAI after GPT models already exist, Anthropic after Claude models already exist |
|
||||
| Top-level provider with owned global models | `definitions/<provider>.toml` and `models/<family>.toml` | OpenAI + GPT/o-series, Anthropic + Claude/Opus, Google + Gemini, DeepSeek + DeepSeek |
|
||||
| Gateway or hosted proxy | `definitions/<gateway>.toml` | OpenRouter, OpenGateway, Groq |
|
||||
| Local endpoint | `definitions/<local>.toml` | Ollama, LM Studio |
|
||||
|
||||
## Recommended Shape
|
||||
|
||||
Keep credential env vars at the provider top level:
|
||||
|
||||
```toml
|
||||
credentialEnvVars = ["OPENAI_API_KEY"]
|
||||
```
|
||||
|
||||
The catalog normalizer copies that value into setup, validation, and preset
|
||||
metadata for runtime consumers. Do not repeat the same env var in every section.
|
||||
|
||||
Keep these nested objects inside one TOML block with dotted keys:
|
||||
|
||||
```toml
|
||||
[transportConfig]
|
||||
kind = "openai-compatible"
|
||||
authHeader.name = "authorization"
|
||||
headers.X-Trace-Source = "zero"
|
||||
|
||||
[catalog]
|
||||
source = "hybrid"
|
||||
discovery.kind = "openai-compatible"
|
||||
|
||||
[preset]
|
||||
id = "my-provider"
|
||||
badge.text = "HOSTED"
|
||||
```
|
||||
|
||||
Use provider-level `costCurrency` for pricing currency. Individual provider
|
||||
model routes can define numeric `cost.*` fields, but global model files should
|
||||
not define pricing.
|
||||
|
||||
## Required Provider Fields
|
||||
|
||||
Every definition needs these top-level fields:
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `id` | Stable provider ID. |
|
||||
| `name` | Human-readable provider name. |
|
||||
| `kind` | `provider`, `gateway`, or `localhost`. |
|
||||
| `description` | Short provider description. |
|
||||
| `baseURL` | Provider API base URL. |
|
||||
| `defaultModel` | Model used when the user does not choose one. |
|
||||
|
||||
## Common Optional Fields
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `category` | `local`, `hosted`, or `aggregating`. |
|
||||
| `vendorId` | Inherit another provider's `transportConfig` when this definition omits one. |
|
||||
| `isFirstParty` | Auto-add owned global models from `models/*.toml`; use this for top-level providers. |
|
||||
| `supportsModelRouting` | Marks providers that can route multiple model families. |
|
||||
| `apiKeyLabel` / `apiKeyPlaceholder` | UI labels for credential entry. |
|
||||
| `apiKeyRequired` | `false` for local/no-auth providers; otherwise defaults from `setup.requiresAuth`. |
|
||||
| `credentialEnvVars` | Canonical env vars that may contain provider credentials. |
|
||||
| `costCurrency` | Currency code for provider-level model pricing, such as `USD`. |
|
||||
|
||||
## Setup
|
||||
|
||||
Use setup for credential flow metadata:
|
||||
|
||||
```toml
|
||||
[setup]
|
||||
requiresAuth = true
|
||||
authMode = "api-key"
|
||||
```
|
||||
|
||||
| Field | Required | Accepted values or behavior |
|
||||
|-------|----------|-----------------------------|
|
||||
| `requiresAuth` | Yes when `setup` is present | `true` for credentialed providers; `false` for no-auth local endpoints. |
|
||||
| `authMode` | Yes when `setup` is present | `api-key`, `oauth`, `adc`, `token`, or `none`. |
|
||||
| `credentialEnvVars` | No | Normally omit this and use top-level `credentialEnvVars`. |
|
||||
| `setupPrompt` | No | Optional credential setup prompt text. |
|
||||
|
||||
## Validation
|
||||
|
||||
Use validation when Zero should check credentials or match configured provider
|
||||
URLs:
|
||||
|
||||
```toml
|
||||
[validation]
|
||||
kind = "credential-env"
|
||||
missingCredentialMessage = "An API key is required."
|
||||
matchBaseUrlHosts = ["api.example.com"]
|
||||
```
|
||||
|
||||
| Field | Required | Accepted values or behavior |
|
||||
|-------|----------|-----------------------------|
|
||||
| `kind` | Yes when `validation` is present | Currently `credential-env`. |
|
||||
| `credentialEnvVars` | No | Normally omit this and use top-level `credentialEnvVars`. |
|
||||
| `missingCredentialMessage` | No | Message shown when required credentials are missing. |
|
||||
| `matchBaseUrlHosts` | No | Hostnames used to match an existing provider config. |
|
||||
|
||||
## Transport
|
||||
|
||||
OpenAI-compatible transports are supported at runtime. Anthropic-compatible
|
||||
descriptors are recognized by the catalog, but runtime creation currently
|
||||
rejects them until that transport is implemented.
|
||||
|
||||
```toml
|
||||
[transportConfig]
|
||||
kind = "openai-compatible"
|
||||
maxTokensField = "max_completion_tokens"
|
||||
removeBodyFields = ["store", "stream_options"]
|
||||
authHeader.name = "authorization"
|
||||
authHeader.scheme = "bearer"
|
||||
```
|
||||
|
||||
| Field | Required | Accepted values or behavior |
|
||||
|-------|----------|-----------------------------|
|
||||
| `kind` | Yes when `transportConfig` is present | `openai-compatible` or `anthropic-compatible`. |
|
||||
| `headers.*` | No | Static headers. Values beginning with `$` are read from `process.env`; unset env vars are omitted. |
|
||||
| `authHeader.name` | No | Header name used for API key auth. Defaults to `authorization` during discovery. |
|
||||
| `authHeader.scheme` | No | `bearer` sends `Bearer <key>`; `raw` sends the key value directly. Defaults to `bearer`. |
|
||||
| `maxTokensField` | No | `max_tokens` or `max_completion_tokens`. |
|
||||
| `removeBodyFields` | No | Request body fields stripped before sending to the provider. |
|
||||
| `endpointPath` | No | Optional transport endpoint path metadata. |
|
||||
|
||||
For runtime OpenAI-compatible calls, the OpenAI SDK supplies its normal
|
||||
`Authorization` header unless a custom raw or non-Authorization `authHeader` is
|
||||
configured. For model discovery, Zero builds fetch headers directly and sends
|
||||
the configured auth header when discovery requires auth.
|
||||
|
||||
## Catalog And Discovery
|
||||
|
||||
Use `catalog` for static model routes, dynamic discovery, or both:
|
||||
|
||||
```toml
|
||||
[catalog]
|
||||
source = "hybrid"
|
||||
discoveryCacheTtl = "1h"
|
||||
discoveryRefreshMode = "manual"
|
||||
allowManualRefresh = true
|
||||
discovery.kind = "openai-compatible"
|
||||
discovery.requiresAuth = true
|
||||
discovery.path = "/models"
|
||||
```
|
||||
|
||||
| Field | Required | Accepted values or behavior |
|
||||
|-------|----------|-----------------------------|
|
||||
| `source` | Yes when `catalog` is present | `static`, `dynamic`, or `hybrid`. |
|
||||
| `models` | No | Static `[[catalog.models]]` entries. These are returned before discovered models. |
|
||||
| `discovery.kind` | No | `openai-compatible`, `ollama`, or `custom`; only OpenAI-compatible and Ollama discovery execute today. |
|
||||
| `discovery.path` | No | Discovery path appended to `baseURL`; defaults to `/models`. |
|
||||
| `discovery.requiresAuth` | No | `false` skips auth headers and cache key partitioning by API key. |
|
||||
| `discoveryCacheTtl` | No | Milliseconds as a number, or a string ending in `m`, `h`, or `d`; defaults to `1h`. |
|
||||
| `discoveryRefreshMode` | No | `manual`, `on-open`, `background-if-stale`, or `startup` metadata. |
|
||||
| `allowManualRefresh` | No | Metadata for UI refresh affordances. |
|
||||
|
||||
OpenAI-compatible discovery accepts either `{ data: [{ id: "..." }] }` or a raw
|
||||
array of model IDs/objects. Ollama discovery calls `/api/tags` on the base URL
|
||||
without the `/v1` suffix and reads `models[].name`.
|
||||
|
||||
## Usage And Presets
|
||||
|
||||
Use `usage` for usage-accounting metadata:
|
||||
|
||||
```toml
|
||||
[usage]
|
||||
supported = false
|
||||
silentlyIgnore = true
|
||||
```
|
||||
|
||||
| Usage field | Required | Accepted values or behavior |
|
||||
|-------------|----------|-----------------------------|
|
||||
| `supported` | Yes when `usage` is present | Whether usage accounting is expected for this provider. |
|
||||
| `silentlyIgnore` | No | Suppress usage-accounting noise when unsupported. |
|
||||
|
||||
Use `preset` for setup fallbacks and display metadata:
|
||||
|
||||
```toml
|
||||
[preset]
|
||||
id = "my-provider"
|
||||
description = "My Provider hosted models"
|
||||
fallbackBaseUrl = "https://api.example.com/v1"
|
||||
fallbackModel = "vendor/model-name"
|
||||
badge.text = "HOSTED"
|
||||
badge.color = "success"
|
||||
```
|
||||
|
||||
| Preset field | Required | Accepted values or behavior |
|
||||
|--------------|----------|-----------------------------|
|
||||
| `id` | Yes when `preset` is present | Stable preset ID. |
|
||||
| `description` | Yes when `preset` is present | Preset description. |
|
||||
| `label` | No | Optional UI label. |
|
||||
| `apiKeyEnvVars` | No | Normally omit this and use top-level `credentialEnvVars`. |
|
||||
| `fallbackBaseUrl` | No | Base URL used when config does not provide one. |
|
||||
| `fallbackModel` | No | Model used when config does not provide one. |
|
||||
| `badge.text` / `badge.color` | No | Optional display badge metadata. |
|
||||
+10
-6
@@ -7,24 +7,28 @@
|
||||
"zero": "src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "bun --preload @opentui/solid/preload src/index.ts",
|
||||
"dev": "bun run src/index.ts",
|
||||
"test": "bun test ./tests --timeout 15000",
|
||||
"build": "bun run scripts/build.ts"
|
||||
"build": "bun scripts/build.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
"@types/bun": "latest",
|
||||
"@types/react": "^19.2.15"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentui/core": "^0.2.16",
|
||||
"@opentui/solid": "^0.2.16",
|
||||
"commander": "^15.0.0",
|
||||
"execa": "^9.6.1",
|
||||
"ink": "^7.0.4",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"ink-text-input": "^6.0.0",
|
||||
"openai": "^6.39.1",
|
||||
"react": "^19.2.6",
|
||||
"react-devtools-core": "^7.0.1",
|
||||
"shiki": "^4.1.0",
|
||||
"solid-js": "1.9.12",
|
||||
"smol-toml": "^1.6.1",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
+25
-6
@@ -1,11 +1,30 @@
|
||||
#!/usr/bin/env bun
|
||||
import solidPlugin from '@opentui/solid/bun-plugin';
|
||||
import { Glob } from 'bun';
|
||||
import { mkdirSync } from 'fs';
|
||||
|
||||
const catalogFiles = [
|
||||
...new Glob('src/providers/catalog/definitions/*.toml').scanSync('.'),
|
||||
...new Glob('src/providers/catalog/models/*.toml').scanSync('.'),
|
||||
];
|
||||
|
||||
mkdirSync('.zero-build', { recursive: true });
|
||||
mkdirSync('dist', { recursive: true });
|
||||
|
||||
const catalogAssetEntrypoint = '.zero-build/provider-catalog-assets.ts';
|
||||
await Bun.write(
|
||||
catalogAssetEntrypoint,
|
||||
catalogFiles
|
||||
.map((file, index) => {
|
||||
const importPath = `../${file.replaceAll('\\', '/')}`;
|
||||
return `import catalogAsset${index} from '${importPath}' with { type: 'file' };\nvoid catalogAsset${index};`;
|
||||
})
|
||||
.join('\n')
|
||||
);
|
||||
|
||||
const result = await Bun.build({
|
||||
entrypoints: ['./src/index.ts'],
|
||||
target: 'bun',
|
||||
plugins: [solidPlugin],
|
||||
outdir: './dist',
|
||||
entrypoints: ['src/index.ts', catalogAssetEntrypoint],
|
||||
compile: {
|
||||
outfile: 'dist/zero',
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
+47
-57
@@ -1,8 +1,8 @@
|
||||
import type { Provider } from '../providers/types';
|
||||
import type { Provider, Message } from '../providers/types';
|
||||
import type { ToolCall, ToolResult } from '../tools/types';
|
||||
import { toolRegistry } from '../tools';
|
||||
import { DEFAULT_SYSTEM_PROMPT, PLAN_MODE_SYSTEM_PROMPT } from './prompts';
|
||||
import { clearPlan, getCurrentPlan, type PlanItem } from '../tools/plan';
|
||||
import { clearPlan } from '../tools/plan';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface AgentOptions {
|
||||
@@ -10,11 +10,9 @@ export interface AgentOptions {
|
||||
onText?: (text: string) => void;
|
||||
onToolCall?: (toolCall: ToolCall) => void;
|
||||
onToolResult?: (result: ToolResult) => void;
|
||||
onUsage?: (usage: { promptTokens: number; completionTokens: number }) => void;
|
||||
onPlanUpdate?: (plan: PlanItem[]) => void;
|
||||
toolsEnabled?: boolean;
|
||||
debug?: boolean;
|
||||
planMode?: boolean;
|
||||
toolsEnabled?: boolean; // allows temporarily disabling tool calling for debugging
|
||||
debug?: boolean; // when true, logs the exact payload sent to the provider
|
||||
planMode?: boolean; // when true, the agent plans without modifying the codebase
|
||||
}
|
||||
|
||||
interface PendingToolCall {
|
||||
@@ -28,25 +26,22 @@ export async function runAgent(
|
||||
provider: Provider,
|
||||
options: AgentOptions = {}
|
||||
): Promise<string> {
|
||||
const {
|
||||
maxTurns = 12,
|
||||
onText,
|
||||
onToolCall,
|
||||
const {
|
||||
maxTurns = 12,
|
||||
onText,
|
||||
onToolCall,
|
||||
onToolResult,
|
||||
onUsage,
|
||||
onPlanUpdate,
|
||||
toolsEnabled = true,
|
||||
debug = false,
|
||||
planMode = false,
|
||||
planMode = false
|
||||
} = options;
|
||||
|
||||
// Clear any previous plan when starting a new task
|
||||
clearPlan();
|
||||
onPlanUpdate?.(getCurrentPlan());
|
||||
|
||||
const systemPrompt = planMode ? PLAN_MODE_SYSTEM_PROMPT : DEFAULT_SYSTEM_PROMPT;
|
||||
|
||||
const messages: any[] = [
|
||||
const messages: Message[] = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: initialPrompt },
|
||||
];
|
||||
@@ -54,23 +49,28 @@ export async function runAgent(
|
||||
const tools = toolRegistry.getAll();
|
||||
let finalAnswer = '';
|
||||
|
||||
// Pre-compute tool definitions once (not every turn)
|
||||
const toolDefinitions = (toolsEnabled && tools.length > 0)
|
||||
? tools.map(t => {
|
||||
const jsonSchema = z.toJSONSchema(t.parameters, {
|
||||
target: 'draft-7',
|
||||
}) as any;
|
||||
|
||||
delete jsonSchema.$schema;
|
||||
|
||||
if (jsonSchema.type === 'object' && !('additionalProperties' in jsonSchema)) {
|
||||
jsonSchema.additionalProperties = false;
|
||||
}
|
||||
|
||||
return {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: jsonSchema,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
for (let turn = 0; turn < maxTurns; turn++) {
|
||||
const toolDefinitions = (toolsEnabled && tools.length > 0)
|
||||
? tools.map(t => {
|
||||
const jsonSchema = z.toJSONSchema(t.parameters, { target: 'draft-7' }) as any;
|
||||
delete jsonSchema.$schema;
|
||||
|
||||
if (jsonSchema.type === 'object' && !('additionalProperties' in jsonSchema)) {
|
||||
jsonSchema.additionalProperties = false;
|
||||
}
|
||||
|
||||
return {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: jsonSchema,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
let currentText = '';
|
||||
const toolCallMap = new Map<string, PendingToolCall>();
|
||||
@@ -78,21 +78,23 @@ export async function runAgent(
|
||||
if (debug) {
|
||||
const red = '\x1b[31m';
|
||||
const reset = '\x1b[0m';
|
||||
const border = '-'.repeat(50);
|
||||
const toolsList = toolDefinitions.map(t => t.name).join(', ');
|
||||
const preview = String(messages[messages.length - 1]?.content || '').slice(0, 45);
|
||||
const border = '─'.repeat(50);
|
||||
|
||||
console.log(`\n${red}+${border}+`);
|
||||
console.log(`| SENDING TO PROVIDER${' '.repeat(31)}|`);
|
||||
console.log(`+${border}+`);
|
||||
console.log(`| Messages: ${messages.length}${' '.repeat(Math.max(0, 40 - String(messages.length).length))}|`);
|
||||
console.log(`| Tools enabled: ${toolDefinitions.length > 0}${' '.repeat(33)}|`);
|
||||
console.log(`| Tool count: ${toolDefinitions.length}${' '.repeat(Math.max(0, 38 - String(toolDefinitions.length).length))}|`);
|
||||
if (toolsList) {
|
||||
console.log(`| Tools: ${toolsList.slice(0, 42)}${' '.repeat(Math.max(0, 43 - toolsList.length))}|`);
|
||||
console.log(`\n${red}┌${border}┐`);
|
||||
console.log(`│ SENDING TO PROVIDER${' '.repeat(31)}│`);
|
||||
console.log(`├${border}┤`);
|
||||
console.log(`│ Messages: ${messages.length}${' '.repeat(Math.max(0, 40 - String(messages.length).length))}│`);
|
||||
console.log(`│ Tools enabled: ${toolDefinitions.length > 0}${' '.repeat(Math.max(0, 33 - String(toolDefinitions.length > 0).length))}│`);
|
||||
console.log(`│ Tool count: ${toolDefinitions.length}${' '.repeat(Math.max(0, 38 - String(toolDefinitions.length).length))}│`);
|
||||
|
||||
if (toolDefinitions.length > 0) {
|
||||
const toolsList = toolDefinitions.map(t => t.name).join(', ');
|
||||
console.log(`│ Tools: ${toolsList.slice(0, 42)}${' '.repeat(Math.max(0, 43 - Math.min(42, toolsList.length)))}│`);
|
||||
}
|
||||
console.log(`| Last message: ${preview}${' '.repeat(Math.max(0, 36 - preview.length))}|`);
|
||||
console.log(`+${border}+${reset}\n`);
|
||||
|
||||
const preview = String(messages[messages.length-1]?.content || '').slice(0, 45);
|
||||
console.log(`│ Last message: ${preview}${' '.repeat(Math.max(0, 36 - preview.length))}│`);
|
||||
console.log(`└${border}┘${reset}\n`);
|
||||
}
|
||||
|
||||
// Stream the response
|
||||
@@ -102,13 +104,6 @@ export async function runAgent(
|
||||
if (onText) onText(event.content);
|
||||
}
|
||||
|
||||
if (event.type === 'usage') {
|
||||
onUsage?.({
|
||||
promptTokens: event.promptTokens,
|
||||
completionTokens: event.completionTokens,
|
||||
});
|
||||
}
|
||||
|
||||
if (event.type === 'tool-call-start') {
|
||||
toolCallMap.set(event.id, {
|
||||
id: event.id,
|
||||
@@ -185,11 +180,6 @@ export async function runAgent(
|
||||
onToolResult({ toolCallId: tc.id, result });
|
||||
}
|
||||
|
||||
const effects = tool?.onAfterExecute?.(result);
|
||||
if (effects?.planUpdated) {
|
||||
onPlanUpdate?.(getCurrentPlan());
|
||||
}
|
||||
|
||||
return { toolCallId: tc.id, result };
|
||||
});
|
||||
|
||||
|
||||
+2
-6
@@ -1,17 +1,13 @@
|
||||
import { loadProviderConfig } from '../config/provider';
|
||||
import { configManager } from '../config/manager';
|
||||
import { OpenAIProvider } from '../providers/openai';
|
||||
import { createProvider } from '../providers/factory';
|
||||
import { runAgent } from '../agent/loop';
|
||||
|
||||
export async function runHeadless(prompt: string) {
|
||||
const providerConfig = await loadProviderConfig();
|
||||
const activeProfile = configManager.getActiveProvider();
|
||||
|
||||
const provider = new OpenAIProvider({
|
||||
apiKey: providerConfig.apiKey || '',
|
||||
baseURL: providerConfig.baseURL,
|
||||
model: providerConfig.model,
|
||||
});
|
||||
const provider = createProvider(providerConfig);
|
||||
|
||||
const source = activeProfile
|
||||
? `profile: ${activeProfile.name}`
|
||||
|
||||
+16
-6
@@ -3,7 +3,7 @@ import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import type { ZeroConfig, ProviderProfile } from './types';
|
||||
|
||||
const CONFIG_DIR = join(homedir(), '.config', 'zero');
|
||||
const CONFIG_DIR = process.env.ZERO_CONFIG_DIR || join(homedir(), '.config', 'zero');
|
||||
const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
|
||||
|
||||
function ensureConfigDir() {
|
||||
@@ -43,6 +43,10 @@ export class ConfigManager {
|
||||
this.config = readConfig();
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
this.config = readConfig();
|
||||
}
|
||||
|
||||
getActiveProvider(): ProviderProfile | undefined {
|
||||
if (!this.config.activeProvider) return undefined;
|
||||
return this.config.providers.find(p => p.name === this.config.activeProvider);
|
||||
@@ -92,6 +96,7 @@ export class ConfigManager {
|
||||
|
||||
// Used by the agent loop
|
||||
getEffectiveProviderConfig(): {
|
||||
providerId?: string;
|
||||
baseURL: string;
|
||||
apiKey?: string;
|
||||
model: string;
|
||||
@@ -101,18 +106,23 @@ export class ConfigManager {
|
||||
const active = this.getActiveProvider();
|
||||
if (active) {
|
||||
return {
|
||||
providerId: active.providerId,
|
||||
baseURL: active.baseURL,
|
||||
apiKey: active.apiKey,
|
||||
model: active.model,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to env vars
|
||||
if (process.env.OPENAI_BASE_URL || process.env.OPENAI_MODEL) {
|
||||
// Fallback to env vars (match provider.ts behavior)
|
||||
const envApiKey = process.env.OPENAI_API_KEY;
|
||||
const envBaseURL = process.env.OPENAI_BASE_URL;
|
||||
const envModel = process.env.OPENAI_MODEL;
|
||||
|
||||
if (envApiKey || envBaseURL || envModel) {
|
||||
return {
|
||||
baseURL: process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1',
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
model: process.env.OPENAI_MODEL || 'gpt-4o',
|
||||
baseURL: envBaseURL || 'https://api.openai.com/v1',
|
||||
apiKey: envApiKey,
|
||||
model: envModel || 'gpt-4o',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+102
-9
@@ -1,11 +1,86 @@
|
||||
import { configManager } from './manager';
|
||||
import { getProviderDefinition } from '../providers/catalog';
|
||||
|
||||
export interface ProviderConfig {
|
||||
providerId?: string;
|
||||
apiKey?: string;
|
||||
baseURL: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export function resolveProviderCommandConfig(parsed: any): ProviderConfig {
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
throw new Error('Provider command must return a JSON object');
|
||||
}
|
||||
|
||||
const definition = parsed.provider_id ? getProviderDefinition(parsed.provider_id) : undefined;
|
||||
|
||||
return {
|
||||
providerId: parsed.provider_id,
|
||||
apiKey: parsed.api_key,
|
||||
baseURL: parsed.base_url || definition?.baseURL || 'https://api.openai.com/v1',
|
||||
model: parsed.model || definition?.defaultModel || 'gpt-4o',
|
||||
};
|
||||
}
|
||||
|
||||
export function parseProviderCommand(command: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let current = '';
|
||||
let quote: '"' | "'" | null = null;
|
||||
|
||||
for (let i = 0; i < command.length; i++) {
|
||||
const char = command[i];
|
||||
|
||||
if (quote) {
|
||||
if (char === quote) {
|
||||
quote = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\' && quote === '"') {
|
||||
const next = command[i + 1];
|
||||
if (next === '"' || next === '\\') {
|
||||
current += next;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
current += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/\s/.test(char)) {
|
||||
if (current) {
|
||||
parts.push(current);
|
||||
current = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
current += char;
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
throw new Error(`Unterminated ${quote} quote in provider command`);
|
||||
}
|
||||
|
||||
if (current) {
|
||||
parts.push(current);
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
throw new Error('Provider command is empty');
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the effective provider configuration.
|
||||
* Priority order:
|
||||
@@ -17,14 +92,32 @@ export async function loadProviderConfig(): Promise<ProviderConfig> {
|
||||
// 1. Highest priority: external provider command
|
||||
const providerCommand = process.env.ZERO_PROVIDER_COMMAND;
|
||||
if (providerCommand) {
|
||||
const { stdout } = await Bun.$`${providerCommand}`.quiet();
|
||||
const parsed = JSON.parse(stdout.toString());
|
||||
try {
|
||||
const parts = parseProviderCommand(providerCommand);
|
||||
const program = parts[0];
|
||||
const args = parts.slice(1);
|
||||
|
||||
return {
|
||||
apiKey: parsed.api_key,
|
||||
baseURL: parsed.base_url || 'https://api.openai.com/v1',
|
||||
model: parsed.model,
|
||||
};
|
||||
const proc = Bun.spawn([program, ...args], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const exitCode = await proc.exited;
|
||||
|
||||
if (exitCode !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
throw new Error(`Command exited with code ${exitCode}: ${stderr}`);
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(stdout);
|
||||
return resolveProviderCommandConfig(parsed);
|
||||
} catch (err: any) {
|
||||
throw new Error(
|
||||
`Failed to run ZERO_PROVIDER_COMMAND "${providerCommand}": ${err.message}\n` +
|
||||
'Expected JSON output with provider configuration.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Active profile from saved config
|
||||
@@ -38,8 +131,8 @@ export async function loadProviderConfig(): Promise<ProviderConfig> {
|
||||
const envBaseURL = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1';
|
||||
const envModel = process.env.OPENAI_MODEL || 'gpt-4o';
|
||||
|
||||
// If we have no API key and no provider command, give a helpful error
|
||||
if (!envApiKey && !process.env.ZERO_PROVIDER_COMMAND) {
|
||||
// If we have no API key, give a helpful error
|
||||
if (!envApiKey) {
|
||||
throw new Error(
|
||||
'No LLM provider configured.\n\n' +
|
||||
'Please run /provider to add one, or set OPENAI_API_KEY environment variable.'
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export interface ProviderProfile {
|
||||
name: string;
|
||||
providerId?: string;
|
||||
kind?: 'provider' | 'gateway' | 'localhost' | 'custom';
|
||||
baseURL: string;
|
||||
apiKey?: string;
|
||||
model: string;
|
||||
|
||||
+25
-10
@@ -1,21 +1,15 @@
|
||||
import { Command } from 'commander';
|
||||
import { ensureSolidTransformPlugin } from '@opentui/solid/bun-plugin';
|
||||
import packageJson from '../package.json';
|
||||
import { runHeadless } from './cli';
|
||||
import { configManager } from './config/manager';
|
||||
import { startTUI } from './tui';
|
||||
import { listProviderDefinitions } from './providers/catalog';
|
||||
|
||||
const program = new Command();
|
||||
|
||||
async function startInteractiveTUI() {
|
||||
ensureSolidTransformPlugin();
|
||||
const { startTUI } = await import('./tui');
|
||||
startTUI();
|
||||
}
|
||||
|
||||
program
|
||||
.name('zero')
|
||||
.description('A clean terminal AI coding agent')
|
||||
.version(packageJson.version);
|
||||
.version('0.1.0');
|
||||
|
||||
program
|
||||
.option('-p, --prompt <prompt>', 'Run in headless mode with the given prompt')
|
||||
@@ -24,7 +18,7 @@ program
|
||||
await runHeadless(options.prompt);
|
||||
} else {
|
||||
// Launch the interactive TUI (Grok Build style)
|
||||
await startInteractiveTUI();
|
||||
startTUI();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -81,4 +75,25 @@ providersCmd
|
||||
}
|
||||
});
|
||||
|
||||
providersCmd
|
||||
.command('catalog')
|
||||
.description('List built-in provider and gateway catalog entries')
|
||||
.action(() => {
|
||||
const catalog = listProviderDefinitions();
|
||||
|
||||
if (catalog.length === 0) {
|
||||
console.log('No catalog providers found.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\nProvider Catalog:\n');
|
||||
catalog.forEach((provider) => {
|
||||
console.log(` ${provider.name} (${provider.kind})`);
|
||||
console.log(` ID: ${provider.id}`);
|
||||
console.log(` Model: ${provider.defaultModel}`);
|
||||
console.log(` BaseURL: ${provider.baseURL}`);
|
||||
console.log('');
|
||||
});
|
||||
});
|
||||
|
||||
program.parse();
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { AuthHeaderConfig } from '../providers/catalog/types';
|
||||
|
||||
/**
|
||||
* Build auth headers from API key and auth header config.
|
||||
* Returns null values to suppress default headers.
|
||||
*/
|
||||
export function buildAuthHeaders(
|
||||
apiKey: string,
|
||||
authHeader?: AuthHeaderConfig
|
||||
): Record<string, string | null> {
|
||||
if (!authHeader) return {};
|
||||
if (!apiKey) {
|
||||
console.warn('[providers] Auth header configured but no API key provided');
|
||||
return {};
|
||||
}
|
||||
|
||||
const headerName = authHeader.name;
|
||||
const lowerName = headerName.toLowerCase();
|
||||
const headerValue = authHeader.scheme === 'raw' ? apiKey : `Bearer ${apiKey}`;
|
||||
|
||||
if (lowerName === 'authorization' && authHeader.scheme !== 'raw') {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
[headerName]: headerValue,
|
||||
...(lowerName === 'authorization' ? {} : { Authorization: null }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve header values, interpolating env vars.
|
||||
* Values starting with $ are resolved from process.env.
|
||||
* Null values are preserved (used to suppress default headers).
|
||||
*/
|
||||
export function resolveHeaders(headers: Record<string, string | null>): Record<string, string | null> {
|
||||
const resolved: Record<string, string | null> = {};
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
if (value == null) {
|
||||
resolved[name] = null;
|
||||
continue;
|
||||
}
|
||||
if (value.startsWith('$')) {
|
||||
const envVar = value.slice(1);
|
||||
const envValue = process.env[envVar];
|
||||
if (envValue) {
|
||||
resolved[name] = envValue;
|
||||
} else {
|
||||
console.warn(`[providers] Header "${name}" references unset env var $${envVar}`);
|
||||
}
|
||||
} else {
|
||||
resolved[name] = value;
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
# Groq - Fast inference gateway
|
||||
|
||||
id = "groq"
|
||||
name = "Groq"
|
||||
kind = "gateway"
|
||||
description = "Fast inference with LPU hardware"
|
||||
category = "hosted"
|
||||
baseURL = "https://api.groq.com/openai/v1"
|
||||
defaultModel = "llama-3.3-70b-versatile"
|
||||
apiKeyRequired = true
|
||||
credentialEnvVars = ["GROQ_API_KEY"]
|
||||
costCurrency = "USD"
|
||||
|
||||
[setup]
|
||||
requiresAuth = true
|
||||
authMode = "api-key"
|
||||
|
||||
[transportConfig]
|
||||
kind = "openai-compatible"
|
||||
maxTokensField = "max_completion_tokens"
|
||||
removeBodyFields = ["store"]
|
||||
|
||||
[catalog]
|
||||
source = "hybrid"
|
||||
discoveryCacheTtl = "30m"
|
||||
discoveryRefreshMode = "on-open"
|
||||
discovery.kind = "openai-compatible"
|
||||
discovery.requiresAuth = true
|
||||
|
||||
[[catalog.models]]
|
||||
id = "llama-3.3-70b-versatile"
|
||||
name = "Llama 3.3 70B"
|
||||
tier = "hosted"
|
||||
contextWindow = 131072
|
||||
maxOutputTokens = 32768
|
||||
capabilities.supportsFunctionCalling = true
|
||||
capabilities.supportsStreaming = true
|
||||
capabilities.supportsJsonMode = true
|
||||
capabilities.supportsReasoning = false
|
||||
cost.inputPerMillion = 0.59
|
||||
cost.outputPerMillion = 0.79
|
||||
|
||||
[[catalog.models]]
|
||||
id = "llama-3.1-8b-instant"
|
||||
name = "Llama 3.1 8B Instant"
|
||||
tier = "hosted"
|
||||
contextWindow = 131072
|
||||
maxOutputTokens = 8192
|
||||
capabilities.supportsFunctionCalling = true
|
||||
capabilities.supportsStreaming = true
|
||||
capabilities.supportsJsonMode = true
|
||||
capabilities.supportsReasoning = false
|
||||
cost.inputPerMillion = 0.05
|
||||
cost.outputPerMillion = 0.08
|
||||
|
||||
[[catalog.models]]
|
||||
globalModelId = "deepseek-v3"
|
||||
apiName = "deepseek-r1-distill-llama-70b"
|
||||
contextWindow = 131072
|
||||
maxOutputTokens = 16384
|
||||
capabilities.supportsReasoning = true
|
||||
cost.inputPerMillion = 0.75
|
||||
cost.outputPerMillion = 0.99
|
||||
|
||||
[preset]
|
||||
id = "groq"
|
||||
description = "Groq fast inference"
|
||||
fallbackBaseUrl = "https://api.groq.com/openai/v1"
|
||||
fallbackModel = "llama-3.3-70b-versatile"
|
||||
@@ -0,0 +1,41 @@
|
||||
# Ollama - Local models via Ollama
|
||||
# No auth required, auto-detectable on localhost
|
||||
|
||||
id = "ollama"
|
||||
name = "Ollama"
|
||||
kind = "localhost"
|
||||
description = "Local models via Ollama"
|
||||
category = "local"
|
||||
baseURL = "http://localhost:11434/v1"
|
||||
defaultModel = "llama3.2"
|
||||
apiKeyRequired = false
|
||||
|
||||
[setup]
|
||||
requiresAuth = false
|
||||
authMode = "none"
|
||||
|
||||
[startup]
|
||||
autoDetectable = true
|
||||
probeReadiness = "openai-compatible-models"
|
||||
|
||||
[transportConfig]
|
||||
kind = "openai-compatible"
|
||||
|
||||
[catalog]
|
||||
source = "dynamic"
|
||||
discoveryCacheTtl = "5m"
|
||||
discoveryRefreshMode = "on-open"
|
||||
allowManualRefresh = true
|
||||
# Ollama models are discovered dynamically from /api/tags
|
||||
discovery.kind = "ollama"
|
||||
discovery.requiresAuth = false
|
||||
|
||||
[usage]
|
||||
supported = false
|
||||
silentlyIgnore = true
|
||||
|
||||
[preset]
|
||||
id = "ollama"
|
||||
description = "Local Ollama models"
|
||||
fallbackBaseUrl = "http://localhost:11434/v1"
|
||||
fallbackModel = "llama3.2"
|
||||
@@ -0,0 +1,50 @@
|
||||
# OpenAI - Direct API access
|
||||
# First-party provider: auto-exposes all global models
|
||||
|
||||
id = "openai"
|
||||
name = "OpenAI"
|
||||
kind = "provider"
|
||||
description = "OpenAI direct API"
|
||||
category = "hosted"
|
||||
baseURL = "https://api.openai.com/v1"
|
||||
defaultModel = "gpt-4o"
|
||||
isFirstParty = true
|
||||
apiKeyRequired = true
|
||||
credentialEnvVars = ["OPENAI_API_KEY"]
|
||||
costCurrency = "USD"
|
||||
|
||||
[setup]
|
||||
requiresAuth = true
|
||||
authMode = "api-key"
|
||||
|
||||
[transportConfig]
|
||||
kind = "openai-compatible"
|
||||
|
||||
[catalog]
|
||||
source = "hybrid"
|
||||
discoveryCacheTtl = "1h"
|
||||
discoveryRefreshMode = "manual"
|
||||
allowManualRefresh = true
|
||||
discovery.kind = "openai-compatible"
|
||||
discovery.requiresAuth = true
|
||||
discovery.path = "/models"
|
||||
|
||||
[[catalog.models]]
|
||||
globalModelId = "gpt-4o"
|
||||
cost.inputPerMillion = 2.50
|
||||
cost.outputPerMillion = 10.00
|
||||
|
||||
[[catalog.models]]
|
||||
globalModelId = "gpt-4o-mini"
|
||||
cost.inputPerMillion = 0.15
|
||||
cost.outputPerMillion = 0.60
|
||||
|
||||
[validation]
|
||||
kind = "credential-env"
|
||||
missingCredentialMessage = "An OpenAI API key is required."
|
||||
|
||||
[preset]
|
||||
id = "openai"
|
||||
description = "OpenAI direct API"
|
||||
fallbackBaseUrl = "https://api.openai.com/v1"
|
||||
fallbackModel = "gpt-4o"
|
||||
@@ -0,0 +1,63 @@
|
||||
# OpenGateway - OpenAI-compatible gateway hosted by Gitlawb
|
||||
|
||||
id = "opengateway"
|
||||
name = "OpenGateway"
|
||||
kind = "gateway"
|
||||
description = "OpenAI-compatible gateway hosted by Gitlawb"
|
||||
category = "aggregating"
|
||||
baseURL = "https://opengateway.gitlawb.com/v1"
|
||||
defaultModel = "mimo-v2.5-pro"
|
||||
supportsModelRouting = true
|
||||
apiKeyLabel = "OpenGateway API key"
|
||||
apiKeyPlaceholder = "ogw_live_..."
|
||||
apiKeyRequired = true
|
||||
credentialEnvVars = ["OPENGATEWAY_API_KEY"]
|
||||
|
||||
[setup]
|
||||
requiresAuth = true
|
||||
authMode = "api-key"
|
||||
|
||||
[validation]
|
||||
kind = "credential-env"
|
||||
missingCredentialMessage = "An OpenGateway API key is required."
|
||||
matchBaseUrlHosts = ["opengateway.gitlawb.com", "opengateway.fly.dev"]
|
||||
|
||||
[transportConfig]
|
||||
kind = "openai-compatible"
|
||||
maxTokensField = "max_completion_tokens"
|
||||
removeBodyFields = ["store", "stream_options"]
|
||||
authHeader.name = "authorization"
|
||||
authHeader.scheme = "bearer"
|
||||
|
||||
[catalog]
|
||||
source = "hybrid"
|
||||
discoveryCacheTtl = "1h"
|
||||
discoveryRefreshMode = "manual"
|
||||
allowManualRefresh = true
|
||||
discovery.kind = "openai-compatible"
|
||||
discovery.requiresAuth = true
|
||||
discovery.path = "/models"
|
||||
|
||||
# Native model to this gateway
|
||||
[[catalog.models]]
|
||||
id = "mimo-v2.5-pro"
|
||||
apiName = "mimo-v2.5-pro"
|
||||
name = "Mimo v2.5 Pro"
|
||||
tier = "hosted"
|
||||
|
||||
# Global model with reduced limits
|
||||
[[catalog.models]]
|
||||
globalModelId = "gpt-4o"
|
||||
contextWindow = 32000
|
||||
maxOutputTokens = 4096
|
||||
|
||||
[usage]
|
||||
supported = false
|
||||
|
||||
[preset]
|
||||
id = "opengateway"
|
||||
description = "OpenGateway hosted models"
|
||||
fallbackBaseUrl = "https://opengateway.gitlawb.com/v1"
|
||||
fallbackModel = "mimo-v2.5-pro"
|
||||
badge.text = "FREE"
|
||||
badge.color = "success"
|
||||
@@ -0,0 +1,62 @@
|
||||
# OpenRouter - Multi-provider gateway
|
||||
|
||||
id = "openrouter"
|
||||
name = "OpenRouter"
|
||||
kind = "gateway"
|
||||
description = "Multi-provider gateway with hundreds of models"
|
||||
category = "aggregating"
|
||||
baseURL = "https://openrouter.ai/api/v1"
|
||||
defaultModel = "openai/gpt-4o"
|
||||
apiKeyRequired = true
|
||||
credentialEnvVars = ["OPENROUTER_API_KEY"]
|
||||
costCurrency = "USD"
|
||||
|
||||
[setup]
|
||||
requiresAuth = true
|
||||
authMode = "api-key"
|
||||
|
||||
[transportConfig]
|
||||
kind = "openai-compatible"
|
||||
# Optional attribution headers
|
||||
headers.HTTP-Referer = "$OPENROUTER_SITE_URL"
|
||||
headers.X-OpenRouter-Title = "$OPENROUTER_SITE_NAME"
|
||||
|
||||
[catalog]
|
||||
source = "hybrid"
|
||||
discoveryCacheTtl = "1h"
|
||||
discoveryRefreshMode = "manual"
|
||||
# OpenRouter uses /api/v1/models (full path from baseURL)
|
||||
discovery.kind = "openai-compatible"
|
||||
discovery.requiresAuth = true
|
||||
discovery.path = "/models"
|
||||
|
||||
# Curated static entries shown first
|
||||
[[catalog.models]]
|
||||
globalModelId = "gpt-4o"
|
||||
apiName = "openai/gpt-4o"
|
||||
contextWindow = 128000
|
||||
maxOutputTokens = 16384
|
||||
cost.inputPerMillion = 2.50
|
||||
cost.outputPerMillion = 10.00
|
||||
|
||||
[[catalog.models]]
|
||||
globalModelId = "claude-sonnet-4"
|
||||
apiName = "anthropic/claude-sonnet-4"
|
||||
contextWindow = 200000
|
||||
maxOutputTokens = 64000
|
||||
cost.inputPerMillion = 3.00
|
||||
cost.outputPerMillion = 15.00
|
||||
|
||||
[[catalog.models]]
|
||||
globalModelId = "deepseek-v3"
|
||||
apiName = "deepseek/deepseek-chat"
|
||||
contextWindow = 65536
|
||||
maxOutputTokens = 8192
|
||||
cost.inputPerMillion = 0.14
|
||||
cost.outputPerMillion = 0.28
|
||||
|
||||
[preset]
|
||||
id = "openrouter"
|
||||
description = "OpenRouter multi-provider gateway"
|
||||
fallbackBaseUrl = "https://openrouter.ai/api/v1"
|
||||
fallbackModel = "openai/gpt-4o"
|
||||
@@ -0,0 +1,358 @@
|
||||
import { existsSync, readdirSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { parse } from 'smol-toml';
|
||||
import type {
|
||||
ModelDefinition,
|
||||
ProviderModelDefinition,
|
||||
ProviderDefinition,
|
||||
ResolvedModelDefinition,
|
||||
TransportConfig,
|
||||
} from './types';
|
||||
|
||||
type EmbeddedFile = Blob & { name: string };
|
||||
|
||||
function parseTomlFile(content: string, directory: 'definitions' | 'models'): unknown[] {
|
||||
const parsed = parse(content);
|
||||
|
||||
if (directory === 'definitions') {
|
||||
// Each definition file is a single object
|
||||
return [parsed];
|
||||
}
|
||||
|
||||
// Model files use [[model]] array-of-tables syntax.
|
||||
// Parsed result is { model: [...] }. Extract the array.
|
||||
if (parsed && typeof parsed === 'object' && 'model' in parsed) {
|
||||
const models = (parsed as any).model;
|
||||
return Array.isArray(models) ? models : [models];
|
||||
}
|
||||
|
||||
return [parsed];
|
||||
}
|
||||
|
||||
async function readTomlFiles<T>(directory: 'definitions' | 'models'): Promise<T[]> {
|
||||
const directoryPath = join(import.meta.dir, directory);
|
||||
let items: unknown[] = [];
|
||||
|
||||
if (existsSync(directoryPath)) {
|
||||
items = readdirSync(directoryPath)
|
||||
.filter((file) => file.endsWith('.toml'))
|
||||
.flatMap((file) => parseTomlFile(readFileSync(join(directoryPath, file), 'utf-8'), directory));
|
||||
} else {
|
||||
// Fallback for bundled/embedded files
|
||||
const embeddedFiles = (globalThis as any).Bun?.embeddedFiles as EmbeddedFile[] | undefined;
|
||||
if (!embeddedFiles) {
|
||||
console.warn(`[providers] No ${directory} directory and no embedded files found`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const tomlFiles = embeddedFiles.filter((file) => file.name.endsWith('.toml'));
|
||||
if (tomlFiles.length === 0) {
|
||||
console.warn(`[providers] No .toml files found in embedded files for ${directory}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
items = (await Promise.all(
|
||||
tomlFiles.map(async (file) => parseTomlFile(await file.text(), directory))
|
||||
)).flat();
|
||||
}
|
||||
|
||||
if (directory === 'definitions') {
|
||||
return items.filter(isProviderDefinition) as T[];
|
||||
}
|
||||
|
||||
return items.filter(isModelDefinition) as T[];
|
||||
}
|
||||
|
||||
function isProviderDefinition(value: any): value is ProviderDefinition {
|
||||
return (
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
typeof value.id === 'string' &&
|
||||
typeof value.name === 'string' &&
|
||||
(value.kind === 'provider' || value.kind === 'gateway' || value.kind === 'localhost') &&
|
||||
typeof value.baseURL === 'string' &&
|
||||
typeof value.defaultModel === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function isModelDefinition(value: any): value is ModelDefinition {
|
||||
return (
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
typeof value.id === 'string' &&
|
||||
// Positive: model definitions have these fields
|
||||
!('baseURL' in value) &&
|
||||
!('defaultModel' in value) &&
|
||||
// Negative: provider definitions have 'kind' as a string enum
|
||||
// Models don't have 'kind' at the top level
|
||||
!('kind' in value && typeof value.kind === 'string' &&
|
||||
['provider', 'gateway', 'localhost'].includes(value.kind))
|
||||
);
|
||||
}
|
||||
|
||||
function validateModelDefinition(model: ModelDefinition): void {
|
||||
if ('cost' in model) {
|
||||
console.warn(`[providers] Global model "${model.id}": cost belongs on provider catalog models, not global models`);
|
||||
}
|
||||
if (model.contextWindow != null && typeof model.contextWindow !== 'number') {
|
||||
console.warn(`[providers] Model "${model.id}": contextWindow should be a number, got ${typeof model.contextWindow}`);
|
||||
}
|
||||
if (model.maxOutputTokens != null && typeof model.maxOutputTokens !== 'number') {
|
||||
console.warn(`[providers] Model "${model.id}": maxOutputTokens should be a number, got ${typeof model.maxOutputTokens}`);
|
||||
}
|
||||
if (model.defaultTemperature != null && typeof model.defaultTemperature !== 'number') {
|
||||
console.warn(`[providers] Model "${model.id}": defaultTemperature should be a number, got ${typeof model.defaultTemperature}`);
|
||||
}
|
||||
if (model.capabilities != null && typeof model.capabilities !== 'object') {
|
||||
console.warn(`[providers] Model "${model.id}": capabilities should be an object, got ${typeof model.capabilities}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateProviderModelDefinition(model: ProviderModelDefinition, providerId: string): void {
|
||||
if (model.cost != null) {
|
||||
if (typeof model.cost !== 'object') {
|
||||
console.warn(`[providers] Provider "${providerId}" model "${model.id ?? model.globalModelId}": cost should be an object, got ${typeof model.cost}`);
|
||||
} else {
|
||||
if (typeof model.cost.inputPerMillion !== 'number') {
|
||||
console.warn(`[providers] Provider "${providerId}" model "${model.id ?? model.globalModelId}": cost.inputPerMillion should be a number`);
|
||||
}
|
||||
if (typeof model.cost.outputPerMillion !== 'number') {
|
||||
console.warn(`[providers] Provider "${providerId}" model "${model.id ?? model.globalModelId}": cost.outputPerMillion should be a number`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a model entry that references a global model via globalModelId.
|
||||
* The global model's fields are used as base; the entry's explicit fields override.
|
||||
*/
|
||||
function resolveGlobalModelReference(
|
||||
model: ProviderModelDefinition,
|
||||
globalModelById: Map<string, ModelDefinition>
|
||||
): ProviderModelDefinition {
|
||||
if (!model.globalModelId) return model;
|
||||
|
||||
const globalModel = globalModelById.get(model.globalModelId);
|
||||
if (!globalModel) return model;
|
||||
|
||||
return {
|
||||
...globalModel,
|
||||
...model,
|
||||
// Only override id/apiName/name if the entry explicitly sets them
|
||||
id: model.id ?? globalModel.id,
|
||||
apiName: model.apiName ?? model.id ?? globalModel.apiName ?? globalModel.id,
|
||||
name: model.name ?? globalModel.name,
|
||||
tier: model.tier ?? globalModel.tier,
|
||||
description: model.description ?? globalModel.description,
|
||||
classification: model.classification ?? globalModel.classification,
|
||||
capabilities: {
|
||||
...globalModel.capabilities,
|
||||
...model.capabilities,
|
||||
},
|
||||
transportOverrides: model.transportOverrides ?? globalModel.transportOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a definition with defaults, and resolve vendorId links.
|
||||
*/
|
||||
function normalizeDefinition(
|
||||
definition: ProviderDefinition,
|
||||
definitionById: Map<string, ProviderDefinition>
|
||||
): ProviderDefinition {
|
||||
const credentialEnvVars =
|
||||
definition.credentialEnvVars ??
|
||||
definition.setup?.credentialEnvVars ??
|
||||
definition.validation?.credentialEnvVars ??
|
||||
definition.preset?.apiKeyEnvVars;
|
||||
|
||||
const setup = {
|
||||
...(definition.setup ?? {
|
||||
requiresAuth: definition.apiKeyRequired !== false,
|
||||
authMode: definition.apiKeyRequired === false ? 'none' : 'api-key',
|
||||
} as const),
|
||||
credentialEnvVars: definition.setup?.credentialEnvVars ?? credentialEnvVars,
|
||||
};
|
||||
|
||||
const validation = definition.validation
|
||||
? {
|
||||
...definition.validation,
|
||||
credentialEnvVars: definition.validation.credentialEnvVars ?? credentialEnvVars,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const preset = definition.preset
|
||||
? {
|
||||
...definition.preset,
|
||||
apiKeyEnvVars: definition.preset.apiKeyEnvVars ?? credentialEnvVars,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const catalog = definition.catalog ?? {
|
||||
source: definition.models ? 'static' : 'dynamic',
|
||||
models: definition.models,
|
||||
} as const;
|
||||
|
||||
// Resolve vendorId: inherit transport config from linked vendor
|
||||
let transportConfig = definition.transportConfig;
|
||||
if (definition.vendorId && !transportConfig) {
|
||||
const vendor = definitionById.get(definition.vendorId);
|
||||
if (vendor?.transportConfig) {
|
||||
transportConfig = vendor.transportConfig;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...definition,
|
||||
setup,
|
||||
validation,
|
||||
preset,
|
||||
transportConfig: transportConfig ?? {
|
||||
kind: 'openai-compatible',
|
||||
},
|
||||
catalog,
|
||||
apiKeyRequired: definition.apiKeyRequired ?? setup.requiresAuth,
|
||||
credentialEnvVars,
|
||||
models: definition.models ?? catalog.models,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveModel(
|
||||
model: ProviderModelDefinition,
|
||||
definition?: ProviderDefinition
|
||||
): ResolvedModelDefinition {
|
||||
return {
|
||||
...model,
|
||||
apiName: model.apiName ?? model.id,
|
||||
providerId: definition?.id,
|
||||
providerName: definition?.name,
|
||||
providerKind: definition?.kind,
|
||||
};
|
||||
}
|
||||
|
||||
// First pass: raw definitions for vendorId resolution
|
||||
const rawDefinitions = (await readTomlFiles<ProviderDefinition>('definitions'))
|
||||
.filter((definition): definition is ProviderDefinition => Boolean(definition));
|
||||
|
||||
const rawDefinitionById = new Map(rawDefinitions.map((d) => [d.id, d]));
|
||||
|
||||
// Second pass: normalize with vendorId resolution
|
||||
const definitions = rawDefinitions
|
||||
.map((definition) => normalizeDefinition(definition, rawDefinitionById))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const globalModels = (await readTomlFiles<ModelDefinition>('models'))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
// Validate parsed models
|
||||
for (const model of globalModels) {
|
||||
validateModelDefinition(model);
|
||||
}
|
||||
|
||||
for (const definition of definitions) {
|
||||
for (const model of definition.catalog?.models ?? definition.models ?? []) {
|
||||
validateProviderModelDefinition(model, definition.id);
|
||||
}
|
||||
}
|
||||
|
||||
const definitionById = new Map(definitions.map((definition) => [definition.id, definition]));
|
||||
const globalModelById = new Map(globalModels.map((model) => [model.id, model]));
|
||||
|
||||
export function listProviderDefinitions(): ProviderDefinition[] {
|
||||
return [...definitions];
|
||||
}
|
||||
|
||||
export function getProviderDefinition(id: string): ProviderDefinition | undefined {
|
||||
return definitionById.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resolved models for a specific provider definition.
|
||||
*/
|
||||
export function getModelsForProvider(definition: ProviderDefinition): ResolvedModelDefinition[] {
|
||||
const catalogModels = definition.catalog?.models ?? definition.models ?? [];
|
||||
|
||||
const resolvedCatalogModels = catalogModels.map((model) =>
|
||||
resolveGlobalModelReference(model, globalModelById)
|
||||
);
|
||||
|
||||
if (definition.isFirstParty) {
|
||||
const catalogApiNames = new Set(
|
||||
resolvedCatalogModels.map((m) => (m.apiName ?? m.id).toLowerCase())
|
||||
);
|
||||
const additionalGlobals = globalModels.filter(
|
||||
(m) => m.ownerProviderId === definition.id && !catalogApiNames.has(m.id.toLowerCase())
|
||||
);
|
||||
return [
|
||||
...resolvedCatalogModels.map((m) => resolveModel(m, definition)),
|
||||
...additionalGlobals.map((m) => resolveModel(m, definition)),
|
||||
];
|
||||
}
|
||||
|
||||
return resolvedCatalogModels.map((m) => resolveModel(m, definition));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective transport config for a specific model within a provider.
|
||||
* Definition-level config is the base; per-model overrides are merged on top.
|
||||
* removeBodyFields is unioned (not replaced).
|
||||
*/
|
||||
export function resolveEffectiveTransportConfig(
|
||||
definition: ProviderDefinition,
|
||||
model?: ProviderModelDefinition
|
||||
): TransportConfig | undefined {
|
||||
const base = definition.transportConfig;
|
||||
if (!base) return undefined;
|
||||
if (!model?.transportOverrides) return base;
|
||||
|
||||
const overrides = model.transportOverrides;
|
||||
return {
|
||||
...base,
|
||||
...overrides,
|
||||
headers: {
|
||||
...(base.headers ?? {}),
|
||||
...(overrides.headers ?? {}),
|
||||
},
|
||||
removeBodyFields: [
|
||||
...new Set([
|
||||
...(base.removeBodyFields ?? []),
|
||||
...(overrides.removeBodyFields ?? []),
|
||||
]),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function listModelDefinitions(): ResolvedModelDefinition[] {
|
||||
const routeModels = definitions.flatMap((definition) =>
|
||||
getModelsForProvider(definition)
|
||||
);
|
||||
|
||||
// Deduplicate by apiName, preferring provider-attributed versions
|
||||
const allModels = [...globalModels.map((model) => resolveModel(model)), ...routeModels];
|
||||
const seen = new Map<string, ResolvedModelDefinition>();
|
||||
for (const model of allModels) {
|
||||
const key = model.apiName.toLowerCase();
|
||||
// Prefer versions with providerId over bare global models
|
||||
if (!seen.has(key) || (model.providerId && !seen.get(key)?.providerId)) {
|
||||
seen.set(key, model);
|
||||
}
|
||||
}
|
||||
return [...seen.values()];
|
||||
}
|
||||
|
||||
export function getGlobalModelDefinition(id: string): ModelDefinition | undefined {
|
||||
return globalModelById.get(id);
|
||||
}
|
||||
|
||||
export function listGlobalModels(): ModelDefinition[] {
|
||||
return [...globalModels];
|
||||
}
|
||||
|
||||
export function listProvidersByKind(kind: ProviderDefinition['kind']): ProviderDefinition[] {
|
||||
return definitions.filter((d) => d.kind === kind);
|
||||
}
|
||||
|
||||
export function listFirstPartyProviders(): ProviderDefinition[] {
|
||||
return definitions.filter((d) => d.isFirstParty === true);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# Anthropic Claude models
|
||||
|
||||
[[model]]
|
||||
id = "claude-sonnet-4"
|
||||
name = "Claude Sonnet 4"
|
||||
ownerProviderId = "anthropic"
|
||||
tier = "first-party"
|
||||
description = "Anthropic balanced model"
|
||||
classification = ["chat", "vision", "coding"]
|
||||
contextWindow = 200000
|
||||
maxOutputTokens = 64000
|
||||
capabilities.supportsVision = true
|
||||
capabilities.supportsStreaming = true
|
||||
capabilities.supportsFunctionCalling = true
|
||||
capabilities.supportsJsonMode = true
|
||||
capabilities.supportsReasoning = true
|
||||
@@ -0,0 +1,16 @@
|
||||
# DeepSeek models
|
||||
|
||||
[[model]]
|
||||
id = "deepseek-v3"
|
||||
name = "DeepSeek V3"
|
||||
ownerProviderId = "deepseek"
|
||||
tier = "first-party"
|
||||
description = "DeepSeek general model"
|
||||
classification = ["chat", "coding"]
|
||||
contextWindow = 65536
|
||||
maxOutputTokens = 8192
|
||||
capabilities.supportsVision = false
|
||||
capabilities.supportsStreaming = true
|
||||
capabilities.supportsFunctionCalling = true
|
||||
capabilities.supportsJsonMode = true
|
||||
capabilities.supportsReasoning = false
|
||||
@@ -0,0 +1,31 @@
|
||||
# Google Gemini models
|
||||
|
||||
[[model]]
|
||||
id = "gemini-2.5-pro"
|
||||
name = "Gemini 2.5 Pro"
|
||||
ownerProviderId = "google"
|
||||
tier = "first-party"
|
||||
description = "Google flagship model"
|
||||
classification = ["chat", "vision", "coding"]
|
||||
contextWindow = 1048576
|
||||
maxOutputTokens = 65536
|
||||
capabilities.supportsVision = true
|
||||
capabilities.supportsStreaming = true
|
||||
capabilities.supportsFunctionCalling = true
|
||||
capabilities.supportsJsonMode = true
|
||||
capabilities.supportsReasoning = true
|
||||
|
||||
[[model]]
|
||||
id = "gemini-2.5-flash"
|
||||
name = "Gemini 2.5 Flash"
|
||||
ownerProviderId = "google"
|
||||
tier = "first-party"
|
||||
description = "Google fast model"
|
||||
classification = ["chat", "vision"]
|
||||
contextWindow = 1048576
|
||||
maxOutputTokens = 65536
|
||||
capabilities.supportsVision = true
|
||||
capabilities.supportsStreaming = true
|
||||
capabilities.supportsFunctionCalling = true
|
||||
capabilities.supportsJsonMode = true
|
||||
capabilities.supportsReasoning = false
|
||||
@@ -0,0 +1,31 @@
|
||||
# OpenAI GPT models
|
||||
|
||||
[[model]]
|
||||
id = "gpt-4o"
|
||||
name = "GPT-4o"
|
||||
ownerProviderId = "openai"
|
||||
tier = "first-party"
|
||||
description = "OpenAI flagship multimodal model"
|
||||
classification = ["chat", "vision", "coding"]
|
||||
contextWindow = 128000
|
||||
maxOutputTokens = 16384
|
||||
capabilities.supportsVision = true
|
||||
capabilities.supportsStreaming = true
|
||||
capabilities.supportsFunctionCalling = true
|
||||
capabilities.supportsJsonMode = true
|
||||
capabilities.supportsReasoning = false
|
||||
|
||||
[[model]]
|
||||
id = "gpt-4o-mini"
|
||||
name = "GPT-4o Mini"
|
||||
ownerProviderId = "openai"
|
||||
tier = "first-party"
|
||||
description = "OpenAI small multimodal model"
|
||||
classification = ["chat", "vision"]
|
||||
contextWindow = 128000
|
||||
maxOutputTokens = 16384
|
||||
capabilities.supportsVision = true
|
||||
capabilities.supportsStreaming = true
|
||||
capabilities.supportsFunctionCalling = true
|
||||
capabilities.supportsJsonMode = true
|
||||
capabilities.supportsReasoning = false
|
||||
@@ -0,0 +1,31 @@
|
||||
# OpenAI reasoning models
|
||||
|
||||
[[model]]
|
||||
id = "o1"
|
||||
name = "o1"
|
||||
ownerProviderId = "openai"
|
||||
tier = "first-party"
|
||||
description = "OpenAI reasoning model"
|
||||
classification = ["chat", "reasoning", "coding"]
|
||||
contextWindow = 200000
|
||||
maxOutputTokens = 100000
|
||||
capabilities.supportsVision = false
|
||||
capabilities.supportsStreaming = true
|
||||
capabilities.supportsFunctionCalling = true
|
||||
capabilities.supportsJsonMode = true
|
||||
capabilities.supportsReasoning = true
|
||||
|
||||
[[model]]
|
||||
id = "o1-mini"
|
||||
name = "o1-mini"
|
||||
ownerProviderId = "openai"
|
||||
tier = "first-party"
|
||||
description = "OpenAI small reasoning model"
|
||||
classification = ["chat", "reasoning"]
|
||||
contextWindow = 128000
|
||||
maxOutputTokens = 65536
|
||||
capabilities.supportsVision = false
|
||||
capabilities.supportsStreaming = true
|
||||
capabilities.supportsFunctionCalling = true
|
||||
capabilities.supportsJsonMode = true
|
||||
capabilities.supportsReasoning = true
|
||||
@@ -0,0 +1,166 @@
|
||||
export type ProviderKind = 'provider' | 'gateway' | 'localhost';
|
||||
export type AuthMode = 'api-key' | 'oauth' | 'adc' | 'token' | 'none';
|
||||
export type TransportKind =
|
||||
| 'openai-compatible'
|
||||
| 'anthropic-compatible';
|
||||
export type ModelCatalogSource = 'static' | 'dynamic' | 'hybrid';
|
||||
export type DiscoveryRefreshMode = 'manual' | 'on-open' | 'background-if-stale' | 'startup';
|
||||
export type ModelDiscoveryKind = 'openai-compatible' | 'ollama' | 'custom';
|
||||
export type MaxTokensField = 'max_tokens' | 'max_completion_tokens';
|
||||
|
||||
export interface ModelDefinition {
|
||||
id: string;
|
||||
name?: string;
|
||||
apiName?: string;
|
||||
label?: string;
|
||||
default?: boolean;
|
||||
hidden?: boolean;
|
||||
globalModelId?: string;
|
||||
ownerProviderId?: string;
|
||||
classification?: ('chat' | 'reasoning' | 'vision' | 'coding')[];
|
||||
tier?: 'first-party' | 'hosted' | 'local' | 'community';
|
||||
description?: string;
|
||||
capabilities?: CapabilityFlags;
|
||||
contextWindow?: number;
|
||||
maxOutputTokens?: number;
|
||||
defaultTemperature?: number;
|
||||
temperatureRange?: TemperatureRange;
|
||||
transportOverrides?: Partial<TransportConfig>;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface ProviderModelDefinition extends ModelDefinition {
|
||||
cost?: ModelCost;
|
||||
}
|
||||
|
||||
export interface CapabilityFlags {
|
||||
supportsVision?: boolean;
|
||||
supportsStreaming?: boolean;
|
||||
supportsFunctionCalling?: boolean;
|
||||
supportsJsonMode?: boolean;
|
||||
supportsReasoning?: boolean;
|
||||
supportsPreciseTokenCount?: boolean;
|
||||
supportsEmbeddings?: boolean;
|
||||
supportsTemperature?: boolean;
|
||||
}
|
||||
|
||||
export interface TemperatureRange {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
export interface ModelCost {
|
||||
inputPerMillion: number;
|
||||
outputPerMillion: number;
|
||||
cachePerMillion?: number;
|
||||
}
|
||||
|
||||
export interface AuthHeaderConfig {
|
||||
name: string;
|
||||
scheme?: 'bearer' | 'raw';
|
||||
}
|
||||
|
||||
export interface TransportConfig {
|
||||
kind: TransportKind;
|
||||
headers?: Record<string, string>;
|
||||
authHeader?: AuthHeaderConfig;
|
||||
maxTokensField?: MaxTokensField;
|
||||
removeBodyFields?: string[];
|
||||
endpointPath?: string;
|
||||
}
|
||||
|
||||
export interface ModelDiscoveryConfig {
|
||||
kind: ModelDiscoveryKind;
|
||||
requiresAuth?: boolean;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface ModelCatalogConfig {
|
||||
source: ModelCatalogSource;
|
||||
discovery?: ModelDiscoveryConfig;
|
||||
discoveryCacheTtl?: string | number;
|
||||
discoveryRefreshMode?: DiscoveryRefreshMode;
|
||||
allowManualRefresh?: boolean;
|
||||
models?: ProviderModelDefinition[];
|
||||
}
|
||||
|
||||
export interface SetupMetadata {
|
||||
requiresAuth: boolean;
|
||||
authMode: AuthMode;
|
||||
credentialEnvVars?: string[];
|
||||
setupPrompt?: string;
|
||||
}
|
||||
|
||||
export interface StartupMetadata {
|
||||
autoDetectable?: boolean;
|
||||
probeReadiness?: 'ollama-generation' | 'openai-compatible-models';
|
||||
enablementEnvVar?: string;
|
||||
}
|
||||
|
||||
export interface UsageMetadata {
|
||||
supported: boolean;
|
||||
silentlyIgnore?: boolean;
|
||||
}
|
||||
|
||||
export interface ValidationMetadata {
|
||||
kind: 'credential-env';
|
||||
credentialEnvVars?: string[];
|
||||
missingCredentialMessage?: string;
|
||||
matchBaseUrlHosts?: string[];
|
||||
}
|
||||
|
||||
export interface PresetBadge {
|
||||
text: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface ProviderPresetMetadata {
|
||||
id: string;
|
||||
description: string;
|
||||
label?: string;
|
||||
apiKeyEnvVars?: string[];
|
||||
fallbackBaseUrl?: string;
|
||||
fallbackModel?: string;
|
||||
badge?: PresetBadge;
|
||||
}
|
||||
|
||||
export interface ProviderDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: ProviderKind;
|
||||
vendorId?: string;
|
||||
category?: 'local' | 'hosted' | 'aggregating';
|
||||
description: string;
|
||||
baseURL: string;
|
||||
defaultModel: string;
|
||||
supportsModelRouting?: boolean;
|
||||
setup?: SetupMetadata;
|
||||
startup?: StartupMetadata;
|
||||
transportConfig?: TransportConfig;
|
||||
catalog?: ModelCatalogConfig;
|
||||
validation?: ValidationMetadata;
|
||||
usage?: UsageMetadata;
|
||||
preset?: ProviderPresetMetadata;
|
||||
isFirstParty?: boolean;
|
||||
apiKeyLabel?: string;
|
||||
apiKeyPlaceholder?: string;
|
||||
apiKeyRequired?: boolean;
|
||||
credentialEnvVars?: string[];
|
||||
costCurrency?: string;
|
||||
models?: ProviderModelDefinition[];
|
||||
}
|
||||
|
||||
export interface ResolvedModelDefinition extends ProviderModelDefinition {
|
||||
apiName: string;
|
||||
providerId?: string;
|
||||
providerName?: string;
|
||||
providerKind?: ProviderKind;
|
||||
}
|
||||
|
||||
export interface ModelDiscoveryResult {
|
||||
providerId: string;
|
||||
models: ResolvedModelDefinition[];
|
||||
source: 'static' | 'network' | 'cache' | 'stale-cache' | 'error';
|
||||
stale: boolean;
|
||||
error?: string;
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { createHash } from 'crypto';
|
||||
import { getProviderDefinition, getModelsForProvider, resolveModel } from './catalog';
|
||||
import { buildAuthHeaders, resolveHeaders } from './auth';
|
||||
import type {
|
||||
ModelDefinition,
|
||||
ModelDiscoveryResult,
|
||||
ProviderDefinition,
|
||||
ResolvedModelDefinition,
|
||||
} from './catalog/types';
|
||||
|
||||
interface CacheEntry {
|
||||
recordedAt: number;
|
||||
models: ResolvedModelDefinition[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface DiscoveryCache {
|
||||
entries: Record<string, CacheEntry>;
|
||||
}
|
||||
|
||||
const DEFAULT_CACHE_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
function cacheDir(): string {
|
||||
return process.env.ZERO_CONFIG_DIR || join(homedir(), '.config', 'zero');
|
||||
}
|
||||
|
||||
function cachePath(): string {
|
||||
return join(cacheDir(), 'model-cache.json');
|
||||
}
|
||||
|
||||
function ensureCacheDir() {
|
||||
const dir = cacheDir();
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function readCache(): DiscoveryCache {
|
||||
ensureCacheDir();
|
||||
|
||||
const path = cachePath();
|
||||
if (!existsSync(path)) {
|
||||
return { entries: {} };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
||||
return { entries: parsed.entries ?? {} };
|
||||
} catch {
|
||||
return { entries: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(cache: DiscoveryCache) {
|
||||
ensureCacheDir();
|
||||
const path = cachePath();
|
||||
const data = JSON.stringify(cache, null, 2);
|
||||
writeFileSync(path, data, 'utf-8');
|
||||
}
|
||||
|
||||
function parseDuration(value: string | number | undefined): number {
|
||||
if (typeof value === 'number') return value;
|
||||
if (!value) return DEFAULT_CACHE_TTL_MS;
|
||||
|
||||
const match = value.trim().match(/^(\d+)(m|h|d)$/);
|
||||
if (!match) {
|
||||
console.warn(`[providers] Unrecognized duration format: "${value}", using default 1h`);
|
||||
return DEFAULT_CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
const amount = Number(match[1]);
|
||||
const unit = match[2];
|
||||
|
||||
if (unit === 'm') return amount * 60 * 1000;
|
||||
if (unit === 'h') return amount * 60 * 60 * 1000;
|
||||
return amount * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function cacheKey(definition: ProviderDefinition, apiKey?: string): string {
|
||||
const baseURL = definition.baseURL.replace(/\/+$/, '').toLowerCase();
|
||||
const authPartition = definition.catalog?.discovery?.requiresAuth === false || !apiKey
|
||||
? 'public'
|
||||
: `key:${createHash('sha256').update(apiKey).digest('hex').slice(0, 16)}`;
|
||||
return `${definition.id}:${baseURL}:${authPartition}`;
|
||||
}
|
||||
|
||||
function staticModels(definition: ProviderDefinition): ResolvedModelDefinition[] {
|
||||
return getModelsForProvider(definition);
|
||||
}
|
||||
|
||||
function mergeModels(
|
||||
staticEntries: ResolvedModelDefinition[],
|
||||
discoveredEntries: ResolvedModelDefinition[]
|
||||
): ResolvedModelDefinition[] {
|
||||
const merged = [...staticEntries];
|
||||
const seen = new Set(staticEntries.map((model) => model.apiName.toLowerCase()));
|
||||
|
||||
for (const entry of discoveredEntries) {
|
||||
if (seen.has(entry.apiName.toLowerCase())) continue;
|
||||
seen.add(entry.apiName.toLowerCase());
|
||||
merged.push(entry);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function discoveryUrl(definition: ProviderDefinition): string {
|
||||
const baseURL = definition.baseURL.replace(/\/+$/, '');
|
||||
const path = definition.catalog?.discovery?.path ?? '/models';
|
||||
return `${baseURL}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
function discoveryHeaders(definition: ProviderDefinition, apiKey?: string): Record<string, string> {
|
||||
const rawHeaders: Record<string, string | null> = {
|
||||
...(definition.transportConfig?.headers ?? {}),
|
||||
};
|
||||
|
||||
// Add auth header if needed
|
||||
if (apiKey && definition.catalog?.discovery?.requiresAuth !== false) {
|
||||
const authHeader = definition.transportConfig?.authHeader ?? {
|
||||
name: 'authorization',
|
||||
scheme: 'bearer' as const,
|
||||
};
|
||||
rawHeaders[authHeader.name] = authHeader.scheme === 'raw' ? apiKey : `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
// Resolve env vars and filter nulls
|
||||
const resolved = resolveHeaders(rawHeaders);
|
||||
const headers: Record<string, string> = {};
|
||||
for (const [name, value] of Object.entries(resolved)) {
|
||||
if (value != null) {
|
||||
headers[name] = value;
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function discoverOpenAICompatibleModels(
|
||||
definition: ProviderDefinition,
|
||||
apiKey?: string
|
||||
): Promise<ResolvedModelDefinition[]> {
|
||||
const response = await fetch(discoveryUrl(definition), {
|
||||
headers: discoveryHeaders(definition, apiKey),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`model discovery failed (${response.status})`);
|
||||
}
|
||||
|
||||
const parsed: any = await response.json();
|
||||
const rows = Array.isArray(parsed?.data) ? parsed.data : Array.isArray(parsed) ? parsed : [];
|
||||
|
||||
return rows
|
||||
.map((row: any) => typeof row === 'string' ? row : row?.id)
|
||||
.filter((id: unknown): id is string => typeof id === 'string' && id.length > 0)
|
||||
.map((id) => resolveModel({ id, name: id }, definition));
|
||||
}
|
||||
|
||||
async function discoverOllamaModels(definition: ProviderDefinition): Promise<ResolvedModelDefinition[]> {
|
||||
const baseURL = definition.baseURL.replace(/\/v1\/?$/, '').replace(/\/+$/, '');
|
||||
const response = await fetch(`${baseURL}/api/tags`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`ollama discovery failed (${response.status})`);
|
||||
}
|
||||
|
||||
const parsed: any = await response.json();
|
||||
const rows = Array.isArray(parsed?.models) ? parsed.models : [];
|
||||
|
||||
return rows
|
||||
.map((row: any) => row?.name)
|
||||
.filter((id: unknown): id is string => typeof id === 'string' && id.length > 0)
|
||||
.map((id) => resolveModel({ id, name: id, tier: 'local' }, definition));
|
||||
}
|
||||
|
||||
async function runDiscovery(
|
||||
definition: ProviderDefinition,
|
||||
apiKey?: string
|
||||
): Promise<ResolvedModelDefinition[]> {
|
||||
const discovery = definition.catalog?.discovery;
|
||||
if (!discovery) return [];
|
||||
|
||||
if (discovery.kind === 'openai-compatible') {
|
||||
return discoverOpenAICompatibleModels(definition, apiKey);
|
||||
}
|
||||
|
||||
if (discovery.kind === 'ollama') {
|
||||
return discoverOllamaModels(definition);
|
||||
}
|
||||
|
||||
throw new Error(`custom discovery is not executable from JSON descriptors`);
|
||||
}
|
||||
|
||||
export async function discoverModelsForProvider(
|
||||
providerId: string,
|
||||
options: {
|
||||
apiKey?: string;
|
||||
forceRefresh?: boolean;
|
||||
allowNetwork?: boolean;
|
||||
} = {}
|
||||
): Promise<ModelDiscoveryResult | null> {
|
||||
const definition = getProviderDefinition(providerId);
|
||||
if (!definition) return null;
|
||||
|
||||
const staticEntries = staticModels(definition);
|
||||
const discovery = definition.catalog?.discovery;
|
||||
if (!discovery) {
|
||||
return {
|
||||
providerId,
|
||||
models: staticEntries,
|
||||
source: 'static',
|
||||
stale: false,
|
||||
};
|
||||
}
|
||||
|
||||
const ttlMs = parseDuration(definition.catalog?.discoveryCacheTtl);
|
||||
const key = cacheKey(definition, options.apiKey);
|
||||
const cache = readCache();
|
||||
const cached = cache.entries[key];
|
||||
const stale = cached ? Date.now() - cached.recordedAt > ttlMs : false;
|
||||
|
||||
if (!options.forceRefresh && cached && !stale) {
|
||||
return {
|
||||
providerId,
|
||||
models: mergeModels(staticEntries, cached.models),
|
||||
source: 'cache',
|
||||
stale: false,
|
||||
error: cached.error,
|
||||
};
|
||||
}
|
||||
|
||||
if (options.allowNetwork === false) {
|
||||
return {
|
||||
providerId,
|
||||
models: mergeModels(staticEntries, cached?.models ?? []),
|
||||
source: cached ? 'stale-cache' : 'static',
|
||||
stale: Boolean(cached && stale),
|
||||
error: cached?.error,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const discovered = await runDiscovery(definition, options.apiKey);
|
||||
cache.entries[key] = { recordedAt: Date.now(), models: discovered };
|
||||
writeCache(cache);
|
||||
|
||||
return {
|
||||
providerId,
|
||||
models: mergeModels(staticEntries, discovered),
|
||||
source: 'network',
|
||||
stale: false,
|
||||
};
|
||||
} catch (error: any) {
|
||||
const message = error?.message ?? String(error);
|
||||
if (cached) {
|
||||
cache.entries[key] = {
|
||||
...cached,
|
||||
error: message,
|
||||
};
|
||||
writeCache(cache);
|
||||
}
|
||||
|
||||
return {
|
||||
providerId,
|
||||
models: mergeModels(staticEntries, cached?.models ?? []),
|
||||
source: cached ? 'stale-cache' : 'error',
|
||||
stale: Boolean(cached),
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { OpenAIProvider } from './openai';
|
||||
import type { Provider } from './types';
|
||||
import type { ProviderConfig } from '../config/provider';
|
||||
import { getProviderDefinition, resolveEffectiveTransportConfig, getGlobalModelDefinition, getModelsForProvider } from './catalog';
|
||||
import type { ProviderDefinition } from './catalog/types';
|
||||
import { buildAuthHeaders, resolveHeaders } from './auth';
|
||||
|
||||
export function createProvider(config: ProviderConfig): Provider {
|
||||
const definition = config.providerId ? getProviderDefinition(config.providerId) : undefined;
|
||||
if (definition && !isProviderRuntimeSupported(definition)) {
|
||||
throw new Error(
|
||||
`Provider "${definition.name}" uses unsupported transport "${definition.transportConfig?.kind}". ` +
|
||||
'Supported: openai-compatible'
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve per-model transport overrides
|
||||
const globalModel = getGlobalModelDefinition(config.model);
|
||||
const catalogModel = definition && getModelsForProvider(definition).find(
|
||||
(m) => m.apiName === config.model || m.id === config.model
|
||||
);
|
||||
const modelDef = catalogModel ?? globalModel;
|
||||
|
||||
const effectiveTransport = definition
|
||||
? resolveEffectiveTransportConfig(definition, modelDef)
|
||||
: undefined;
|
||||
|
||||
const apiKey = config.apiKey || '';
|
||||
const rawHeaders = {
|
||||
...(effectiveTransport?.headers ?? {}),
|
||||
...buildAuthHeaders(apiKey, effectiveTransport?.authHeader),
|
||||
};
|
||||
const headers = resolveHeaders(rawHeaders);
|
||||
|
||||
return new OpenAIProvider({
|
||||
apiKey,
|
||||
baseURL: config.baseURL,
|
||||
model: config.model,
|
||||
defaultHeaders: Object.keys(headers).length > 0 ? headers : undefined,
|
||||
maxTokensField: effectiveTransport?.maxTokensField,
|
||||
removeBodyFields: effectiveTransport?.removeBodyFields,
|
||||
});
|
||||
}
|
||||
|
||||
export function isProviderRuntimeSupported(definition: ProviderDefinition): boolean {
|
||||
const kind = definition.transportConfig?.kind ?? 'openai-compatible';
|
||||
// TODO: implement anthropic-compatible transport
|
||||
return kind === 'openai-compatible';
|
||||
}
|
||||
+79
-92
@@ -1,44 +1,31 @@
|
||||
import OpenAI from 'openai';
|
||||
import type { Provider, Message, ToolDefinition, StreamEvent } from './types';
|
||||
import type { MaxTokensField } from './catalog/types';
|
||||
|
||||
interface OpenAIProviderOptions {
|
||||
apiKey: string;
|
||||
baseURL?: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
function isUnsupportedStreamOptionsError(error: unknown): boolean {
|
||||
const candidate = error as {
|
||||
status?: number;
|
||||
code?: string;
|
||||
message?: string;
|
||||
error?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const statusAllowsRetry = candidate.status === 400 || candidate.status === 422;
|
||||
const errorText = [
|
||||
candidate.code,
|
||||
candidate.message,
|
||||
candidate.error?.code,
|
||||
candidate.error?.message,
|
||||
].filter(Boolean).join(' ').toLowerCase();
|
||||
|
||||
return statusAllowsRetry && /stream[ _-]?options?/.test(errorText);
|
||||
defaultHeaders?: Record<string, string | null>;
|
||||
maxTokensField?: MaxTokensField;
|
||||
removeBodyFields?: string[];
|
||||
}
|
||||
|
||||
export class OpenAIProvider implements Provider {
|
||||
private client: OpenAI;
|
||||
private model: string;
|
||||
private maxTokensField: MaxTokensField;
|
||||
private removeBodyFields: Set<string>;
|
||||
|
||||
constructor({ apiKey, baseURL, model }: OpenAIProviderOptions) {
|
||||
constructor({ apiKey, baseURL, model, defaultHeaders, maxTokensField, removeBodyFields }: OpenAIProviderOptions) {
|
||||
this.client = new OpenAI({
|
||||
apiKey,
|
||||
baseURL: baseURL || 'https://api.openai.com/v1',
|
||||
defaultHeaders,
|
||||
});
|
||||
this.model = model;
|
||||
this.maxTokensField = maxTokensField ?? 'max_tokens';
|
||||
this.removeBodyFields = new Set(removeBodyFields ?? []);
|
||||
}
|
||||
|
||||
async *streamCompletion(
|
||||
@@ -70,45 +57,59 @@ export class OpenAIProvider implements Provider {
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
const createStreamRequest = (includeUsage: boolean) => ({
|
||||
// Build request body
|
||||
const body: Record<string, unknown> = {
|
||||
model: this.model,
|
||||
messages: openaiMessages as any,
|
||||
messages: openaiMessages,
|
||||
tools: openaiTools,
|
||||
stream: true as const,
|
||||
...(includeUsage ? {
|
||||
stream_options: {
|
||||
include_usage: true,
|
||||
},
|
||||
} : {}),
|
||||
});
|
||||
stream: true,
|
||||
};
|
||||
|
||||
// Strip configured body fields
|
||||
for (const field of this.removeBodyFields) {
|
||||
delete body[field];
|
||||
}
|
||||
|
||||
// Validate required fields before sending
|
||||
if (!body.model) {
|
||||
throw new Error('Provider request missing required field: model');
|
||||
}
|
||||
if (!body.messages || !Array.isArray(body.messages) || body.messages.length === 0) {
|
||||
throw new Error('Provider request missing required field: messages');
|
||||
}
|
||||
|
||||
let stream;
|
||||
try {
|
||||
stream = await this.client.chat.completions.create(createStreamRequest(true));
|
||||
} catch (error) {
|
||||
if (isUnsupportedStreamOptionsError(error)) {
|
||||
try {
|
||||
stream = await this.client.chat.completions.create(createStreamRequest(false));
|
||||
} catch (retryError) {
|
||||
throw formatProviderCreateError(retryError);
|
||||
}
|
||||
} else {
|
||||
throw formatProviderCreateError(error);
|
||||
stream = await this.client.chat.completions.create(
|
||||
body as Parameters<typeof this.client.chat.completions.create>[0],
|
||||
{ timeout: 120_000 } // 2 minute timeout
|
||||
);
|
||||
} catch (err: any) {
|
||||
const message = getDetailedErrorMessage(err);
|
||||
if (message.includes('401') || message.toLowerCase().includes('invalid') || message.toLowerCase().includes('unauthorized')) {
|
||||
throw new Error(`Provider authentication error (check your API key): ${message}`);
|
||||
}
|
||||
if (message.toLowerCase().includes('rate') || message.toLowerCase().includes('quota')) {
|
||||
throw new Error(`Provider rate limit error: ${message}`);
|
||||
}
|
||||
throw new Error(`Provider returned error: ${message}`);
|
||||
}
|
||||
|
||||
const toolCallAccumulators = new Map<number, {
|
||||
id: string;
|
||||
name: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
started: boolean;
|
||||
ended: boolean;
|
||||
}>();
|
||||
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
// Some OpenAI-compatible servers send errors as special chunks
|
||||
if ((chunk as any).error) {
|
||||
const errorData = (chunk as any).error;
|
||||
throw formatProviderCreateError(errorData);
|
||||
const errData = (chunk as any).error;
|
||||
const msg = errData.message || JSON.stringify(errData);
|
||||
throw new Error(`Provider returned error: ${msg}`);
|
||||
}
|
||||
|
||||
const delta = chunk.choices[0]?.delta;
|
||||
@@ -124,17 +125,15 @@ export class OpenAIProvider implements Provider {
|
||||
|
||||
let acc = toolCallAccumulators.get(tc.index);
|
||||
if (!acc) {
|
||||
acc = { id: '', name: '', arguments: '', started: false };
|
||||
acc = { id: '', name: '', arguments: '', started: false, ended: false };
|
||||
toolCallAccumulators.set(tc.index, acc);
|
||||
}
|
||||
|
||||
// If we already had data at this index and now get a new id,
|
||||
// If we already had data at this index and now get a new id,
|
||||
// the previous tool call is complete.
|
||||
if (tc.id && acc.id && acc.id !== tc.id) {
|
||||
if (acc.id) {
|
||||
yield { type: 'tool-call-end', id: acc.id };
|
||||
}
|
||||
acc = { id: '', name: '', arguments: '', started: false };
|
||||
yield { type: 'tool-call-end', id: acc.id };
|
||||
acc = { id: '', name: '', arguments: '', started: false, ended: false };
|
||||
toolCallAccumulators.set(tc.index, acc);
|
||||
}
|
||||
|
||||
@@ -142,11 +141,14 @@ export class OpenAIProvider implements Provider {
|
||||
if (tc.function?.name) acc.name = tc.function.name;
|
||||
if (tc.function?.arguments) {
|
||||
acc.arguments += tc.function.arguments;
|
||||
yield {
|
||||
type: 'tool-call-delta',
|
||||
id: acc.id || `pending-${tc.index}`,
|
||||
argumentsFragment: tc.function.arguments,
|
||||
};
|
||||
// Only yield deltas once we have the real id
|
||||
if (acc.id) {
|
||||
yield {
|
||||
type: 'tool-call-delta',
|
||||
id: acc.id,
|
||||
argumentsFragment: tc.function.arguments,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Emit start event the first time we have both id and name
|
||||
@@ -168,8 +170,9 @@ export class OpenAIProvider implements Provider {
|
||||
// If the model signaled it's done with tool calls, close any open ones
|
||||
if (finishReason === 'tool_calls') {
|
||||
for (const [_, acc] of toolCallAccumulators) {
|
||||
if (acc.id) {
|
||||
if (acc.id && !acc.ended) {
|
||||
yield { type: 'tool-call-end', id: acc.id };
|
||||
acc.ended = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,58 +180,42 @@ export class OpenAIProvider implements Provider {
|
||||
|
||||
// End of stream - close any remaining open tool calls
|
||||
for (const [_, acc] of toolCallAccumulators) {
|
||||
if (acc.id) {
|
||||
if (acc.id && !acc.ended) {
|
||||
yield { type: 'tool-call-end', id: acc.id };
|
||||
acc.ended = true;
|
||||
}
|
||||
}
|
||||
|
||||
yield { type: 'done' };
|
||||
} catch (error) {
|
||||
throw formatProviderCreateError(error);
|
||||
} catch (err: any) {
|
||||
const message = getDetailedErrorMessage(err);
|
||||
throw new Error(`Provider returned error during streaming: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatProviderCreateError(error: unknown): Error {
|
||||
const message = getDetailedErrorMessage(error);
|
||||
const lower = message.toLowerCase();
|
||||
function getDetailedErrorMessage(err: any): string {
|
||||
if (!err) return 'Unknown error';
|
||||
|
||||
if (message.includes('401') || lower.includes('invalid') || lower.includes('unauthorized') || lower.includes('api key')) {
|
||||
return new Error(`Provider authentication error (check your API key): ${message}`);
|
||||
// Try common places where real error lives (especially for custom gateways)
|
||||
if (err.message && !err.message.includes('Provider returned error')) {
|
||||
return err.message;
|
||||
}
|
||||
|
||||
if (lower.includes('rate') || lower.includes('quota')) {
|
||||
return new Error(`Provider rate limit error: ${message}`);
|
||||
if (err.error) {
|
||||
if (typeof err.error === 'string') return err.error;
|
||||
if (err.error.message) return err.error.message;
|
||||
try { return JSON.stringify(err.error); } catch { return String(err.error); }
|
||||
}
|
||||
|
||||
return new Error(`Provider returned error: ${message}`);
|
||||
}
|
||||
|
||||
function getDetailedErrorMessage(error: any): string {
|
||||
if (!error) return 'Unknown error';
|
||||
|
||||
if (error.message && !error.message.includes('Provider returned error')) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (error.error) {
|
||||
if (typeof error.error === 'string') return error.error;
|
||||
if (error.error.message) return error.error.message;
|
||||
try {
|
||||
return JSON.stringify(error.error);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (error.response?.data) {
|
||||
const data = error.response.data;
|
||||
if (err.response?.data) {
|
||||
const data = err.response.data;
|
||||
if (data.error?.message) return data.error.message;
|
||||
if (typeof data.error === 'string') return data.error;
|
||||
try {
|
||||
return JSON.stringify(data);
|
||||
} catch {}
|
||||
try { return JSON.stringify(data); } catch { return '[unserializable response data]'; }
|
||||
}
|
||||
|
||||
if (error.cause?.message) return error.cause.message;
|
||||
if (err.cause?.message) return err.cause.message;
|
||||
|
||||
return error.message || String(error);
|
||||
return err.message || String(err);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
export interface Message {
|
||||
role: 'system' | 'user' | 'assistant' | 'tool';
|
||||
content: string;
|
||||
content: string | null;
|
||||
toolCalls?: ToolCall[];
|
||||
toolCallId?: string;
|
||||
}
|
||||
|
||||
+4
-1
@@ -19,12 +19,15 @@ export const bashTool: Tool = {
|
||||
cwd: cwd || process.cwd(),
|
||||
shell: true,
|
||||
timeout: 120_000, // 2 minutes
|
||||
reject: false, // Don't throw on non-zero exit
|
||||
});
|
||||
|
||||
let output = '';
|
||||
if (result.exitCode !== 0) {
|
||||
output += `[Command failed with exit code ${result.exitCode}]\n`;
|
||||
}
|
||||
if (result.stdout) output += `stdout:\n${result.stdout}\n`;
|
||||
if (result.stderr) output += `stderr:\n${result.stderr}\n`;
|
||||
if (result.exitCode !== 0) output += `Exit code: ${result.exitCode}`;
|
||||
|
||||
return output.trim() || 'Command completed with no output.';
|
||||
} catch (err: any) {
|
||||
|
||||
+13
-1
@@ -15,6 +15,11 @@ export const editFileTool: Tool = {
|
||||
async execute(args) {
|
||||
const { path, old_string, new_string } = EditFileParams.parse(args);
|
||||
|
||||
// Detect no-op edits
|
||||
if (old_string === new_string) {
|
||||
return `No-op edit: old_string and new_string are identical in ${path}.`;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await readFile(path, 'utf-8');
|
||||
|
||||
@@ -22,7 +27,14 @@ export const editFileTool: Tool = {
|
||||
return `Error: Could not find the exact string to replace in ${path}.`;
|
||||
}
|
||||
|
||||
const newContent = content.replace(old_string, new_string);
|
||||
// Warn if multiple matches exist
|
||||
const matchCount = content.split(old_string).length - 1;
|
||||
if (matchCount > 1) {
|
||||
return `Error: Found ${matchCount} occurrences of the string in ${path}. Please provide a more specific string to match a single occurrence.`;
|
||||
}
|
||||
|
||||
// Use function replacement to avoid $&/$'/$` pattern expansion
|
||||
const newContent = content.replace(old_string, () => new_string);
|
||||
await writeFile(path, newContent, 'utf-8');
|
||||
|
||||
return `Successfully edited ${path}.`;
|
||||
|
||||
+52
-8
@@ -7,8 +7,8 @@ const GrepParams = z.object({
|
||||
path: z.string().optional().describe('Directory or file to search in. Defaults to current working directory.'),
|
||||
glob: z.string().optional().describe('Glob pattern to filter files (e.g. "**/*.ts", "*.tsx")'),
|
||||
output_mode: z.enum(['content', 'files_with_matches', 'count']).optional().default('content'),
|
||||
'-i': z.boolean().optional().describe('Case insensitive search'),
|
||||
'-n': z.boolean().optional().default(true).describe('Show line numbers'),
|
||||
case_insensitive: z.boolean().optional().describe('Case insensitive search'),
|
||||
show_line_numbers: z.boolean().optional().default(true).describe('Show line numbers'),
|
||||
head_limit: z.number().int().min(1).optional().default(50).describe('Maximum number of results to return'),
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ Features:
|
||||
parameters: GrepParams,
|
||||
async execute(args) {
|
||||
const params = GrepParams.parse(args);
|
||||
const { pattern, path = '.', glob, output_mode, '-i': caseInsensitive, '-n': showLineNumbers, head_limit } = params;
|
||||
const { pattern, path = '.', glob, output_mode, case_insensitive, show_line_numbers, head_limit } = params;
|
||||
|
||||
const cwd = process.cwd();
|
||||
const targetPath = path === '.' ? cwd : path;
|
||||
@@ -35,8 +35,8 @@ Features:
|
||||
try {
|
||||
const rgArgs = ['--json', '--no-heading', '--with-filename'];
|
||||
|
||||
if (caseInsensitive) rgArgs.push('-i');
|
||||
if (showLineNumbers) rgArgs.push('-n');
|
||||
if (case_insensitive) rgArgs.push('-i');
|
||||
if (show_line_numbers) rgArgs.push('-n');
|
||||
if (glob) rgArgs.push('--glob', glob);
|
||||
|
||||
rgArgs.push('-m', String(head_limit));
|
||||
@@ -54,6 +54,7 @@ Features:
|
||||
// Parse ripgrep JSON output
|
||||
const lines = stdout.trim().split('\n');
|
||||
const results: any[] = [];
|
||||
const parseErrors: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
@@ -61,7 +62,13 @@ Features:
|
||||
if (parsed.type === 'match') {
|
||||
results.push(parsed);
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
parseErrors.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (parseErrors.length > 0 && results.length === 0) {
|
||||
return `Error: Failed to parse ripgrep output. Raw output:\n${stdout.slice(0, 500)}`;
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
@@ -87,8 +94,45 @@ Features:
|
||||
|
||||
return formatted.join('\n');
|
||||
} catch (err) {
|
||||
// Fallback to simple grep if rg not available (not ideal but works)
|
||||
return `ripgrep (rg) not found. Falling back to basic search is not yet implemented. Please install ripgrep for best results.`;
|
||||
// Fallback: use basic grep via bash
|
||||
try {
|
||||
const grepArgs = ['-r'];
|
||||
if (show_line_numbers) grepArgs.push('-n');
|
||||
if (case_insensitive) grepArgs.push('-i');
|
||||
grepArgs.push('-m', String(head_limit));
|
||||
|
||||
let searchTarget = targetPath;
|
||||
if (glob) {
|
||||
// Use find to filter by glob pattern
|
||||
grepArgs.push('--include', glob);
|
||||
}
|
||||
|
||||
grepArgs.push(pattern, searchTarget);
|
||||
|
||||
const { stdout } = await execa('grep', grepArgs, {
|
||||
cwd,
|
||||
reject: false,
|
||||
});
|
||||
|
||||
if (!stdout) {
|
||||
return output_mode === 'count' ? '0 matches found' : 'No matches found.';
|
||||
}
|
||||
|
||||
const lines = stdout.trim().split('\n');
|
||||
|
||||
if (output_mode === 'count') {
|
||||
return `${lines.length} matches found (limited to first ${head_limit})`;
|
||||
}
|
||||
|
||||
if (output_mode === 'files_with_matches') {
|
||||
const files = new Set(lines.map(l => l.split(':')[0]));
|
||||
return Array.from(files).slice(0, head_limit).join('\n');
|
||||
}
|
||||
|
||||
return lines.slice(0, head_limit).join('\n');
|
||||
} catch {
|
||||
return `Search failed: ripgrep (rg) not available and grep fallback failed. Please install ripgrep for best results.`;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -59,9 +59,27 @@ async function listDirRecursive(
|
||||
const fullPath = join(currentPath, dirent.name);
|
||||
const relPath = relative(root, fullPath);
|
||||
|
||||
if (dirent.isDirectory()) {
|
||||
if (dirent.isDirectory() || dirent.isSymbolicLink()) {
|
||||
if (ignoreDirs.has(dirent.name)) continue;
|
||||
|
||||
// For symlinks, verify it's actually a directory
|
||||
if (dirent.isSymbolicLink()) {
|
||||
try {
|
||||
const linkStat = await stat(fullPath);
|
||||
if (!linkStat.isDirectory()) {
|
||||
// Symlink points to a file, treat as file
|
||||
const indent = ' '.repeat(currentDepth);
|
||||
results.push(`${indent}📄 ${dirent.name} -> symlink`);
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
// Broken symlink
|
||||
const indent = ' '.repeat(currentDepth);
|
||||
results.push(`${indent}❓ ${dirent.name} -> broken symlink`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const indent = ' '.repeat(currentDepth);
|
||||
results.push(`${indent}📁 ${dirent.name}/`);
|
||||
|
||||
@@ -70,12 +88,20 @@ async function listDirRecursive(
|
||||
results.push(...children);
|
||||
}
|
||||
} else {
|
||||
// Skip ignored files
|
||||
if (ignoreDirs.has(dirent.name)) continue;
|
||||
|
||||
const indent = ' '.repeat(currentDepth);
|
||||
results.push(`${indent}📄 ${dirent.name}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore permission errors on individual dirs
|
||||
} catch (e: any) {
|
||||
// Only ignore permission errors; log others
|
||||
if (e.code === 'EACCES' || e.code === 'EPERM') {
|
||||
// Permission denied - skip silently
|
||||
} else {
|
||||
console.warn(`[list-directory] Error reading ${currentPath}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
|
||||
+3
-3
@@ -15,6 +15,9 @@ const UpdatePlanParams = z.object({
|
||||
type PlanItem = z.infer<typeof PlanItemSchema>;
|
||||
|
||||
// In-memory plan storage for the current session
|
||||
// NOTE: This is module-level state. If multiple agent loops run in parallel,
|
||||
// they will share and overwrite each other's plans. This is acceptable for
|
||||
// single-agent usage but should be refactored for parallel agents.
|
||||
let currentPlan: PlanItem[] = [];
|
||||
|
||||
function formatPlan(plan: PlanItem[]): string {
|
||||
@@ -59,9 +62,6 @@ The plan should be clear, actionable, and ordered. Keep it up to date as you wor
|
||||
|
||||
return formatPlan(currentPlan);
|
||||
},
|
||||
onAfterExecute() {
|
||||
return { planUpdated: true };
|
||||
},
|
||||
};
|
||||
|
||||
// Helper to get the current plan (can be used by other parts of the system later)
|
||||
|
||||
+19
-1
@@ -1,7 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { readFile, stat } from 'fs/promises';
|
||||
import type { Tool } from './types';
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
const ReadFileParams = z.object({
|
||||
path: z.string().min(1),
|
||||
});
|
||||
@@ -14,6 +16,22 @@ export const readFileTool: Tool = {
|
||||
const { path } = ReadFileParams.parse(args);
|
||||
|
||||
try {
|
||||
const fileStat = await stat(path);
|
||||
if (fileStat.size > MAX_FILE_SIZE) {
|
||||
return `Error: File ${path} is too large (${(fileStat.size / 1024 / 1024).toFixed(1)}MB). Maximum size is 10MB.`;
|
||||
}
|
||||
|
||||
// Check for binary content by reading first 8KB
|
||||
const buffer = Buffer.alloc(Math.min(8192, fileStat.size));
|
||||
const file = await import('fs/promises').then(fs => fs.open(path, 'r'));
|
||||
await file.read(buffer, 0, buffer.length, 0);
|
||||
await file.close();
|
||||
|
||||
// If file contains null bytes, it's likely binary
|
||||
if (buffer.includes(0)) {
|
||||
return `Error: File ${path} appears to be binary and cannot be read as text.`;
|
||||
}
|
||||
|
||||
const content = await readFile(path, 'utf-8');
|
||||
return `File: ${path}\n\n${content}`;
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -14,14 +14,6 @@ export class ToolRegistry {
|
||||
getAll(): Tool[] {
|
||||
return Array.from(this.tools.values());
|
||||
}
|
||||
|
||||
getDefinitionsForProvider() {
|
||||
return this.getAll().map(tool => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters.shape, // We'll convert properly later
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export const toolRegistry = new ToolRegistry();
|
||||
+2
-10
@@ -1,21 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolCall } from '../providers/types';
|
||||
|
||||
export interface ToolExecutionEffects {
|
||||
planUpdated?: boolean;
|
||||
}
|
||||
export type { ToolCall };
|
||||
|
||||
export interface Tool {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: z.ZodObject<any>; // Zod schema for validation
|
||||
execute: (args: any) => Promise<string>; // Returns tool result as string
|
||||
onAfterExecute?: (result: string) => ToolExecutionEffects | void;
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: string; // raw JSON string from model
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
import { configManager } from '../config/manager';
|
||||
import { listProviderDefinitions } from '../providers/catalog';
|
||||
import { discoverModelsForProvider } from '../providers/discovery';
|
||||
import { isProviderRuntimeSupported } from '../providers/factory';
|
||||
import type { ProviderDefinition, ResolvedModelDefinition } from '../providers/catalog/types';
|
||||
|
||||
type AddMode = 'choose' | 'catalog' | 'generic';
|
||||
|
||||
interface AddProviderProps {
|
||||
onDone: (providerName?: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const catalogProviders = listProviderDefinitions().filter(isProviderRuntimeSupported);
|
||||
|
||||
export const AddProvider: React.FC<AddProviderProps> = ({ onDone, onCancel }) => {
|
||||
const [mode, setMode] = useState<AddMode>('choose');
|
||||
const [selectedOption, setSelectedOption] = useState(0);
|
||||
const [selectedDefinition, setSelectedDefinition] = useState<ProviderDefinition | null>(null);
|
||||
const [catalogStep, setCatalogStep] = useState(0);
|
||||
|
||||
const [catalogKey, setCatalogKey] = useState('');
|
||||
const [catalogModel, setCatalogModel] = useState('');
|
||||
const catalogModelTouched = React.useRef(false);
|
||||
const [modelOptions, setModelOptions] = useState<ResolvedModelDefinition[]>([]);
|
||||
const [selectedModelIndex, setSelectedModelIndex] = useState(0);
|
||||
const [discoveryStatus, setDiscoveryStatus] = useState('');
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [baseURL, setBaseURL] = useState('https://api.openai.com/v1');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [model, setModel] = useState('gpt-4o');
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const totalOptions = catalogProviders.length + 1;
|
||||
const visibleModelStart = Math.max(
|
||||
0,
|
||||
Math.min(selectedModelIndex - 3, Math.max(0, modelOptions.length - 8))
|
||||
);
|
||||
const visibleModelOptions = modelOptions.slice(visibleModelStart, visibleModelStart + 8);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (mode !== 'catalog' || catalogStep !== 1 || !selectedDefinition) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const loadModels = async () => {
|
||||
const staticModels = selectedDefinition.catalog?.models ?? selectedDefinition.models ?? [];
|
||||
setModelOptions(staticModels.map((model) => ({
|
||||
...model,
|
||||
apiName: model.apiName ?? model.id,
|
||||
providerId: selectedDefinition.id,
|
||||
providerName: selectedDefinition.name,
|
||||
providerKind: selectedDefinition.kind,
|
||||
})));
|
||||
|
||||
if (!selectedDefinition.catalog?.discovery) {
|
||||
setDiscoveryStatus(staticModels.length > 0 ? 'Using static catalog models.' : '');
|
||||
return;
|
||||
}
|
||||
|
||||
setDiscoveryStatus('Discovering models...');
|
||||
const result = await discoverModelsForProvider(selectedDefinition.id, {
|
||||
apiKey: catalogKey.trim() || undefined,
|
||||
});
|
||||
|
||||
if (cancelled || !result) return;
|
||||
|
||||
setModelOptions(result.models);
|
||||
setDiscoveryStatus(
|
||||
result.error
|
||||
? `Discovery ${result.source}: ${result.error}`
|
||||
: `Models loaded from ${result.source}.`
|
||||
);
|
||||
|
||||
const firstModel = result.models[0];
|
||||
if (firstModel && !catalogModelTouched.current) {
|
||||
setCatalogModel(firstModel.apiName);
|
||||
setSelectedModelIndex(0);
|
||||
}
|
||||
};
|
||||
|
||||
loadModels();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [mode, catalogStep, selectedDefinition, catalogKey]);
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.escape) {
|
||||
if (mode === 'choose') {
|
||||
onCancel();
|
||||
} else {
|
||||
setMode('choose');
|
||||
setCatalogStep(0);
|
||||
setSelectedDefinition(null);
|
||||
setError('');
|
||||
setSuccess(false);
|
||||
setSelectedOption(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'catalog' && catalogStep === 1 && modelOptions.length > 0) {
|
||||
if (key.upArrow) {
|
||||
setSelectedModelIndex((prev) => {
|
||||
const next = Math.max(0, prev - 1);
|
||||
catalogModelTouched.current = true;
|
||||
setCatalogModel(modelOptions[next]?.apiName ?? catalogModel);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.downArrow) {
|
||||
setSelectedModelIndex((prev) => {
|
||||
const next = Math.min(modelOptions.length - 1, prev + 1);
|
||||
catalogModelTouched.current = true;
|
||||
setCatalogModel(modelOptions[next]?.apiName ?? catalogModel);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode !== 'choose') return;
|
||||
|
||||
if (key.upArrow) {
|
||||
setSelectedOption((prev) => Math.max(0, prev - 1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.downArrow) {
|
||||
setSelectedOption((prev) => Math.min(totalOptions - 1, prev + 1));
|
||||
return;
|
||||
}
|
||||
|
||||
const chooseOption = (index: number) => {
|
||||
const definition = catalogProviders[index];
|
||||
if (definition) {
|
||||
setSelectedDefinition(definition);
|
||||
catalogModelTouched.current = false;
|
||||
setCatalogModel(definition.defaultModel);
|
||||
setModelOptions([]);
|
||||
setSelectedModelIndex(0);
|
||||
setDiscoveryStatus('');
|
||||
setMode('catalog');
|
||||
setCatalogStep(definition.apiKeyRequired === false ? 1 : 0);
|
||||
} else {
|
||||
setMode('generic');
|
||||
}
|
||||
};
|
||||
|
||||
if (key.return) {
|
||||
chooseOption(selectedOption);
|
||||
return;
|
||||
}
|
||||
|
||||
const quickNumber = parseInt(input, 10);
|
||||
if (!isNaN(quickNumber) && quickNumber >= 1 && quickNumber <= totalOptions) {
|
||||
chooseOption(quickNumber - 1);
|
||||
}
|
||||
});
|
||||
|
||||
const saveCatalogProvider = () => {
|
||||
if (!selectedDefinition) return;
|
||||
|
||||
if (selectedDefinition.apiKeyRequired !== false && !catalogKey.trim()) {
|
||||
setError('API key is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const profileName = selectedDefinition.id;
|
||||
|
||||
configManager.addProvider({
|
||||
name: profileName,
|
||||
providerId: selectedDefinition.id,
|
||||
kind: selectedDefinition.kind,
|
||||
baseURL: selectedDefinition.baseURL,
|
||||
apiKey: catalogKey.trim() || undefined,
|
||||
model: catalogModel.trim() || selectedDefinition.defaultModel,
|
||||
description: selectedDefinition.name,
|
||||
});
|
||||
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
onDone(profileName);
|
||||
}, 1200);
|
||||
};
|
||||
|
||||
const saveGeneric = () => {
|
||||
if (!name.trim() || !baseURL.trim() || !model.trim()) {
|
||||
setError('Name, Base URL, and Model are required');
|
||||
return;
|
||||
}
|
||||
|
||||
configManager.addProvider({
|
||||
name: name.trim(),
|
||||
kind: 'custom',
|
||||
baseURL: baseURL.trim(),
|
||||
apiKey: apiKey.trim() || undefined,
|
||||
model: model.trim(),
|
||||
description: 'Custom OpenAI-compatible',
|
||||
});
|
||||
|
||||
setSuccess(true);
|
||||
setTimeout(() => onDone(name.trim()), 1200);
|
||||
};
|
||||
|
||||
if (mode === 'choose') {
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Text bold color="cyan">Add New Provider</Text>
|
||||
<Text color="gray">Esc to go back - Up/Down to navigate - Enter to select</Text>
|
||||
|
||||
<Box marginY={1} flexDirection="column">
|
||||
{catalogProviders.map((definition, index) => (
|
||||
<Box key={definition.id} flexDirection="column" marginBottom={1}>
|
||||
<Text color={selectedOption === index ? 'greenBright' : 'white'}>
|
||||
{selectedOption === index ? '> ' : ' '}
|
||||
{index + 1}. Add {definition.name}
|
||||
</Text>
|
||||
{selectedOption === index && (
|
||||
<Text color="gray" dimColor>
|
||||
{' '}{definition.kind}: {definition.description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Text color={selectedOption === catalogProviders.length ? 'greenBright' : 'white'}>
|
||||
{selectedOption === catalogProviders.length ? '> ' : ' '}
|
||||
{catalogProviders.length + 1}. Add custom OpenAI-compatible provider
|
||||
</Text>
|
||||
{selectedOption === catalogProviders.length && (
|
||||
<Text color="gray" dimColor>
|
||||
{' '}For providers not yet described by a catalog file
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === 'catalog' && selectedDefinition) {
|
||||
if (success) {
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Text color="greenBright" bold>
|
||||
{selectedDefinition.name} provider added successfully!
|
||||
</Text>
|
||||
<Text color="gray" dimColor>
|
||||
It is now your active provider.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Text bold color="cyan">Add {selectedDefinition.name}</Text>
|
||||
<Text color="gray">Esc to go back</Text>
|
||||
|
||||
{catalogStep === 0 && (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color="yellowBright">Step 1/2 - Enter API key</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text>{selectedDefinition.apiKeyLabel ?? 'API key'}: </Text>
|
||||
<TextInput
|
||||
value={catalogKey}
|
||||
onChange={setCatalogKey}
|
||||
mask="*"
|
||||
placeholder={selectedDefinition.apiKeyPlaceholder}
|
||||
/>
|
||||
</Box>
|
||||
{error && <Text color="red">{error}</Text>}
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>Press Enter to continue</Text>
|
||||
</Box>
|
||||
<TextInput
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {
|
||||
if (selectedDefinition.apiKeyRequired === false || catalogKey.trim()) {
|
||||
setCatalogStep(1);
|
||||
setError('');
|
||||
} else {
|
||||
setError('API key cannot be empty');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{catalogStep === 1 && (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color="yellowBright">Step 2/2 - Model name</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text>Model: </Text>
|
||||
<TextInput
|
||||
value={catalogModel}
|
||||
onChange={(value) => {
|
||||
catalogModelTouched.current = true;
|
||||
setCatalogModel(value);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{selectedDefinition.models && selectedDefinition.models.length > 0 && (
|
||||
<Text color="gray" dimColor>
|
||||
Default catalog model: {selectedDefinition.defaultModel}
|
||||
</Text>
|
||||
)}
|
||||
{discoveryStatus && (
|
||||
<Text color="gray" dimColor>{discoveryStatus}</Text>
|
||||
)}
|
||||
{modelOptions.length > 0 && (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color="gray" dimColor>Up/Down to choose a catalog model</Text>
|
||||
{visibleModelOptions.map((option, index) => {
|
||||
const modelIndex = visibleModelStart + index;
|
||||
return (
|
||||
<Text
|
||||
key={`${option.providerId ?? 'global'}:${option.apiName}`}
|
||||
color={modelIndex === selectedModelIndex ? 'greenBright' : 'white'}
|
||||
>
|
||||
{modelIndex === selectedModelIndex ? '> ' : ' '}
|
||||
{option.name ?? option.label ?? option.apiName}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
{error && <Text color="red">{error}</Text>}
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>Press Enter to save</Text>
|
||||
</Box>
|
||||
<TextInput
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSubmit={saveCatalogProvider}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === 'generic') {
|
||||
if (success) {
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Text color="greenBright" bold>
|
||||
Provider added successfully!
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Text bold color="cyan">Add Custom Provider</Text>
|
||||
<Text color="gray">Esc to go back</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>Name: </Text>
|
||||
<TextInput value={name} onChange={setName} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Base URL: </Text>
|
||||
<TextInput value={baseURL} onChange={setBaseURL} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>API Key: </Text>
|
||||
<TextInput value={apiKey} onChange={setApiKey} mask="*" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Model: </Text>
|
||||
<TextInput value={model} onChange={setModel} />
|
||||
</Box>
|
||||
|
||||
{error && <Text color="red">{error}</Text>}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">Press Enter to save</Text>
|
||||
</Box>
|
||||
|
||||
<TextInput
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSubmit={saveGeneric}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
+586
-1356
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
|
||||
export const Logo: React.FC = () => {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="cyanBright" bold>
|
||||
{` ███████╗ ███████╗ ██████╗ ██████╗ `}
|
||||
</Text>
|
||||
<Text color="cyanBright" bold>
|
||||
{` ╚══███╔╝ ██╔════╝ ██╔══██╗ ██╔═══██╗`}
|
||||
</Text>
|
||||
<Text color="cyanBright" bold>
|
||||
{` ███╔╝ █████╗ ██████╔╝ ██║ ██║`}
|
||||
</Text>
|
||||
<Text color="cyanBright" bold>
|
||||
{` ███╔╝ ██╔══╝ ██╔══██╗ ██║ ██║`}
|
||||
</Text>
|
||||
<Text color="cyanBright" bold>
|
||||
{` ███████╗ ███████╗ ██║ ██║ ╚██████╔╝`}
|
||||
</Text>
|
||||
<Text color="cyanBright" bold>
|
||||
{` ╚══════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═════╝ `}
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>
|
||||
A clean terminal AI coding agent
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { highlightCode } from './highlighter';
|
||||
|
||||
// Formatter for model responses: paragraphs, inline code, and highlighted code blocks
|
||||
interface MessageRendererProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export const MessageRenderer: React.FC<MessageRendererProps> = ({ content }) => {
|
||||
const [highlightedBlocks, setHighlightedBlocks] = useState<Map<number, string>>(new Map());
|
||||
|
||||
const codeBlockRegex = /```(\w+)?\n?([\s\S]*?)```/g;
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
const matches: Array<{ index: number; lang: string; code: string; fullMatch: string }> = [];
|
||||
|
||||
while ((match = codeBlockRegex.exec(content)) !== null) {
|
||||
matches.push({
|
||||
index: match.index,
|
||||
lang: match[1] || 'text',
|
||||
code: match[2].trim(),
|
||||
fullMatch: match[0],
|
||||
});
|
||||
}
|
||||
|
||||
// Highlight code blocks asynchronously
|
||||
useEffect(() => {
|
||||
const highlightAll = async () => {
|
||||
const newHighlights = new Map<number, string>();
|
||||
|
||||
for (let i = 0; i < matches.length; i++) {
|
||||
const m = matches[i];
|
||||
try {
|
||||
const ansi = await highlightCode(m.code, m.lang);
|
||||
newHighlights.set(i, ansi);
|
||||
} catch {
|
||||
newHighlights.set(i, m.code);
|
||||
}
|
||||
}
|
||||
|
||||
setHighlightedBlocks(newHighlights);
|
||||
};
|
||||
|
||||
if (matches.length > 0) {
|
||||
highlightAll();
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
for (let i = 0; i < matches.length; i++) {
|
||||
const m = matches[i];
|
||||
|
||||
// Add text before this code block (with paragraph formatting)
|
||||
if (m.index > lastIndex) {
|
||||
const textBefore = content.slice(lastIndex, m.index);
|
||||
parts.push(renderTextBlock(textBefore, `text-${parts.length}`));
|
||||
}
|
||||
|
||||
const highlighted = highlightedBlocks.get(i);
|
||||
|
||||
parts.push(
|
||||
<Box
|
||||
key={`code-${i}`}
|
||||
flexDirection="column"
|
||||
borderStyle="single"
|
||||
borderColor="gray"
|
||||
marginTop={1}
|
||||
marginBottom={1}
|
||||
paddingX={1}
|
||||
>
|
||||
{m.lang && m.lang !== 'text' && (
|
||||
<Text color="cyan" dimColor>{m.lang}</Text>
|
||||
)}
|
||||
{highlighted ? (
|
||||
<Text>{highlighted}</Text>
|
||||
) : (
|
||||
<Text color="gray" dimColor>Highlighting...</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
lastIndex = m.index + m.fullMatch.length;
|
||||
}
|
||||
|
||||
// Add remaining text after last code block (with paragraph formatting)
|
||||
if (lastIndex < content.length) {
|
||||
const remaining = content.slice(lastIndex);
|
||||
parts.push(renderTextBlock(remaining, `text-${parts.length}`));
|
||||
}
|
||||
|
||||
return <>{parts.length > 0 ? parts : renderTextBlock(content, 'full')}</>;
|
||||
};
|
||||
|
||||
// Stronger formatter for model responses (paragraphs + basic lists + inline formatting)
|
||||
function renderTextBlock(text: string, keyPrefix: string): React.ReactNode {
|
||||
const lines = text.split('\n');
|
||||
|
||||
// Group into logical blocks: paragraphs vs lists
|
||||
const blocks: Array<{ type: 'paragraph' | 'list'; lines: string[] }> = [];
|
||||
let current: { type: 'paragraph' | 'list'; lines: string[] } | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
const isListItem = /^\s*(\d+\.|[-*+])\s+/.test(line);
|
||||
|
||||
if (isListItem) {
|
||||
if (!current || current.type !== 'list') {
|
||||
if (current) blocks.push(current);
|
||||
current = { type: 'list', lines: [] };
|
||||
}
|
||||
current.lines.push(line);
|
||||
} else {
|
||||
if (!current || current.type !== 'paragraph') {
|
||||
if (current) blocks.push(current);
|
||||
current = { type: 'paragraph', lines: [] };
|
||||
}
|
||||
current.lines.push(line);
|
||||
}
|
||||
}
|
||||
if (current) blocks.push(current);
|
||||
|
||||
return (
|
||||
<Box key={keyPrefix} flexDirection="column">
|
||||
{blocks.map((block, bIndex) => {
|
||||
const isLastBlock = bIndex === blocks.length - 1;
|
||||
|
||||
if (block.type === 'list') {
|
||||
return (
|
||||
<Box
|
||||
key={`${keyPrefix}-list-${bIndex}`}
|
||||
flexDirection="column"
|
||||
marginBottom={isLastBlock ? 0 : 1}
|
||||
>
|
||||
{block.lines.map((line, lIndex) => (
|
||||
<Text key={`${keyPrefix}-l-${lIndex}`}>
|
||||
{formatInline(line)}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Paragraph block — join lines and split on double newlines for sub-paragraphs
|
||||
const paragraphText = block.lines.join('\n').trim();
|
||||
const subParagraphs = paragraphText
|
||||
.split(/\n\s*\n/)
|
||||
.map(p => p.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={`${keyPrefix}-p-${bIndex}`}
|
||||
flexDirection="column"
|
||||
marginBottom={isLastBlock ? 0 : 1}
|
||||
>
|
||||
{subParagraphs.map((p, pIndex) => (
|
||||
<Box key={`${keyPrefix}-sp-${pIndex}`} marginBottom={pIndex < subParagraphs.length - 1 ? 1 : 0}>
|
||||
<Text>{formatInline(p)}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Handles inline formatting: **bold**, `inline code`
|
||||
function formatInline(text: string): React.ReactNode {
|
||||
const elements: React.ReactNode[] = [];
|
||||
let remaining = text;
|
||||
let idx = 0;
|
||||
|
||||
// Simple sequential parser for **bold** and `code`
|
||||
while (remaining.length > 0) {
|
||||
// Try bold first
|
||||
const boldMatch = remaining.match(/^\*\*([^*]+)\*\*/);
|
||||
if (boldMatch) {
|
||||
elements.push(
|
||||
<Text key={`bold-${idx++}`} bold color="white">
|
||||
{boldMatch[1]}
|
||||
</Text>
|
||||
);
|
||||
remaining = remaining.slice(boldMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try inline code
|
||||
const codeMatch = remaining.match(/^`([^`]+)`/);
|
||||
if (codeMatch) {
|
||||
elements.push(
|
||||
<Text key={`code-${idx++}`} color="cyan" backgroundColor="#2d2d2d">
|
||||
{codeMatch[1]}
|
||||
</Text>
|
||||
);
|
||||
remaining = remaining.slice(codeMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Take plain text until next special char
|
||||
const nextSpecial = remaining.search(/(\*\*|`)/);
|
||||
const plain = nextSpecial === -1 ? remaining : remaining.slice(0, nextSpecial);
|
||||
if (plain) {
|
||||
elements.push(<Text key={`plain-${idx++}`}>{plain}</Text>);
|
||||
remaining = remaining.slice(plain.length);
|
||||
} else {
|
||||
// Fallback single char
|
||||
elements.push(<Text key={`plain-${idx++}`}>{remaining[0]}</Text>);
|
||||
remaining = remaining.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
return <>{elements}</>;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { configManager } from '../config/manager';
|
||||
import type { ProviderProfile } from '../config/types';
|
||||
|
||||
interface ProviderPickerProps {
|
||||
onSelect: (name: string) => void;
|
||||
onCancel: () => void;
|
||||
onAddNew: () => void;
|
||||
}
|
||||
|
||||
export const ProviderPicker: React.FC<ProviderPickerProps> = ({ onSelect, onCancel, onAddNew }) => {
|
||||
const providers = configManager.listProviders();
|
||||
const activeProvider = configManager.getActiveProvider()?.name;
|
||||
|
||||
const totalItems = providers.length + 1; // +1 for "Add new"
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.escape || (key.ctrl && input === 'c')) {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.downArrow) {
|
||||
setSelectedIndex((prev) => Math.min(totalItems - 1, prev + 1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
if (selectedIndex < providers.length) {
|
||||
const selected = providers[selectedIndex];
|
||||
onSelect(selected.name);
|
||||
} else {
|
||||
// Last item = Add new
|
||||
onAddNew();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Quick select by number
|
||||
const num = parseInt(input, 10);
|
||||
if (!isNaN(num) && num >= 1 && num <= totalItems) {
|
||||
if (num <= providers.length) {
|
||||
const selected = providers[num - 1];
|
||||
onSelect(selected.name);
|
||||
} else {
|
||||
onAddNew();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Text bold color="cyan">
|
||||
Select Provider
|
||||
</Text>
|
||||
<Text color="gray" dimColor>
|
||||
↑↓ to navigate • Enter to select • Esc to cancel
|
||||
</Text>
|
||||
|
||||
<Box marginY={1} flexDirection="column">
|
||||
{providers.map((provider, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const isActive = provider.name === activeProvider;
|
||||
|
||||
return (
|
||||
<Box key={provider.name} paddingLeft={1}>
|
||||
<Text color={isSelected ? 'green' : 'white'}>
|
||||
{isSelected ? '› ' : ' '}
|
||||
{provider.name}
|
||||
{isActive && <Text color="blue"> (current)</Text>}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Add new option */}
|
||||
<Box paddingLeft={1}>
|
||||
<Text color={selectedIndex === providers.length ? 'green' : 'cyan'}>
|
||||
{selectedIndex === providers.length ? '› ' : ' '}
|
||||
+ Add new provider...
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{providers[selectedIndex] && (
|
||||
<Box flexDirection="column" marginLeft={2} borderStyle="round" paddingX={1}>
|
||||
<Text>
|
||||
<Text bold>Model:</Text> {providers[selectedIndex].model}
|
||||
</Text>
|
||||
<Text>
|
||||
<Text bold>Base URL:</Text> {providers[selectedIndex].baseURL}
|
||||
</Text>
|
||||
{providers[selectedIndex].description && (
|
||||
<Text>
|
||||
<Text bold>Description:</Text> {providers[selectedIndex].description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>
|
||||
Press 1-{totalItems} for quick selection
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import Spinner from 'ink-spinner';
|
||||
|
||||
interface ThinkingSpinnerProps {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const ThinkingSpinner: React.FC<ThinkingSpinnerProps> = ({ label = 'thinking' }) => {
|
||||
return (
|
||||
<Text color="gray" dimColor>
|
||||
<Spinner type="dots" /> {label}...
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,202 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { highlightCode } from './highlighter';
|
||||
|
||||
interface ToolCallRendererProps {
|
||||
name: string;
|
||||
args: string;
|
||||
result?: string;
|
||||
status?: 'running' | 'success' | 'error';
|
||||
}
|
||||
|
||||
export const ToolCallRenderer: React.FC<ToolCallRendererProps> = ({
|
||||
name,
|
||||
args,
|
||||
result,
|
||||
status = 'success',
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [highlightedArgs, setHighlightedArgs] = useState<string | null>(null);
|
||||
const [highlightedResult, setHighlightedResult] = useState<string | null>(null);
|
||||
|
||||
const hasResult = !!result;
|
||||
const isLongResult = hasResult && (result!.length > 400 || result!.split('\n').length > 12);
|
||||
const [showFullResult, setShowFullResult] = useState(false);
|
||||
|
||||
// Generate a nice one-line summary for collapsed state
|
||||
const summary = getToolSummary(name, args);
|
||||
|
||||
// Highlight arguments (only when expanded)
|
||||
useEffect(() => {
|
||||
if (!isExpanded) return;
|
||||
|
||||
const highlight = async () => {
|
||||
try {
|
||||
let jsonArgs = args;
|
||||
try {
|
||||
const parsed = JSON.parse(args);
|
||||
jsonArgs = JSON.stringify(parsed, null, 2);
|
||||
} catch {
|
||||
// not valid JSON
|
||||
}
|
||||
const ansi = await highlightCode(jsonArgs, 'json');
|
||||
setHighlightedArgs(ansi);
|
||||
} catch {
|
||||
setHighlightedArgs(args);
|
||||
}
|
||||
};
|
||||
highlight();
|
||||
}, [args, isExpanded]);
|
||||
|
||||
// Highlight result (only when expanded)
|
||||
useEffect(() => {
|
||||
if (!isExpanded || !result) return;
|
||||
|
||||
const highlight = async () => {
|
||||
try {
|
||||
const looksLikeCode =
|
||||
result.includes('function') ||
|
||||
result.includes('const ') ||
|
||||
result.includes('import ') ||
|
||||
result.includes('=>') ||
|
||||
result.includes('class ');
|
||||
|
||||
const ansi = await highlightCode(result, looksLikeCode ? 'typescript' : 'text');
|
||||
setHighlightedResult(ansi);
|
||||
} catch {
|
||||
setHighlightedResult(result);
|
||||
}
|
||||
};
|
||||
highlight();
|
||||
}, [result, isExpanded]);
|
||||
|
||||
const borderColor =
|
||||
status === 'running' ? 'yellow' : status === 'error' ? 'red' : 'green';
|
||||
|
||||
const statusIcon =
|
||||
status === 'running' ? '⟳' : status === 'error' ? '✕' : '✓';
|
||||
|
||||
const statusColor =
|
||||
status === 'running' ? 'yellow' : status === 'error' ? 'red' : 'green';
|
||||
|
||||
// === COLLAPSED VIEW — clean formatter for model actions ===
|
||||
if (!isExpanded) {
|
||||
const showToggle = args || hasResult;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" paddingX={1} paddingY={0}>
|
||||
<Text color={statusColor} bold>
|
||||
{statusIcon} {name}
|
||||
</Text>
|
||||
<Text color="gray" dimColor> {summary}</Text>
|
||||
|
||||
{showToggle && (
|
||||
<Text
|
||||
color="cyan"
|
||||
dimColor
|
||||
{...({ onPress: () => setIsExpanded(true) } as any)}
|
||||
>
|
||||
{' '}[show]
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// === EXPANDED VIEW — formatted details ===
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="single"
|
||||
borderColor={borderColor}
|
||||
paddingX={0}
|
||||
paddingY={0}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box paddingX={1} flexDirection="row" justifyContent="space-between">
|
||||
<Text color={statusColor} bold>
|
||||
{statusIcon} {name}
|
||||
</Text>
|
||||
<Text
|
||||
color="cyan"
|
||||
dimColor
|
||||
{...({ onPress: () => setIsExpanded(false) } as any)}
|
||||
>
|
||||
[hide]
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Arguments */}
|
||||
<Box paddingX={1} paddingTop={0} flexDirection="column">
|
||||
<Text color="gray" dimColor bold>args</Text>
|
||||
{highlightedArgs ? (
|
||||
<Text>{highlightedArgs}</Text>
|
||||
) : (
|
||||
<Text color="gray" dimColor>…</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Result */}
|
||||
{hasResult && (
|
||||
<Box paddingX={1} paddingTop={0} flexDirection="column">
|
||||
<Text color="gray" dimColor bold>result</Text>
|
||||
{highlightedResult || result ? (
|
||||
<Text color="green" dimColor>
|
||||
{isLongResult && !showFullResult
|
||||
? (highlightedResult || result!).slice(0, 200) + '...'
|
||||
: (highlightedResult || result)}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{isLongResult && (
|
||||
<Text
|
||||
color="cyan"
|
||||
dimColor
|
||||
{...({ onPress: () => setShowFullResult(!showFullResult) } as any)}
|
||||
>
|
||||
{showFullResult ? ' [less]' : ' [more]'}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Smart one-line summary for collapsed tool calls.
|
||||
// We want: "tool name + the command it actually ran" (e.g. "read src/App.tsx", "ls -la .")
|
||||
function getToolSummary(name: string, args: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(args);
|
||||
|
||||
if (name === 'bash') {
|
||||
const cmd = parsed.command || '';
|
||||
return cmd.length > 65 ? cmd.slice(0, 62) + '...' : cmd;
|
||||
}
|
||||
|
||||
if (name === 'read_file') {
|
||||
const path = parsed.path || '';
|
||||
const short = path.length > 60 ? path.slice(0, 57) + '...' : path;
|
||||
return `read ${short}`;
|
||||
}
|
||||
|
||||
if (name === 'edit_file') {
|
||||
const path = parsed.path || '';
|
||||
const short = path.length > 60 ? path.slice(0, 57) + '...' : path;
|
||||
return `edit ${short}`;
|
||||
}
|
||||
|
||||
// Fallback for future tools
|
||||
const keys = Object.keys(parsed);
|
||||
if (keys.length > 0) {
|
||||
const firstKey = keys[0]!;
|
||||
const val = String((parsed as any)[firstKey] ?? '');
|
||||
const shortVal = val.length > 50 ? val.slice(0, 47) + '...' : val;
|
||||
return `${firstKey}: ${shortVal}`;
|
||||
}
|
||||
|
||||
return args.length > 65 ? args.slice(0, 62) + '...' : args;
|
||||
} catch {
|
||||
return args.length > 65 ? args.slice(0, 62) + '...' : args;
|
||||
}
|
||||
}
|
||||
+29
-56
@@ -1,71 +1,44 @@
|
||||
import { createHighlighter } from 'shiki';
|
||||
import { createHighlighter } from 'shiki'
|
||||
|
||||
export type HighlightedToken = {
|
||||
content: string;
|
||||
color?: string;
|
||||
};
|
||||
let highlighterPromise: ReturnType<typeof createHighlighter> | null = null
|
||||
|
||||
export type HighlightedLine = HighlightedToken[];
|
||||
|
||||
let highlighterPromise: ReturnType<typeof createHighlighter> | undefined;
|
||||
const highlightCache = new Map<string, Promise<HighlightedLine[]>>();
|
||||
|
||||
function hashCode(input: string) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash = ((hash << 5) - hash + input.charCodeAt(i)) | 0;
|
||||
}
|
||||
return hash.toString(36);
|
||||
}
|
||||
|
||||
export async function highlightCode(code: string, lang = 'text'): Promise<HighlightedLine[]> {
|
||||
const cacheKey = `${lang}:${code.length}:${hashCode(code)}`;
|
||||
const cached = highlightCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const promise = tokenizeCode(code, lang);
|
||||
highlightCache.set(cacheKey, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function tokenizeCode(code: string, lang: string): Promise<HighlightedLine[]> {
|
||||
try {
|
||||
highlighterPromise ??= createHighlighter({
|
||||
themes: ['github-dark'],
|
||||
export async function getHighlighter() {
|
||||
if (!highlighterPromise) {
|
||||
highlighterPromise = createHighlighter({
|
||||
themes: ['github-dark', 'github-light'],
|
||||
langs: [
|
||||
'bash',
|
||||
'css',
|
||||
'dockerfile',
|
||||
'go',
|
||||
'html',
|
||||
'typescript',
|
||||
'javascript',
|
||||
'json',
|
||||
'tsx',
|
||||
'jsx',
|
||||
'markdown',
|
||||
'json',
|
||||
'python',
|
||||
'rust',
|
||||
'go',
|
||||
'bash',
|
||||
'shell',
|
||||
'markdown',
|
||||
'css',
|
||||
'html',
|
||||
'sql',
|
||||
'toml',
|
||||
'tsx',
|
||||
'typescript',
|
||||
'yaml',
|
||||
'toml',
|
||||
'dockerfile',
|
||||
],
|
||||
});
|
||||
})
|
||||
}
|
||||
return highlighterPromise
|
||||
}
|
||||
|
||||
const highlighter = await highlighterPromise;
|
||||
const result = highlighter.codeToTokens(code, {
|
||||
lang: (lang || 'text') as any,
|
||||
export async function highlightCode(code: string, lang: string = 'text'): Promise<string> {
|
||||
try {
|
||||
const highlighter = await getHighlighter()
|
||||
return highlighter.codeToAnsi(code, {
|
||||
lang: lang || 'text',
|
||||
theme: 'github-dark',
|
||||
});
|
||||
|
||||
return result.tokens.map((line) =>
|
||||
line.map((token) => ({
|
||||
content: token.content,
|
||||
color: token.color,
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
return code.split(/\r?\n/).map((line) => [{ content: line }]);
|
||||
})
|
||||
} catch (error) {
|
||||
// Fallback to plain text if highlighting fails
|
||||
return code
|
||||
}
|
||||
}
|
||||
+9
-31
@@ -1,35 +1,13 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { createCliRenderer } from '@opentui/core';
|
||||
import { render } from '@opentui/solid';
|
||||
import React from 'react';
|
||||
import { render } from 'ink';
|
||||
import { App } from './App';
|
||||
import { getHighlighter } from './highlighter';
|
||||
|
||||
// Preload Shiki highlighter early for fast syntax highlighting
|
||||
getHighlighter().catch(() => {
|
||||
// Silently fail - we'll fallback gracefully
|
||||
});
|
||||
|
||||
export function startTUI() {
|
||||
void start();
|
||||
}
|
||||
|
||||
async function start() {
|
||||
const renderer = await createCliRenderer({
|
||||
externalOutputMode: 'passthrough',
|
||||
targetFps: 60,
|
||||
gatherStats: false,
|
||||
exitOnCtrlC: false,
|
||||
useMouse: true,
|
||||
enableMouseMovement: true,
|
||||
useKittyKeyboard: {},
|
||||
openConsoleOnError: false,
|
||||
});
|
||||
|
||||
const exit = () => {
|
||||
if (!renderer.isDestroyed) {
|
||||
renderer.setTerminalTitle('');
|
||||
renderer.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
process.once('SIGINT', exit);
|
||||
renderer.once('destroy', () => {
|
||||
process.off('SIGINT', exit);
|
||||
});
|
||||
|
||||
await render(() => <App onExit={exit} />, renderer);
|
||||
render(<App />);
|
||||
}
|
||||
@@ -59,11 +59,13 @@ describe('editFileTool', () => {
|
||||
expect(result).toContain('Could not find the exact string');
|
||||
});
|
||||
|
||||
it('only replaces the first occurrence', async () => {
|
||||
it('rejects when multiple occurrences exist', async () => {
|
||||
const file = join(dir, 'dup.txt');
|
||||
await writeFile(file, 'x\nx\n', 'utf-8');
|
||||
|
||||
await editFileTool.execute({ path: file, old_string: 'x', new_string: 'y' });
|
||||
expect(await readFile(file, 'utf-8')).toBe('y\nx\n');
|
||||
const result = await editFileTool.execute({ path: file, old_string: 'x', new_string: 'y' });
|
||||
expect(result).toContain('2 occurrences');
|
||||
// File should not be modified
|
||||
expect(await readFile(file, 'utf-8')).toBe('x\nx\n');
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test';
|
||||
|
||||
const createMock: any = mock(async () => {
|
||||
throw new Error('create mock not configured');
|
||||
});
|
||||
|
||||
mock.module('openai', () => ({
|
||||
default: class MockOpenAI {
|
||||
chat = {
|
||||
completions: {
|
||||
create: createMock,
|
||||
},
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
async function* streamFrom(chunks: any[]) {
|
||||
for (const chunk of chunks) {
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
createMock.mockReset();
|
||||
});
|
||||
|
||||
describe('OpenAIProvider stream options compatibility', () => {
|
||||
it('requests usage and retries without stream_options when a compatible provider rejects it', async () => {
|
||||
const { OpenAIProvider } = await import('../src/providers/openai');
|
||||
const unsupportedStreamOptions = Object.assign(new Error('unknown parameter: stream_options.include_usage'), {
|
||||
status: 400,
|
||||
});
|
||||
|
||||
createMock
|
||||
.mockRejectedValueOnce(unsupportedStreamOptions)
|
||||
.mockResolvedValueOnce(streamFrom([
|
||||
{
|
||||
choices: [{ delta: { content: 'hi' }, finish_reason: 'stop' }],
|
||||
},
|
||||
]));
|
||||
|
||||
const provider = new OpenAIProvider({ apiKey: 'test', model: 'test-model' });
|
||||
const events = [];
|
||||
|
||||
for await (const event of provider.streamCompletion([{ role: 'user', content: 'hello' }], [])) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(createMock).toHaveBeenCalledTimes(2);
|
||||
expect(createMock.mock.calls[0]?.[0]?.stream_options).toEqual({ include_usage: true });
|
||||
expect(createMock.mock.calls[1]?.[0]?.stream_options).toBeUndefined();
|
||||
expect(events).toContainEqual({ type: 'text', content: 'hi' });
|
||||
});
|
||||
|
||||
it('does not retry unrelated unknown-parameter errors', async () => {
|
||||
const { OpenAIProvider } = await import('../src/providers/openai');
|
||||
const unsupportedToolChoice = Object.assign(new Error('unknown parameter: tool_choice'), {
|
||||
status: 400,
|
||||
});
|
||||
|
||||
createMock.mockRejectedValueOnce(unsupportedToolChoice);
|
||||
|
||||
const provider = new OpenAIProvider({ apiKey: 'test', model: 'test-model' });
|
||||
|
||||
await expect((async () => {
|
||||
for await (const _event of provider.streamCompletion([{ role: 'user', content: 'hello' }], [])) {
|
||||
// Drain the stream.
|
||||
}
|
||||
})()).rejects.toThrow('unknown parameter: tool_choice');
|
||||
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('categorizes mid-stream authentication errors', async () => {
|
||||
const { OpenAIProvider } = await import('../src/providers/openai');
|
||||
|
||||
createMock.mockResolvedValueOnce(streamFrom([
|
||||
{
|
||||
error: {
|
||||
message: 'Incorrect API key provided',
|
||||
},
|
||||
},
|
||||
]));
|
||||
|
||||
const provider = new OpenAIProvider({ apiKey: 'test', model: 'test-model' });
|
||||
|
||||
await expect((async () => {
|
||||
for await (const _event of provider.streamCompletion([{ role: 'user', content: 'hello' }], [])) {
|
||||
// Drain the stream.
|
||||
}
|
||||
})()).rejects.toThrow('Provider authentication error');
|
||||
});
|
||||
|
||||
it('emits usage events from compatible OpenAI streams', async () => {
|
||||
const { OpenAIProvider } = await import('../src/providers/openai');
|
||||
|
||||
createMock.mockResolvedValueOnce(streamFrom([
|
||||
{
|
||||
choices: [{ delta: {}, finish_reason: 'stop' }],
|
||||
usage: {
|
||||
prompt_tokens: 8,
|
||||
completion_tokens: 3,
|
||||
},
|
||||
},
|
||||
]));
|
||||
|
||||
const provider = new OpenAIProvider({ apiKey: 'test', model: 'test-model' });
|
||||
const events = [];
|
||||
|
||||
for await (const event of provider.streamCompletion([{ role: 'user', content: 'hello' }], [])) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(createMock.mock.calls[0]?.[0]?.stream_options).toEqual({ include_usage: true });
|
||||
expect(events).toContainEqual({ type: 'usage', promptTokens: 8, completionTokens: 3 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,663 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { existsSync, readFileSync, readdirSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
getGlobalModelDefinition,
|
||||
getModelsForProvider,
|
||||
getProviderDefinition,
|
||||
listFirstPartyProviders,
|
||||
listGlobalModels,
|
||||
listModelDefinitions,
|
||||
listProviderDefinitions,
|
||||
listProvidersByKind,
|
||||
resolveEffectiveTransportConfig,
|
||||
} from '../src/providers/catalog';
|
||||
import { discoverModelsForProvider } from '../src/providers/discovery';
|
||||
import { createProvider, isProviderRuntimeSupported } from '../src/providers/factory';
|
||||
import { OpenAIProvider } from '../src/providers/openai';
|
||||
import { parseProviderCommand, resolveProviderCommandConfig } from '../src/config/provider';
|
||||
import type { ProviderDefinition } from '../src/providers/catalog/types';
|
||||
|
||||
function testCacheDir(name: string): string {
|
||||
return join(
|
||||
import.meta.dir,
|
||||
'..',
|
||||
'.zero-test-cache',
|
||||
`${name}-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
);
|
||||
}
|
||||
|
||||
function cleanupTestCacheDir(path: string): void {
|
||||
try {
|
||||
rmSync(path, { recursive: true, force: true });
|
||||
} catch (err: any) {
|
||||
if (err?.code !== 'EACCES' && err?.code !== 'EPERM') {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function catalogTomlFiles(): string[] {
|
||||
const catalogRoot = join(import.meta.dir, '..', 'src', 'providers', 'catalog');
|
||||
const definitionsDir = join(catalogRoot, 'definitions');
|
||||
const modelsDir = join(catalogRoot, 'models');
|
||||
|
||||
return [
|
||||
...readdirSync(definitionsDir)
|
||||
.filter((file) => file.endsWith('.toml'))
|
||||
.map((file) => join(definitionsDir, file)),
|
||||
...readdirSync(modelsDir)
|
||||
.filter((file) => file.endsWith('.toml'))
|
||||
.map((file) => join(modelsDir, file)),
|
||||
];
|
||||
}
|
||||
|
||||
function catalogDefinitionTomlFiles(): string[] {
|
||||
const definitionsDir = join(import.meta.dir, '..', 'src', 'providers', 'catalog', 'definitions');
|
||||
|
||||
return readdirSync(definitionsDir)
|
||||
.filter((file) => file.endsWith('.toml'))
|
||||
.map((file) => join(definitionsDir, file));
|
||||
}
|
||||
|
||||
function providerCatalogDocFiles(): string[] {
|
||||
const docsDir = join(import.meta.dir, '..', 'docs', 'provider-catalog');
|
||||
|
||||
return readdirSync(docsDir)
|
||||
.filter((file) => file.endsWith('.md'))
|
||||
.map((file) => join(docsDir, file));
|
||||
}
|
||||
|
||||
function catalogDocumentationAndTomlFiles(): string[] {
|
||||
return [
|
||||
join(import.meta.dir, '..', 'README.md'),
|
||||
...providerCatalogDocFiles(),
|
||||
...catalogTomlFiles(),
|
||||
];
|
||||
}
|
||||
|
||||
describe('provider catalog', () => {
|
||||
it('keeps model metadata in a single TOML block with dotted keys', () => {
|
||||
const forbiddenModelSubtables = /^\s*\[(?:catalog\.models|model)\.(?:capabilities|cost|transportOverrides|temperatureRange)\]\s*$/m;
|
||||
|
||||
for (const file of catalogTomlFiles()) {
|
||||
const content = readFileSync(file, 'utf-8');
|
||||
expect(content, `${file} should use dotted model keys instead of nested model sub-tables`)
|
||||
.not.toMatch(forbiddenModelSubtables);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps provider metadata sections in single TOML blocks with dotted keys', () => {
|
||||
const forbiddenProviderSubtables = /^\s*\[(?:transportConfig\.(?:authHeader|headers)|catalog\.discovery|preset\.badge|setup\..+|validation\..+|usage\..+)\]\s*$/m;
|
||||
|
||||
for (const file of catalogDocumentationAndTomlFiles()) {
|
||||
const content = readFileSync(file, 'utf-8');
|
||||
expect(content, `${file} should use dotted keys instead of nested provider sub-tables`)
|
||||
.not.toMatch(forbiddenProviderSubtables);
|
||||
}
|
||||
});
|
||||
|
||||
it('documents the provider catalog model schema without nested model sub-tables', () => {
|
||||
const modelDocs = readFileSync(join(import.meta.dir, '..', 'docs', 'provider-catalog', 'models.md'), 'utf-8');
|
||||
|
||||
expect(modelDocs).toContain('entry must stay as one TOML block');
|
||||
expect(modelDocs).toContain('Global models describe identity, ownership, limits, and');
|
||||
expect(modelDocs.match(/\[\[model\]\]/g)?.length).toBeGreaterThanOrEqual(2);
|
||||
expect(modelDocs).toContain('contextWindow');
|
||||
expect(modelDocs).toContain('maxOutputTokens');
|
||||
expect(modelDocs).toContain('temperatureRange.min');
|
||||
expect(modelDocs).toContain('temperatureRange.max');
|
||||
expect(modelDocs).toContain('cost.inputPerMillion');
|
||||
expect(modelDocs).toContain('cost.outputPerMillion');
|
||||
expect(modelDocs).toContain('cost.cachePerMillion');
|
||||
expect(modelDocs).toContain('capabilities.supportsVision');
|
||||
expect(modelDocs).toContain('transportOverrides.maxTokensField');
|
||||
expect(modelDocs).toContain('costCurrency');
|
||||
expect(modelDocs).toContain('Do not put `cost` or `costCurrency` in');
|
||||
expect(modelDocs).toContain('| Field | Required | Accepted values or behavior |');
|
||||
expect(modelDocs).not.toMatch(/^\s*\[(?:catalog\.models|model)\.(?:capabilities|cost|transportOverrides|temperatureRange)\]\s*$/m);
|
||||
expect(modelDocs).not.toContain('cost.currency');
|
||||
});
|
||||
|
||||
it('keeps README lightweight and links to detailed provider catalog docs', () => {
|
||||
const readme = readFileSync(join(import.meta.dir, '..', 'README.md'), 'utf-8');
|
||||
|
||||
expect(readme).toContain('[Provider definitions](docs/provider-catalog/providers.md)');
|
||||
expect(readme).toContain('[Model catalog entries](docs/provider-catalog/models.md)');
|
||||
expect(readme).toContain('[Examples](docs/provider-catalog/examples.md)');
|
||||
expect(readme).toContain('credentialEnvVars = ["MY_GATEWAY_API_KEY"]');
|
||||
expect(readme).toContain('costCurrency = "USD"');
|
||||
expect(readme.match(/MY_GATEWAY_API_KEY/g)?.length).toBe(1);
|
||||
expect(readme.match(/\[\[catalog\.models\]\]/g)?.length).toBe(2);
|
||||
expect(readme).not.toContain('cost.currency');
|
||||
});
|
||||
|
||||
it('documents provider schema guardrails outside the README', () => {
|
||||
const providerDocs = readFileSync(join(import.meta.dir, '..', 'docs', 'provider-catalog', 'providers.md'), 'utf-8');
|
||||
|
||||
expect(providerDocs).toContain('A top-level provider is the company or service that self-publishes a model');
|
||||
expect(providerDocs).toContain('| What you\'re adding | Files to touch | Brand/provider examples |');
|
||||
expect(providerDocs).toContain('OpenAI + GPT/o-series');
|
||||
expect(providerDocs).toContain('Anthropic + Claude/Opus');
|
||||
expect(providerDocs).toContain('Keep credential env vars at the provider top level');
|
||||
expect(providerDocs).toContain('costCurrency');
|
||||
expect(providerDocs).toContain('Keep these nested objects inside one TOML block with dotted keys');
|
||||
expect(providerDocs).toContain('authHeader.name = "authorization"');
|
||||
expect(providerDocs).toContain('discovery.kind = "openai-compatible"');
|
||||
expect(providerDocs).toContain('badge.text = "HOSTED"');
|
||||
});
|
||||
|
||||
it('keeps credential env vars declared once in provider definition TOML', () => {
|
||||
for (const file of catalogDefinitionTomlFiles()) {
|
||||
const content = readFileSync(file, 'utf-8');
|
||||
expect(content.match(/^credentialEnvVars\s*=/gm)?.length ?? 0, `${file} should declare credentialEnvVars at most once`)
|
||||
.toBeLessThanOrEqual(1);
|
||||
expect(content, `${file} should not duplicate credential env vars in presets`)
|
||||
.not.toMatch(/^apiKeyEnvVars\s*=/m);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps provider credential env vars scoped to the owning provider', () => {
|
||||
const expectedCredentialEnvVars: Record<string, string[] | undefined> = {
|
||||
groq: ['GROQ_API_KEY'],
|
||||
ollama: undefined,
|
||||
openai: ['OPENAI_API_KEY'],
|
||||
opengateway: ['OPENGATEWAY_API_KEY'],
|
||||
openrouter: ['OPENROUTER_API_KEY'],
|
||||
};
|
||||
|
||||
for (const definition of listProviderDefinitions()) {
|
||||
expect(
|
||||
definition.credentialEnvVars,
|
||||
`${definition.id} should not borrow another provider's credential env vars`
|
||||
).toEqual(expectedCredentialEnvVars[definition.id]);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps global model TOML free of provider pricing metadata', () => {
|
||||
for (const file of catalogTomlFiles().filter((path) => path.includes(`${join('catalog', 'models')}`))) {
|
||||
const content = readFileSync(file, 'utf-8');
|
||||
expect(content, `${file} should not define provider route pricing on global models`)
|
||||
.not.toMatch(/^(?:cost\.|costCurrency\s*=)/m);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps currency at provider level instead of model cost level', () => {
|
||||
for (const file of catalogDocumentationAndTomlFiles()) {
|
||||
const content = readFileSync(file, 'utf-8');
|
||||
expect(content, `${file} should use provider-level costCurrency instead of model cost.currency`)
|
||||
.not.toContain('cost.currency');
|
||||
}
|
||||
});
|
||||
|
||||
it('auto-discovers provider and gateway definitions', () => {
|
||||
const definitions = listProviderDefinitions();
|
||||
const opengateway = getProviderDefinition('opengateway');
|
||||
|
||||
expect(definitions.map((definition) => definition.id)).toContain('opengateway');
|
||||
expect(opengateway?.kind).toBe('gateway');
|
||||
expect(opengateway?.baseURL).toBe('https://opengateway.gitlawb.com/v1');
|
||||
expect(opengateway?.defaultModel).toBe('mimo-v2.5-pro');
|
||||
});
|
||||
|
||||
it('parses dotted TOML provider sections as nested config objects', () => {
|
||||
const opengateway = getProviderDefinition('opengateway');
|
||||
const openrouter = getProviderDefinition('openrouter');
|
||||
|
||||
expect(opengateway?.transportConfig?.authHeader).toEqual({
|
||||
name: 'authorization',
|
||||
scheme: 'bearer',
|
||||
});
|
||||
expect(opengateway?.catalog?.discovery).toEqual({
|
||||
kind: 'openai-compatible',
|
||||
requiresAuth: true,
|
||||
path: '/models',
|
||||
});
|
||||
expect(opengateway?.preset?.badge).toEqual({
|
||||
text: 'FREE',
|
||||
color: 'success',
|
||||
});
|
||||
|
||||
expect(openrouter?.transportConfig?.headers).toEqual({
|
||||
'HTTP-Referer': '$OPENROUTER_SITE_URL',
|
||||
'X-OpenRouter-Title': '$OPENROUTER_SITE_NAME',
|
||||
});
|
||||
expect(openrouter?.catalog?.discovery?.kind).toBe('openai-compatible');
|
||||
expect(openrouter?.catalog?.discovery?.path).toBe('/models');
|
||||
});
|
||||
|
||||
it('fans out top-level credential env vars into normalized provider metadata', () => {
|
||||
const opengateway = getProviderDefinition('opengateway');
|
||||
|
||||
expect(opengateway?.credentialEnvVars).toEqual(['OPENGATEWAY_API_KEY']);
|
||||
expect(opengateway?.setup?.credentialEnvVars).toEqual(opengateway?.credentialEnvVars);
|
||||
expect(opengateway?.validation?.credentialEnvVars).toEqual(opengateway?.credentialEnvVars);
|
||||
expect(opengateway?.preset?.apiKeyEnvVars).toEqual(opengateway?.credentialEnvVars);
|
||||
});
|
||||
|
||||
it('keeps first-party pricing on provider catalog routes, not global models', () => {
|
||||
const openai = getProviderDefinition('openai');
|
||||
expect(openai?.costCurrency).toBe('USD');
|
||||
|
||||
const models = getModelsForProvider(openai!);
|
||||
const gpt4o = models.find((model) => model.apiName === 'gpt-4o');
|
||||
const gpt4oMini = models.find((model) => model.apiName === 'gpt-4o-mini');
|
||||
|
||||
expect(gpt4o?.cost).toEqual({
|
||||
inputPerMillion: 2.50,
|
||||
outputPerMillion: 10.00,
|
||||
});
|
||||
expect(gpt4oMini?.cost).toEqual({
|
||||
inputPerMillion: 0.15,
|
||||
outputPerMillion: 0.60,
|
||||
});
|
||||
expect(getGlobalModelDefinition('gpt-4o')).not.toHaveProperty('cost');
|
||||
});
|
||||
|
||||
it('auto-discovers localhost provider definitions', () => {
|
||||
const ollama = getProviderDefinition('ollama');
|
||||
|
||||
expect(ollama).toBeDefined();
|
||||
expect(ollama?.kind).toBe('localhost');
|
||||
expect(ollama?.category).toBe('local');
|
||||
expect(ollama?.apiKeyRequired).toBe(false);
|
||||
expect(ollama?.setup?.authMode).toBe('none');
|
||||
});
|
||||
|
||||
it('lists providers by kind', () => {
|
||||
const gateways = listProvidersByKind('gateway');
|
||||
const localhost = listProvidersByKind('localhost');
|
||||
|
||||
expect(gateways.map((d) => d.id)).toContain('opengateway');
|
||||
expect(localhost.map((d) => d.id)).toContain('ollama');
|
||||
});
|
||||
|
||||
it('auto-discovers global model catalog entries', () => {
|
||||
const models = listModelDefinitions();
|
||||
const gpt4o = getGlobalModelDefinition('gpt-4o');
|
||||
|
||||
expect(models.map((model) => model.id)).toContain('gpt-4o');
|
||||
expect(gpt4o?.tier).toBe('first-party');
|
||||
expect(gpt4o?.contextWindow).toBe(128000);
|
||||
expect(gpt4o?.maxOutputTokens).toBe(16384);
|
||||
expect(gpt4o?.capabilities?.supportsVision).toBe(true);
|
||||
});
|
||||
|
||||
it('lists all global models', () => {
|
||||
const globals = listGlobalModels();
|
||||
const ids = globals.map((m) => m.id);
|
||||
|
||||
expect(ids).toContain('gpt-4o');
|
||||
expect(ids).toContain('gpt-4o-mini');
|
||||
expect(ids).toContain('claude-sonnet-4');
|
||||
expect(ids).toContain('deepseek-v3');
|
||||
});
|
||||
|
||||
it('resolves globalModelId references in gateway catalog models', () => {
|
||||
const opengateway = getProviderDefinition('opengateway');
|
||||
expect(opengateway).toBeDefined();
|
||||
|
||||
const models = getModelsForProvider(opengateway!);
|
||||
// Model inherits id from global catalog when not explicitly set
|
||||
const gpt4oOverride = models.find((m) => m.apiName === 'gpt-4o');
|
||||
|
||||
expect(gpt4oOverride).toBeDefined();
|
||||
expect(gpt4oOverride?.apiName).toBe('gpt-4o');
|
||||
// Gateway overrides reduced limits
|
||||
expect(gpt4oOverride?.contextWindow).toBe(32000);
|
||||
expect(gpt4oOverride?.maxOutputTokens).toBe(4096);
|
||||
// Inherited from global model
|
||||
expect(gpt4oOverride?.capabilities?.supportsVision).toBe(true);
|
||||
expect(gpt4oOverride?.tier).toBe('first-party');
|
||||
// Provider metadata
|
||||
expect(gpt4oOverride?.providerId).toBe('opengateway');
|
||||
expect(gpt4oOverride?.providerKind).toBe('gateway');
|
||||
});
|
||||
|
||||
it('creates runtime providers from provider config', () => {
|
||||
const provider = createProvider({
|
||||
providerId: 'opengateway',
|
||||
apiKey: 'test-key',
|
||||
baseURL: 'https://opengateway.gitlawb.com/v1',
|
||||
model: 'mimo-v2.5-pro',
|
||||
});
|
||||
|
||||
expect(provider).toBeInstanceOf(OpenAIProvider);
|
||||
});
|
||||
|
||||
it('applies custom headers with env var interpolation', () => {
|
||||
const originalEnv = process.env.HICAP_SECRET;
|
||||
process.env.HICAP_SECRET = 'test-secret-value';
|
||||
|
||||
try {
|
||||
const provider = createProvider({
|
||||
providerId: 'opengateway',
|
||||
apiKey: 'test-key',
|
||||
baseURL: 'https://opengateway.gitlawb.com/v1',
|
||||
model: 'mimo-v2.5-pro',
|
||||
});
|
||||
|
||||
expect(provider).toBeInstanceOf(OpenAIProvider);
|
||||
} finally {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.HICAP_SECRET;
|
||||
} else {
|
||||
process.env.HICAP_SECRET = originalEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('anthropic-compatible transport is not yet supported', () => {
|
||||
const anthropicDef: ProviderDefinition = {
|
||||
id: 'anthropic-test',
|
||||
name: 'Anthropic Test',
|
||||
kind: 'provider',
|
||||
description: 'Test anthropic-compatible provider',
|
||||
baseURL: 'https://api.anthropic.com',
|
||||
defaultModel: 'claude-sonnet-4',
|
||||
transportConfig: {
|
||||
kind: 'anthropic-compatible',
|
||||
},
|
||||
};
|
||||
|
||||
// TODO: implement anthropic-compatible transport
|
||||
expect(isProviderRuntimeSupported(anthropicDef)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects unsupported transport kinds', () => {
|
||||
const nativeDefinition: ProviderDefinition = {
|
||||
id: 'native-test',
|
||||
name: 'Native Test',
|
||||
kind: 'provider',
|
||||
description: 'Unsupported native test provider',
|
||||
baseURL: 'https://example.com',
|
||||
defaultModel: 'native-model',
|
||||
transportConfig: {
|
||||
kind: 'gemini-native' as any,
|
||||
},
|
||||
};
|
||||
|
||||
expect(isProviderRuntimeSupported(nativeDefinition)).toBe(false);
|
||||
});
|
||||
|
||||
it('uses catalog defaults when provider commands only return a provider id', () => {
|
||||
const config = resolveProviderCommandConfig({
|
||||
provider_id: 'opengateway',
|
||||
api_key: 'ogw_live_test',
|
||||
});
|
||||
|
||||
expect(config).toEqual({
|
||||
providerId: 'opengateway',
|
||||
apiKey: 'ogw_live_test',
|
||||
baseURL: 'https://opengateway.gitlawb.com/v1',
|
||||
model: 'mimo-v2.5-pro',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses quoted provider command arguments', () => {
|
||||
expect(parseProviderCommand('"C:\\Program Files\\Zero\\provider.exe" --json "{\\"provider_id\\":\\"opengateway\\"}"')).toEqual([
|
||||
'C:\\Program Files\\Zero\\provider.exe',
|
||||
'--json',
|
||||
'{"provider_id":"opengateway"}',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects unterminated provider command quotes', () => {
|
||||
expect(() => parseProviderCommand('provider-command "unterminated')).toThrow('Unterminated');
|
||||
});
|
||||
|
||||
it('discovers OpenAI-compatible models and merges them with static catalog models', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalConfigDir = process.env.ZERO_CONFIG_DIR;
|
||||
const testConfigDir = testCacheDir('provider-catalog');
|
||||
const requests: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
|
||||
|
||||
cleanupTestCacheDir(testConfigDir);
|
||||
process.env.ZERO_CONFIG_DIR = testConfigDir;
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
requests.push({ input, init });
|
||||
return new Response(JSON.stringify({
|
||||
data: [
|
||||
{ id: 'discovered-model' },
|
||||
{ id: 'mimo-v2.5-pro' },
|
||||
],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await discoverModelsForProvider('opengateway', {
|
||||
apiKey: 'ogw_live_test',
|
||||
forceRefresh: true,
|
||||
});
|
||||
|
||||
expect(result?.source).toBe('network');
|
||||
expect(result?.models.map((model) => model.apiName)).toContain('mimo-v2.5-pro');
|
||||
expect(result?.models.map((model) => model.apiName)).toContain('gpt-4o');
|
||||
expect(result?.models.map((model) => model.apiName)).toContain('discovered-model');
|
||||
expect(String(requests[0]?.input)).toBe('https://opengateway.gitlawb.com/v1/models');
|
||||
expect((requests[0]?.init?.headers as Record<string, string>)?.authorization).toBe('Bearer ogw_live_test');
|
||||
expect(readFileSync(join(testConfigDir, 'model-cache.json'), 'utf-8')).not.toContain('ogw_live_test');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.ZERO_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.ZERO_CONFIG_DIR = originalConfigDir;
|
||||
}
|
||||
cleanupTestCacheDir(testConfigDir);
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves effective transport config with per-model overrides', () => {
|
||||
const opengateway = getProviderDefinition('opengateway');
|
||||
expect(opengateway).toBeDefined();
|
||||
|
||||
const gpt4oModel = {
|
||||
id: 'gpt-4o-og',
|
||||
apiName: 'gpt-4o',
|
||||
globalModelId: 'gpt-4o',
|
||||
contextWindow: 32000,
|
||||
maxOutputTokens: 4096,
|
||||
transportOverrides: {
|
||||
maxTokensField: 'max_tokens' as const,
|
||||
removeBodyFields: ['store'],
|
||||
},
|
||||
};
|
||||
|
||||
const effective = resolveEffectiveTransportConfig(opengateway!, gpt4oModel);
|
||||
expect(effective).toBeDefined();
|
||||
// Per-model override should take precedence
|
||||
expect(effective?.maxTokensField).toBe('max_tokens');
|
||||
// removeBodyFields should be unioned (definition + model)
|
||||
expect(effective?.removeBodyFields).toContain('store');
|
||||
expect(effective?.removeBodyFields).toContain('stream_options');
|
||||
});
|
||||
|
||||
it('first-party providers auto-expose their owned global models', () => {
|
||||
const openai = getProviderDefinition('openai');
|
||||
expect(openai).toBeDefined();
|
||||
expect(openai?.isFirstParty).toBe(true);
|
||||
|
||||
const models = getModelsForProvider(openai!);
|
||||
const modelIds = models.map((m) => m.apiName);
|
||||
|
||||
expect(modelIds).toContain('gpt-4o');
|
||||
expect(modelIds).toContain('gpt-4o-mini');
|
||||
expect(modelIds).toContain('o1');
|
||||
expect(modelIds).toContain('o1-mini');
|
||||
expect(modelIds).not.toContain('claude-sonnet-4');
|
||||
expect(modelIds).not.toContain('deepseek-v3');
|
||||
|
||||
const gpt4o = models.find((m) => m.apiName === 'gpt-4o');
|
||||
expect(gpt4o?.providerId).toBe('openai');
|
||||
expect(gpt4o?.providerKind).toBe('provider');
|
||||
});
|
||||
|
||||
it('non-first-party providers only show their own catalog models', () => {
|
||||
const opengateway = getProviderDefinition('opengateway');
|
||||
expect(opengateway?.isFirstParty).toBeUndefined();
|
||||
|
||||
const models = getModelsForProvider(opengateway!);
|
||||
const modelIds = models.map((m) => m.apiName);
|
||||
|
||||
expect(modelIds).toContain('mimo-v2.5-pro');
|
||||
expect(modelIds).toContain('gpt-4o'); // via globalModelId override
|
||||
expect(modelIds).not.toContain('claude-sonnet-4');
|
||||
expect(modelIds).not.toContain('deepseek-v3');
|
||||
});
|
||||
|
||||
it('returns definition-level transport config when no model overrides exist', () => {
|
||||
const opengateway = getProviderDefinition('opengateway');
|
||||
const effective = resolveEffectiveTransportConfig(opengateway!, undefined);
|
||||
|
||||
expect(effective?.maxTokensField).toBe('max_completion_tokens');
|
||||
expect(effective?.removeBodyFields).toEqual(['store', 'stream_options']);
|
||||
});
|
||||
|
||||
it('does not cache first-time discovery failures as fresh results', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalConfigDir = process.env.ZERO_CONFIG_DIR;
|
||||
const testConfigDir = testCacheDir('provider-catalog-failure');
|
||||
let requestCount = 0;
|
||||
|
||||
cleanupTestCacheDir(testConfigDir);
|
||||
process.env.ZERO_CONFIG_DIR = testConfigDir;
|
||||
|
||||
globalThis.fetch = (async () => {
|
||||
requestCount += 1;
|
||||
return new Response(JSON.stringify({ error: 'temporary failure' }), {
|
||||
status: 503,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const first = await discoverModelsForProvider('opengateway', {
|
||||
apiKey: 'ogw_live_test',
|
||||
});
|
||||
const second = await discoverModelsForProvider('opengateway', {
|
||||
apiKey: 'ogw_live_test',
|
||||
});
|
||||
|
||||
expect(first?.source).toBe('error');
|
||||
expect(second?.source).toBe('error');
|
||||
expect(requestCount).toBe(2);
|
||||
expect(existsSync(join(testConfigDir, 'model-cache.json'))).toBe(false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.ZERO_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.ZERO_CONFIG_DIR = originalConfigDir;
|
||||
}
|
||||
cleanupTestCacheDir(testConfigDir);
|
||||
}
|
||||
});
|
||||
|
||||
it('discovers Ollama models via /api/tags', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalConfigDir = process.env.ZERO_CONFIG_DIR;
|
||||
const testConfigDir = testCacheDir('ollama-discovery');
|
||||
|
||||
cleanupTestCacheDir(testConfigDir);
|
||||
process.env.ZERO_CONFIG_DIR = testConfigDir;
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/api/tags')) {
|
||||
return new Response(JSON.stringify({
|
||||
models: [
|
||||
{ name: 'llama3.2:latest' },
|
||||
{ name: 'mistral:7b' },
|
||||
],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response('not found', { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await discoverModelsForProvider('ollama', {
|
||||
forceRefresh: true,
|
||||
});
|
||||
|
||||
expect(result?.source).toBe('network');
|
||||
expect(result?.models.map((m) => m.apiName)).toContain('llama3.2:latest');
|
||||
expect(result?.models.map((m) => m.apiName)).toContain('mistral:7b');
|
||||
// Ollama models should be tagged as local tier
|
||||
const llama = result?.models.find((m) => m.apiName === 'llama3.2:latest');
|
||||
expect(llama?.tier).toBe('local');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.ZERO_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.ZERO_CONFIG_DIR = originalConfigDir;
|
||||
}
|
||||
cleanupTestCacheDir(testConfigDir);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns stale-cache on network failure when cache exists', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalConfigDir = process.env.ZERO_CONFIG_DIR;
|
||||
const testConfigDir = testCacheDir('stale-cache-recovery');
|
||||
let fetchCount = 0;
|
||||
|
||||
cleanupTestCacheDir(testConfigDir);
|
||||
process.env.ZERO_CONFIG_DIR = testConfigDir;
|
||||
|
||||
// First call succeeds and populates cache
|
||||
globalThis.fetch = (async () => {
|
||||
fetchCount++;
|
||||
return new Response(JSON.stringify({
|
||||
data: [{ id: 'cached-model' }],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
// Populate cache
|
||||
await discoverModelsForProvider('opengateway', {
|
||||
apiKey: 'ogw_test',
|
||||
forceRefresh: true,
|
||||
});
|
||||
|
||||
// Now make fetch fail
|
||||
globalThis.fetch = (async () => {
|
||||
fetchCount++;
|
||||
return new Response(JSON.stringify({ error: 'down' }), {
|
||||
status: 503,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
// Should return stale-cache, not error
|
||||
const result = await discoverModelsForProvider('opengateway', {
|
||||
apiKey: 'ogw_test',
|
||||
forceRefresh: true,
|
||||
});
|
||||
|
||||
expect(result?.source).toBe('stale-cache');
|
||||
expect(result?.stale).toBe(true);
|
||||
// Should still have the cached models
|
||||
expect(result?.models.map((m) => m.apiName)).toContain('cached-model');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.ZERO_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.ZERO_CONFIG_DIR = originalConfigDir;
|
||||
}
|
||||
cleanupTestCacheDir(testConfigDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
+1
-2
@@ -5,8 +5,7 @@
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "@opentui/solid",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
|
||||
Reference in new issue
Block a user