Compare commits

..
Author SHA1 Message Date
jatmn b43f059aa8 Fix plan update callback isolation 2026-05-31 21:33:18 -07:00
jatmn bc0127b0f2 Fix PR review findings 2026-05-31 21:14:36 -07:00
jatmn 7838988867 Split out agent plumbing from OpenTUI branch 2026-05-31 19:31:55 -07:00
50 changed files with 731 additions and 3270 deletions

No files matched your search

-4
View File
@@ -5,7 +5,6 @@ node_modules
out
dist
*.tgz
/.zero-build
# code coverage
coverage
@@ -26,7 +25,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# caches
.eslintcache
.cache
.zero-test-cache
*.tsbuildinfo
# IntelliJ based IDEs
@@ -38,5 +36,3 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
.cursor
zero
zero.exe
.*.bun-build
+1 -76
View File
@@ -9,82 +9,7 @@ bun install
To run:
```bash
bun run dev
bun run index.ts
```
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.
-3
View File
@@ -14,7 +14,6 @@
"react": "^19.2.6",
"react-devtools-core": "^7.0.1",
"shiki": "^4.1.0",
"smol-toml": "^1.6.1",
"zod": "^4.4.3",
},
"devDependencies": {
@@ -209,8 +208,6 @@
"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=="],
-159
View File
@@ -1,159 +0,0 @@
# 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
```
-129
View File
@@ -1,129 +0,0 @@
# 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.
-213
View File
@@ -1,213 +0,0 @@
# 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. |
+1 -2
View File
@@ -9,7 +9,7 @@
"scripts": {
"dev": "bun run src/index.ts",
"test": "bun test ./tests --timeout 15000",
"build": "bun scripts/build.ts"
"build": "bun build src/index.ts --compile --outfile zero"
},
"devDependencies": {
"@types/bun": "latest",
@@ -28,7 +28,6 @@
"react": "^19.2.6",
"react-devtools-core": "^7.0.1",
"shiki": "^4.1.0",
"smol-toml": "^1.6.1",
"zod": "^4.4.3"
}
}
-35
View File
@@ -1,35 +0,0 @@
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);
}
+54 -28
View File
@@ -1,8 +1,8 @@
import type { Provider, Message } from '../providers/types';
import type { Provider } from '../providers/types';
import type { ToolCall, ToolResult } from '../tools/types';
import { toolRegistry } from '../tools';
import { DEFAULT_SYSTEM_PROMPT, PLAN_MODE_SYSTEM_PROMPT } from './prompts';
import { clearPlan } from '../tools/plan';
import { clearPlan, getCurrentPlan, type PlanItem } from '../tools/plan';
import { z } from 'zod';
export interface AgentOptions {
@@ -10,6 +10,8 @@ export interface AgentOptions {
onText?: (text: string) => void;
onToolCall?: (toolCall: ToolCall) => void;
onToolResult?: (result: ToolResult) => void;
onUsage?: (usage: { promptTokens: number; completionTokens: number }) => void;
onPlanUpdate?: (plan: PlanItem[]) => void;
toolsEnabled?: boolean; // allows temporarily disabling tool calling for debugging
debug?: boolean; // when true, logs the exact payload sent to the provider
planMode?: boolean; // when true, the agent plans without modifying the codebase
@@ -31,6 +33,8 @@ export async function runAgent(
onText,
onToolCall,
onToolResult,
onUsage,
onPlanUpdate,
toolsEnabled = true,
debug = false,
planMode = false
@@ -38,10 +42,11 @@ export async function runAgent(
// Clear any previous plan when starting a new task
clearPlan();
onPlanUpdate?.(getCurrentPlan());
const systemPrompt = planMode ? PLAN_MODE_SYSTEM_PROMPT : DEFAULT_SYSTEM_PROMPT;
const messages: Message[] = [
const messages: any[] = [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: initialPrompt },
];
@@ -49,28 +54,30 @@ 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>();
@@ -83,13 +90,20 @@ 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(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))}│`);
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)}│`);
if (toolDefinitions.length > 0) {
const toolsList = toolDefinitions.map(t => t.name).join(', ');
console.log(`│ Tools: ${toolsList.slice(0, 42)}${' '.repeat(Math.max(0, 43 - Math.min(42, toolsList.length)))}│`);
console.log(`│ 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}...`);
}
}
const preview = String(messages[messages.length-1]?.content || '').slice(0, 45);
@@ -104,6 +118,13 @@ export async function runAgent(
if (onText) onText(event.content);
}
if (event.type === 'usage') {
onUsage?.({
promptTokens: event.promptTokens,
completionTokens: event.completionTokens,
});
}
if (event.type === 'tool-call-start') {
toolCallMap.set(event.id, {
id: event.id,
@@ -180,6 +201,11 @@ export async function runAgent(
onToolResult({ toolCallId: tc.id, result });
}
const effects = tool?.onAfterExecute?.(result);
if (effects?.planUpdated) {
onPlanUpdate?.(getCurrentPlan());
}
return { toolCallId: tc.id, result };
});
+6 -2
View File
@@ -1,13 +1,17 @@
import { loadProviderConfig } from '../config/provider';
import { configManager } from '../config/manager';
import { createProvider } from '../providers/factory';
import { OpenAIProvider } from '../providers/openai';
import { runAgent } from '../agent/loop';
export async function runHeadless(prompt: string) {
const providerConfig = await loadProviderConfig();
const activeProfile = configManager.getActiveProvider();
const provider = createProvider(providerConfig);
const provider = new OpenAIProvider({
apiKey: providerConfig.apiKey || '',
baseURL: providerConfig.baseURL,
model: providerConfig.model,
});
const source = activeProfile
? `profile: ${activeProfile.name}`
+6 -16
View File
@@ -3,7 +3,7 @@ import { join } from 'path';
import { homedir } from 'os';
import type { ZeroConfig, ProviderProfile } from './types';
const CONFIG_DIR = process.env.ZERO_CONFIG_DIR || join(homedir(), '.config', 'zero');
const CONFIG_DIR = join(homedir(), '.config', 'zero');
const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
function ensureConfigDir() {
@@ -43,10 +43,6 @@ 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);
@@ -96,7 +92,6 @@ export class ConfigManager {
// Used by the agent loop
getEffectiveProviderConfig(): {
providerId?: string;
baseURL: string;
apiKey?: string;
model: string;
@@ -106,23 +101,18 @@ 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 (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) {
// Fallback to env vars
if (process.env.OPENAI_BASE_URL || process.env.OPENAI_MODEL) {
return {
baseURL: envBaseURL || 'https://api.openai.com/v1',
apiKey: envApiKey,
model: envModel || 'gpt-4o',
baseURL: process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1',
apiKey: process.env.OPENAI_API_KEY,
model: process.env.OPENAI_MODEL || 'gpt-4o',
};
}
+9 -102
View File
@@ -1,86 +1,11 @@
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:
@@ -92,32 +17,14 @@ export async function loadProviderConfig(): Promise<ProviderConfig> {
// 1. Highest priority: external provider command
const providerCommand = process.env.ZERO_PROVIDER_COMMAND;
if (providerCommand) {
try {
const parts = parseProviderCommand(providerCommand);
const program = parts[0];
const args = parts.slice(1);
const { stdout } = await Bun.$`${providerCommand}`.quiet();
const parsed = JSON.parse(stdout.toString());
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.'
);
}
return {
apiKey: parsed.api_key,
baseURL: parsed.base_url || 'https://api.openai.com/v1',
model: parsed.model,
};
}
// 2. Active profile from saved config
@@ -131,8 +38,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, give a helpful error
if (!envApiKey) {
// If we have no API key and no provider command, give a helpful error
if (!envApiKey && !process.env.ZERO_PROVIDER_COMMAND) {
throw new Error(
'No LLM provider configured.\n\n' +
'Please run /provider to add one, or set OPENAI_API_KEY environment variable.'
-2
View File
@@ -1,7 +1,5 @@
export interface ProviderProfile {
name: string;
providerId?: string;
kind?: 'provider' | 'gateway' | 'localhost' | 'custom';
baseURL: string;
apiKey?: string;
model: string;
-22
View File
@@ -2,7 +2,6 @@ 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();
@@ -75,25 +74,4 @@ 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();
-56
View File
@@ -1,56 +0,0 @@
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;
}
@@ -1,69 +0,0 @@
# 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"
@@ -1,41 +0,0 @@
# 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"
@@ -1,50 +0,0 @@
# 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"
@@ -1,63 +0,0 @@
# 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"
@@ -1,62 +0,0 @@
# 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"
-358
View File
@@ -1,358 +0,0 @@
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);
}
@@ -1,16 +0,0 @@
# 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
@@ -1,16 +0,0 @@
# 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
-31
View File
@@ -1,31 +0,0 @@
# 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
-31
View File
@@ -1,31 +0,0 @@
# 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
-31
View File
@@ -1,31 +0,0 @@
# 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
-166
View File
@@ -1,166 +0,0 @@
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;
}
-274
View File
@@ -1,274 +0,0 @@
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,
};
}
}
-49
View File
@@ -1,49 +0,0 @@
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';
}
+85 -73
View File
@@ -1,31 +1,44 @@
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[];
}
function isUnsupportedStreamOptionsError(error: unknown): boolean {
const candidate = error as {
status?: number;
code?: string;
message?: string;
error?: {
code?: string;
message?: string;
};
};
const statusAllowsRetry = candidate.status === 400 || candidate.status === 422;
const errorText = [
candidate.code,
candidate.message,
candidate.error?.code,
candidate.error?.message,
].filter(Boolean).join(' ').toLowerCase();
return statusAllowsRetry && /stream[ _-]?options?/.test(errorText);
}
export class OpenAIProvider implements Provider {
private client: OpenAI;
private model: string;
private maxTokensField: MaxTokensField;
private removeBodyFields: Set<string>;
constructor({ apiKey, baseURL, model, defaultHeaders, maxTokensField, removeBodyFields }: OpenAIProviderOptions) {
constructor({ apiKey, baseURL, model }: 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(
@@ -57,50 +70,38 @@ export class OpenAIProvider implements Provider {
}))
: undefined;
// Build request body
const body: Record<string, unknown> = {
const createStreamRequest = (includeUsage: boolean) => ({
model: this.model,
messages: openaiMessages,
messages: openaiMessages as any,
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');
}
stream: true as const,
...(includeUsage ? {
stream_options: {
include_usage: true,
},
} : {}),
});
let stream;
try {
stream = await this.client.chat.completions.create(
body as Parameters<typeof this.client.chat.completions.create>[0],
{ timeout: 120_000 } // 2 minute timeout
);
} catch (err: any) {
const message = getDetailedErrorMessage(err);
if (message.includes('401') || message.toLowerCase().includes('invalid') || message.toLowerCase().includes('unauthorized')) {
throw new Error(`Provider authentication error (check your API key): ${message}`);
stream = await this.client.chat.completions.create(createStreamRequest(true));
} catch (error) {
if (isUnsupportedStreamOptionsError(error)) {
try {
stream = await this.client.chat.completions.create(createStreamRequest(false));
} catch (retryError) {
throw formatProviderCreateError(retryError);
}
} else {
throw formatProviderCreateError(error);
}
if (message.toLowerCase().includes('rate') || message.toLowerCase().includes('quota')) {
throw new Error(`Provider rate limit error: ${message}`);
}
throw new Error(`Provider returned error: ${message}`);
}
const toolCallAccumulators = new Map<number, {
id: string;
name: string;
name: string;
arguments: string;
started: boolean;
ended: boolean;
}>();
try {
@@ -125,15 +126,17 @@ export class OpenAIProvider implements Provider {
let acc = toolCallAccumulators.get(tc.index);
if (!acc) {
acc = { id: '', name: '', arguments: '', started: false, ended: false };
acc = { id: '', name: '', arguments: '', started: 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) {
yield { type: 'tool-call-end', id: acc.id };
acc = { id: '', name: '', arguments: '', started: false, ended: false };
if (acc.id) {
yield { type: 'tool-call-end', id: acc.id };
}
acc = { id: '', name: '', arguments: '', started: false };
toolCallAccumulators.set(tc.index, acc);
}
@@ -141,14 +144,11 @@ export class OpenAIProvider implements Provider {
if (tc.function?.name) acc.name = tc.function.name;
if (tc.function?.arguments) {
acc.arguments += 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,
};
}
yield {
type: 'tool-call-delta',
id: acc.id || `pending-${tc.index}`,
argumentsFragment: tc.function.arguments,
};
}
// Emit start event the first time we have both id and name
@@ -170,9 +170,8 @@ 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 && !acc.ended) {
if (acc.id) {
yield { type: 'tool-call-end', id: acc.id };
acc.ended = true;
}
}
}
@@ -180,42 +179,55 @@ export class OpenAIProvider implements Provider {
// End of stream - close any remaining open tool calls
for (const [_, acc] of toolCallAccumulators) {
if (acc.id && !acc.ended) {
if (acc.id) {
yield { type: 'tool-call-end', id: acc.id };
acc.ended = true;
}
}
yield { type: 'done' };
} catch (err: any) {
const message = getDetailedErrorMessage(err);
throw new Error(`Provider returned error during streaming: ${message}`);
} catch (error) {
throw formatProviderCreateError(error);
}
}
}
function getDetailedErrorMessage(err: any): string {
if (!err) return 'Unknown error';
function formatProviderCreateError(error: unknown): Error {
const message = getDetailedErrorMessage(error);
const lower = message.toLowerCase();
if (message.includes('401') || lower.includes('invalid') || lower.includes('unauthorized') || lower.includes('api key')) {
return new Error(`Provider authentication error (check your API key): ${message}`);
}
if (lower.includes('rate') || lower.includes('quota')) {
return new Error(`Provider rate limit error: ${message}`);
}
return new Error(`Provider returned error: ${message}`);
}
function getDetailedErrorMessage(error: any): string {
if (!error) return 'Unknown error';
// Try common places where real error lives (especially for custom gateways)
if (err.message && !err.message.includes('Provider returned error')) {
return err.message;
if (error.message && !error.message.includes('Provider returned error')) {
return error.message;
}
if (err.error) {
if (typeof err.error === 'string') return err.error;
if (err.error.message) return err.error.message;
try { return JSON.stringify(err.error); } catch { return String(err.error); }
if (error.error) {
if (typeof error.error === 'string') return error.error;
if (error.error.message) return error.error.message;
try { return JSON.stringify(error.error); } catch {}
}
if (err.response?.data) {
const data = err.response.data;
if (error.response?.data) {
const data = error.response.data;
if (data.error?.message) return data.error.message;
if (typeof data.error === 'string') return data.error;
try { return JSON.stringify(data); } catch { return '[unserializable response data]'; }
try { return JSON.stringify(data); } catch {}
}
if (err.cause?.message) return err.cause.message;
if (error.cause?.message) return error.cause.message;
return err.message || String(err);
return error.message || String(error);
}
+1 -1
View File
@@ -1,6 +1,6 @@
export interface Message {
role: 'system' | 'user' | 'assistant' | 'tool';
content: string | null;
content: string;
toolCalls?: ToolCall[];
toolCallId?: string;
}
+1 -4
View File
@@ -19,15 +19,12 @@ 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) {
+1 -13
View File
@@ -15,11 +15,6 @@ 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');
@@ -27,14 +22,7 @@ export const editFileTool: Tool = {
return `Error: Could not find the exact string to replace in ${path}.`;
}
// 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);
const newContent = content.replace(old_string, new_string);
await writeFile(path, newContent, 'utf-8');
return `Successfully edited ${path}.`;
+8 -52
View File
@@ -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'),
case_insensitive: z.boolean().optional().describe('Case insensitive search'),
show_line_numbers: z.boolean().optional().default(true).describe('Show line numbers'),
'-i': z.boolean().optional().describe('Case insensitive search'),
'-n': 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, case_insensitive, show_line_numbers, head_limit } = params;
const { pattern, path = '.', glob, output_mode, '-i': caseInsensitive, '-n': showLineNumbers, 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 (case_insensitive) rgArgs.push('-i');
if (show_line_numbers) rgArgs.push('-n');
if (caseInsensitive) rgArgs.push('-i');
if (showLineNumbers) rgArgs.push('-n');
if (glob) rgArgs.push('--glob', glob);
rgArgs.push('-m', String(head_limit));
@@ -54,7 +54,6 @@ Features:
// Parse ripgrep JSON output
const lines = stdout.trim().split('\n');
const results: any[] = [];
const parseErrors: string[] = [];
for (const line of lines) {
try {
@@ -62,13 +61,7 @@ Features:
if (parsed.type === 'match') {
results.push(parsed);
}
} 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)}`;
} catch {}
}
if (results.length === 0) {
@@ -94,45 +87,8 @@ Features:
return formatted.join('\n');
} catch (err) {
// 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.`;
}
// 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.`;
}
},
};
+3 -29
View File
@@ -59,27 +59,9 @@ async function listDirRecursive(
const fullPath = join(currentPath, dirent.name);
const relPath = relative(root, fullPath);
if (dirent.isDirectory() || dirent.isSymbolicLink()) {
if (dirent.isDirectory()) {
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}/`);
@@ -88,20 +70,12 @@ 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: 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}`);
}
} catch (e) {
// ignore permission errors on individual dirs
}
return results;
+3 -3
View File
@@ -15,9 +15,6 @@ 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 {
@@ -62,6 +59,9 @@ The plan should be clear, actionable, and ordered. Keep it up to date as you wor
return formatPlan(currentPlan);
},
onAfterExecute() {
return { planUpdated: true };
},
};
// Helper to get the current plan (can be used by other parts of the system later)
+1 -19
View File
@@ -1,9 +1,7 @@
import { z } from 'zod';
import { readFile, stat } from 'fs/promises';
import { readFile } 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),
});
@@ -16,22 +14,6 @@ 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) {
+8
View File
@@ -14,6 +14,14 @@ 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();
+10 -2
View File
@@ -1,13 +1,21 @@
import { z } from 'zod';
import type { ToolCall } from '../providers/types';
export type { ToolCall };
export interface ToolExecutionEffects {
planUpdated?: boolean;
}
export interface Tool {
name: string;
description: string;
parameters: z.ZodObject<any>; // Zod schema for validation
execute: (args: any) => Promise<string>; // Returns tool result as string
onAfterExecute?: (result: string) => ToolExecutionEffects | void;
}
export interface ToolCall {
id: string;
name: string;
arguments: string; // raw JSON string from model
}
export interface ToolResult {
+77 -203
View File
@@ -2,33 +2,28 @@ import React, { useState } from 'react';
import { Box, Text, useInput } from 'ink';
import TextInput from 'ink-text-input';
import { configManager } from '../config/manager';
import { listProviderDefinitions } from '../providers/catalog';
import { discoverModelsForProvider } from '../providers/discovery';
import { isProviderRuntimeSupported } from '../providers/factory';
import type { ProviderDefinition, ResolvedModelDefinition } from '../providers/catalog/types';
type AddMode = 'choose' | 'catalog' | 'generic';
type AddMode = 'choose' | 'opengateway' | 'generic';
interface AddProviderProps {
onDone: (providerName?: string) => void;
onCancel: () => void;
}
const catalogProviders = listProviderDefinitions().filter(isProviderRuntimeSupported);
export const AddProvider: React.FC<AddProviderProps> = ({ onDone, onCancel }) => {
const [mode, setMode] = useState<AddMode>('choose');
const [selectedOption, setSelectedOption] = useState(0);
const [selectedDefinition, setSelectedDefinition] = useState<ProviderDefinition | null>(null);
const [catalogStep, setCatalogStep] = useState(0);
const [catalogKey, setCatalogKey] = useState('');
const [catalogModel, setCatalogModel] = useState('');
const catalogModelTouched = React.useRef(false);
const [modelOptions, setModelOptions] = useState<ResolvedModelDefinition[]>([]);
const [selectedModelIndex, setSelectedModelIndex] = useState(0);
const [discoveryStatus, setDiscoveryStatus] = useState('');
// For the choose menu
const [selectedOption, setSelectedOption] = useState(0); // 0 = OpenGateway, 1 = Generic
// 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('');
@@ -37,69 +32,14 @@ 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');
setCatalogStep(0);
setSelectedDefinition(null);
setOgwFormStep(0);
setError('');
setSuccess(false);
setSelectedOption(0);
@@ -107,88 +47,56 @@ export const AddProvider: React.FC<AddProviderProps> = ({ onDone, onCancel }) =>
return;
}
if (mode === 'catalog' && catalogStep === 1 && modelOptions.length > 0) {
// Arrow key + Enter navigation for the choose menu
if (mode === 'choose') {
if (key.upArrow) {
setSelectedModelIndex((prev) => {
const next = Math.max(0, prev - 1);
catalogModelTouched.current = true;
setCatalogModel(modelOptions[next]?.apiName ?? catalogModel);
return next;
});
setSelectedOption((prev) => Math.max(0, prev - 1));
return;
}
if (key.downArrow) {
setSelectedModelIndex((prev) => {
const next = Math.min(modelOptions.length - 1, prev + 1);
catalogModelTouched.current = true;
setCatalogModel(modelOptions[next]?.apiName ?? catalogModel);
return next;
});
setSelectedOption((prev) => Math.min(1, prev + 1));
return;
}
if (key.return) {
if (selectedOption === 0) {
setMode('opengateway');
setOgwFormStep(0);
} else {
setMode('generic');
}
return;
}
}
if (mode !== 'choose') return;
if (key.upArrow) {
setSelectedOption((prev) => Math.max(0, prev - 1));
return;
}
if (key.downArrow) {
setSelectedOption((prev) => Math.min(totalOptions - 1, prev + 1));
return;
}
const chooseOption = (index: number) => {
const definition = catalogProviders[index];
if (definition) {
setSelectedDefinition(definition);
catalogModelTouched.current = false;
setCatalogModel(definition.defaultModel);
setModelOptions([]);
setSelectedModelIndex(0);
setDiscoveryStatus('');
setMode('catalog');
setCatalogStep(definition.apiKeyRequired === false ? 1 : 0);
} else {
// Still support numbers for convenience
if (input === '1') {
setMode('opengateway');
setOgwFormStep(0);
}
if (input === '2') {
setMode('generic');
}
};
if (key.return) {
chooseOption(selectedOption);
return;
}
const quickNumber = parseInt(input, 10);
if (!isNaN(quickNumber) && quickNumber >= 1 && quickNumber <= totalOptions) {
chooseOption(quickNumber - 1);
}
});
const saveCatalogProvider = () => {
if (!selectedDefinition) return;
if (selectedDefinition.apiKeyRequired !== false && !catalogKey.trim()) {
const saveOpenGateway = () => {
if (!ogwKey.trim()) {
setError('API key is required');
return;
}
const profileName = selectedDefinition.id;
const profileName = 'opengateway';
configManager.addProvider({
name: profileName,
providerId: selectedDefinition.id,
kind: selectedDefinition.kind,
baseURL: selectedDefinition.baseURL,
apiKey: catalogKey.trim() || undefined,
model: catalogModel.trim() || selectedDefinition.defaultModel,
description: selectedDefinition.name,
baseURL: 'https://opengateway.gitlawb.com/v1',
apiKey: ogwKey.trim(),
model: ogwModel.trim(),
description: 'OpenGateway',
});
setSuccess(true);
// Auto-close after a short delay so the user sees the success message
setTimeout(() => {
onDone(profileName);
}, 1200);
@@ -202,7 +110,6 @@ 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(),
@@ -217,30 +124,26 @@ 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 - Up/Down to navigate - Enter to select</Text>
<Text color="gray">Esc to go back • ↑↓ to navigate • Enter to select</Text>
<Box marginY={1} flexDirection="column">
{catalogProviders.map((definition, index) => (
<Box key={definition.id} flexDirection="column" marginBottom={1}>
<Text color={selectedOption === index ? 'greenBright' : 'white'}>
{selectedOption === index ? '> ' : ' '}
{index + 1}. Add {definition.name}
</Text>
{selectedOption === index && (
<Text color="gray" dimColor>
{' '}{definition.kind}: {definition.description}
</Text>
)}
</Box>
))}
<Text color={selectedOption === catalogProviders.length ? 'greenBright' : 'white'}>
{selectedOption === catalogProviders.length ? '> ' : ' '}
{catalogProviders.length + 1}. Add custom OpenAI-compatible provider
<Text color={selectedOption === 0 ? 'greenBright' : 'white'}>
{selectedOption === 0 ? '› ' : ' '}1. Add OpenGateway (recommended)
</Text>
{selectedOption === catalogProviders.length && (
{selectedOption === 0 && (
<Text color="gray" dimColor>
{' '}For providers not yet described by a catalog file
{' '}You'll be asked for your ogw_live_... API key
</Text>
)}
<Box marginTop={1}>
<Text color={selectedOption === 1 ? 'greenBright' : 'white'}>
{selectedOption === 1 ? '› ' : ' '}2. Add custom OpenAI-compatible provider
</Text>
</Box>
{selectedOption === 1 && (
<Text color="gray" dimColor>
{' '}For Groq, OpenAI, Ollama, etc.
</Text>
)}
</Box>
@@ -248,12 +151,12 @@ export const AddProvider: React.FC<AddProviderProps> = ({ onDone, onCancel }) =>
);
}
if (mode === 'catalog' && selectedDefinition) {
if (mode === 'opengateway') {
if (success) {
return (
<Box flexDirection="column" padding={1}>
<Text color="greenBright" bold>
{selectedDefinition.name} provider added successfully!
✓ OpenGateway provider added successfully!
</Text>
<Text color="gray" dimColor>
It is now your active provider.
@@ -264,22 +167,24 @@ export const AddProvider: React.FC<AddProviderProps> = ({ onDone, onCancel }) =>
return (
<Box flexDirection="column" padding={1}>
<Text bold color="cyan">Add {selectedDefinition.name}</Text>
<Text bold color="cyan">Add OpenGateway Provider</Text>
<Text color="gray">Esc to go back</Text>
{catalogStep === 0 && (
{ogwFormStep === 0 && (
<Box marginTop={1} flexDirection="column">
<Text color="yellowBright">Step 1/2 - Enter API key</Text>
<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>
<Box marginTop={1}>
<Text>{selectedDefinition.apiKeyLabel ?? 'API key'}: </Text>
<Text>API Key: </Text>
<TextInput
value={catalogKey}
onChange={setCatalogKey}
value={ogwKey}
onChange={setOgwKey}
mask="*"
placeholder={selectedDefinition.apiKeyPlaceholder}
placeholder="ogw_live_..."
/>
</Box>
{error && <Text color="red">{error}</Text>}
<Box marginTop={1}>
<Text color="gray" dimColor>Press Enter to continue</Text>
</Box>
@@ -287,8 +192,8 @@ export const AddProvider: React.FC<AddProviderProps> = ({ onDone, onCancel }) =>
value=""
onChange={() => {}}
onSubmit={() => {
if (selectedDefinition.apiKeyRequired === false || catalogKey.trim()) {
setCatalogStep(1);
if (ogwKey.trim()) {
setOgwFormStep(1);
setError('');
} else {
setError('API key cannot be empty');
@@ -298,52 +203,21 @@ export const AddProvider: React.FC<AddProviderProps> = ({ onDone, onCancel }) =>
</Box>
)}
{catalogStep === 1 && (
{ogwFormStep === 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={catalogModel}
onChange={(value) => {
catalogModelTouched.current = true;
setCatalogModel(value);
}}
/>
<TextInput value={ogwModel} onChange={setOgwModel} />
</Box>
{selectedDefinition.models && selectedDefinition.models.length > 0 && (
<Text color="gray" dimColor>
Default catalog model: {selectedDefinition.defaultModel}
</Text>
)}
{discoveryStatus && (
<Text color="gray" dimColor>{discoveryStatus}</Text>
)}
{modelOptions.length > 0 && (
<Box marginTop={1} flexDirection="column">
<Text color="gray" dimColor>Up/Down to choose a catalog model</Text>
{visibleModelOptions.map((option, index) => {
const modelIndex = visibleModelStart + index;
return (
<Text
key={`${option.providerId ?? 'global'}:${option.apiName}`}
color={modelIndex === selectedModelIndex ? 'greenBright' : 'white'}
>
{modelIndex === selectedModelIndex ? '> ' : ' '}
{option.name ?? option.label ?? option.apiName}
</Text>
);
})}
</Box>
)}
{error && <Text color="red">{error}</Text>}
{error && <Text color="red">⚠ {error}</Text>}
<Box marginTop={1}>
<Text color="gray" dimColor>Press Enter to save</Text>
</Box>
<TextInput
value=""
onChange={() => {}}
onSubmit={saveCatalogProvider}
onSubmit={saveOpenGateway}
/>
</Box>
)}
@@ -356,7 +230,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>
);
+108 -91
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useRef, useState } from 'react';
import { Box, Text, useApp, useInput } from 'ink';
import { ProviderPicker } from './ProviderPicker';
import { AddProvider } from './AddProvider';
@@ -6,13 +6,34 @@ import { Logo } from './Logo';
import { ThinkingSpinner } from './Spinner';
import { MessageRenderer } from './MessageRenderer';
import { ToolCallRenderer } from './ToolCallRenderer';
import { Header } from './Header';
import { TodoRail } from './TodoRail';
import { configManager } from '../config/manager';
import { loadProviderConfig } from '../config/provider';
import { createProvider } from '../providers/factory';
import { OpenAIProvider } from '../providers/openai';
import { runAgent } from '../agent/loop';
import type { PlanItem } from '../tools/plan';
type Screen = 'chat' | 'provider-picker' | 'add-provider';
type Usage = {
promptTokens: number;
completionTokens: number;
};
type ProviderHeaderInfo = {
name: string;
model: string;
};
function getProviderHeaderInfo(): ProviderHeaderInfo {
const activeProfile = configManager.getActiveProvider();
return {
name: activeProfile?.name || (process.env.ZERO_PROVIDER_COMMAND ? 'command' : 'env'),
model: activeProfile?.model || process.env.OPENAI_MODEL || 'default',
};
}
// Map low-level errors back to actionable guidance for the user. The full
// error object is still surfaced separately when debug mode is on.
function toFriendlyError(err: any): string {
@@ -75,6 +96,7 @@ export const App: React.FC = () => {
}, []);
const [isThinking, setIsThinking] = useState(false);
const [streamingMessageIndex, setStreamingMessageIndex] = useState<number | null>(null);
const streamingMessageIndexRef = useRef<number | null>(null);
// Plan Mode (inspired by OpenClaude / Claude Code)
const [isPlanMode, setIsPlanMode] = useState(false);
@@ -85,11 +107,18 @@ export const App: React.FC = () => {
// Tools enabled (useful for debugging provider errors)
const [toolsEnabled, setToolsEnabled] = useState(true);
const [usage, setUsage] = useState<Usage>({ promptTokens: 0, completionTokens: 0 });
const [plan, setPlan] = useState<PlanItem[]>([]);
const [todoRailOpen, setTodoRailOpen] = useState(false);
const [todoRailHidden, setTodoRailHidden] = useState(false);
const planLengthRef = useRef(0);
const todoRailHiddenRef = useRef(false);
const [providerHeaderInfo, setProviderHeaderInfo] = useState<ProviderHeaderInfo>(() => getProviderHeaderInfo());
// Command suggestions
const [suggestions, setSuggestions] = useState<string[]>([]);
const knownCommands = ['/provider', '/plan', '/debug-mode', '/debug', '/tools', '/help', '/exit', '/quit'];
const knownCommands = ['/provider', '/plan', '/todo', '/debug-mode', '/debug', '/tools', '/help', '/exit', '/quit'];
// Update suggestions when input changes
React.useEffect(() => {
@@ -102,34 +131,13 @@ export const App: React.FC = () => {
}
}, [input]);
// Scrolling state (Grok Build style internal scrolling)
const [scrollOffset, setScrollOffset] = useState(0);
const [terminalRows, setTerminalRows] = useState(24); // default fallback
// Current provider info for the input bar (Grok Build style)
const activeProfile = configManager.getActiveProvider();
const currentProviderName = activeProfile?.name || (process.env.ZERO_PROVIDER_COMMAND ? 'command' : 'env');
const currentModel = activeProfile?.model || process.env.OPENAI_MODEL || 'default';
// Track terminal size for proper scrolling
React.useEffect(() => {
const updateSize = () => {
setTerminalRows(process.stdout.rows || 24);
};
process.stdout.on('resize', updateSize);
updateSize();
return () => {
process.stdout.off('resize', updateSize);
};
}, []);
// Auto-scroll to bottom when new messages arrive (unless user scrolled up)
React.useEffect(() => {
// Only auto-scroll if user is near the bottom
if (scrollOffset <= 3) {
setScrollOffset(0);
}
}, [messages.length]);
const currentProviderName = providerHeaderInfo.name;
const currentModel = providerHeaderInfo.model;
const totalTokens = usage.promptTokens + usage.completionTokens;
const hasPlanItems = plan.length > 0;
const hasActivePlanItems = plan.some((item) => item.status !== 'completed');
const showTodoRail = todoRailOpen || (hasPlanItems && hasActivePlanItems && !todoRailHidden);
// Only capture main chat input when we're actually in the chat screen
const isInChat = screen === 'chat';
@@ -143,32 +151,9 @@ export const App: React.FC = () => {
// Don't process chat input while in provider picker or add flow
if (!isInChat) return;
// Scrolling controls (when input is empty)
if (!input) {
if (key.upArrow) {
setScrollOffset((prev) => Math.min(prev + 1, messages.length - 1));
return;
}
if (key.downArrow) {
setScrollOffset((prev) => Math.max(prev - 1, 0));
return;
}
if (key.pageUp) {
setScrollOffset((prev) => Math.min(prev + 8, messages.length - 1));
return;
}
if (key.pageDown) {
setScrollOffset((prev) => Math.max(prev - 8, 0));
return;
}
if (key.home) {
setScrollOffset(messages.length - 1);
return;
}
if (key.end) {
setScrollOffset(0);
return;
}
if (!input && key.ctrl && inputChar === 't') {
toggleTodoRail();
return;
}
if (key.return) {
@@ -215,12 +200,18 @@ export const App: React.FC = () => {
try {
const providerConfig = await loadProviderConfig();
const provider = createProvider(providerConfig);
const provider = new OpenAIProvider({
apiKey: providerConfig.apiKey || '',
baseURL: providerConfig.baseURL,
model: providerConfig.model,
});
// Add empty assistant message that we'll stream into
setMessages((prev) => {
const newMessages = [...prev, { type: 'assistant' as const, content: '' }];
setStreamingMessageIndex(newMessages.length - 1);
const nextIndex = newMessages.length - 1;
streamingMessageIndexRef.current = nextIndex;
setStreamingMessageIndex(nextIndex);
return newMessages;
});
@@ -232,7 +223,7 @@ export const App: React.FC = () => {
setIsThinking(false);
setMessages((prev) => {
const newMessages = [...prev];
const idx = streamingMessageIndex ?? newMessages.length - 1;
const idx = streamingMessageIndexRef.current ?? newMessages.length - 1;
if (newMessages[idx]?.type === 'assistant') {
const current = newMessages[idx] as { type: 'assistant'; content: string };
@@ -250,8 +241,6 @@ export const App: React.FC = () => {
...prev,
{ type: 'tool-call', name: tc.name, args: tc.arguments },
]);
// Reset streaming index since we inserted a message
setStreamingMessageIndex(null);
},
onToolResult: (result) => {
// Attach result to the most recent tool call that doesn't have one yet
@@ -270,6 +259,19 @@ export const App: React.FC = () => {
return newMessages;
});
},
onUsage: (nextUsage) => {
setUsage((current) => ({
promptTokens: current.promptTokens + nextUsage.promptTokens,
completionTokens: current.completionTokens + nextUsage.completionTokens,
}));
},
onPlanUpdate: (nextPlan) => {
if (nextPlan.length > 0 && planLengthRef.current === 0 && !todoRailHiddenRef.current) {
setTodoRailOpen(false);
}
planLengthRef.current = nextPlan.length;
setPlan(nextPlan);
},
});
} catch (err: any) {
setIsThinking(false);
@@ -306,6 +308,7 @@ export const App: React.FC = () => {
setMessages((prev) => [...prev, { type: 'system', content: friendlyMessage }]);
} finally {
setIsThinking(false);
streamingMessageIndexRef.current = null;
setStreamingMessageIndex(null);
}
};
@@ -315,7 +318,7 @@ export const App: React.FC = () => {
const handleSlashCommand = (command: string) => {
const parts = command.trim().split(/\s+/);
const cmd = parts[0].toLowerCase();
const cmd = (parts[0] ?? '').toLowerCase();
const arg = parts[1]?.toLowerCase();
if (cmd === '/provider') {
@@ -373,12 +376,22 @@ export const App: React.FC = () => {
return;
}
if (cmd === '/todo') {
const nextVisibility = toggleTodoRail();
setMessages((prev) => [
...prev,
{ type: 'system', content: `Todo rail ${nextVisibility}.` },
]);
return;
}
if (cmd === '/help') {
setMessages((prev) => [
...prev,
{ type: 'system', content: 'Available commands:' },
{ type: 'system', content: ' /provider - Manage LLM providers (fix provider errors here)' },
{ type: 'system', content: ' /plan - Toggle Plan Mode (agent plans first, makes no edits)' },
{ type: 'system', content: ' /todo - Show or hide the current plan rail' },
{ type: 'system', content: ' /debug-mode - Toggle debug mode (prints full errors to console)' },
{ type: 'system', content: ' /tools - Toggle tool calling (useful for debugging provider errors)' },
{ type: 'system', content: ' /help - Show this help' },
@@ -395,9 +408,26 @@ export const App: React.FC = () => {
setMessages((prev) => [...prev, { type: 'system', content: `Unknown command: ${command}` }]);
};
const toggleTodoRail = (): 'shown' | 'hidden' => {
const nextVisibility = showTodoRail ? 'hidden' : 'shown';
if (showTodoRail) {
setTodoRailOpen(false);
todoRailHiddenRef.current = true;
setTodoRailHidden(true);
} else {
todoRailHiddenRef.current = false;
setTodoRailHidden(false);
setTodoRailOpen(true);
}
return nextVisibility;
};
const handleProviderSelected = (name: string) => {
const success = configManager.setActiveProvider(name);
if (success) {
setProviderHeaderInfo(getProviderHeaderInfo());
setMessages((prev) => [...prev, { type: 'system', content: `Switched to provider: ${name}` }]);
}
setScreen('chat');
@@ -419,6 +449,7 @@ export const App: React.FC = () => {
const switched = configManager.setActiveProvider(providerName);
if (switched) {
setProviderHeaderInfo(getProviderHeaderInfo());
setMessages((prev) => [
...prev,
{ type: 'system', content: `Added and switched to provider: ${providerName}` },
@@ -459,20 +490,20 @@ export const App: React.FC = () => {
const showLogo = messages.length <= 2;
// Calculate visible messages for scrolling (Grok Build style)
const chatHeight = Math.max(8, terminalRows - 6); // leave room for input + status
const visibleMessages = messages.slice(scrollOffset, scrollOffset + chatHeight);
const canScrollUp = scrollOffset < messages.length - 1;
const canScrollDown = scrollOffset > 0;
return (
<Box flexDirection="column" height="100%">
{/* Scrollable messages area with right-side scroll indicator (Grok Build style) */}
<Header
provider={currentProviderName}
model={currentModel}
usage={usage}
totalTokens={totalTokens}
planMode={isPlanMode}
/>
{/* Message area; render the transcript directly so terminal scrollback stays useful. */}
<Box
flexGrow={1}
flexDirection="row"
overflow="hidden"
>
{/* Main chat content */}
<Box
@@ -483,17 +514,8 @@ export const App: React.FC = () => {
>
{showLogo && <Logo />}
{/* Scroll indicator */}
{(canScrollUp || canScrollDown) && (
<Text color="gray" dimColor>
{canScrollUp ? '↑ ' : ' '}Scroll with ↑↓ / PgUp/PgDn / Home/End {canScrollDown ? '↓' : ''}
</Text>
)}
<Box flexDirection="column">
{visibleMessages.map((msg, index) => {
const realIndex = scrollOffset + index;
{messages.map((msg, realIndex) => {
if (msg.type === 'user') {
return (
<Box key={realIndex} marginBottom={1}>
@@ -551,16 +573,11 @@ export const App: React.FC = () => {
{isThinking && <ThinkingSpinner />}
</Box>
</Box>
</Box>
{/* Scroll position (Grok Build style) */}
{(canScrollUp || canScrollDown) && (
<Box paddingX={1} justifyContent="flex-end">
<Text color="gray" dimColor>
{scrollOffset + 1}/{messages.length}{canScrollUp ? ' ↑' : ''}{canScrollDown ? ' ↓' : ''}
</Text>
</Box>
)}
{showTodoRail && (
<TodoRail plan={plan} />
)}
</Box>
{/* Command suggestions */}
{suggestions.length > 0 && (
@@ -625,7 +642,7 @@ export const App: React.FC = () => {
{/* Very subtle status line */}
<Box paddingX={1} flexDirection="row">
<Text color="gray" dimColor>
/help • ↑↓ scroll • Ctrl+C exit
/help • /todo or Ctrl+T todo • Ctrl+C exit
</Text>
{isPlanMode && (
<Text color="green"> • PLAN MODE</Text>
+39
View File
@@ -0,0 +1,39 @@
import React from 'react';
import { Box, Text } from 'ink';
type Usage = {
promptTokens: number;
completionTokens: number;
};
interface HeaderProps {
provider: string;
model: string;
usage: Usage;
totalTokens: number;
planMode: boolean;
}
export const Header: React.FC<HeaderProps> = ({ provider, model, usage, totalTokens, planMode }) => (
<Box
borderStyle="single"
borderColor={planMode ? 'green' : 'gray'}
paddingX={1}
flexDirection="row"
justifyContent="space-between"
>
<Box flexDirection="row">
<Text color="cyanBright" bold>ZERO</Text>
<Text color="gray" dimColor> local agent</Text>
{planMode && <Text color="green"> - PLAN</Text>}
</Box>
<Box flexDirection="row">
<Text color="cyan">{provider}</Text>
<Text color="gray"> / </Text>
<Text color="magenta">{model}</Text>
<Text color="gray" dimColor>
{' '}p:{usage.promptTokens} c:{usage.completionTokens} t:{totalTokens}
</Text>
</Box>
</Box>
);
+16 -2
View File
@@ -14,6 +14,7 @@ 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 }> = [];
@@ -21,18 +22,21 @@ export const MessageRenderer: React.FC<MessageRendererProps> = ({ content }) =>
matches.push({
index: match.index,
lang: match[1] || 'text',
code: match[2].trim(),
code: (match[2] ?? '').trim(),
fullMatch: match[0],
});
}
// Highlight code blocks asynchronously
useEffect(() => {
let cancelled = false;
const highlightAll = async () => {
const newHighlights = new Map<number, string>();
for (let i = 0; i < matches.length; i++) {
const m = matches[i];
if (!m) continue;
try {
const ansi = await highlightCode(m.code, m.lang);
newHighlights.set(i, ansi);
@@ -41,16 +45,25 @@ export const MessageRenderer: React.FC<MessageRendererProps> = ({ content }) =>
}
}
setHighlightedBlocks(newHighlights);
if (!cancelled) {
setHighlightedBlocks(newHighlights);
}
};
if (matches.length > 0) {
highlightAll();
} else {
setHighlightedBlocks(new Map());
}
return () => {
cancelled = true;
};
}, [content]);
for (let i = 0; i < matches.length; i++) {
const m = matches[i];
if (!m) continue;
// Add text before this code block (with paragraph formatting)
if (m.index > lastIndex) {
@@ -82,6 +95,7 @@ export const MessageRenderer: React.FC<MessageRendererProps> = ({ content }) =>
);
lastIndex = m.index + m.fullMatch.length;
blockIndex++;
}
// Add remaining text after last code block (with paragraph formatting)
+2 -2
View File
@@ -35,7 +35,7 @@ export const ProviderPicker: React.FC<ProviderPickerProps> = ({ onSelect, onCanc
if (key.return) {
if (selectedIndex < providers.length) {
const selected = providers[selectedIndex];
onSelect(selected.name);
if (selected) onSelect(selected.name);
} else {
// Last item = Add new
onAddNew();
@@ -48,7 +48,7 @@ export const ProviderPicker: React.FC<ProviderPickerProps> = ({ onSelect, onCanc
if (!isNaN(num) && num >= 1 && num <= totalItems) {
if (num <= providers.length) {
const selected = providers[num - 1];
onSelect(selected.name);
if (selected) onSelect(selected.name);
} else {
onAddNew();
}
+58
View File
@@ -0,0 +1,58 @@
import React from 'react';
import { Box, Text } from 'ink';
import type { PlanItem } from '../tools/plan';
export const TodoRail: React.FC<{ plan: PlanItem[] }> = ({ plan }) => (
<Box
width={34}
flexShrink={0}
flexDirection="column"
borderStyle="single"
borderColor="gray"
paddingX={1}
>
<Text color="green" bold>todo</Text>
{plan.length === 0 ? (
<Text color="gray" dimColor>No active plan yet.</Text>
) : (
plan.map((item, index) => (
<Box key={item.id || index} flexDirection="column" marginTop={1}>
<Text color={planStatusColor(item.status)}>
{index + 1}. {planStatusMark(item.status)} {item.content}
</Text>
{item.notes && (
<Text color="gray" dimColor>
{' '}{item.notes}
</Text>
)}
</Box>
))
)}
</Box>
);
export function planStatusMark(status: PlanItem['status']): string {
switch (status) {
case 'completed':
return 'x';
case 'in_progress':
return '>';
case 'failed':
return '!';
default:
return 'o';
}
}
export function planStatusColor(status: PlanItem['status']): 'green' | 'yellow' | 'red' | 'gray' {
switch (status) {
case 'completed':
return 'green';
case 'in_progress':
return 'yellow';
case 'failed':
return 'red';
default:
return 'gray';
}
}
+44 -2
View File
@@ -1,6 +1,7 @@
import { createHighlighter } from 'shiki'
let highlighterPromise: ReturnType<typeof createHighlighter> | null = null
const highlightCache = new Map<string, Promise<string>>()
export async function getHighlighter() {
if (!highlighterPromise) {
@@ -31,14 +32,55 @@ export async function getHighlighter() {
}
export async function highlightCode(code: string, lang: string = 'text'): Promise<string> {
const cacheKey = `${lang}:${code.length}:${hashString(code)}`
const cached = highlightCache.get(cacheKey)
if (cached) return cached
const highlighted = highlightCodeUncached(code, lang)
highlightCache.set(cacheKey, highlighted)
return highlighted
}
async function highlightCodeUncached(code: string, lang: string): Promise<string> {
try {
const highlighter = await getHighlighter()
return highlighter.codeToAnsi(code, {
lang: lang || 'text',
const lines = highlighter.codeToTokensBase(code, {
lang: (lang || 'text') as any,
theme: 'github-dark',
})
return lines
.map((line) =>
line
.map((token) => token.color ? `${hexToAnsi(token.color)}${token.content}\x1b[39m` : token.content)
.join(''),
)
.join('\n')
} catch (error) {
// Fallback to plain text if highlighting fails
return code
}
}
function hashString(value: string): string {
let hash = 5381
for (let i = 0; i < value.length; i++) {
hash = ((hash << 5) + hash) ^ value.charCodeAt(i)
}
return (hash >>> 0).toString(36)
}
function hexToAnsi(hex: string): string {
const normalized = hex.replace('#', '')
const value = Number.parseInt(normalized, 16)
if (Number.isNaN(value)) {
return ''
}
const red = (value >> 16) & 255
const green = (value >> 8) & 255
const blue = value & 255
return `\x1b[38;2;${red};${green};${blue}m`
}
+69
View File
@@ -85,4 +85,73 @@ describe('runAgent tool-call flow', () => {
expect(answer).toBe('just an answer');
expect(provider.received).toHaveLength(1);
});
it('reports usage and plan updates through callbacks', async () => {
const provider = new MockProvider([
[
{ type: 'usage', promptTokens: 10, completionTokens: 2 },
{ type: 'tool-call-start', id: 'call_1', name: 'update_plan' },
{
type: 'tool-call-delta',
id: 'call_1',
argumentsFragment: JSON.stringify({
plan: [{ id: '1', content: 'track this', status: 'in_progress' }],
}),
},
{ type: 'tool-call-end', id: 'call_1' },
],
[{ type: 'text', content: 'done' }],
]);
const usageEvents: Array<{ promptTokens: number; completionTokens: number }> = [];
const planSnapshots: string[][] = [];
await runAgent('make a plan', provider, {
onUsage: (usage) => usageEvents.push(usage),
onPlanUpdate: (plan) => planSnapshots.push(plan.map((item) => item.content)),
});
expect(usageEvents).toEqual([{ promptTokens: 10, completionTokens: 2 }]);
expect(planSnapshots).toEqual([[], ['track this']]);
});
it('does not leak plan update callbacks across separate runs', async () => {
const firstProvider = new MockProvider([
[
{ type: 'tool-call-start', id: 'call_1', name: 'update_plan' },
{
type: 'tool-call-delta',
id: 'call_1',
argumentsFragment: JSON.stringify({
plan: [{ id: '1', content: 'first plan', status: 'pending' }],
}),
},
{ type: 'tool-call-end', id: 'call_1' },
],
[{ type: 'text', content: 'first done' }],
]);
const secondProvider = new MockProvider([
[
{ type: 'tool-call-start', id: 'call_2', name: 'update_plan' },
{
type: 'tool-call-delta',
id: 'call_2',
argumentsFragment: JSON.stringify({
plan: [{ id: '2', content: 'second plan', status: 'pending' }],
}),
},
{ type: 'tool-call-end', id: 'call_2' },
],
[{ type: 'text', content: 'second done' }],
]);
const planSnapshots: string[][] = [];
await runAgent('first', firstProvider, {
onPlanUpdate: (plan) => planSnapshots.push(plan.map((item) => item.content)),
});
await runAgent('second', secondProvider, {});
expect(planSnapshots).toEqual([[], ['first plan']]);
});
});
+3 -5
View File
@@ -59,13 +59,11 @@ describe('editFileTool', () => {
expect(result).toContain('Could not find the exact string');
});
it('rejects when multiple occurrences exist', async () => {
it('only replaces the first occurrence', async () => {
const file = join(dir, 'dup.txt');
await writeFile(file, 'x\nx\n', 'utf-8');
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');
await editFileTool.execute({ path: file, old_string: 'x', new_string: 'y' });
expect(await readFile(file, 'utf-8')).toBe('y\nx\n');
});
});
+117
View File
@@ -0,0 +1,117 @@
import { afterEach, describe, expect, it, mock } from 'bun:test';
const createMock: any = mock(async () => {
throw new Error('create mock not configured');
});
mock.module('openai', () => ({
default: class MockOpenAI {
chat = {
completions: {
create: createMock,
},
};
},
}));
async function* streamFrom(chunks: any[]) {
for (const chunk of chunks) {
yield chunk;
}
}
afterEach(() => {
createMock.mockReset();
});
describe('OpenAIProvider stream options compatibility', () => {
it('requests usage and retries without stream_options when a compatible provider rejects it', async () => {
const { OpenAIProvider } = await import('../src/providers/openai');
const unsupportedStreamOptions = Object.assign(new Error('unknown parameter: stream_options.include_usage'), {
status: 400,
});
createMock
.mockRejectedValueOnce(unsupportedStreamOptions)
.mockResolvedValueOnce(streamFrom([
{
choices: [{ delta: { content: 'hi' }, finish_reason: 'stop' }],
},
]));
const provider = new OpenAIProvider({ apiKey: 'test', model: 'test-model' });
const events = [];
for await (const event of provider.streamCompletion([{ role: 'user', content: 'hello' }], [])) {
events.push(event);
}
expect(createMock).toHaveBeenCalledTimes(2);
expect(createMock.mock.calls[0]?.[0]?.stream_options).toEqual({ include_usage: true });
expect(createMock.mock.calls[1]?.[0]?.stream_options).toBeUndefined();
expect(events).toContainEqual({ type: 'text', content: 'hi' });
});
it('does not retry unrelated unknown-parameter errors', async () => {
const { OpenAIProvider } = await import('../src/providers/openai');
const unsupportedToolChoice = Object.assign(new Error('unknown parameter: tool_choice'), {
status: 400,
});
createMock.mockRejectedValueOnce(unsupportedToolChoice);
const provider = new OpenAIProvider({ apiKey: 'test', model: 'test-model' });
await expect((async () => {
for await (const _event of provider.streamCompletion([{ role: 'user', content: 'hello' }], [])) {
// Drain the stream.
}
})()).rejects.toThrow('unknown parameter: tool_choice');
expect(createMock).toHaveBeenCalledTimes(1);
});
it('categorizes mid-stream authentication errors', async () => {
const { OpenAIProvider } = await import('../src/providers/openai');
createMock.mockResolvedValueOnce(streamFrom([
{
error: {
message: 'Incorrect API key provided',
},
},
]));
const provider = new OpenAIProvider({ apiKey: 'test', model: 'test-model' });
await expect((async () => {
for await (const _event of provider.streamCompletion([{ role: 'user', content: 'hello' }], [])) {
// Drain the stream.
}
})()).rejects.toThrow('Provider authentication error');
});
it('emits usage events from compatible OpenAI streams', async () => {
const { OpenAIProvider } = await import('../src/providers/openai');
createMock.mockResolvedValueOnce(streamFrom([
{
choices: [{ delta: {}, finish_reason: 'stop' }],
usage: {
prompt_tokens: 8,
completion_tokens: 3,
},
},
]));
const provider = new OpenAIProvider({ apiKey: 'test', model: 'test-model' });
const events = [];
for await (const event of provider.streamCompletion([{ role: 'user', content: 'hello' }], [])) {
events.push(event);
}
expect(createMock.mock.calls[0]?.[0]?.stream_options).toEqual({ include_usage: true });
expect(events).toContainEqual({ type: 'usage', promptTokens: 8, completionTokens: 3 });
});
});
-663
View File
@@ -1,663 +0,0 @@
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);
}
});
});