mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
6
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.
|
||||
@@ -12,7 +12,9 @@
|
||||
"ink-text-input": "^6.0.0",
|
||||
"openai": "^6.39.1",
|
||||
"react": "^19.2.6",
|
||||
"react-devtools-core": "^7.0.1",
|
||||
"shiki": "^4.1.0",
|
||||
"smol-toml": "^1.6.1",
|
||||
"zod": "^4.4.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -181,6 +183,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -197,12 +201,16 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"slice-ansi": ["[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=="],
|
||||
|
||||
"stack-utils": ["[email protected]", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
|
||||
@@ -265,6 +273,8 @@
|
||||
|
||||
"npm-run-path/path-key": ["[email protected]", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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. |
|
||||
+3
-1
@@ -9,7 +9,7 @@
|
||||
"scripts": {
|
||||
"dev": "bun run src/index.ts",
|
||||
"test": "bun test ./tests --timeout 15000",
|
||||
"build": "bun build src/index.ts --compile --outfile zero"
|
||||
"build": "bun scripts/build.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
@@ -26,7 +26,9 @@
|
||||
"ink-text-input": "^6.0.0",
|
||||
"openai": "^6.39.1",
|
||||
"react": "^19.2.6",
|
||||
"react-devtools-core": "^7.0.1",
|
||||
"shiki": "^4.1.0",
|
||||
"smol-toml": "^1.6.1",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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', catalogAssetEntrypoint],
|
||||
compile: {
|
||||
outfile: 'dist/zero',
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
for (const log of result.logs) {
|
||||
console.error(log);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
+27
-36
@@ -1,4 +1,4 @@
|
||||
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';
|
||||
@@ -41,7 +41,7 @@ export async function runAgent(
|
||||
|
||||
const systemPrompt = planMode ? PLAN_MODE_SYSTEM_PROMPT : DEFAULT_SYSTEM_PROMPT;
|
||||
|
||||
const messages: any[] = [
|
||||
const messages: Message[] = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: initialPrompt },
|
||||
];
|
||||
@@ -49,30 +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 => {
|
||||
// Convert Zod schema to proper JSON Schema (critical for many providers).
|
||||
// zod v4 ships this natively — no external package needed.
|
||||
const jsonSchema = z.toJSONSchema(t.parameters, {
|
||||
target: 'draft-7',
|
||||
}) as any;
|
||||
|
||||
// Remove $schema if present (some providers dislike it)
|
||||
delete jsonSchema.$schema;
|
||||
|
||||
// Make it strict by default (good practice)
|
||||
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>();
|
||||
@@ -85,20 +83,13 @@ export async function runAgent(
|
||||
console.log(`\n${red}┌${border}┐`);
|
||||
console.log(`│ SENDING TO PROVIDER${' '.repeat(31)}│`);
|
||||
console.log(`├${border}┤`);
|
||||
console.log(`│ Messages: ${messages.length}${' '.repeat(40 - String(messages.length).length)}│`);
|
||||
console.log(`│ Tools enabled: ${toolDefinitions.length > 0}${' '.repeat(33)}│`);
|
||||
console.log(`│ Tool count: ${toolDefinitions.length}${' '.repeat(38 - String(toolDefinitions.length).length)}│`);
|
||||
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 - toolsList.length))}│`);
|
||||
|
||||
// Show a sample of the schema for the first tool (very useful for debugging)
|
||||
const firstTool = toolDefinitions[0];
|
||||
if (firstTool.parameters) {
|
||||
const schemaPreview = JSON.stringify(firstTool.parameters, null, 2).slice(0, 300);
|
||||
console.log(`│ First tool schema sample:\n${schemaPreview}...`);
|
||||
}
|
||||
console.log(`│ Tools: ${toolsList.slice(0, 42)}${' '.repeat(Math.max(0, 43 - Math.min(42, toolsList.length)))}│`);
|
||||
}
|
||||
|
||||
const preview = String(messages[messages.length-1]?.content || '').slice(0, 45);
|
||||
|
||||
+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;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Command } from 'commander';
|
||||
import { runHeadless } from './cli';
|
||||
import { configManager } from './config/manager';
|
||||
import { startTUI } from './tui';
|
||||
import { listProviderDefinitions } from './providers/catalog';
|
||||
|
||||
const program = new Command();
|
||||
|
||||
@@ -74,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';
|
||||
}
|
||||
+54
-23
@@ -1,22 +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;
|
||||
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(
|
||||
@@ -48,14 +57,33 @@ export class OpenAIProvider implements Provider {
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
// Build request body
|
||||
const body: Record<string, unknown> = {
|
||||
model: this.model,
|
||||
messages: openaiMessages,
|
||||
tools: openaiTools,
|
||||
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({
|
||||
model: this.model,
|
||||
messages: openaiMessages as any,
|
||||
tools: openaiTools,
|
||||
stream: true,
|
||||
});
|
||||
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')) {
|
||||
@@ -69,9 +97,10 @@ export class OpenAIProvider implements Provider {
|
||||
|
||||
const toolCallAccumulators = new Map<number, {
|
||||
id: string;
|
||||
name: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
started: boolean;
|
||||
ended: boolean;
|
||||
}>();
|
||||
|
||||
try {
|
||||
@@ -96,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,
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -114,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
|
||||
@@ -140,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,8 +180,9 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,18 +205,17 @@ function getDetailedErrorMessage(err: any): string {
|
||||
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 {}
|
||||
try { return JSON.stringify(err.error); } catch { return String(err.error); }
|
||||
}
|
||||
|
||||
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 (err.cause?.message) return err.cause.message;
|
||||
|
||||
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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+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();
|
||||
+3
-6
@@ -1,4 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolCall } from '../providers/types';
|
||||
|
||||
export type { ToolCall };
|
||||
|
||||
export interface Tool {
|
||||
name: string;
|
||||
@@ -7,12 +10,6 @@ export interface Tool {
|
||||
execute: (args: any) => Promise<string>; // Returns tool result as string
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: string; // raw JSON string from model
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
toolCallId: string;
|
||||
result: string;
|
||||
|
||||
+204
-76
@@ -2,28 +2,33 @@ 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' | 'opengateway' | 'generic';
|
||||
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);
|
||||
|
||||
// For the choose menu
|
||||
const [selectedOption, setSelectedOption] = useState(0); // 0 = OpenGateway, 1 = Generic
|
||||
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('');
|
||||
|
||||
// For the OpenGateway form (step based)
|
||||
const [ogwFormStep, setOgwFormStep] = useState(0); // 0 = API Key, 1 = Model, 2 = Done
|
||||
|
||||
// OpenGateway fields
|
||||
const [ogwKey, setOgwKey] = useState('');
|
||||
const [ogwModel, setOgwModel] = useState('mimo-v2.5-pro');
|
||||
|
||||
// Generic fields
|
||||
const [name, setName] = useState('');
|
||||
const [baseURL, setBaseURL] = useState('https://api.openai.com/v1');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
@@ -32,14 +37,69 @@ export const AddProvider: React.FC<AddProviderProps> = ({ onDone, onCancel }) =>
|
||||
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 {
|
||||
// Reset states when going back
|
||||
setMode('choose');
|
||||
setOgwFormStep(0);
|
||||
setCatalogStep(0);
|
||||
setSelectedDefinition(null);
|
||||
setError('');
|
||||
setSuccess(false);
|
||||
setSelectedOption(0);
|
||||
@@ -47,56 +107,88 @@ export const AddProvider: React.FC<AddProviderProps> = ({ onDone, onCancel }) =>
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow key + Enter navigation for the choose menu
|
||||
if (mode === 'choose') {
|
||||
if (mode === 'catalog' && catalogStep === 1 && modelOptions.length > 0) {
|
||||
if (key.upArrow) {
|
||||
setSelectedOption((prev) => Math.max(0, prev - 1));
|
||||
return;
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setSelectedOption((prev) => Math.min(1, prev + 1));
|
||||
return;
|
||||
}
|
||||
if (key.return) {
|
||||
if (selectedOption === 0) {
|
||||
setMode('opengateway');
|
||||
setOgwFormStep(0);
|
||||
} else {
|
||||
setMode('generic');
|
||||
}
|
||||
setSelectedModelIndex((prev) => {
|
||||
const next = Math.max(0, prev - 1);
|
||||
catalogModelTouched.current = true;
|
||||
setCatalogModel(modelOptions[next]?.apiName ?? catalogModel);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Still support numbers for convenience
|
||||
if (input === '1') {
|
||||
setMode('opengateway');
|
||||
setOgwFormStep(0);
|
||||
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 (input === '2') {
|
||||
}
|
||||
|
||||
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 saveOpenGateway = () => {
|
||||
if (!ogwKey.trim()) {
|
||||
const saveCatalogProvider = () => {
|
||||
if (!selectedDefinition) return;
|
||||
|
||||
if (selectedDefinition.apiKeyRequired !== false && !catalogKey.trim()) {
|
||||
setError('API key is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const profileName = 'opengateway';
|
||||
const profileName = selectedDefinition.id;
|
||||
|
||||
configManager.addProvider({
|
||||
name: profileName,
|
||||
baseURL: 'https://opengateway.gitlawb.com/v1',
|
||||
apiKey: ogwKey.trim(),
|
||||
model: ogwModel.trim(),
|
||||
description: 'OpenGateway',
|
||||
providerId: selectedDefinition.id,
|
||||
kind: selectedDefinition.kind,
|
||||
baseURL: selectedDefinition.baseURL,
|
||||
apiKey: catalogKey.trim() || undefined,
|
||||
model: catalogModel.trim() || selectedDefinition.defaultModel,
|
||||
description: selectedDefinition.name,
|
||||
});
|
||||
|
||||
setSuccess(true);
|
||||
|
||||
// Auto-close after a short delay so the user sees the success message
|
||||
setTimeout(() => {
|
||||
onDone(profileName);
|
||||
}, 1200);
|
||||
@@ -110,6 +202,7 @@ export const AddProvider: React.FC<AddProviderProps> = ({ onDone, onCancel }) =>
|
||||
|
||||
configManager.addProvider({
|
||||
name: name.trim(),
|
||||
kind: 'custom',
|
||||
baseURL: baseURL.trim(),
|
||||
apiKey: apiKey.trim() || undefined,
|
||||
model: model.trim(),
|
||||
@@ -124,24 +217,30 @@ export const AddProvider: React.FC<AddProviderProps> = ({ onDone, onCancel }) =>
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Text bold color="cyan">Add New Provider</Text>
|
||||
<Text color="gray">Esc to go back • ↑↓ to navigate • Enter to select</Text>
|
||||
<Text color="gray">Esc to go back - Up/Down to navigate - Enter to select</Text>
|
||||
|
||||
<Box marginY={1} flexDirection="column">
|
||||
<Text color={selectedOption === 0 ? 'greenBright' : 'white'}>
|
||||
{selectedOption === 0 ? '› ' : ' '}1. Add OpenGateway (recommended)
|
||||
</Text>
|
||||
{selectedOption === 0 && (
|
||||
<Text color="gray" dimColor>
|
||||
{' '}You'll be asked for your ogw_live_... API key
|
||||
</Text>
|
||||
)}
|
||||
{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 marginTop={1} color={selectedOption === 1 ? 'greenBright' : 'white'}>
|
||||
{selectedOption === 1 ? '› ' : ' '}2. Add custom OpenAI-compatible provider
|
||||
<Text color={selectedOption === catalogProviders.length ? 'greenBright' : 'white'}>
|
||||
{selectedOption === catalogProviders.length ? '> ' : ' '}
|
||||
{catalogProviders.length + 1}. Add custom OpenAI-compatible provider
|
||||
</Text>
|
||||
{selectedOption === 1 && (
|
||||
{selectedOption === catalogProviders.length && (
|
||||
<Text color="gray" dimColor>
|
||||
{' '}For Groq, OpenAI, Ollama, etc.
|
||||
{' '}For providers not yet described by a catalog file
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
@@ -149,12 +248,12 @@ export const AddProvider: React.FC<AddProviderProps> = ({ onDone, onCancel }) =>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === 'opengateway') {
|
||||
if (mode === 'catalog' && selectedDefinition) {
|
||||
if (success) {
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Text color="greenBright" bold>
|
||||
✓ OpenGateway provider added successfully!
|
||||
{selectedDefinition.name} provider added successfully!
|
||||
</Text>
|
||||
<Text color="gray" dimColor>
|
||||
It is now your active provider.
|
||||
@@ -165,24 +264,22 @@ export const AddProvider: React.FC<AddProviderProps> = ({ onDone, onCancel }) =>
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Text bold color="cyan">Add OpenGateway Provider</Text>
|
||||
<Text bold color="cyan">Add {selectedDefinition.name}</Text>
|
||||
<Text color="gray">Esc to go back</Text>
|
||||
|
||||
{ogwFormStep === 0 && (
|
||||
{catalogStep === 0 && (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color="yellowBright">Step 1/2 — Enter your OpenGateway API key</Text>
|
||||
<Text color="gray" dimColor>
|
||||
You can get one at https://opengateway.gitlawb.com
|
||||
</Text>
|
||||
<Text color="yellowBright">Step 1/2 - Enter API key</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text>API Key: </Text>
|
||||
<Text>{selectedDefinition.apiKeyLabel ?? 'API key'}: </Text>
|
||||
<TextInput
|
||||
value={ogwKey}
|
||||
onChange={setOgwKey}
|
||||
value={catalogKey}
|
||||
onChange={setCatalogKey}
|
||||
mask="*"
|
||||
placeholder="ogw_live_..."
|
||||
placeholder={selectedDefinition.apiKeyPlaceholder}
|
||||
/>
|
||||
</Box>
|
||||
{error && <Text color="red">{error}</Text>}
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>Press Enter to continue</Text>
|
||||
</Box>
|
||||
@@ -190,8 +287,8 @@ export const AddProvider: React.FC<AddProviderProps> = ({ onDone, onCancel }) =>
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {
|
||||
if (ogwKey.trim()) {
|
||||
setOgwFormStep(1);
|
||||
if (selectedDefinition.apiKeyRequired === false || catalogKey.trim()) {
|
||||
setCatalogStep(1);
|
||||
setError('');
|
||||
} else {
|
||||
setError('API key cannot be empty');
|
||||
@@ -201,21 +298,52 @@ export const AddProvider: React.FC<AddProviderProps> = ({ onDone, onCancel }) =>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{ogwFormStep === 1 && (
|
||||
{catalogStep === 1 && (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color="yellowBright">Step 2/2 — Model name</Text>
|
||||
<Text color="yellowBright">Step 2/2 - Model name</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text>Model: </Text>
|
||||
<TextInput value={ogwModel} onChange={setOgwModel} />
|
||||
<TextInput
|
||||
value={catalogModel}
|
||||
onChange={(value) => {
|
||||
catalogModelTouched.current = true;
|
||||
setCatalogModel(value);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{error && <Text color="red">⚠ {error}</Text>}
|
||||
{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={saveOpenGateway}
|
||||
onSubmit={saveCatalogProvider}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
@@ -228,7 +356,7 @@ export const AddProvider: React.FC<AddProviderProps> = ({ onDone, onCancel }) =>
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Text color="greenBright" bold>
|
||||
✓ Provider added successfully!
|
||||
Provider added successfully!
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
+2
-6
@@ -8,7 +8,7 @@ import { MessageRenderer } from './MessageRenderer';
|
||||
import { ToolCallRenderer } from './ToolCallRenderer';
|
||||
import { configManager } from '../config/manager';
|
||||
import { loadProviderConfig } from '../config/provider';
|
||||
import { OpenAIProvider } from '../providers/openai';
|
||||
import { createProvider } from '../providers/factory';
|
||||
import { runAgent } from '../agent/loop';
|
||||
|
||||
type Screen = 'chat' | 'provider-picker' | 'add-provider';
|
||||
@@ -215,11 +215,7 @@ export const App: React.FC = () => {
|
||||
|
||||
try {
|
||||
const providerConfig = await loadProviderConfig();
|
||||
const provider = new OpenAIProvider({
|
||||
apiKey: providerConfig.apiKey || '',
|
||||
baseURL: providerConfig.baseURL,
|
||||
model: providerConfig.model,
|
||||
});
|
||||
const provider = createProvider(providerConfig);
|
||||
|
||||
// Add empty assistant message that we'll stream into
|
||||
setMessages((prev) => {
|
||||
|
||||
@@ -14,7 +14,6 @@ export const MessageRenderer: React.FC<MessageRendererProps> = ({ content }) =>
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
let blockIndex = 0;
|
||||
|
||||
const matches: Array<{ index: number; lang: string; code: string; fullMatch: string }> = [];
|
||||
|
||||
@@ -83,7 +82,6 @@ export const MessageRenderer: React.FC<MessageRendererProps> = ({ content }) =>
|
||||
);
|
||||
|
||||
lastIndex = m.index + m.fullMatch.length;
|
||||
blockIndex++;
|
||||
}
|
||||
|
||||
// Add remaining text after last code block (with paragraph formatting)
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user