merge: sync sdk/pr3-sdk-runtime with origin/main (PR2 + upstream)

Resolve 5 conflict files — all taking main's side:
- src/QueryEngine.ts: selective tool schema cache invalidation
- src/bootstrap/state.ts: parentSessionId field, AsyncLocalStorage JSDoc
- src/entrypoints/sdk/permissions.ts: SDKLogger injection, NO_SESSION_PLACEHOLDER,
  race condition fix (register before emit), try-catch host callback
- src/entrypoints/sdk/shared.ts: snake_case naming, enriched JSDoc, timeout cleanup
- tests/sdk/permissions.test.ts: regression tests for PR2 bug fixes
This commit is contained in:
Ali Alakbarli
2026-05-02 09:09:45 +04:00
227 changed files with 23046 additions and 2744 deletions
+1
View File
@@ -1,5 +1,6 @@
node_modules
dist
web
.git
.gitignore
.env
+4 -4
View File
@@ -167,20 +167,20 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here
# OPENAI_AUTH_SCHEME=raw
# OPENAI_AUTH_HEADER_VALUE=your-header-value-here
# Fallback context window size (tokens) when the model is not found in the
# built-in table (default: 128000). Increase this for models with larger
# Fallback context window size (tokens) when the model is not found in
# integration model metadata (default: 128000). Increase this for models with larger
# context windows (e.g. 200000 for Claude-sized contexts).
# CLAUDE_CODE_OPENAI_FALLBACK_CONTEXT_WINDOW=128000
# Per-model context window overrides as a JSON object.
# Takes precedence over the built-in table, so you can register new or
# Takes precedence over integration model metadata, so you can register new or
# custom models without patching source.
# Example: CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS={"my-corp/llm-v3":262144,"gpt-4o-mini":128000}
# CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS=
# Per-model maximum output token overrides as a JSON object.
# Use this alongside CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS when your model
# supports a different output limit than what the built-in table specifies.
# supports a different output limit than what integration metadata specifies.
# Example: CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS={"my-corp/llm-v3":8192}
# CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS=
+26
View File
@@ -58,3 +58,29 @@ jobs:
- name: Provider recommendation tests
run: npm run test:provider-recommendation
web:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
- name: Set up Bun
uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.1
with:
bun-version: 1.3.11
- name: Install web dependencies
run: bun install --cwd web --frozen-lockfile
- name: Typecheck web
run: bun run --cwd web typecheck
- name: Build web
run: bun run --cwd web build
+3
View File
@@ -1,6 +1,8 @@
node_modules/
dist/
*.tsbuildinfo
web/dist/
web/*.tsbuildinfo
.env
.env.*
!.env.example
@@ -12,3 +14,4 @@ package-lock.json
/.claude
coverage/
agent.log
plan/
+6
View File
@@ -0,0 +1,6 @@
# npm publish only ships what the `files` field in package.json allows.
# This .npmignore is a safety net: if `files` is ever removed or expanded,
# nothing in this list can be published.
# Marketing / landing site — never ship with the CLI package.
web/
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "0.7.0"
".": "0.8.0"
}
+29
View File
@@ -1,5 +1,34 @@
# Changelog
## [0.8.0](https://github.com/Gitlawb/openclaude/compare/v0.7.0...v0.8.0) (2026-05-02)
### Features
* add Opus 4.7 as default model and fix alias/thinking bugs ([#928](https://github.com/Gitlawb/openclaude/issues/928)) ([4c93a9f](https://github.com/Gitlawb/openclaude/commit/4c93a9f9f168217d4bdd53d103337e43f28be074))
* add streaming token counter ([#797](https://github.com/Gitlawb/openclaude/issues/797)) ([0ca4333](https://github.com/Gitlawb/openclaude/commit/0ca43335375beec6e58711b797d5b0c4bb5019b8))
* **api:** deterministic request-body serialization via stableStringify ([#882](https://github.com/Gitlawb/openclaude/issues/882)) ([6ea3eb6](https://github.com/Gitlawb/openclaude/commit/6ea3eb64830ccfec1436bcebe2406158e14a7e81))
* **cli:** improve SSH interactivity detection via SSH_TTY and SSH_CONNECTION ([#946](https://github.com/Gitlawb/openclaude/issues/946)) ([aae96aa](https://github.com/Gitlawb/openclaude/commit/aae96aa52a1241661116d62aac884ddeafd7835b))
* context preloading and hybrid context strategy ([#860](https://github.com/Gitlawb/openclaude/issues/860)) ([92d297e](https://github.com/Gitlawb/openclaude/commit/92d297e50efcc7225f57f0d3cb0ba989dc40d624))
* **lsp:** add first-class code intelligence setup ([#950](https://github.com/Gitlawb/openclaude/issues/950)) ([677d29f](https://github.com/Gitlawb/openclaude/commit/677d29ffd42410710150f1eb8942190c8d317fe0))
* SDK Core — Permission System, Async Context, and Engine Extensions ([#951](https://github.com/Gitlawb/openclaude/issues/951)) ([a46b31c](https://github.com/Gitlawb/openclaude/commit/a46b31c3ec1840a712b9ad2cdd4f9d0f359544c9))
* SDK Foundation — Type Declarations, Errors, and Utilities ([#866](https://github.com/Gitlawb/openclaude/issues/866)) ([91f93ce](https://github.com/Gitlawb/openclaude/commit/91f93ce61533a9cadd1d107e09a442451c09f5db))
### Bug Fixes
* avoid legacy Windows PasswordVault reads by default ([#941](https://github.com/Gitlawb/openclaude/issues/941)) ([d321c8f](https://github.com/Gitlawb/openclaude/commit/d321c8fc6a0be6731c1ccfec0fca8023b1a8b67e))
* **errors:** show actual host in 404 message instead of Ollama hint ([#926](https://github.com/Gitlawb/openclaude/issues/926)) ([#931](https://github.com/Gitlawb/openclaude/issues/931)) ([4fab8b9](https://github.com/Gitlawb/openclaude/commit/4fab8b913f8b5301b98eb8dcf42dd75f095a3c60))
* **input:** strip leading ! when entering bash mode ([#947](https://github.com/Gitlawb/openclaude/issues/947)) ([5943c5c](https://github.com/Gitlawb/openclaude/commit/5943c5c269cdeba45879dac0d8da0082e28cc2a2)), closes [#662](https://github.com/Gitlawb/openclaude/issues/662)
* **oauth:** skip refresh for third-party providers ([#955](https://github.com/Gitlawb/openclaude/issues/955)) ([208c896](https://github.com/Gitlawb/openclaude/commit/208c896c07b878e2859fbae7e0f31697d59943ce))
* **openai-shim:** don't label transport failures as HTTP 503 ([#971](https://github.com/Gitlawb/openclaude/issues/971)) ([#975](https://github.com/Gitlawb/openclaude/issues/975)) ([cc0dab6](https://github.com/Gitlawb/openclaude/commit/cc0dab60a3721921f949165b93c8c997b1aae4a2))
* **openai-shim:** strip `store` when baseUrl points at Gemini ([#959](https://github.com/Gitlawb/openclaude/issues/959)) ([0f0fd26](https://github.com/Gitlawb/openclaude/commit/0f0fd266dbe9363b0ea1db29d8c10ed0b9b18413)), closes [#664](https://github.com/Gitlawb/openclaude/issues/664)
* **plugins:** sanitize env before spawning git so /plugin marketplace add works ([#751](https://github.com/Gitlawb/openclaude/issues/751)) ([#934](https://github.com/Gitlawb/openclaude/issues/934)) ([5c4fdca](https://github.com/Gitlawb/openclaude/commit/5c4fdca21743f82071d0ee22534d61c9ad677efe))
* **provider:** apply Codex OAuth session switch correctly ([#974](https://github.com/Gitlawb/openclaude/issues/974)) ([95a817f](https://github.com/Gitlawb/openclaude/commit/95a817fdb08a97b6293c6c7f87457bcd98283714))
* **ripgrep:** use @vscode/ripgrep package as the builtin source ([#911](https://github.com/Gitlawb/openclaude/issues/911)) ([#932](https://github.com/Gitlawb/openclaude/issues/932)) ([ee0d930](https://github.com/Gitlawb/openclaude/commit/ee0d9300939db0c6178bfad4707a0be45f126d1f))
* **typecheck:** make `bun run typecheck` actionable on main ([#473](https://github.com/Gitlawb/openclaude/issues/473)) ([#938](https://github.com/Gitlawb/openclaude/issues/938)) ([8106880](https://github.com/Gitlawb/openclaude/commit/8106880855ee0bb4b5bbca8827cfe97fe99558b8))
* **worktree:** surface git stderr in rev-parse failure message ([#690](https://github.com/Gitlawb/openclaude/issues/690)) ([#954](https://github.com/Gitlawb/openclaude/issues/954)) ([7711dda](https://github.com/Gitlawb/openclaude/commit/7711ddae4807332526ea128c0246b479d5c0ed00))
## [0.7.0](https://github.com/Gitlawb/openclaude/compare/v0.6.0...v0.7.0) (2026-04-26)
+2
View File
@@ -104,6 +104,8 @@ OpenClaude supports multiple provider paths. If you change provider logic:
- avoid breaking third-party providers while fixing first-party behavior
- test the exact provider/model path you changed when possible
- call out any limitations or follow-up work in the PR description
- if you are adding or changing descriptor-era integrations, start with `docs/integrations/overview.md`
- use the focused how-to guides under `docs/integrations/how-to/` for new vendors, gateways, models, anthropic proxies, and `/usage` support
## Community
+48 -12
View File
@@ -15,7 +15,7 @@ npm install -g @gitlawb/openclaude
Use Bun `1.3.11` or newer for source builds on Windows. Older Bun versions can fail during `bun run build`.
```bash
git clone https://node.gitlawb.com/z6MkqDnb7Siv3Cwj7pGJq4T5EsUisECqR8KpnDLwcaZq5TPr/openclaude.git
git clone https://github.com/Gitlawb/openclaude.git
cd openclaude
bun install
@@ -26,7 +26,7 @@ npm link
### Option C: Run directly with Bun
```bash
git clone https://node.gitlawb.com/z6MkqDnb7Siv3Cwj7pGJq4T5EsUisECqR8KpnDLwcaZq5TPr/openclaude.git
git clone https://github.com/Gitlawb/openclaude.git
cd openclaude
bun install
@@ -45,19 +45,24 @@ export OPENAI_MODEL=gpt-4o
### Codex via ChatGPT auth
`codexplan` maps to GPT-5.4 on the Codex backend with high reasoning.
`codexplan` maps to GPT-5.5 on the Codex backend with high reasoning.
`codexspark` maps to GPT-5.3 Codex Spark for faster loops.
If you use the in-app provider wizard, choose `Codex OAuth` to open ChatGPT sign-in in your browser and let OpenClaude store Codex credentials securely.
If you already use the Codex CLI, OpenClaude reads `~/.codex/auth.json` automatically. You can also point it elsewhere with `CODEX_AUTH_JSON_PATH` or override the token directly with `CODEX_API_KEY`.
If you set `CODEX_API_KEY` manually and are not relying on `auth.json` or stored
Codex OAuth credentials, also set `CHATGPT_ACCOUNT_ID` (or
`CODEX_ACCOUNT_ID`).
```bash
export CLAUDE_CODE_USE_OPENAI=1
export OPENAI_MODEL=codexplan
# optional if you do not already have ~/.codex/auth.json
export CODEX_API_KEY=...
export CHATGPT_ACCOUNT_ID=...
openclaude
```
@@ -73,13 +78,24 @@ export OPENAI_MODEL=deepseek-v4-flash
Use `deepseek-v4-pro` when you want the stronger model. `deepseek-chat` and `deepseek-reasoner` remain available as DeepSeek's legacy API aliases.
### Google Gemini via OpenRouter
### Google Gemini
```bash
export CLAUDE_CODE_USE_GEMINI=1
export GEMINI_API_KEY=...
export GEMINI_MODEL=gemini-3-flash-preview
```
If you prefer access-token or ADC-based Gemini auth instead of an API key, use
the guided `/provider` flow.
### Gemini via OpenRouter
```bash
export CLAUDE_CODE_USE_OPENAI=1
export OPENAI_API_KEY=sk-or-...
export OPENAI_BASE_URL=https://openrouter.ai/api/v1
export OPENAI_MODEL=google/gemini-2.0-flash-001
export OPENAI_MODEL=google/gemini-2.5-pro
```
OpenRouter model availability changes over time. If a model stops working, try another current OpenRouter model before assuming the integration is broken.
@@ -153,7 +169,7 @@ export OPENAI_MODEL=llama-3.3-70b-versatile
```bash
export CLAUDE_CODE_USE_MISTRAL=1
export MISTRAL_API_KEY=...
export MISTRAL_MODEL=mistral-large-latest
export MISTRAL_MODEL=devstral-latest
```
### Azure OpenAI
@@ -169,17 +185,29 @@ export OPENAI_MODEL=gpt-4o
| Variable | Required | Description |
|----------|----------|-------------|
| `CLAUDE_CODE_USE_OPENAI` | Yes | Set to `1` to enable the OpenAI provider |
| `OPENAI_API_KEY` | Yes* | Your API key (`*` not needed for local models like Ollama or Atomic Chat) |
| `OPENAI_MODEL` | Yes | Model name such as `gpt-4o`, `deepseek-v4-flash`, or `llama3.3:70b` |
| `CLAUDE_CODE_USE_OPENAI` | OpenAI-compatible only | Set to `1` to enable the OpenAI-compatible provider path |
| `OPENAI_API_KEY` | OpenAI-compatible cloud routes* | Your API key (`*` not needed for local models like Ollama, LM Studio, Atomic Chat, or other local OpenAI-compatible proxies) |
| `OPENAI_MODEL` | OpenAI-compatible only | Model name such as `gpt-4o`, `deepseek-v4-flash`, or `llama3.3:70b` |
| `OPENAI_BASE_URL` | No | API endpoint, defaulting to `https://api.openai.com/v1` |
| `OPENAI_API_BASE` | No | Compatibility alias for `OPENAI_BASE_URL` |
| `CLAUDE_CODE_USE_GEMINI` | Gemini only | Set to `1` to enable the direct Gemini provider path |
| `GEMINI_API_KEY` / `GOOGLE_API_KEY` | Gemini API-key auth | Gemini API key for direct Gemini setup |
| `GEMINI_MODEL` | Gemini only | Model name such as `gemini-3-flash-preview` or `gemini-2.5-pro` |
| `GEMINI_BASE_URL` | No | Override the Gemini base URL |
| `CLAUDE_CODE_USE_MISTRAL` | Mistral only | Set to `1` to enable the dedicated Mistral provider path |
| `MISTRAL_API_KEY` | Mistral only | Mistral API key |
| `MISTRAL_MODEL` | Mistral only | Model name such as `devstral-latest` |
| `MISTRAL_BASE_URL` | No | Override the Mistral base URL |
| `CODEX_API_KEY` | Codex only | Codex or ChatGPT access token override |
| `CHATGPT_ACCOUNT_ID` / `CODEX_ACCOUNT_ID` | Codex only | Required for manual Codex env setup when the account id is not coming from `auth.json` or stored OAuth credentials |
| `CODEX_AUTH_JSON_PATH` | Codex only | Path to a Codex CLI `auth.json` file |
| `CODEX_HOME` | Codex only | Alternative Codex home directory |
| `OPENCLAUDE_DISABLE_CO_AUTHORED_BY` | No | Suppress the default `Co-Authored-By` trailer in generated git commits |
| `OPENCLAUDE_LOG_TOKEN_USAGE` | No | When truthy (e.g. `verbose`), emits one JSON line on stderr per API request with input/output/cache tokens and the resolved provider. **User-facing debug output** — complements the REPL display controlled by `/config showCacheStats`. Distinct from `CLAUDE_CODE_ENABLE_TOKEN_USAGE_ATTACHMENT`, which is **model-facing** (injects context usage info into the prompt itself). Both can run together. |
You can also use `ANTHROPIC_MODEL` to override the model name. `OPENAI_MODEL` takes priority.
Model env vars are provider-scoped: Anthropic-native sessions read
`ANTHROPIC_MODEL`, OpenAI-compatible sessions read `OPENAI_MODEL`, Gemini reads
`GEMINI_MODEL`, and Mistral reads `MISTRAL_MODEL`.
## Runtime Hardening
@@ -208,6 +236,7 @@ bun run hardening:strict
Notes:
- `doctor:runtime` fails fast if `CLAUDE_CODE_USE_OPENAI=1` with a placeholder key or a missing key for non-local providers.
- `doctor:runtime` also validates the dedicated Gemini and Mistral env paths when `CLAUDE_CODE_USE_GEMINI=1` or `CLAUDE_CODE_USE_MISTRAL=1`.
- Local providers such as `http://localhost:11434/v1`, `http://10.0.0.1:11434/v1`, and `http://127.0.0.1:1337/v1` can run without `OPENAI_API_KEY`.
- Codex profiles validate `CODEX_API_KEY` or the Codex CLI auth file and probe `POST /responses` instead of `GET /models`.
@@ -231,6 +260,9 @@ bun run profile:codex
# openai bootstrap with explicit key
bun run profile:init -- --provider openai --api-key sk-...
# gemini bootstrap with explicit key
bun run profile:init -- --provider gemini --api-key ...
# ollama bootstrap with custom model
bun run profile:init -- --provider ollama --model llama3.1:8b
@@ -249,9 +281,12 @@ bun run dev:profile
# codex profile (uses CODEX_API_KEY or ~/.codex/auth.json)
bun run dev:codex
# OpenAI profile (requires OPENAI_API_KEY in your shell)
# OpenAI profile (uses the saved OpenAI profile, or OPENAI_API_KEY from your shell)
bun run dev:openai
# Gemini profile (uses the saved Gemini profile, or GEMINI_API_KEY / GOOGLE_API_KEY from your shell)
bun run dev:gemini
# Ollama profile (defaults: localhost:11434, llama3.1:8b)
bun run dev:ollama
@@ -269,7 +304,8 @@ Use `--provider atomic-chat` when you want Atomic Chat as the local Apple Silico
Use `profile:codex` or `--provider codex` when you want the ChatGPT Codex backend.
`dev:openai`, `dev:ollama`, `dev:atomic-chat`, and `dev:codex` run `doctor:runtime` first and only launch the app if checks pass.
`dev:openai`, `dev:gemini`, `dev:ollama`, `dev:atomic-chat`, and `dev:codex`
run `doctor:runtime` first and only launch the app if checks pass.
For `dev:ollama`, make sure Ollama is running locally before launch.
+274
View File
@@ -0,0 +1,274 @@
# Integrations Architecture
## Purpose
OpenClaude's provider system is now descriptor-first:
- descriptors under `src/integrations/` define vendors, gateways, brands,
shared model metadata, validation hints, discovery strategy, and supported
transport capabilities;
- registry helpers load those descriptors and expose route/model lookups;
- runtime metadata bridges descriptor state into request execution without
reintroducing broad hand-maintained provider switches.
This note captures the post-Phase-3 architecture plus the remaining
constraints and known exceptions that are still expected to exist.
Companion docs:
- `docs/integrations/overview.md`
Contributor-facing map of the integration docs set and the authoring rules.
- `docs/integrations/glossary.md`
Standard terminology for vendors, gateways, routes, models, brands, and
anthropic proxies.
## Source of truth
The primary source of truth now lives in these layers:
1. `src/integrations/descriptors.ts`
Defines the descriptor shapes.
2. `src/integrations/index.ts` and `registry.ts`
Load and expose the registered vendors, gateways, brands, and models.
3. `src/integrations/routeMetadata.ts`
Resolves route labels/defaults and maps active env state onto route ids.
4. `src/integrations/runtimeMetadata.ts`
Derives request-time OpenAI-shim behavior from the active route plus the
selected model/catalog entry.
5. Discovery, validation, and provider-profile helpers
Consume descriptor metadata instead of owning their own provider lists.
In other words: descriptor metadata should decide which route exists and what
it supports; runtime code should execute that metadata, not replace it with a
parallel provider matrix.
## Metadata, routing, and transport
These concerns are related, but they are not interchangeable:
- metadata
Descriptor files declare labels, defaults, catalogs, validation hints,
discovery policy, and capability flags.
- routing
Route-resolution helpers decide which descriptor is active and which runtime
path should receive the request.
- transport
Runtime code such as native Anthropic handlers, Gemini handling, and
`openaiShim.ts` performs the actual request shaping and execution.
The rule of thumb is:
- descriptors own what a route is and what it says it supports;
- routing helpers own how current config/env state maps onto that route;
- transport code owns how requests are executed for the active route.
If a future change needs a new label, default model, setup hint, discovery
policy, or request-shaping flag, it probably belongs in descriptor/runtime
metadata. If it changes the actual HTTP/API contract, it probably belongs in
transport code.
## Gateway routing contract
For gateway descriptors, `transportConfig.kind` is the routing contract.
- use `transportConfig.kind` to decide whether a route is local,
OpenAI-compatible, Anthropic-proxy, Bedrock, Vertex, or another supported
transport family;
- do not use gateway `category` to choose runtime routing behavior.
Gateway `category` is optional display/grouping metadata only:
- `local` helps group routes like Ollama or LM Studio in UI/docs;
- `hosted` helps describe remote first-party or managed endpoints;
- `aggregating` helps describe routes that expose mixed third-party catalogs.
That category is useful for contributor understanding, but runtime selection
must continue to key off `transportConfig.kind`.
## Descriptor authoring pattern
Normal descriptor files should follow the `define*` + default-export pattern:
```ts
import { defineGateway, defineCatalog } from '../define.js'
const catalog = defineCatalog({
source: 'static',
models: [
{
id: 'acme-fast',
apiName: 'acme/fast',
modelDescriptorId: 'acme-fast',
},
],
})
export default defineGateway({
id: 'acme',
label: 'Acme AI',
category: 'hosted',
defaultBaseUrl: 'https://api.acme.example/v1',
defaultModel: 'acme/fast',
setup: {
requiresAuth: true,
authMode: 'api-key',
credentialEnvVars: ['ACME_API_KEY'],
},
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
supportsApiFormatSelection: false,
supportsAuthHeaders: true,
},
},
catalog,
})
```
Contributors should not call `registerGateway`, `registerVendor`,
`registerModel`, or other registry functions directly from normal descriptor
files. Registration is loader-owned:
- the descriptor file defines typed data;
- `bun run integrations:generate` derives
`src/integrations/generated/integrationArtifacts.generated.ts`;
- `src/integrations/index.ts` loads and registers that generated descriptor
inventory;
- registry helpers expose the loaded data to the rest of the app.
That keeps onboarding additive and prevents descriptor files from turning back
into distributed registration logic.
## Compatibility layer
The repo still has a few intentionally named compatibility bridges because the
public env/config contract is not descriptor-native yet:
- `src/integrations/compatibility.ts`
is a thin derived view over the generated preset manifest and maps legacy
provider preset names onto descriptor-backed route ids;
- `src/integrations/profileResolver.ts`
keeps stored/sanitized provider ids compatible with descriptor routes;
- `src/utils/model/providers.ts`
preserves `APIProvider` / `LegacyAPIProvider` for older callers;
- `src/utils/providerFlag.ts`
still writes the env-facing provider contract even though it now reads shared
descriptor metadata.
When contributor docs say "compatibility layer," they mean these env/preset/
legacy-name bridges rather than the descriptor registry itself.
Preset ordering for `/provider` flows is also derived. The generated manifest
pins `anthropic` first, sorts the remaining preset-participating routes by
preset description using standard alphanumeric sorting, and always keeps
`custom` at the bottom automatically.
## Current constraints
The architecture is descriptor-first, but not descriptor-only yet. A few
compatibility surfaces still exist because public/runtime contracts are still
env-centric.
### Temporary compatibility bridges
These are expected to shrink in later work, but they are still correct today:
- `src/integrations/routeMetadata.ts`
`resolveActiveRouteIdFromEnv()` still honors `CLAUDE_CODE_USE_*` flags and
OpenAI-compatible env fallback because bootstrap and saved-profile flows are
still env-driven.
- `src/utils/providerFlag.ts`
`--provider` still writes the legacy env contract directly, even though it
now reads descriptor defaults where possible.
- `src/utils/model/providers.ts`
`LegacyAPIProvider`/`APIProvider` remain as the compatibility surface for
older callers, including env-only MiniMax and NVIDIA NIM recovery.
- `src/commands/provider/provider.tsx`
Current/saved-provider summaries still read provider-specific env/profile
fields directly.
- `src/components/StartupScreen.ts`
Startup banner labels are still derived from active env state and heuristics.
These bridges are not evidence that the descriptor migration failed; they are
evidence that the public env/config contract has not been redesigned yet.
### Intentional long-term runtime exceptions
Some provider-specific behavior is real protocol or capability divergence and
should remain explicit unless the external API changes.
- GitHub is a dual-mode route.
Claude models can use Anthropic-native message format, while Copilot/Models
traffic still uses OpenAI/Codex-style transport behavior.
- Mistral is not just "generic OpenAI-compatible".
It still requires dedicated env selection and request shaping.
- Azure OpenAI and Bankr have distinct auth/header contracts.
Azure uses `api-key` and deployment URLs; Bankr uses `X-API-Key`.
- Gemini still has provider-specific credential handling and thought-signature
behavior at the shim boundary.
- DeepSeek and Moonshot/Kimi still need route-specific `reasoning_content`,
`max_tokens`, and `store` shaping.
- Bedrock, Vertex, and Foundry stay on dedicated Anthropic-family SDK/auth
flows rather than the generic OpenAI-compatible transport.
- Native web search is only valid on native Anthropic-family paths
(`firstParty`, `vertex`, `foundry`) and the separate Codex path.
- MiniMax keeps dedicated `/usage` execution logic because its usage endpoints
are not the same as the generic vendor path.
- Conversation recovery must preserve Anthropic-native thinking blocks for
native transports while stripping them for OpenAI-compatible routes.
## Known exceptions
As of Phase 3 completion, the main known exception categories are:
- `github`
- `mistral`
- `bedrock`
- `vertex`
- `foundry`
- env-only MiniMax fallback
- env-only NVIDIA NIM fallback
- Bankr auth/header aliasing
- Azure deployment/auth request shaping
- MiniMax dedicated `/usage`
- native web-search gating
- Anthropic-native thinking preservation during conversation recovery
These are not all the same kind of exception:
- some are long-term protocol differences;
- some are temporary env/config bridges;
- some are hybrid compatibility shims at the transport boundary.
Any future cleanup should preserve that distinction.
## What Phase 3 finished
Phase 3 removed stale metadata/naming/env duplication, but it intentionally did
not force every provider down one synthetic execution path.
Completed in Phase 3:
- removed metadata-only dead switches;
- renamed compatibility surfaces so they read as compatibility bridges rather
than descriptor-native routing;
- consolidated env shaping between startup/profile helpers and config-backed
profile activation;
- moved eligible OpenAI-shim base URL/model selection under route/runtime
metadata;
- recorded the remaining exceptions instead of leaving them implicit.
## Follow-on guidance
If a future change touches provider-specific runtime behavior:
- first decide whether the branch is a real external API contract difference or
only a compatibility bridge;
- if it is a real contract difference, keep it explicit and document it;
- if it is a compatibility bridge, prefer moving the decision closer to
descriptor/runtime metadata rather than cloning more env-specific logic;
- do not remove a documented exception just because it looks repetitive; remove
it only when equivalent behavior is proven by tests.
For the detailed post-Phase-3 inventory, see
`plan/phase-3d-final-audit.md`.
+196
View File
@@ -0,0 +1,196 @@
# Common Pitfalls
## Purpose
This is the short pre-PR checklist for descriptor-era integration work.
Use it after reading the architecture note, the relevant how-to guide, and the
reference samples.
## Pitfall 1: Using the wrong descriptor type
Common mistake:
Modeling a route as a gateway or vendor just because it feels close enough.
Safer rule:
- use `VendorDescriptor` when the route is the canonical direct vendor API;
- use `GatewayDescriptor` when the route hosts, proxies, or aggregates models
behind its own endpoint contract;
- use `AnthropicProxyDescriptor` when the route accepts Anthropic-native
traffic through its own Anthropic-style env contract;
- use `ModelDescriptor` for shared model metadata, not route availability.
## Pitfall 2: Treating `category` as the routing contract
Common mistake:
Using gateway `category` to decide runtime behavior.
Safer rule:
`transportConfig.kind` is the routing contract. `category` is only optional
grouping/display metadata.
## Pitfall 3: Reintroducing removed gateway fields
Common mistake:
Adding fields such as `targetVendorId`, `isOpenAICompatible`, or routing-style
gateway `classification`.
Safer rule:
Keep routing in `transportConfig.kind`. Use current descriptor fields from
`src/integrations/descriptors.ts`, not legacy examples from older branches or
notes.
## Pitfall 4: Calling registry mutation helpers from descriptor files
Common mistake:
Using `registerGateway(...)`, `registerVendor(...)`, or `registerModel(...)`
inside contributor-authored descriptor files.
Safer rule:
Use the `define*` helpers from `src/integrations/define.ts` and default-export
the descriptor. Loader-owned registration stays in `src/integrations/index.ts`.
## Pitfall 5: Putting route availability into shared model files
Common mistake:
Treating `src/integrations/models/*.ts` as the main place to say where a model
is available.
Safer rule:
Shared model descriptors answer what a model is. Route-owned catalogs answer
where it is offered.
## Pitfall 6: Duplicating model defaults in catalog entries
Common mistake:
Marking catalog entries with per-model `default` or `recommended` flags after
the route already declares `defaultModel`.
Safer rule:
Declare the route's default once with `defaultModel`. UI recommendation labels
derive from that route default.
## Pitfall 7: Forgetting `providerModelMap` boundaries
Common mistake:
Assuming `providerModelMap` enables a route automatically.
Safer rule:
Use `providerModelMap` only to record route-specific API names for the same
conceptual model. The route catalog still decides whether that route exposes
the model.
## Pitfall 8: Omitting `openaiShim.maxTokensField` on strict routes
Common mistake:
Assuming every OpenAI-compatible route accepts the same max-token field.
Safer rule:
- use `max_completion_tokens` for newer hosted OpenAI-style contracts;
- use `max_tokens` for local or legacy-shaped routes and other providers that
reject the newer field;
- keep the choice explicit in `transportConfig.openaiShim.maxTokensField` when
the route is strict.
## Pitfall 9: Flattening real protocol differences
Common mistake:
Treating Bedrock, Vertex, Gemini, GitHub native Claude mode, or Mistral as if
they were all just generic OpenAI-compatible routes.
Safer rule:
If the external API contract is genuinely different, keep that difference
explicit. Descriptor-first does not mean protocol differences should be hidden.
## Pitfall 10: Overstating `/usage` support
Common mistake:
Declaring usage support because the descriptor schema allows it, without
checking the active runtime/UI path.
Safer rule:
- use `usage.supported: true` only when the route has real current support;
- delegate from a gateway to a vendor only when the vendor is the true source
of usage data;
- keep unsupported routes explicit with `usage: { supported: false }`;
- remember that the current resolver in `src/commands/usage/index.ts` is still
vendor/gateway-focused, with the current settings UI still concrete for
Anthropic, MiniMax, and Codex.
## Pitfall 11: Hiding discovery complexity in the descriptor file
Common mistake:
Packing a large hybrid catalog or complex discovery rules inline in
`gateways/<id>.ts`.
Safer rule:
Move large catalog or discovery-specific logic into a companion
`gateways/<id>.models.ts` file and keep the descriptor file small.
## Pitfall 12: Rebuilding the old OpenAI context table
Common mistake:
Adding built-in context or output limits to
`src/utils/model/openaiContextWindows.ts`.
Safer rule:
Put built-in model metadata in `src/integrations/models/`. Keep
`openaiContextWindows.ts` focused on documented env overrides such as
`CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS` and
`CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS`.
## Pitfall 13: Forgetting the compatibility layer
Common mistake:
Changing descriptor metadata and assuming every public surface is already
descriptor-native.
Safer rule:
If the route should be user-facing in preset flows, add descriptor `preset`
metadata and regenerate the artifacts:
- `bun run integrations:generate`
- `src/integrations/generated/integrationArtifacts.generated.ts`
- env-facing flows that still preserve legacy names
Do not hand-edit:
- `src/integrations/compatibility.ts`
- `src/integrations/profileResolver.ts`
- `src/integrations/providerUiMetadata.ts`
- preset typing or preset ordering tables
Only touch remaining env-facing compatibility surfaces when the route truly
needs them.
## Pitfall 14: Using stale repo paths in docs
Common mistake:
Pointing contributors at outdated files or command entrypoints.
Safer rule:
Use the current repo surfaces:
- `src/commands/usage/index.ts` for descriptor-backed `/usage` routing
- `src/components/Settings/Usage.tsx` for the current usage UI boundary
- `src/integrations/routeMetadata.ts` for route/default/label helpers
- `src/integrations/runtimeMetadata.ts` for request-shaping metadata
- `src/integrations/discoveryCache.ts` and `src/integrations/discoveryService.ts`
for discovery caching and loading
## Final check
Before opening or landing integration docs or descriptor changes:
- confirm the descriptor type is correct;
- confirm `transportConfig.kind` is doing the routing work;
- confirm examples use `define*` helpers plus default exports;
- confirm route catalogs own availability;
- confirm route defaults are declared once through `defaultModel`;
- confirm built-in model limits live in `src/integrations/models/`;
- confirm strict OpenAI-compatible routes specify the correct max-token field;
- confirm `/usage` docs match the actual current resolver/UI behavior;
- confirm any illustrative sample is clearly marked as illustrative.
+183
View File
@@ -0,0 +1,183 @@
# Integrations Glossary
## Brand
A shared model-family identity such as Claude, GPT, Kimi, DeepSeek, Llama, or
Qwen. Brands provide reusable family-level metadata and help related model
descriptors stay organized.
## Catalog
The route-owned list of models that a vendor route or gateway route actually
offers. Catalog entries should name the route-facing API model and may point at
shared model metadata with `modelDescriptorId`. A catalog can be:
- `static`
- `dynamic`
- `hybrid`
Catalog ownership lives with the route, not with the global model index. Route
defaults live on the descriptor's `defaultModel`, not as per-entry default or
recommended flags.
## Category
Optional gateway display/grouping metadata:
- `local`
- `hosted`
- `aggregating`
Category is descriptive only. It must not drive runtime routing.
## Compatibility Layer
The set of env/preset/legacy-name bridges that keep older user config and older
callers working while the repo transitions to descriptor-backed metadata.
Examples include:
- `src/integrations/compatibility.ts`
- `src/integrations/profileResolver.ts`
- `src/utils/model/providers.ts`
- `src/utils/providerFlag.ts`
## Descriptor
A typed metadata object defined under `src/integrations/` through one of the
`define*` helpers. Descriptors describe integrations; they are not runtime
executors.
## Direct Vendor Route
A vendor that also acts as its own model-serving route, rather than only
existing behind a separate gateway.
Current examples include first-party or direct vendors such as OpenAI,
DeepSeek, Gemini, MiniMax, and Bankr.
## Gateway
A route that hosts, proxies, or aggregates models behind its own endpoint and
transport contract.
Examples:
- Ollama
- LM Studio
- OpenRouter
- Together
- Groq
## Loader-Owned Registration
The rule that descriptor files define typed data, while
`src/integrations/index.ts` is responsible for loading and registering that
data into the registry through generated descriptor artifacts. Normal
descriptor files should not call registry mutation helpers directly.
## Metadata
The descriptive information about an integration, such as:
- label
- defaults
- setup/auth hints
- validation selection
- discovery policy
- catalog entries
- request-shaping flags
Metadata answers what a route is and what it supports.
## Model Descriptor
A shared model metadata record under `src/integrations/models/`. Model
descriptors own reusable model identity, family metadata, capabilities, context
windows, output limits, cache behavior, and route-specific API aliases through
`providerModelMap`.
Model descriptors do not declare route availability by themselves. Routes still
declare their offered subset in their catalogs.
## Provider
Legacy umbrella term that historically mixed multiple concerns together.
Contributor docs should prefer more precise terms when possible:
- vendor
- gateway
- model
- brand
- anthropic proxy
- route
## Route
The runtime-selectable integration surface that serves models.
In practice, a route may be:
- a gateway descriptor, or
- a direct vendor descriptor that exposes models itself.
Route-centric helpers resolve labels, defaults, transport kind, discovery, and
runtime metadata for the currently active integration.
## Routing
The logic that maps presets, profile ids, env state, or base URLs onto the
active route and transport family.
Routing is not the same as metadata and not the same as transport execution.
## Transport
The request-execution contract used to talk to an external API or local
runtime.
Examples:
- Anthropic-native
- Anthropic-proxy
- OpenAI-compatible
- local
- Gemini-native
- Bedrock
- Vertex
Transport code executes requests. It should consume descriptor/runtime metadata
rather than redefining the integration matrix itself.
## `transportConfig.kind`
The routing contract field on a route descriptor. This is the authoritative
transport-family selector for gateways and other routes.
If runtime behavior differs because the underlying protocol differs,
`transportConfig.kind` is the first field to inspect.
## Vendor
The canonical API or first-party model service behind an integration.
Examples:
- Anthropic
- OpenAI
- Google/Gemini
- Moonshot
- DeepSeek
- MiniMax
- Bankr
Vendors own auth defaults, canonical base URLs, direct catalogs when
applicable, and vendor-specific metadata.
## Anthropic Proxy
A distinct descriptor type for third-party endpoints that accept Anthropic-
native requests through a non-Anthropic endpoint and auth/base-URL contract.
An anthropic proxy is not the same thing as an OpenAI-compatible gateway.
@@ -0,0 +1,179 @@
# How To Add an Anthropic Proxy
## What an anthropic proxy is
An anthropic proxy is a third-party route that accepts Anthropic-native
requests through a non-Anthropic endpoint and env contract.
It is a distinct descriptor type because the transport contract is different
from an OpenAI-compatible gateway:
- request/response shape stays Anthropic-native;
- auth and base URL env vars are Anthropic-proxy specific;
- routing should stay on the Anthropic-family transport path, not the generic
OpenAI-compatible shim path.
Even if the repo has not started shipping concrete anthropic proxy descriptors
yet, this is the contract future contributors should follow.
## When to add an anthropic proxy
Add an anthropic proxy descriptor when:
- the upstream accepts Anthropic-native requests;
- the route is not simply another OpenAI-compatible endpoint;
- the route needs Anthropic-style auth/base URL handling through its own env
variable contract.
Do not use an anthropic proxy descriptor when the route is actually
OpenAI-compatible. In that case, use a gateway or direct-vendor descriptor with
the appropriate `transportConfig.kind`.
## Step-by-step
1. Create the descriptor file under `src/integrations/anthropicProxies/`.
2. Use `defineAnthropicProxy(...)`.
3. Set the proxy identity fields.
Include `id`, `label`, `classification: 'anthropic-proxy'`,
`defaultBaseUrl`, and `defaultModel`.
4. Fill the setup metadata.
Add `setup.requiresAuth`, `setup.authMode`, and
`setup.credentialEnvVars`.
5. Fill `envVarConfig`.
This is the Anthropic-proxy-specific env contract.
6. Set `transportConfig.kind: 'anthropic-proxy'`.
7. Add capabilities and optional catalog/usage/validation metadata as needed.
8. Run `bun run integrations:generate` so the generated loader picks up the
new descriptor.
## Authoring rules
Anthropic proxy examples should:
- use `defineAnthropicProxy`;
- default-export the descriptor;
- keep registration out of the file;
- make the proxy env contract explicit through `envVarConfig`;
- keep the route on Anthropic-family transport behavior.
Do not treat an anthropic proxy as "just another gateway with a different
header." The transport contract is different.
## Anthropic-specific env var contract
`envVarConfig` tells the rest of the system which env vars control the proxy's
auth and routing.
The descriptor contract is:
```ts
envVarConfig: {
authTokenEnvVar: string
baseUrlEnvVar: string
modelEnvVar?: string
}
```
That means the proxy should explicitly declare:
- which env var contains the auth token;
- which env var contains the Anthropic-proxy base URL;
- optionally which env var overrides the model.
This is different from the OpenAI-compatible `OPENAI_*` contract.
## Example: anthropic proxy using Anthropic-native auth and base URL config
```ts
import { defineAnthropicProxy } from '../define.js'
export default defineAnthropicProxy({
id: 'acme-anthropic-proxy',
label: 'Acme Anthropic Proxy',
classification: 'anthropic-proxy',
defaultBaseUrl: 'https://anthropic-proxy.acme.example',
defaultModel: 'claude-sonnet-4-5',
requiredEnvVars: ['ACME_ANTHROPIC_PROXY_TOKEN'],
setup: {
requiresAuth: true,
authMode: 'token',
credentialEnvVars: ['ACME_ANTHROPIC_PROXY_TOKEN'],
setupPrompt: 'Paste your Acme Anthropic proxy token.',
},
envVarConfig: {
authTokenEnvVar: 'ACME_ANTHROPIC_PROXY_TOKEN',
baseUrlEnvVar: 'ACME_ANTHROPIC_PROXY_BASE_URL',
modelEnvVar: 'ACME_ANTHROPIC_PROXY_MODEL',
},
capabilities: {
supportsStreaming: true,
supportsVision: true,
supportsFunctionCalling: true,
supportsJsonMode: true,
supportsReasoning: true,
},
transportConfig: {
kind: 'anthropic-proxy',
},
usage: {
supported: false,
},
})
```
Why this is the right shape:
- the route keeps the Anthropic-native contract instead of pretending to be
OpenAI-compatible;
- auth/base URL/model env wiring is explicit;
- the descriptor default-exports typed data and leaves registration to the
loader;
- the transport family is encoded through `transportConfig.kind`.
## How anthropic proxies differ from OpenAI-compatible gateways
Anthropic proxies:
- use `defineAnthropicProxy`;
- use `classification: 'anthropic-proxy'`;
- use `transportConfig.kind: 'anthropic-proxy'`;
- keep Anthropic-native auth/base-URL env contracts in `envVarConfig`;
- should continue down Anthropic-family routing/transport behavior.
OpenAI-compatible gateways:
- use `defineGateway`;
- use `transportConfig.kind: 'openai-compatible'` or `local`;
- rely on OpenAI-compatible request/response shaping;
- do not use `envVarConfig` for Anthropic-native auth/base-URL wiring.
If the upstream expects OpenAI-compatible JSON bodies, it is not an anthropic
proxy even if it can reach Claude-family models.
## Current repo note
The `src/integrations/anthropicProxies/` directory is already part of the
generated loader flow, even though the repo does not currently ship any live
anthropic-proxy descriptors. That means contributors can add one through the
same descriptor-plus-regeneration workflow used for vendors and gateways.
## What not to do
Avoid these patterns:
- documenting an Anthropic-native route as an OpenAI-compatible gateway;
- hiding the env contract instead of declaring it in `envVarConfig`;
- calling registry mutation helpers directly from the descriptor file;
- flattening the route into a generic transport kind when the external API
contract is actually Anthropic-native.
## Verification checklist
Before calling an anthropic-proxy doc update complete:
- the example uses `defineAnthropicProxy`;
- the env contract is explicit through `envVarConfig`;
- the guide explains Anthropic-native auth/base URL expectations;
- the guide explains how the proxy differs from an OpenAI-compatible gateway;
- the transport family stays encoded as `transportConfig.kind:
'anthropic-proxy'`.
+562
View File
@@ -0,0 +1,562 @@
# How To Add a Gateway
## When to add a gateway
Add a gateway descriptor when the route hosts, proxies, or aggregates models
behind its own endpoint contract.
Typical gateway cases:
- a hosted OpenAI-compatible route with its own base URL and auth;
- a local route such as Ollama or LM Studio;
- an aggregating route that mixes third-party brands/models;
- a route that needs discovery metadata, discovery caching, or readiness
probing.
## Step-by-step
1. Choose the file layout.
Use `src/integrations/gateways/<id>.ts` for the descriptor. Add
`src/integrations/gateways/<id>.models.ts` only when the catalog/discovery
details are large enough to deserve a companion file.
2. Pick the transport family.
`transportConfig.kind` is the routing contract.
3. Pick a `category`.
`category` is optional grouping/display metadata only. It must not drive
runtime routing.
4. Define setup and startup metadata.
Gateways often need readiness or auto-detection hints in `startup`.
5. Choose the catalog strategy.
Use `static`, `dynamic`, or `hybrid`.
6. Decide whether the gateway needs discovery cache TTL, refresh mode, and
manual refresh.
7. For OpenAI-compatible or local routes, add any required static headers and
decide whether users may edit API mode and auth/header fields through
`transportConfig.openaiShim.supportsApiFormatSelection` and
`transportConfig.openaiShim.supportsAuthHeaders`.
8. If the gateway should appear in preset-driven `/provider` flows, add a
`preset` block on the descriptor.
9. Run `bun run integrations:generate` so the generated loader and preset
manifest stay in sync.
## Authoring rules
Normal gateway examples should:
- use `defineGateway` and `defineCatalog`;
- default-export the gateway descriptor;
- default-export the catalog from any companion `*.models.ts` file;
- avoid `registerGateway(...)` in contributor-authored examples;
- avoid removed legacy fields such as `targetVendorId`,
`isOpenAICompatible`, or routing-oriented gateway `classification`.
The routing decision belongs to `transportConfig.kind`, not to `category`.
## Generated loader and preset manifest
Normal gateway onboarding is additive now:
1. add or edit the descriptor file;
2. add a `preset` block only if the route should be user-facing in preset
flows;
3. run `bun run integrations:generate`;
4. let `src/integrations/generated/integrationArtifacts.generated.ts` feed the
loader, compatibility mapping, preset typing, and provider UI metadata.
Preset ordering is not configured manually. The generated manifest pins
`anthropic` first, sorts the remaining preset-participating routes by preset
description using standard alphanumeric sorting, and always pins `custom` to
the bottom automatically.
For gateway presets, set `preset.vendorId` so compatibility/profile helpers
know which vendor contract the gateway belongs to.
## One-file example: hosted gateway with only first-party models
This is the simplest hosted OpenAI-compatible gateway pattern.
```ts
import { defineCatalog, defineGateway } from '../define.js'
const catalog = defineCatalog({
source: 'static',
models: [
{
id: 'acme-hosted-fast',
apiName: 'acme-hosted-fast',
label: 'Acme Hosted Fast',
modelDescriptorId: 'acme-hosted-fast',
},
{
id: 'acme-hosted-pro',
apiName: 'acme-hosted-pro',
label: 'Acme Hosted Pro',
modelDescriptorId: 'acme-hosted-pro',
capabilities: {
supportsReasoning: true,
},
notes: 'Practical input limit is lower than the full context window.',
},
],
})
export default defineGateway({
id: 'acme-hosted',
label: 'Acme Hosted',
category: 'hosted',
defaultBaseUrl: 'https://gateway.acme.example/v1',
defaultModel: 'acme-hosted-fast',
supportsModelRouting: true,
setup: {
requiresAuth: true,
authMode: 'api-key',
credentialEnvVars: ['ACME_HOSTED_API_KEY'],
},
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
headers: {
'X-Acme-Client': 'openclaude',
},
supportsApiFormatSelection: false,
supportsAuthHeaders: true,
maxTokensField: 'max_completion_tokens',
},
},
preset: {
id: 'acme-hosted',
description: 'Acme Hosted gateway',
vendorId: 'openai',
apiKeyEnvVars: ['ACME_HOSTED_API_KEY'],
},
catalog,
usage: {
supported: false,
},
})
```
What this example covers:
- one-file descriptor authoring;
- hosted OpenAI-compatible routing;
- required static custom headers;
- API mode editing disabled for a fixed hosted gateway;
- optional user-supplied auth/header fields enabled;
- a static catalog;
- a gateway with only its own hosted models;
- different reasoning/context/input/output behavior across models;
- route defaults declared once through `defaultModel`.
## Transport family examples
### Hosted OpenAI-compatible gateway
Use `transportConfig.kind: 'openai-compatible'` when the route speaks an
OpenAI-compatible request/response contract.
```ts
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
supportsApiFormatSelection: false,
supportsAuthHeaders: false,
},
}
```
### Local gateway
Use `transportConfig.kind: 'local'` for routes such as Ollama or LM Studio.
```ts
transportConfig: {
kind: 'local',
openaiShim: {
supportsApiFormatSelection: false,
supportsAuthHeaders: true,
maxTokensField: 'max_tokens',
},
}
```
### Anthropic-proxy transport family
If you truly have a gateway-shaped route that accepts Anthropic-native traffic,
the routing contract still comes from `transportConfig.kind`.
```ts
transportConfig: {
kind: 'anthropic-proxy',
}
```
In most cases, a real Anthropic-native third-party route should eventually be
documented through the dedicated anthropic-proxy guide. The key
point here is that the transport family belongs in `transportConfig.kind`, not
in a gateway-specific compatibility flag.
## Local dynamic discovery example
This is the common local gateway shape.
```ts
import { defineGateway } from '../define.js'
export default defineGateway({
id: 'acme-local',
label: 'Acme Local',
category: 'local',
defaultBaseUrl: 'http://localhost:11434/v1',
defaultModel: 'acme-local:latest',
supportsModelRouting: true,
setup: {
requiresAuth: false,
authMode: 'none',
},
startup: {
autoDetectable: true,
probeReadiness: 'openai-compatible-models',
},
transportConfig: {
kind: 'local',
openaiShim: {
supportsApiFormatSelection: false,
supportsAuthHeaders: true,
maxTokensField: 'max_tokens',
},
},
catalog: {
source: 'dynamic',
discovery: {
kind: 'openai-compatible',
},
discoveryCacheTtl: '1d',
discoveryRefreshMode: 'startup',
allowManualRefresh: true,
},
usage: {
supported: false,
},
})
```
What this example covers:
- `transportConfig.kind: 'local'`;
- `catalog.source: 'dynamic'`;
- a local readiness/discovery flow;
- `max_tokens` for a local/legacy-compatible token field;
- a `startup` refresh mode example.
## Two-file example: hybrid gateway with discovery cache
Use a companion `*.models.ts` file when the catalog or discovery rules are too
large to keep inline.
`src/integrations/gateways/galaxy.models.ts`
```ts
import { defineCatalog } from '../define.js'
export default defineCatalog({
source: 'hybrid',
discovery: {
kind: 'openai-compatible',
},
discoveryCacheTtl: '1h',
discoveryRefreshMode: 'background-if-stale',
allowManualRefresh: true,
models: [
{
id: 'galaxy-curated-default',
apiName: 'galaxy/gpt-5-mini',
label: 'GPT-5 Mini (via Galaxy)',
modelDescriptorId: 'gpt-5-mini',
},
{
id: 'galaxy-curated-reasoner',
apiName: 'galaxy/deepseek-r1',
label: 'DeepSeek R1 (via Galaxy)',
modelDescriptorId: 'deepseek-reasoner',
capabilities: {
supportsReasoning: true,
},
notes: 'Practical input limit is 192k tokens on this route.',
transportOverrides: {
openaiShim: {
preserveReasoningContent: true,
requireReasoningContentOnAssistantMessages: true,
reasoningContentFallback: '',
},
},
},
],
})
```
`src/integrations/gateways/galaxy.ts`
```ts
import { defineGateway } from '../define.js'
import catalog from './galaxy.models.js'
export default defineGateway({
id: 'galaxy',
label: 'Galaxy Gateway',
category: 'aggregating',
defaultBaseUrl: 'https://api.galaxy.example/v1',
defaultModel: 'galaxy/gpt-5-mini',
supportsModelRouting: true,
setup: {
requiresAuth: true,
authMode: 'api-key',
credentialEnvVars: ['GALAXY_API_KEY'],
},
startup: {
probeReadiness: 'openai-compatible-models',
},
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
supportsApiFormatSelection: false,
supportsAuthHeaders: true,
maxTokensField: 'max_completion_tokens',
},
},
catalog,
usage: {
supported: false,
},
})
```
What this example covers:
- a two-file gateway pattern;
- `catalog.source: 'hybrid'`;
- human-readable discovery cache TTL;
- `background-if-stale` refresh;
- manual refresh enabled;
- stale cache fallback by design through the shared discovery cache service;
- a mixed catalog of hosted third-party models;
- different reasoning/context/input/output behavior across entries.
Because `allowManualRefresh` is enabled, this is the right pattern for routes
that should support `/model refresh` and in-picker refresh. The shared
discovery cache keeps curated entries visible while refreshes fail or become
stale.
## `providerModelMap` in mixed gateway catalogs
If the gateway exposes a shared model under a route-specific API name, point
the gateway catalog entry at a shared model descriptor and use that model
descriptor's `providerModelMap` to record route-specific names.
Minimal pattern:
```ts
import { defineModel } from '../define.js'
export default [
defineModel({
id: 'deepseek-reasoner',
label: 'DeepSeek Reasoner',
vendorId: 'deepseek',
classification: ['chat', 'reasoning'],
defaultModel: 'deepseek-reasoner',
providerModelMap: {
galaxy: 'galaxy/deepseek-r1',
openrouter: 'deepseek/deepseek-r1',
},
capabilities: {
supportsReasoning: true,
},
}),
]
```
The gateway still owns route availability. `providerModelMap` only helps shared
model metadata stay reusable across multiple routes.
## Static vs dynamic vs hybrid
Use:
- `static`
when discovery is unavailable or unnecessary;
- `dynamic`
when the route should rely entirely on runtime discovery;
- `hybrid`
when you need curated entries plus discovered models.
Typical choices:
- `static`
stable hosted routes with a small fixed catalog;
- `dynamic`
local routes or provider catalogs that change frequently;
- `hybrid`
aggregators where curated defaults should stay visible even while discovery
fills in the rest.
## Discovery cache TTL examples
Use human-readable TTLs in `discoveryCacheTtl`:
- `30m`
fast-changing catalogs where freshness matters;
- `1h`
moderately active hosted routes;
- `1d`
stable hosted or local routes where churn is low.
## Discovery refresh mode examples
Use `discoveryRefreshMode` to match the operational shape of the route:
- `manual`
flaky or rate-limited providers where refresh should happen only on demand;
- `on-open`
routes where the picker should always try for a fresh list;
- `background-if-stale`
the normal hosted-gateway choice when cached models should appear immediately;
- `startup`
fast local routes where startup probing is cheap and useful.
## `max_tokens` vs `max_completion_tokens`
OpenAI-compatible APIs do not all accept the same max-token field.
Use `openaiShim.maxTokensField: 'max_tokens'` when:
- the route is local or legacy-shaped;
- the provider rejects `max_completion_tokens`;
- the provider is Z.AI-style or otherwise strict about the older field;
- the route matches Moonshot/DeepSeek/local compatibility behavior.
Use `openaiShim.maxTokensField: 'max_completion_tokens'` when:
- the route expects the newer OpenAI/Azure-style contract;
- the provider rejects `max_tokens`;
- you want the route to stay aligned with newer hosted OpenAI-compatible APIs.
Strict-route example:
```ts
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
supportsApiFormatSelection: false,
supportsAuthHeaders: false,
maxTokensField: 'max_tokens',
},
}
```
Hosted modern-route example:
```ts
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
supportsApiFormatSelection: false,
supportsAuthHeaders: false,
maxTokensField: 'max_completion_tokens',
},
}
```
## Custom headers
For OpenAI-compatible or local routes, required static headers belong in
`transportConfig.openaiShim.headers`.
Optional user-editable API mode and auth/header fields should be allowed only
when the route really supports them:
```ts
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
headers: {
'X-Acme-Client': 'openclaude',
},
supportsApiFormatSelection: false,
supportsAuthHeaders: true,
},
}
```
Do not use custom headers as a substitute for transport-family selection.
Set these flags explicitly. When `supportsAuthHeaders` is false, `/provider
add` and `/provider edit` should only expose the route's normal credential
fields. When `supportsApiFormatSelection` is false, `/provider add` and
`/provider edit` should not expose the API mode picker.
Use:
- `supportsApiFormatSelection: true`
for broad custom gateways where users may need to choose the API surface.
- `supportsApiFormatSelection: false`
for fixed hosted or local routes where the descriptor owns the API contract.
- `supportsAuthHeaders: true`
for gateways that support user-provided custom auth/header fields.
- `supportsAuthHeaders: false`
for gateways that require a fixed auth contract and should only collect the
configured credential.
## Presets and user-facing gateway onboarding
Most runtime/UI surfaces now consume generated descriptor-backed metadata, so a
normal gateway addition should not require broad switch editing.
Only add `preset` metadata when the gateway is supposed to appear as a preset
or explicit selectable route.
```ts
preset: {
id: 'acme-hosted',
description: 'Acme Hosted gateway',
vendorId: 'openai',
apiKeyEnvVars: ['ACME_HOSTED_API_KEY'],
}
```
Then regenerate:
```bash
bun run integrations:generate
```
That keeps `src/integrations/index.ts`, `src/integrations/compatibility.ts`,
`src/integrations/providerUiMetadata.ts`, and the generated preset-id type in
sync without hand-editing them.
## What not to do
Avoid these patterns:
- `registerGateway(...)` in the descriptor file;
- `targetVendorId`, `isOpenAICompatible`, or routing-oriented gateway
`classification`;
- using `category` to make runtime routing decisions;
- placing large discovery/cached-catalog logic inline when a companion
`*.models.ts` file would be clearer;
- treating every gateway as if it exposes every shared model.
## Verification checklist
Before calling the gateway guide complete:
- the descriptor lives under `src/integrations/gateways/`;
- one-file and two-file patterns are both covered where useful;
- the gateway declares only the model subset it actually offers;
- the route default is declared once through `defaultModel`;
- `transportConfig.kind` is the routing contract;
- `category` is treated as grouping/display metadata only;
- any discovery route includes the right cache TTL, refresh mode, and manual
refresh behavior;
- API mode, auth/header, and token-field behavior are explicit where required;
- user-facing preset participation is expressed through descriptor `preset`
metadata and regenerated artifacts rather than handwritten follow-through.
+204
View File
@@ -0,0 +1,204 @@
# How To Add a Model
## When to add a model descriptor
Add a shared model descriptor when the metadata is useful across more than one
route or when the model deserves a stable glossary/index entry of its own.
Good reasons to add a model descriptor:
- the model appears across multiple vendor or gateway catalogs;
- the model has stable capabilities, context, or output limits worth reusing;
- route-specific catalogs should be able to reference one shared model id;
- you want `providerModelMap` to document route-specific API names for the same
conceptual model.
Do add or update a shared model descriptor when the model's context window,
output limit, or capabilities need to be available to runtime model lookup. Do
not use model descriptors as route availability lists. Route-owned catalogs are
still the source of truth for where a model is offered.
## Step-by-step
1. Pick the model file.
Use an existing family file under `src/integrations/models/` when the model
belongs to a current family such as `gpt`, `claude`, or `deepseek`.
2. Decide whether the model also needs a brand descriptor.
Add or update a brand descriptor only when shared model-family identity is
useful across multiple model descriptors.
3. Add the `defineModel(...)` entry.
Include `id`, `label`, `vendorId`, `classification`, `defaultModel`, and
capabilities.
4. Add optional shared metadata.
Include `brandId`, `contextWindow`, `maxOutputTokens`, and `cacheConfig`
when the data is stable enough to be reused.
5. Add `providerModelMap` only when the same model needs route-specific API
names across multiple catalogs.
6. Update route-owned catalogs only if the model should be offered by those
routes.
## Authoring rules
Model descriptor files should:
- use `defineModel`;
- default-export model descriptors, typically as an array for a family file;
- act as glossary/index metadata and optional route enrichment;
- avoid encoding gateway availability as if every route automatically exposes
the shared model.
Normal contributor-facing examples should not call `registerModel(...)`
directly.
## Shared model descriptors vs route catalogs
The important boundary is:
- shared model descriptors answer what the model is;
- route-owned catalogs answer where the model is offered.
That is why gateway or direct-vendor onboarding should not normally require
editing multiple shared model files. In the common path:
- add or update the route descriptor/catalog first;
- add a shared model descriptor only if the metadata is reusable beyond that
one route;
- let the route catalog continue to own the offered subset.
## When to add a brand descriptor
Add or update a brand descriptor when:
- multiple related models share a recognizable family identity;
- shared defaults or capability guidance are useful across that family;
- the docs/UI benefit from grouping those models under one brand.
Skip the brand descriptor when:
- the model is one-off or route-local;
- the shared family metadata would not actually be reused;
- the route catalog is enough on its own.
## Example: model attached to a canonical vendor only
This is the simplest pattern: one model, one canonical vendor, no shared
route-name aliases needed.
```ts
import { defineModel } from '../define.js'
export default [
defineModel({
id: 'acme-chat',
label: 'Acme Chat',
vendorId: 'acme',
classification: ['chat', 'coding'],
defaultModel: 'acme-chat',
capabilities: {
supportsStreaming: true,
supportsFunctionCalling: true,
supportsJsonMode: true,
supportsReasoning: false,
},
contextWindow: 128_000,
maxOutputTokens: 8_192,
}),
]
```
Use this when:
- the model belongs to one canonical vendor;
- the route does not need multiple route-specific API names recorded in the
shared model metadata;
- the descriptor is mainly reusable metadata, not a route-availability table.
## Example: shared model across multiple routes with `providerModelMap`
Use `providerModelMap` when the same conceptual model appears on multiple
routes under different API names.
```ts
import { defineModel } from '../define.js'
export default [
defineModel({
id: 'deepseek-reasoner',
label: 'DeepSeek Reasoner',
brandId: 'deepseek',
vendorId: 'deepseek',
classification: ['chat', 'reasoning', 'coding'],
defaultModel: 'deepseek-reasoner',
providerModelMap: {
deepseek: 'deepseek-reasoner',
openrouter: 'deepseek/deepseek-r1',
galaxy: 'galaxy/deepseek-r1',
},
capabilities: {
supportsStreaming: true,
supportsFunctionCalling: true,
supportsJsonMode: true,
supportsReasoning: true,
},
contextWindow: 128_000,
maxOutputTokens: 8_192,
}),
]
```
What `providerModelMap` is for:
- recording route-specific API names for the same model;
- helping route catalogs reference one shared descriptor while still using the
route's real API name;
- keeping shared metadata reusable across direct vendors and gateways.
What `providerModelMap` is not for:
- declaring route availability by itself;
- replacing the route-owned catalog;
- assuming every route in the map is automatically enabled.
## Model lookup and fallback behavior
Model lookup should prefer:
1. route-owned catalog metadata;
2. shared model-descriptor enrichment when a catalog entry references
`modelDescriptorId`;
3. global shared model descriptors under `src/integrations/models/` for legacy
and custom OpenAI-compatible model names;
4. documented env overrides from `src/utils/model/openaiContextWindows.ts`
(`CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS` and
`CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS`).
`openaiContextWindows.ts` is compatibility glue for user-provided env
overrides. It should not grow a second built-in model table. Built-in model
limits belong in model descriptor files.
## What not to do
Avoid these patterns:
- turning shared model files into the default place to list every route's
offered subset;
- assuming a shared model descriptor means every gateway supports it;
- using `providerModelMap` as a substitute for route catalogs;
- adding a brand descriptor when no shared family metadata is actually useful;
- calling `registerModel(...)` from contributor-authored examples.
- adding built-in model limits to `src/utils/model/openaiContextWindows.ts`
instead of `src/integrations/models/`.
## Verification checklist
Before calling a model doc update complete:
- the example uses `defineModel`;
- the example makes clear that shared model descriptors are glossary/index
metadata plus optional route enrichment;
- `providerModelMap` is shown only as a route-name mapping tool;
- the doc explains when a brand descriptor is useful;
- the doc explains that global lookup reads `src/integrations/models/`, while
`src/utils/model/openaiContextWindows.ts` only preserves env overrides;
- the doc explains why normal gateway/direct-vendor onboarding should not
require editing multiple shared model files.
@@ -0,0 +1,408 @@
# How To Add `/usage` Support
## What `/usage` resolves today
The descriptor-era `/usage` flow is centered on `getUsageDescriptor()` in
`src/commands/usage/index.ts`.
That resolver:
1. finds the active vendor or gateway descriptor;
2. reads the descriptor's `usage` metadata;
3. follows `delegateToVendorId` / `delegateToGatewayId` if present;
4. returns the final resolved usage target plus a `supported` flag.
Current implementation note:
- descriptor metadata already owns the support/delegation decision;
- the current resolver is still vendor/gateway-focused, with the `firstParty`
compatibility id mapped to the `anthropic` vendor;
- `src/components/Settings/Usage.tsx` still has concrete UI branches for the
currently supported runtime paths (`Anthropic`, `MiniMax`, and the separate
`Codex` path);
- the descriptor schema already includes `fetchModule` and `parseModule`, but
those fields are still a contract for supported integrations rather than a
fully generic module-loader pipeline in the current implementation.
That means new docs should describe both the descriptor contract and the
current runtime reality.
## The `usage` field
The descriptor schema exposes these usage fields:
```ts
usage?: {
supported: boolean
delegateToVendorId?: string
delegateToGatewayId?: string
fetchModule?: string
parseModule?: string
ui?: {
showResetCountdown?: boolean
compactProgressBar?: boolean
fallbackMessage?: string
}
silentlyIgnore?: boolean
}
```
The same `UsageMetadata` shape can be attached to:
- `VendorDescriptor`
- `GatewayDescriptor`
- `AnthropicProxyDescriptor`
What each field means:
- `supported`
Whether the route has real `/usage` support.
- `delegateToVendorId`
Use the linked vendor's usage behavior instead of defining separate gateway
behavior.
- `delegateToGatewayId`
Use another gateway's usage behavior.
- `fetchModule`
The module that should fetch raw usage data when a module-backed runtime is
added or expanded.
- `parseModule`
The module that should normalize raw usage data into the UI/runtime shape.
- `ui`
Presentation hints for the usage UI.
- `silentlyIgnore`
Reserved for cases where unsupported usage should avoid noisy user-facing
errors.
## Anthropic proxy note
Anthropic proxy descriptors can declare the same `usage` field as vendors and
gateways.
Authoring rule:
- use the same explicit `supported`, delegation, fallback, and `ui` rules you
would use on any other descriptor;
- do not assume usage support is inherited automatically just because the proxy
speaks an Anthropic-compatible transport;
- keep unsupported proxies explicit with `usage: { supported: false }` until a
real usage path exists.
Current runtime note:
- the descriptor schema supports this metadata today;
- the active `/usage` resolver in `src/commands/usage/index.ts` currently
resolves vendor and gateway targets, plus the `firstParty` compatibility id;
- document anthropic-proxy usage metadata as part of the descriptor contract,
but do not describe it as a separately routed `/usage` surface in this
branch unless that resolver is expanded.
## When `/usage` belongs on the vendor descriptor
Put usage support on the vendor descriptor when:
- the vendor is the canonical owner of the usage API;
- direct vendor sessions should resolve to that usage behavior;
- gateways serving the same upstream should generally inherit the vendor's
usage behavior rather than redefining it.
This is the normal pattern for first-party or direct vendors.
Current real examples in the implementation:
- `anthropic`
- `minimax`
## When a gateway should delegate usage to a linked vendor
Use gateway delegation when:
- the gateway does not have its own separate usage API;
- the correct usage information comes from the underlying vendor;
- the route should resolve to the vendor's usage label/behavior after
delegation.
In descriptor terms:
```ts
usage: {
supported: true,
delegateToVendorId: 'anthropic',
}
```
This lets the gateway stay explicit about support while avoiding duplicated
vendor usage logic.
## When a gateway should define its own usage handling
Give a gateway its own usage handling when:
- the gateway exposes its own usage/quota API;
- the numbers are not equivalent to the underlying vendor's usage view;
- UI text or refresh behavior must follow the gateway's own limits.
In that case the gateway keeps its own `usage` block instead of delegating:
```ts
usage: {
supported: true,
fetchModule: './usage/fetchGatewayUsage.js',
parseModule: './usage/parseGatewayUsage.js',
ui: {
compactProgressBar: true,
showResetCountdown: true,
},
}
```
Current implementation note:
- the branch already understands `supported` and delegation through
`getUsageDescriptor()`;
- if you add a truly new gateway-owned usage API, you will also need the
runtime/UI follow-through in the usage settings surface until a more generic
module-backed pipeline is introduced.
## Required fetch/parse module structure
The descriptor contract already reserves `fetchModule` and `parseModule` for
module-backed usage integrations.
Recommended structure:
`fetchModule`
- performs the network call or SDK call;
- handles auth and endpoint specifics for that provider/gateway;
- returns raw usage payloads without UI-specific formatting.
`parseModule`
- receives the raw usage payload;
- normalizes it into the shape the usage UI expects;
- keeps provider-specific quirks out of the higher-level usage resolver;
- should be deterministic and easy to test with fixtures.
Recommended file layout:
```text
src/
services/
api/
usage/
fetchAcmeUsage.ts
parseAcmeUsage.ts
```
Recommended contract split:
- keep transport/auth/API calling in the fetch module;
- keep response-shape normalization in the parse module;
- keep descriptor wiring in the descriptor's `usage` field;
- keep route selection in `getUsageDescriptor()`.
## Worked example: vendor with native usage API
This is the simplest supported vendor pattern.
```ts
import { defineVendor } from '../define.js'
export default defineVendor({
id: 'acme',
label: 'Acme AI',
classification: 'openai-compatible',
defaultBaseUrl: 'https://api.acme.example/v1',
defaultModel: 'acme-chat',
setup: {
requiresAuth: true,
authMode: 'api-key',
credentialEnvVars: ['ACME_API_KEY'],
},
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
supportsApiFormatSelection: false,
supportsAuthHeaders: false,
},
},
usage: {
supported: true,
fetchModule: './usage/fetchAcmeUsage.js',
parseModule: './usage/parseAcmeUsage.js',
ui: {
showResetCountdown: true,
},
},
})
```
Use this pattern when the vendor really owns the usage endpoint and the route
should be the final resolved usage target.
## Worked example: gateway delegating usage to a linked vendor
Use this when the gateway should appear supported, but the actual usage source
of truth is the linked vendor.
```ts
import { defineGateway } from '../define.js'
export default defineGateway({
id: 'acme-gateway',
label: 'Acme Gateway',
category: 'hosted',
defaultBaseUrl: 'https://gateway.acme.example/v1',
defaultModel: 'acme-chat',
supportsModelRouting: true,
setup: {
requiresAuth: true,
authMode: 'api-key',
credentialEnvVars: ['ACME_GATEWAY_API_KEY'],
},
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
supportsApiFormatSelection: false,
supportsAuthHeaders: true,
},
},
usage: {
supported: true,
delegateToVendorId: 'acme',
},
})
```
This keeps the gateway descriptor honest while preventing duplicated usage
fetch/parse logic.
## Worked example: gateway with its own usage API
Use this when the gateway's quota or billing view is independent from the
linked model vendors.
```ts
import { defineGateway } from '../define.js'
export default defineGateway({
id: 'galaxy',
label: 'Galaxy Gateway',
category: 'aggregating',
defaultBaseUrl: 'https://api.galaxy.example/v1',
defaultModel: 'galaxy/gpt-5-mini',
supportsModelRouting: true,
setup: {
requiresAuth: true,
authMode: 'api-key',
credentialEnvVars: ['GALAXY_API_KEY'],
},
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
supportsApiFormatSelection: false,
supportsAuthHeaders: true,
},
},
usage: {
supported: true,
fetchModule: './usage/fetchGalaxyUsage.js',
parseModule: './usage/parseGalaxyUsage.js',
ui: {
compactProgressBar: true,
showResetCountdown: true,
},
},
})
```
This is the right shape for a gateway whose own account limits matter more than
the upstream vendor's usage accounting.
## Worked example: unsupported-provider fallback
Be explicit when usage is unsupported.
```ts
import { defineVendor } from '../define.js'
export default defineVendor({
id: 'acme-unsupported',
label: 'Acme Unsupported',
classification: 'openai-compatible',
defaultBaseUrl: 'https://api.acme-unsupported.example/v1',
defaultModel: 'acme-basic',
setup: {
requiresAuth: true,
authMode: 'api-key',
credentialEnvVars: ['ACME_UNSUPPORTED_API_KEY'],
},
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
supportsApiFormatSelection: false,
supportsAuthHeaders: false,
},
},
usage: {
supported: false,
ui: {
fallbackMessage: '/usage is not available for this provider.',
},
},
})
```
The important part is that unsupported routes stay explicit and resolve to a
neutral fallback rather than silently disappearing.
## Fallback behavior for unsupported providers
When usage is unsupported:
- keep `usage.supported` false;
- let the resolver return an unsupported descriptor;
- let the UI render the unsupported-provider fallback;
- do not fake vendor support just to avoid the fallback state.
The current settings usage screen already resolves unsupported providers to
`UnsupportedUsage` with the active provider label.
## Current supported routes
As of the current implementation:
- `anthropic` is supported;
- `minimax` is supported;
- most other vendor and gateway descriptors are explicitly unsupported;
- `codex` still uses its own direct UI path outside the descriptor-backed
vendor/gateway resolver.
That split is important when you update docs or runtime behavior.
## What not to do
Avoid these patterns:
- adding gateway-specific usage logic when the vendor should be the source of
truth;
- using delegation when the gateway actually has its own distinct usage API;
- hiding unsupported usage by omitting the `usage` field when the route should
explicitly report unsupported behavior;
- calling registry mutation helpers directly in usage examples;
- treating `fetchModule` / `parseModule` as already fully generic runtime hooks
without also checking the current settings/runtime integration path.
## Verification checklist
Before calling a `/usage` guide complete:
- the doc lives under `/docs` as Markdown;
- it documents the `usage` field for vendors, gateways, and anthropic proxies;
- it distinguishes vendor-owned, delegated, and gateway-owned usage behavior;
- it explains the current unsupported fallback behavior;
- it includes vendor, gateway, and unsupported worked examples;
- examples use `define*` helpers and default exports rather than direct
registry calls;
- the guide accurately notes the current implementation boundary between
descriptor metadata and the still-concrete runtime/UI usage handlers.
+323
View File
@@ -0,0 +1,323 @@
# How To Add a Vendor
## When to add a vendor
Add a vendor descriptor when the integration is the canonical API or
first-party model service for that provider.
Typical vendor cases:
- a direct OpenAI-compatible API with its own auth/base URL contract;
- a first-party model-serving endpoint that owns its own catalog;
- a vendor that should be selectable directly rather than only through a
gateway.
Use a gateway descriptor instead when the route primarily hosts, proxies, or
aggregates models behind a separate endpoint contract.
## Step-by-step
1. Pick the descriptor file path.
Use `src/integrations/vendors/<id>.ts`.
2. Choose the transport family.
Common direct vendors use `transportConfig.kind: 'openai-compatible'`.
Gemini-native and Anthropic-native routes keep their own transport kinds.
3. Define setup/auth metadata.
Fill `setup.requiresAuth`, `setup.authMode`, and
`setup.credentialEnvVars`.
4. Set the route defaults.
Add `defaultBaseUrl`, `defaultModel`, and any required env vars or
validation metadata.
5. For OpenAI-compatible vendors, set the `/provider` UI capability flags in
`transportConfig.openaiShim`.
Use `supportsApiFormatSelection` for API mode editing and
`supportsAuthHeaders` for auth/header editing.
6. Add a catalog if the vendor exposes models directly.
Put the vendor's offered model subset on the vendor descriptor itself. Use
`modelDescriptorId` when an entry should inherit shared model metadata.
7. Add usage metadata if the vendor has real `/usage` support.
If `/usage` is still unsupported, keep that explicit with
`usage: { supported: false }`.
8. If the vendor should appear in preset-driven `/provider` flows, add a
`preset` block on the descriptor.
9. Run `bun run integrations:generate` so the generated loader and preset
manifest stay in sync.
## Authoring rules
Normal vendor descriptor files should:
- use `defineVendor` and `defineCatalog`;
- default-export the descriptor;
- keep registration out of the file;
- avoid direct `registerVendor(...)` calls;
- avoid extra `import type` boilerplate in contributor-facing patterns unless a
real type import is unavoidable.
Registration is loader-owned through the generated artifacts consumed by
`src/integrations/index.ts`.
## Generated loader and preset manifest
Normal vendor onboarding is additive now:
1. add or edit the descriptor file;
2. add a `preset` block only if the vendor should be user-facing in preset
flows;
3. run `bun run integrations:generate`;
4. let `src/integrations/generated/integrationArtifacts.generated.ts` feed the
loader, compatibility mapping, preset typing, and provider UI metadata.
Preset ordering is derived automatically: `anthropic` is pinned first, middle
entries sort by preset description using standard alphanumeric sorting, and
`custom` is pinned last by the generated manifest. This ordering is not
configurable from descriptor files.
## Example: standard API-key vendor with direct OpenAI-compatible routing
This is the common "direct hosted vendor" shape.
```ts
import { defineCatalog, defineVendor } from '../define.js'
const catalog = defineCatalog({
source: 'static',
models: [
{
id: 'acme-chat',
apiName: 'acme-chat',
label: 'Acme Chat',
modelDescriptorId: 'acme-chat',
},
],
})
export default defineVendor({
id: 'acme',
label: 'Acme AI',
classification: 'openai-compatible',
defaultBaseUrl: 'https://api.acme.example/v1',
defaultModel: 'acme-chat',
requiredEnvVars: ['ACME_API_KEY'],
setup: {
requiresAuth: true,
authMode: 'api-key',
credentialEnvVars: ['ACME_API_KEY'],
setupPrompt: 'Paste your Acme API key.',
},
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
supportsApiFormatSelection: false,
supportsAuthHeaders: false,
},
},
preset: {
id: 'acme',
description: 'Acme AI API',
apiKeyEnvVars: ['ACME_API_KEY'],
},
catalog,
usage: {
supported: false,
},
})
```
Why this is the right shape:
- the route is first-party and direct, so it is a vendor, not a gateway;
- `transportConfig.kind` owns the transport choice;
- `supportsApiFormatSelection: false` means `/provider` should not expose API
mode editing for this fixed direct-vendor route;
- `supportsAuthHeaders: false` means `/provider` should only ask for the API
key, not custom auth-header fields;
- the vendor owns its own catalog because it exposes models directly;
- `defaultModel` on the vendor selects the default catalog entry;
- the file default-exports one typed descriptor and leaves registration to the
loader.
## Example: vendor with custom static headers
For OpenAI-compatible vendors, put fixed request headers in
`transportConfig.openaiShim.headers`. Secrets still belong in credential env
vars or runtime auth handling.
```ts
import { defineVendor } from '../define.js'
export default defineVendor({
id: 'acme-labs',
label: 'Acme Labs',
classification: 'openai-compatible',
defaultBaseUrl: 'https://labs.acme.example/v1',
defaultModel: 'acme-research',
requiredEnvVars: ['ACME_LABS_API_KEY'],
setup: {
requiresAuth: true,
authMode: 'api-key',
credentialEnvVars: ['ACME_LABS_API_KEY'],
},
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
headers: {
'X-Acme-Client': 'openclaude',
'X-Acme-Protocol': 'labs-v1',
},
supportsApiFormatSelection: false,
supportsAuthHeaders: false,
maxTokensField: 'max_completion_tokens',
},
},
usage: {
supported: false,
},
})
```
Use this pattern when:
- the provider requires fixed non-secret headers on every request;
- the route still speaks an OpenAI-compatible body shape;
- the token-field contract needs to be explicit;
- users should not edit API mode or auth/header fields for this fixed vendor
route.
## Example: vendor that owns a first-party model catalog
This is the OpenAI/DeepSeek-style pattern where the vendor serves multiple
first-party models directly.
```ts
import { defineCatalog, defineVendor } from '../define.js'
const catalog = defineCatalog({
source: 'static',
models: [
{
id: 'acme-fast',
apiName: 'acme-fast',
label: 'Acme Fast',
modelDescriptorId: 'acme-fast',
},
{
id: 'acme-reasoner',
apiName: 'acme-reasoner',
label: 'Acme Reasoner',
modelDescriptorId: 'acme-reasoner',
capabilities: {
supportsReasoning: true,
},
transportOverrides: {
openaiShim: {
preserveReasoningContent: true,
requireReasoningContentOnAssistantMessages: true,
reasoningContentFallback: '',
},
},
},
],
})
export default defineVendor({
id: 'acme-first-party',
label: 'Acme First-Party',
classification: 'openai-compatible',
defaultBaseUrl: 'https://api.acme-first-party.example/v1',
defaultModel: 'acme-fast',
setup: {
requiresAuth: true,
authMode: 'api-key',
credentialEnvVars: ['ACME_FIRST_PARTY_API_KEY'],
},
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
supportsApiFormatSelection: false,
supportsAuthHeaders: false,
},
},
catalog,
usage: {
supported: false,
},
})
```
Use this when the vendor really is the route that serves the models. Do not
move route availability into the shared model index by default. Put reusable
context windows, output limits, and cross-route capability metadata in
`src/integrations/models/`, then point catalog entries at those descriptors
with `modelDescriptorId`.
## OpenAI-compatible UI capability flags
For OpenAI-compatible vendors, be explicit about the provider editor surface:
- `supportsApiFormatSelection: false`
for fixed vendor APIs where OpenClaude should choose the API surface.
- `supportsApiFormatSelection: true`
only when users should choose between compatible API modes such as chat
completions and responses.
- `supportsAuthHeaders: false`
when the route should only collect the configured credential env var/API key.
- `supportsAuthHeaders: true`
only when users should be able to edit custom auth/header fields in
`/provider add` and `/provider edit`.
Most direct vendors should set both flags to `false`. Broad custom routes are
the usual place where both are `true`.
## Presets and user-facing vendor onboarding
Most metadata-driven consumers now read generated descriptor-backed state, so a
normal vendor addition should not require broad switch editing.
Only add `preset` metadata when the vendor should appear as an explicit preset
or legacy-facing selectable route.
```ts
preset: {
id: 'acme',
description: 'Acme AI API',
apiKeyEnvVars: ['ACME_API_KEY'],
}
```
Then regenerate:
```bash
bun run integrations:generate
```
That keeps `src/integrations/index.ts`, `src/integrations/compatibility.ts`,
`src/integrations/providerUiMetadata.ts`, and the generated preset-id type in
sync without hand-editing them.
## What not to do
Avoid these patterns in new vendor docs and examples:
- `registerVendor(...)` inside the descriptor file;
- direct registry mutation from contributor-authored descriptor files;
- inventing extra runtime routing fields when `transportConfig.kind` already
expresses the transport family;
- pushing route-owned model availability into shared model files by default;
- treating the legacy word "provider" as precise when you really mean vendor,
gateway, route, or model.
## Verification checklist
Before calling the vendor guide complete:
- the file lives under `src/integrations/vendors/`;
- the descriptor default-exports a `defineVendor(...)` result;
- any direct model-serving route owns the subset of models it actually exposes;
- the route default is declared once through `defaultModel`;
- the transport family is expressed through `transportConfig.kind`;
- OpenAI-compatible `/provider` UI capabilities are explicit through
`openaiShim.supportsApiFormatSelection` and `openaiShim.supportsAuthHeaders`;
- auth/setup metadata and validation routing are explicit;
- user-facing preset participation is expressed through descriptor `preset`
metadata and regenerated artifacts rather than handwritten follow-through.
+189
View File
@@ -0,0 +1,189 @@
# Integrations Overview
## Purpose
This folder is the contributor-facing documentation set for the descriptor-era
integration system.
Use it for:
- terminology and architecture rules;
- authoring rules for descriptor files;
- how-to guides for vendors, gateways, models, anthropic proxies, and
`/usage`;
- reference samples that match the current implementation.
## Documentation Structure
This is the current docs layout:
```text
docs/
architecture/
integrations.md
integrations/
overview.md
glossary.md
how-to/
add-vendor.md
add-gateway.md
add-model.md
add-anthropic-proxy.md
add-usage-support.md
reference-samples.md
common-pitfalls.md
```
All of the files listed above are part of the current contributor guide for the
descriptor-era integration system.
## Reading Order
If you are onboarding to the integration system:
1. Read `docs/architecture/integrations.md` for the system boundaries.
2. Read `docs/integrations/glossary.md` for the shared vocabulary.
3. Use the how-to guides for the specific descriptor type you are adding.
4. Use `docs/integrations/reference-samples.md` once the architecture and the relevant how-to guide are clear.
5. Read `docs/integrations/common-pitfalls.md` before opening a docs or implementation PR for a new integration.
## Core Rules
### Metadata vs routing vs transport
Keep these concerns separate:
- metadata
Descriptor files declare labels, defaults, catalogs, setup requirements,
validation hints, and request-shaping metadata.
- routing
Route/profile helpers map user config, presets, and env state onto the active
descriptor route.
- transport
Runtime execution code actually performs the request using the active
transport family.
If a change is about what a route is, it likely belongs in descriptors. If it
is about how a request is executed against an external API contract, it likely
belongs in transport code.
### `transportConfig.kind` is the routing contract
For gateways, `transportConfig.kind` is the field that tells runtime code which
transport family the route belongs to.
Examples:
- `'openai-compatible'`
- `'local'`
- `'anthropic-proxy'`
- `'bedrock'`
- `'vertex'`
Do not use gateway `category` for routing decisions. `category` is optional
display/grouping metadata only.
### `category` is descriptive, not executable
Gateway `category` exists to help people understand the route:
- `local`
- `hosted`
- `aggregating`
It is valid to use `category` for docs, grouping, or display copy. It is not
valid to treat `category` as the transport selector.
### OpenAI-compatible request shaping belongs in `openaiShim`
For OpenAI-compatible or local routes, keep request-shaping metadata in
`transportConfig.openaiShim`.
Examples:
- `maxTokensField`
- `headers`
- `supportsApiFormatSelection`
- `supportsAuthHeaders`
That matches the current runtime metadata flow in
`src/integrations/runtimeMetadata.ts`.
`supportsApiFormatSelection` and `supportsAuthHeaders` also control the
advanced `/provider add` and `/provider edit` fields for OpenAI-compatible
routes. Fixed direct vendors usually set both to `false`; broad custom routes
or gateways that intentionally accept user-supplied auth/header details set the
relevant flag to `true`.
## Descriptor Authoring Pattern
Normal descriptor files should:
- use the `define*` helpers from `src/integrations/define.ts`;
- default-export the descriptor object or model list;
- keep registration out of the descriptor file;
- keep route-owned catalogs with the route unless shared model metadata is
genuinely useful;
- put built-in model limits and capabilities in `src/integrations/models/`,
not in env-override compatibility helpers.
Typical helper usage:
- `defineVendor`
- `defineGateway`
- `defineCatalog`
- `defineModel`
- `defineBrand`
- `defineAnthropicProxy`
Normal descriptor files should not:
- call `registerGateway`, `registerVendor`, `registerModel`, or similar
registry functions directly;
- import registry mutation helpers just to make a descriptor visible;
- turn simple route additions into scattered consumer edits.
## Loader-Owned Registration
Registration is owned by `src/integrations/index.ts`.
That means the normal contributor workflow is:
1. create or edit the descriptor file;
2. keep the export typed through the appropriate `define*` helper;
3. let the loader own registration;
4. let registry consumers read the loaded descriptor state.
The loader may still be manually enumerated in some places today, but that is a
generated-artifact concern, not a descriptor-file concern.
Normal contributor flow for new preset-participating routes is:
1. add or edit the descriptor file;
2. add `preset` metadata only when the route should be user-facing;
3. run `bun run integrations:generate`;
4. let the generated manifest feed the loader, compatibility mapping, preset
typing, and provider UI metadata.
## Compatibility Layer
The descriptor system is the source of truth, but a compatibility layer still
exists for older env/config/public-callers.
Important compatibility surfaces include:
- `src/integrations/compatibility.ts`
derived legacy preset name to descriptor-route mapping;
- `src/integrations/profileResolver.ts`
stored provider/profile id resolution;
- `src/utils/model/providers.ts`
`APIProvider` / `LegacyAPIProvider`;
- `src/utils/providerFlag.ts`
env-facing `--provider` behavior.
Contributor docs should describe these as compatibility bridges, not as the
primary architecture.
Preset ordering pins `anthropic` first, derives the middle entries from preset
descriptions with standard alphanumeric sorting, and pins `custom` last
automatically.
+507
View File
@@ -0,0 +1,507 @@
# Integration Reference Samples
## Purpose
This file gathers the safest descriptor-era sample patterns into one place.
Use it when you want a quick starting point after reading:
- `docs/architecture/integrations.md`
- `docs/integrations/glossary.md`
- the relevant how-to guide under `docs/integrations/how-to/`
All samples here are implementation-aligned with the current implementation, but most
of them are still illustrative patterns. Replace ids, env vars, labels, and
URLs with real route-specific values before shipping them.
## Accuracy notes
This pack was reviewed against the current implementation surface:
- helper imports come from `src/integrations/define.ts`
- descriptor field shapes come from `src/integrations/descriptors.ts`
- generated loader/preset artifacts come from
`src/integrations/generated/integrationArtifacts.generated.ts`
- route/profile compatibility docs reference `src/integrations/profileResolver.ts`
- route/default/provider label behavior references `src/integrations/routeMetadata.ts`
- runtime request-shaping notes reference `src/integrations/runtimeMetadata.ts`
- provider selection UI metadata derives from the generated preset manifest
through `src/integrations/providerUiMetadata.ts`
- discovery caching behavior references `src/integrations/discoveryCache.ts` and
`src/integrations/discoveryService.ts`
- `/usage` routing notes reference `src/commands/usage/index.ts` and the
current settings UI in `src/components/Settings/Usage.tsx`
## Sample 1: Minimal direct vendor
Status: Illustrative pattern. Adapt ids, env vars, and URL before use.
Use when:
- the route is the canonical first-party vendor endpoint;
- the route is directly selectable;
- no companion catalog file is needed.
```ts
import { defineVendor } from '../define.js'
export default defineVendor({
id: 'acme',
label: 'Acme AI',
classification: 'openai-compatible',
defaultBaseUrl: 'https://api.acme.example/v1',
defaultModel: 'acme-chat',
requiredEnvVars: ['ACME_API_KEY'],
setup: {
requiresAuth: true,
authMode: 'api-key',
credentialEnvVars: ['ACME_API_KEY'],
},
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
supportsApiFormatSelection: false,
supportsAuthHeaders: false,
},
},
usage: {
supported: false,
},
})
```
Why this is safe:
- it uses `defineVendor` plus a default export;
- it keeps routing on `transportConfig.kind`;
- it makes `/provider` API mode and auth/header editing behavior explicit;
- it does not call registry mutation helpers directly.
## Sample 2: Direct vendor with a first-party catalog
Status: Illustrative pattern. Safe shape, but catalog contents are placeholder data.
Use when:
- the vendor directly serves multiple models;
- the route should own its offered subset;
- the route should point entries at shared model descriptors for model-specific
runtime metadata.
```ts
import { defineCatalog, defineVendor } from '../define.js'
const catalog = defineCatalog({
source: 'static',
models: [
{
id: 'acme-fast',
apiName: 'acme-fast',
label: 'Acme Fast',
modelDescriptorId: 'acme-fast',
},
{
id: 'acme-reasoner',
apiName: 'acme-reasoner',
label: 'Acme Reasoner',
modelDescriptorId: 'acme-reasoner',
capabilities: {
supportsReasoning: true,
},
transportOverrides: {
openaiShim: {
preserveReasoningContent: true,
requireReasoningContentOnAssistantMessages: true,
reasoningContentFallback: '',
},
},
},
],
})
export default defineVendor({
id: 'acme-first-party',
label: 'Acme First-Party',
classification: 'openai-compatible',
defaultBaseUrl: 'https://api.acme-first-party.example/v1',
defaultModel: 'acme-fast',
setup: {
requiresAuth: true,
authMode: 'api-key',
credentialEnvVars: ['ACME_FIRST_PARTY_API_KEY'],
},
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
supportsApiFormatSelection: false,
supportsAuthHeaders: false,
maxTokensField: 'max_completion_tokens',
},
},
catalog,
usage: {
supported: false,
},
})
```
Note:
Use `openaiShim.maxTokensField: 'max_completion_tokens'` when the route should
follow the newer hosted OpenAI-style contract. The route's `defaultModel`
selects the default; catalog entries should not add separate `default` or
`recommended` flags.
## Sample 3: Local gateway with dynamic discovery
Status: Illustrative pattern. Matches the current discovery schema and
local-route shape.
Use when:
- the route is local;
- discovery should populate the catalog dynamically;
- startup probing is cheap enough to be useful.
```ts
import { defineGateway } from '../define.js'
export default defineGateway({
id: 'acme-local',
label: 'Acme Local',
category: 'local',
defaultBaseUrl: 'http://localhost:11434/v1',
defaultModel: 'acme-local:latest',
supportsModelRouting: true,
setup: {
requiresAuth: false,
authMode: 'none',
},
startup: {
autoDetectable: true,
probeReadiness: 'openai-compatible-models',
},
transportConfig: {
kind: 'local',
openaiShim: {
supportsApiFormatSelection: false,
supportsAuthHeaders: true,
maxTokensField: 'max_tokens',
},
},
catalog: {
source: 'dynamic',
discovery: {
kind: 'openai-compatible',
},
discoveryCacheTtl: '1d',
discoveryRefreshMode: 'startup',
allowManualRefresh: true,
},
usage: {
supported: false,
},
})
```
Notes:
- `category: 'local'` is descriptive only.
- `transportConfig.kind: 'local'` is the actual routing contract.
- `maxTokensField: 'max_tokens'` is the right pattern for local and other
legacy-shaped OpenAI-compatible routes.
## Sample 4: Hosted gateway with a hybrid catalog in two files
Status: Illustrative pattern. This is the recommended large-catalog or
discovery-heavy gateway shape.
`src/integrations/gateways/galaxy.models.ts`
```ts
import { defineCatalog } from '../define.js'
export default defineCatalog({
source: 'hybrid',
discovery: {
kind: 'openai-compatible',
},
discoveryCacheTtl: '1h',
discoveryRefreshMode: 'background-if-stale',
allowManualRefresh: true,
models: [
{
id: 'galaxy-curated-default',
apiName: 'galaxy/gpt-5-mini',
label: 'GPT-5 Mini (via Galaxy)',
modelDescriptorId: 'gpt-5-mini',
},
{
id: 'galaxy-curated-reasoner',
apiName: 'galaxy/deepseek-r1',
label: 'DeepSeek R1 (via Galaxy)',
modelDescriptorId: 'deepseek-reasoner',
capabilities: {
supportsReasoning: true,
},
transportOverrides: {
openaiShim: {
preserveReasoningContent: true,
requireReasoningContentOnAssistantMessages: true,
reasoningContentFallback: '',
},
},
},
],
})
```
`src/integrations/gateways/galaxy.ts`
```ts
import { defineGateway } from '../define.js'
import catalog from './galaxy.models.js'
export default defineGateway({
id: 'galaxy',
label: 'Galaxy Gateway',
category: 'aggregating',
defaultBaseUrl: 'https://api.galaxy.example/v1',
defaultModel: 'galaxy/gpt-5-mini',
supportsModelRouting: true,
setup: {
requiresAuth: true,
authMode: 'api-key',
credentialEnvVars: ['GALAXY_API_KEY'],
},
startup: {
probeReadiness: 'openai-compatible-models',
},
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
supportsApiFormatSelection: false,
supportsAuthHeaders: true,
maxTokensField: 'max_completion_tokens',
},
},
catalog,
usage: {
supported: false,
},
})
```
Notes:
- this is the right pattern for `discoveryCache.ts` plus `discoveryService.ts`;
- `background-if-stale` is the normal hosted-gateway choice when cached models
should appear immediately and refresh in the background;
- `allowManualRefresh: true` is the shape that supports `/model refresh` and
the in-picker refresh flow in the current implementation.
## Sample 5: Shared model descriptor with `providerModelMap`
Status: Illustrative pattern. Good for reusable shared-model metadata.
Use when:
- the same conceptual model appears on multiple routes;
- route catalogs should share one model identity;
- route-specific API names still need to be explicit.
```ts
import { defineModel } from '../define.js'
export default [
defineModel({
id: 'deepseek-reasoner',
label: 'DeepSeek Reasoner',
brandId: 'deepseek',
vendorId: 'deepseek',
classification: ['chat', 'reasoning', 'coding'],
defaultModel: 'deepseek-reasoner',
providerModelMap: {
deepseek: 'deepseek-reasoner',
openrouter: 'deepseek/deepseek-r1',
galaxy: 'galaxy/deepseek-r1',
},
capabilities: {
supportsStreaming: true,
supportsFunctionCalling: true,
supportsJsonMode: true,
supportsReasoning: true,
},
contextWindow: 128_000,
maxOutputTokens: 8_192,
}),
]
```
Important boundary:
`providerModelMap` records route-specific names. It does not declare route
availability by itself. The route catalog still owns the offered subset.
## Sample 6: Anthropic proxy
Status: Illustrative pattern. Matches the current descriptor interface even
though the repo does not yet ship concrete anthropic-proxy descriptors.
```ts
import { defineAnthropicProxy } from '../define.js'
export default defineAnthropicProxy({
id: 'acme-anthropic-proxy',
label: 'Acme Anthropic Proxy',
classification: 'anthropic-proxy',
defaultBaseUrl: 'https://anthropic-proxy.acme.example',
defaultModel: 'claude-sonnet-4-5',
requiredEnvVars: ['ACME_ANTHROPIC_PROXY_TOKEN'],
setup: {
requiresAuth: true,
authMode: 'token',
credentialEnvVars: ['ACME_ANTHROPIC_PROXY_TOKEN'],
},
envVarConfig: {
authTokenEnvVar: 'ACME_ANTHROPIC_PROXY_TOKEN',
baseUrlEnvVar: 'ACME_ANTHROPIC_PROXY_BASE_URL',
modelEnvVar: 'ACME_ANTHROPIC_PROXY_MODEL',
},
capabilities: {
supportsStreaming: true,
supportsVision: true,
supportsFunctionCalling: true,
supportsJsonMode: true,
supportsReasoning: true,
},
transportConfig: {
kind: 'anthropic-proxy',
},
usage: {
supported: false,
},
})
```
Note:
Treat this as an Anthropic-family transport contract, not as a generic
OpenAI-compatible gateway with different headers.
## Sample 7: `/usage` patterns
Status: Illustrative patterns. The metadata shapes are current, but runtime
support is still limited to the existing resolver/UI paths in the current
implementation.
Vendor-owned usage:
```ts
import { defineVendor } from '../define.js'
export default defineVendor({
id: 'acme',
label: 'Acme AI',
classification: 'openai-compatible',
defaultBaseUrl: 'https://api.acme.example/v1',
defaultModel: 'acme-chat',
setup: {
requiresAuth: true,
authMode: 'api-key',
credentialEnvVars: ['ACME_API_KEY'],
},
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
supportsApiFormatSelection: false,
supportsAuthHeaders: false,
},
},
usage: {
supported: true,
fetchModule: './usage/fetchAcmeUsage.js',
parseModule: './usage/parseAcmeUsage.js',
},
})
```
Gateway delegating to a vendor:
```ts
import { defineGateway } from '../define.js'
export default defineGateway({
id: 'acme-gateway',
label: 'Acme Gateway',
category: 'hosted',
defaultBaseUrl: 'https://gateway.acme.example/v1',
defaultModel: 'acme-chat',
supportsModelRouting: true,
setup: {
requiresAuth: true,
authMode: 'api-key',
credentialEnvVars: ['ACME_GATEWAY_API_KEY'],
},
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
supportsApiFormatSelection: false,
supportsAuthHeaders: true,
},
},
usage: {
supported: true,
delegateToVendorId: 'acme',
},
})
```
Explicit unsupported fallback:
```ts
import { defineVendor } from '../define.js'
export default defineVendor({
id: 'acme-unsupported',
label: 'Acme Unsupported',
classification: 'openai-compatible',
defaultBaseUrl: 'https://api.acme-unsupported.example/v1',
defaultModel: 'acme-basic',
setup: {
requiresAuth: true,
authMode: 'api-key',
credentialEnvVars: ['ACME_UNSUPPORTED_API_KEY'],
},
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
supportsApiFormatSelection: false,
supportsAuthHeaders: false,
},
},
usage: {
supported: false,
},
})
```
Current implementation rule:
`src/commands/usage/index.ts` currently resolves vendor and gateway targets,
plus the `firstParty` compatibility id. `src/components/Settings/Usage.tsx`
still has concrete UI branches for Anthropic, MiniMax, and Codex.
## Copy-paste safety checklist
Before promoting any sample from this file into a real descriptor:
- replace placeholder ids, labels, env vars, and URLs;
- confirm the descriptor type matches the external API contract;
- keep `transportConfig.kind` as the routing contract;
- keep `category` descriptive only;
- keep route-owned availability in the route catalog;
- set `openaiShim.supportsApiFormatSelection` and
`openaiShim.supportsAuthHeaders` explicitly for OpenAI-compatible route
templates;
- add `openaiShim.maxTokensField` when the provider is strict about
`max_tokens` versus `max_completion_tokens`;
- keep `/usage` metadata honest about current runtime support;
- update compatibility or UI metadata only when the route should actually be
user-facing.
+11 -7
View File
@@ -61,7 +61,7 @@ The proxy will start at `http://localhost:4000` by default.
```bash
export CLAUDE_CODE_USE_OPENAI=1
export OPENAI_BASE_URL=http://localhost:4000
export OPENAI_BASE_URL=http://localhost:4000/v1
export OPENAI_API_KEY=<your-master-key-or-placeholder>
export OPENAI_MODEL=<your-litellm-model-alias>
openclaude
@@ -69,16 +69,20 @@ openclaude
Replace `<your-litellm-model-alias>` with a model name from your `litellm_config.yaml` (e.g., `gpt-4o`, `claude-sonnet-4`, `gemini-2.5-flash`).
If your LiteLLM proxy is local and does not enforce auth, `OPENAI_API_KEY` can
be omitted when you configure env vars manually.
### Option B: Using /provider
1. Run `openclaude`
2. Type `/provider` to open the provider setup flow
3. Choose the **OpenAI-compatible** option
4. When prompted for the API key, enter the key required by your LiteLLM proxy
If your local LiteLLM setup does not enforce auth, you may still need to enter a placeholder value
- 5. When prompted for the base URL, enter `http://localhost:4000`
6. 6. When prompted for the model, enter the LiteLLM model name or alias you configured
7. 7. Save the provider configuration
4. When prompted for the API key, enter the key required by your LiteLLM proxy.
If your local LiteLLM setup does not enforce auth, you may still need to
enter a placeholder value because the guided flow expects one.
5. When prompted for the base URL, enter `http://localhost:4000/v1`
6. When prompted for the model, enter the LiteLLM model name or alias you configured
7. Save the provider configuration
## 3. Example LiteLLM Configs
@@ -114,7 +118,7 @@ litellm --config litellm_config.yaml --port 4000 --master_key sk-my-master-key
# Connect OpenClaude
export CLAUDE_CODE_USE_OPENAI=1
export OPENAI_BASE_URL=http://localhost:4000
export OPENAI_BASE_URL=http://localhost:4000/v1
export OPENAI_API_KEY=sk-my-master-key
export OPENAI_MODEL=gpt-4o
openclaude
+1
View File
@@ -114,3 +114,4 @@ Fix:
If you want source builds, advanced provider profiles, diagnostics, or Bun-based workflows, use:
- [Advanced Setup](advanced-setup.md)
This is also where to find Codex, Gemini, Mistral, LiteLLM, and profile-launcher setup.
+1
View File
@@ -143,3 +143,4 @@ npm uninstall -g @gitlawb/openclaude
Use:
- [Advanced Setup](advanced-setup.md)
For Codex, Gemini, Mistral, LiteLLM, provider profiles, and runtime diagnostics.
+1
View File
@@ -143,3 +143,4 @@ npm uninstall -g @gitlawb/openclaude
Use:
- [Advanced Setup](advanced-setup.md)
For Codex, Gemini, Mistral, LiteLLM, provider profiles, and runtime diagnostics.
+7 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@gitlawb/openclaude",
"version": "0.7.0",
"version": "0.8.0",
"description": "OpenClaude opens coding-agent workflows to any LLM — OpenAI, Gemini, DeepSeek, Ollama, and 200+ models",
"type": "module",
"bin": {
@@ -21,6 +21,8 @@
],
"scripts": {
"build": "bun run scripts/build.ts",
"integrations:generate": "bun run scripts/generate-integrations-artifacts.ts",
"integrations:check": "bun run scripts/generate-integrations-artifacts.ts --check",
"dev": "bun run build && node dist/cli.mjs",
"dev:profile": "bun run scripts/provider-launch.ts",
"dev:profile:fast": "bun run scripts/provider-launch.ts auto --fast --bare",
@@ -41,6 +43,10 @@
"dev:grpc": "bun run scripts/start-grpc.ts",
"dev:grpc:cli": "bun run scripts/grpc-cli.ts",
"start": "node dist/cli.mjs",
"web:dev": "bun run --cwd web dev",
"web:build": "bun run --cwd web build",
"web:preview": "bun run --cwd web preview",
"web:typecheck": "bun run --cwd web typecheck",
"test": "bun test",
"test:coverage": "bun test --coverage --coverage-reporter=lcov --coverage-dir=coverage --max-concurrency=1 && bun run scripts/render-coverage-heatmap.ts",
"test:coverage:ui": "bun run scripts/render-coverage-heatmap.ts",
@@ -0,0 +1,20 @@
import { generatedIntegrationArtifactsAreCurrent, writeIntegrationArtifacts } from '../src/integrations/artifactGenerator.js'
const shouldCheck = process.argv.includes('--check')
if (shouldCheck) {
const isCurrent = await generatedIntegrationArtifactsAreCurrent()
if (!isCurrent) {
console.error(
'Integration artifacts are out of date. Run `bun run scripts/generate-integrations-artifacts.ts`.',
)
process.exit(1)
}
console.log('Integration artifacts are up to date.')
} else {
const artifacts = await writeIntegrationArtifacts()
for (const artifact of artifacts) {
console.log(`Wrote ${artifact.path}`)
}
}
+17 -2
View File
@@ -137,6 +137,21 @@ function printSummary(profile: ProviderProfile): void {
}
}
function hasUsableGeminiLaunchAuth(env: NodeJS.ProcessEnv): boolean {
const authMode = env.GEMINI_AUTH_MODE?.trim().toLowerCase()
if (authMode === 'adc') {
return true
}
if (authMode === 'access-token') {
return Boolean(env.GEMINI_ACCESS_TOKEN?.trim())
}
return Boolean(
env.GEMINI_API_KEY?.trim() ||
env.GOOGLE_API_KEY?.trim() ||
env.GEMINI_ACCESS_TOKEN?.trim(),
)
}
async function main(): Promise<void> {
const options = parseLaunchOptions(process.argv.slice(2))
const requestedProfile = options.requestedProfile
@@ -202,8 +217,8 @@ async function main(): Promise<void> {
applyFastFlags(env)
}
if (profile === 'gemini' && !env.GEMINI_API_KEY) {
console.error('GEMINI_API_KEY is required for gemini profile. Run: bun run profile:init -- --provider gemini --api-key <key>')
if (profile === 'gemini' && !hasUsableGeminiLaunchAuth(env)) {
console.error('Gemini credentials are required for gemini profile. Use `bun run profile:init -- --provider gemini --api-key <key>`, save an access-token/ADC Gemini profile with `/provider`, or set GEMINI_API_KEY/GOOGLE_API_KEY/GEMINI_ACCESS_TOKEN.')
process.exit(1)
}
+3 -2
View File
@@ -11,6 +11,7 @@ import {
getLocalOpenAICompatibleProviderLabel,
probeOllamaGenerationReadiness,
} from '../src/utils/providerDiscovery.js'
import { DEFAULT_GEMINI_MODEL } from '../src/utils/providerProfile.js'
import { redactUrlForDisplay } from '../src/utils/urlRedaction.js'
type CheckResult = {
@@ -148,7 +149,7 @@ function checkGeminiEnv(): CheckResult[] {
results.push(pass('Provider mode', 'Google Gemini provider enabled.'))
if (!model) {
results.push(pass('GEMINI_MODEL', 'Not set. Default gemini-2.0-flash will be used.'))
results.push(pass('GEMINI_MODEL', `Not set. Default ${DEFAULT_GEMINI_MODEL} will be used.`))
} else {
results.push(pass('GEMINI_MODEL', model))
}
@@ -555,7 +556,7 @@ function serializeSafeEnvSummary(): Record<string, string | boolean> {
if (isTruthy(process.env.CLAUDE_CODE_USE_GEMINI)) {
return {
CLAUDE_CODE_USE_GEMINI: true,
GEMINI_MODEL: process.env.GEMINI_MODEL ?? '(unset, default: gemini-2.0-flash)',
GEMINI_MODEL: process.env.GEMINI_MODEL ?? `(unset, default: ${DEFAULT_GEMINI_MODEL})`,
GEMINI_BASE_URL: process.env.GEMINI_BASE_URL ?? 'https://generativelanguage.googleapis.com/v1beta/openai',
GEMINI_API_KEY_SET: Boolean(process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY),
}
+6 -6
View File
@@ -43,7 +43,7 @@ import type { Message } from './types/message.js'
import type { OrphanedPermission } from './types/textInputTypes.js'
import { createAbortController } from './utils/abortController.js'
import { validateArrayOf, assertNonEmptyString, assertObject, assertFunction } from './utils/validation.js'
import { clearToolSchemaCache } from './utils/toolSchemaCache.js'
import { invalidateRemovedToolSchemas } from './utils/toolSchemaCache.js'
import type { AttributionState } from './utils/commitAttribution.js'
import { getGlobalConfig } from './utils/config.js'
import { getCwd } from './utils/cwd.js'
@@ -1267,11 +1267,11 @@ export class QueryEngine {
// Phase 3: Commit — only reached if all validations pass
this.config.tools = toolArray as Tools
// Phase 4: Invalidate schema cache since tool set changed.
// NOTE: This is a process-wide clear, consistent with auth.ts/logout.tsx usage.
// Multi-session SDK consumers share one cache; a scoped invalidation would require
// per-engine cache keys — deferred until multi-session perf data warrants it.
clearToolSchemaCache()
// Phase 4: Invalidate schema cache for removed tools only.
// Selective invalidation preserves cached schemas for tools that remain,
// avoiding unnecessary recomputation for concurrent engines in multi-session
// SDK scenarios. New tools (not yet cached) will be computed on first render.
invalidateRemovedToolSchemas(validToolNames)
}
getReadFileState(): FileStateCache {
+51 -26
View File
@@ -18,23 +18,30 @@ const file = (relative: string) => Bun.file(resolve(SRC, relative))
// Fix 1: Gemini `store: false` rejection
// ---------------------------------------------------------------------------
describe('Gemini store field fix', () => {
test('isGeminiMode is imported and used in openaiShim', async () => {
const content = await file('services/api/openaiShim.ts').text()
test('descriptor-backed shim config strips store for Gemini and Mistral routes', async () => {
const runtimeMetadata = await file('integrations/runtimeMetadata.ts').text()
const geminiDescriptor = await file('integrations/vendors/gemini.ts').text()
const mistralDescriptor = await file('integrations/gateways/mistral.ts').text()
// Verify the fix: store deletion should check for Gemini mode
expect(content).toContain('isGeminiMode()')
expect(content).toContain("mistral and gemini don't recognize body.store")
// Ensure the delete body.store is guarded for both Mistral and Gemini
expect(content).toMatch(/isMistral\s*\|\|\s*isGeminiMode\(\)/)
expect(runtimeMetadata).toContain('removeBodyFields')
expect(geminiDescriptor).toContain("removeBodyFields: ['store']")
expect(mistralDescriptor).toContain("removeBodyFields: ['store']")
})
test('store: false is still set by default (OpenAI needs it)', async () => {
test('store: false is still set by default and only removed via shim config', async () => {
const content = await file('services/api/openaiShim.ts').text()
// The body should still have store: false by default
expect(content).toMatch(/store:\s*false/)
// But it should be deleted for non-OpenAI providers
expect(content).toMatch(/delete body\.store/)
expect(content).toContain('shimConfig.removeBodyFields')
expect(content).toContain('delete body[field]')
})
test('openaiShim does not keep a hardcoded descriptor route fallback list', async () => {
const content = await file('services/api/openaiShim.ts').text()
expect(content).not.toContain(
"['mistral', 'gemini', 'moonshot', 'deepseek', 'zai', 'kimi-code']",
)
})
})
@@ -219,22 +226,13 @@ describe('MCP tool timeout fix', () => {
// Cross-cutting: verify no regressions
// ---------------------------------------------------------------------------
describe('Regression checks', () => {
test('store field is still set for OpenAI (not deleted unconditionally)', async () => {
const content = await file('services/api/openaiShim.ts').text()
test('store field remains opt-out by per-route config rather than unconditional deletion', async () => {
const openaiShim = await file('services/api/openaiShim.ts').text()
const runtimeMetadata = await file('integrations/runtimeMetadata.ts').text()
// store: false should exist in body construction
expect(content).toMatch(/store:\s*false/)
// But delete body.store should be conditional (guarded by if)
const deleteLines = content.split('\n').filter(l => l.includes('delete body.store'))
expect(deleteLines.length).toBeGreaterThan(0)
// Verify the delete is inside a conditional block by checking surrounding context
for (const line of deleteLines) {
const trimmed = line.trim()
// Should be either inside an if block (indented delete) or a comment
expect(
trimmed.startsWith('delete') && !trimmed.includes('// unconditional'),
).toBe(true)
}
expect(openaiShim).toMatch(/store:\s*false/)
expect(openaiShim).toContain('for (const field of shimConfig.removeBodyFields ?? [])')
expect(runtimeMetadata).toContain('mergeRemoveBodyFields')
})
})
@@ -288,3 +286,30 @@ describe('Context overflow 500 fix', () => {
expect(content).toContain('automatic compaction has failed')
})
})
// ---------------------------------------------------------------------------
// Fix N: Project-scope MCP servers from .mcp.json not detected for 3P providers (issue #696)
// ---------------------------------------------------------------------------
describe('Project-scope MCP approval — third-party providers (issue #696)', () => {
test('handleMcpjsonServerApprovals is NOT gated behind usesAnthropicSetup', async () => {
const content = await file('interactiveHelpers.tsx').text()
// The call site for handleMcpjsonServerApprovals must not sit inside an
// `if (usesAnthropicSetup) { ... }` block, or third-party providers will
// never get the dialog and project-scope .mcp.json servers will be silently
// dropped from /mcp listings (issue #696).
const approvalCallIdx = content.indexOf('await handleMcpjsonServerApprovals(root)')
expect(approvalCallIdx).toBeGreaterThan(-1)
// Look at the 800 chars BEFORE the call site for any `if (usesAnthropicSetup)`
// block that would still be open. Pick a window that's definitely inside the
// showSetupScreens function but not in earlier dialogs.
const before = content.slice(Math.max(0, approvalCallIdx - 800), approvalCallIdx)
expect(before).not.toMatch(/if\s*\(\s*usesAnthropicSetup\s*\)\s*{[^}]*$/)
})
test('issue #696 is referenced from the comment so future readers can find context', async () => {
const content = await file('interactiveHelpers.tsx').text()
expect(content).toContain('#696')
})
})
+50 -8
View File
@@ -431,12 +431,17 @@ const STATE: State = getInitialState()
/**
* Per-query SDK context for AsyncLocalStorage-based isolation.
* When set, overrides global STATE reads for the current async context.
*
* **Runtime Requirement:** Uses Node.js `async_hooks.AsyncLocalStorage`.
* Not available in browsers or non-Node JavaScript environments.
* SDK consumers must run in a Node.js runtime (Node.js 12.17.0+ or 14.0.0+).
*/
type SdkContext = {
sessionId: SessionId
sessionProjectDir: string | null
cwd: string
originalCwd: string
parentSessionId?: SessionId
}
import { AsyncLocalStorage } from 'async_hooks'
@@ -447,6 +452,10 @@ const sdkContextStorage = new AsyncLocalStorage<SdkContext>()
* Run a function with an SDK-specific context that overrides global state.
* All reads of sessionId, sessionProjectDir, cwd, originalCwd within fn
* return context-scoped values instead of global STATE.
*
* **Node.js Only:** Requires AsyncLocalStorage from async_hooks module.
* This function will throw if called in a non-Node environment where
* async_hooks is not available.
*/
export function runWithSdkContext<T>(context: SdkContext, fn: () => T): T {
return sdkContextStorage.run(context, fn)
@@ -464,21 +473,37 @@ export function getSessionId(): SessionId {
export function regenerateSessionId(
options: { setCurrentAsParent?: boolean } = {},
): SessionId {
const ctx = getSdkContext()
const currentSessionId = ctx?.sessionId ?? STATE.sessionId
if (options.setCurrentAsParent) {
STATE.parentSessionId = STATE.sessionId
if (ctx) {
ctx.parentSessionId = currentSessionId
} else {
STATE.parentSessionId = currentSessionId
}
}
// Drop the outgoing session's plan-slug entry so the Map doesn't
// accumulate stale keys. Callers that need to carry the slug across
// (REPL.tsx clearContext) read it before calling clearConversation.
STATE.planSlugCache.delete(STATE.sessionId)
STATE.planSlugCache.delete(currentSessionId)
// Regenerated sessions live in the current project: reset projectDir to
// null so getTranscriptPath() derives from originalCwd.
STATE.sessionId = randomUUID() as SessionId
STATE.sessionProjectDir = null
return STATE.sessionId
const newId = randomUUID() as SessionId
if (ctx) {
ctx.sessionId = newId
ctx.sessionProjectDir = null
} else {
STATE.sessionId = newId
STATE.sessionProjectDir = null
}
return newId
}
export function getParentSessionId(): SessionId | undefined {
const ctx = getSdkContext()
if (ctx) {
return ctx.parentSessionId
}
return STATE.parentSessionId
}
@@ -498,12 +523,19 @@ export function switchSession(
sessionId: SessionId,
projectDir: string | null = null,
): void {
const ctx = getSdkContext()
const currentSessionId = ctx?.sessionId ?? STATE.sessionId
// Drop the outgoing session's plan-slug entry so the Map stays bounded
// across repeated /resume. Only the current session's slug is ever read
// (plans.ts getPlanSlug defaults to getSessionId()).
STATE.planSlugCache.delete(STATE.sessionId)
STATE.sessionId = sessionId
STATE.sessionProjectDir = projectDir
STATE.planSlugCache.delete(currentSessionId)
if (ctx) {
ctx.sessionId = sessionId
ctx.sessionProjectDir = projectDir
} else {
STATE.sessionId = sessionId
STATE.sessionProjectDir = projectDir
}
sessionSwitched.emit(sessionId)
}
@@ -544,6 +576,11 @@ export function getProjectRoot(): string {
}
export function setOriginalCwd(cwd: string): void {
const ctx = getSdkContext()
if (ctx) {
ctx.originalCwd = cwd.normalize('NFC')
return
}
STATE.originalCwd = cwd.normalize('NFC')
}
@@ -561,6 +598,11 @@ export function getCwdState(): string {
}
export function setCwdState(cwd: string): void {
const ctx = getSdkContext()
if (ctx) {
ctx.cwd = cwd.normalize('NFC')
return
}
STATE.cwd = cwd.normalize('NFC')
}
+8 -1
View File
@@ -1,4 +1,11 @@
import { formatDescriptionWithSource } from './commands.js'
import { describe, expect, test } from 'bun:test'
import { builtInCommandNames, formatDescriptionWithSource } from './commands.js'
describe('builtInCommandNames', () => {
test('includes the LSP command', () => {
expect(builtInCommandNames()).toContain('lsp')
})
})
describe('formatDescriptionWithSource', () => {
test('returns empty text for prompt commands missing a description', () => {
+2
View File
@@ -28,6 +28,7 @@ import ide from './commands/ide/index.js'
import init from './commands/init.js'
import initVerifiers from './commands/init-verifiers.js'
import keybindings from './commands/keybindings/index.js'
import lsp from './commands/lsp/index.js'
import login from './commands/login/index.js'
import logout from './commands/logout/index.js'
import installGitHubApp from './commands/install-github-app/index.js'
@@ -296,6 +297,7 @@ const COMMANDS = memoize((): Command[] => [
init,
keybindings,
knowledge,
lsp,
installGitHubApp,
installSlackApp,
mcp,
+13
View File
@@ -0,0 +1,13 @@
import type { Command } from '../../commands.js'
const lsp = {
type: 'local',
name: 'lsp',
description: 'Inspect and set up Language Server Protocol code intelligence',
argumentHint:
'status | recommend [path] | install <plugin-id> | uninstall <plugin-id> | restart',
supportsNonInteractive: false,
load: () => import('./lsp.js'),
} satisfies Command
export default lsp
+675
View File
@@ -0,0 +1,675 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test'
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
type InitializationStatus =
| { status: 'not-started' }
| { status: 'pending' }
| { status: 'success' }
| { status: 'failed'; error: Error }
type TestServerConfig = {
command?: string
args?: string[]
extensionToLanguage?: Record<string, string>
}
type TestServerInstance = {
state?: string
lastError?: Error
config?: TestServerConfig
}
type OfficialMarketplaceCheckResult = {
installed: boolean
skipped: boolean
reason?:
| 'already_attempted'
| 'already_installed'
| 'policy_blocked'
| 'git_unavailable'
| 'gcs_unavailable'
| 'unknown'
configSaveFailed?: boolean
}
let initializationStatus: InitializationStatus = { status: 'not-started' }
let configuredServers: Record<string, TestServerConfig> = {}
let serverInstances = new Map<string, TestServerInstance>()
let candidateCallOptions: unknown[] = []
let candidates: Array<{
pluginId: string
pluginName: string
marketplaceName: string
description?: string
isOfficial: boolean
extensions: string[]
command: string
binaryInstalled: boolean
installed: boolean
}> = []
const installPluginOp = mock(
async (_plugin: string, _scope?: 'user' | 'local' | 'project') => ({
success: true,
message: 'Installed plugin',
pluginId: 'typescript-lsp@claude-plugins-official',
scope: 'user' as const,
}),
)
const refreshActivePlugins = mock(async () => ({
enabled_count: 1,
disabled_count: 0,
command_count: 0,
agent_count: 0,
hook_count: 0,
mcp_count: 0,
lsp_count: 1,
error_count: 0,
agentDefinitions: { activeAgents: [], allAgents: [] },
pluginCommands: [],
}))
const uninstallPluginOp = mock(
async (_plugin: string, _scope?: 'user' | 'local' | 'project') => ({
success: true,
message: 'Uninstalled plugin',
pluginId: 'typescript-lsp@claude-plugins-official',
}),
)
const reinitializeLspServerManager = mock(() => {})
const waitForInitialization = mock(async () => {})
const checkAndInstallOfficialMarketplace = mock(
async (): Promise<OfficialMarketplaceCheckResult> => ({
installed: false,
skipped: true,
reason: 'already_installed',
}),
)
const discoverWorkspaceExtensions = async (pathspec?: string) =>
pathspec === 'src' || pathspec === '.'
? ['.ts', '.tsx']
: pathspec
? []
: ['.ts']
const { discoverWorkspaceExtensions: discoverRealWorkspaceExtensions, runLspCommand } =
await import('./lsp.js')
const EMPTY_CONTEXT = {
setAppState: () => {},
} as Parameters<typeof runLspCommand>[1]
const deps = {
getInitializationStatus: () => initializationStatus,
getLspServerManager: () =>
serverInstances.size > 0
? {
getAllServers: () => serverInstances,
}
: undefined,
getAllLspServers: async () => ({ servers: configuredServers }),
listLspPluginCandidates: async (options: unknown) => {
candidateCallOptions.push(options)
return candidates
},
checkAndInstallOfficialMarketplace,
installPluginOp,
uninstallPluginOp,
refreshActivePlugins,
reinitializeLspServerManager,
waitForInitialization,
discoverWorkspaceExtensions,
}
beforeEach(() => {
initializationStatus = { status: 'not-started' }
configuredServers = {}
serverInstances = new Map()
candidateCallOptions = []
candidates = []
installPluginOp.mockClear()
uninstallPluginOp.mockClear()
refreshActivePlugins.mockClear()
reinitializeLspServerManager.mockClear()
waitForInitialization.mockClear()
checkAndInstallOfficialMarketplace.mockClear()
checkAndInstallOfficialMarketplace.mockImplementation(async () => ({
installed: false,
skipped: true,
reason: 'already_installed' as const,
}))
deps.getInitializationStatus = () => initializationStatus
deps.listLspPluginCandidates = async (options: unknown) => {
candidateCallOptions.push(options)
return candidates
}
deps.discoverWorkspaceExtensions = discoverWorkspaceExtensions
})
async function run(args: string): Promise<string> {
const result = await runLspCommand(args, EMPTY_CONTEXT, deps)
expect(result.type).toBe('text')
return result.type === 'text' ? result.value : ''
}
describe('/lsp status', () => {
test('shows not-started status with no configured servers', async () => {
const output = await run('status')
expect(output).toContain('LSP status')
expect(output).toContain('Initialization: not-started')
expect(output).toContain('Configured plugin LSP servers: none')
})
test('shows success status with zero configured servers', async () => {
initializationStatus = { status: 'success' }
const output = await run('status')
expect(output).toContain('Initialization: success')
expect(output).toContain('Configured plugin LSP servers: none')
})
test('shows success status with configured servers', async () => {
initializationStatus = { status: 'success' }
configuredServers = {
'plugin:typescript-lsp:typescript': {
command: 'typescript-language-server',
args: ['--stdio'],
extensionToLanguage: { '.ts': 'typescript' },
},
}
const output = await run('status')
expect(output).toContain('Configured plugin LSP servers: 1')
expect(output).toContain('state: configured')
expect(output).toContain('typescript-language-server --stdio')
expect(output).toContain('extensions: .ts')
})
test('explains stopped servers as lazy-started rather than broken', async () => {
initializationStatus = { status: 'success' }
configuredServers = {
'plugin:typescript-lsp:typescript': {
command: 'typescript-language-server',
args: ['--stdio'],
extensionToLanguage: { '.ts': 'typescript' },
},
}
serverInstances = new Map([
[
'plugin:typescript-lsp:typescript',
{
state: 'stopped',
config: configuredServers['plugin:typescript-lsp:typescript'],
},
],
])
const output = await run('status')
expect(output).toContain(
'state: stopped (lazy start; starts on first LSP request)',
)
})
test('shows configured server state and initialization error', async () => {
initializationStatus = {
status: 'failed',
error: new Error('startup failed'),
}
configuredServers = {
'plugin:typescript-lsp:typescript': {
command: 'typescript-language-server',
extensionToLanguage: { '.ts': 'typescript', '.tsx': 'typescriptreact' },
},
}
serverInstances = new Map([
[
'plugin:typescript-lsp:typescript',
{
state: 'error',
lastError: new Error('server crashed'),
config: configuredServers['plugin:typescript-lsp:typescript'],
},
],
])
const output = await run('status')
expect(output).toContain('Initialization: failed')
expect(output).toContain('startup failed')
expect(output).toContain('plugin:typescript-lsp:typescript')
expect(output).toContain('state: error')
expect(output).toContain('typescript-language-server')
expect(output).toContain('.ts, .tsx')
expect(output).toContain('server crashed')
})
})
describe('/lsp recommend', () => {
test('lists candidates with binary state and next commands', async () => {
candidates = [
{
pluginId: 'typescript-lsp@claude-plugins-official',
pluginName: 'typescript-lsp',
marketplaceName: 'claude-plugins-official',
isOfficial: true,
extensions: ['.ts', '.tsx'],
command: 'typescript-language-server',
binaryInstalled: false,
installed: false,
},
]
const output = await run('recommend src/main.ts')
expect(output).toContain('LSP recommendations for .ts')
expect(output).toContain('typescript-lsp@claude-plugins-official')
expect(output).toContain('binary: missing')
expect(output).toContain(
'npm install -g typescript typescript-language-server',
)
expect(output).toContain(
'/lsp install typescript-lsp@claude-plugins-official',
)
})
test('uses directory paths and bare extensions for recommendation scope', async () => {
candidates = [
{
pluginId: 'typescript-lsp@claude-plugins-official',
pluginName: 'typescript-lsp',
marketplaceName: 'claude-plugins-official',
isOfficial: true,
extensions: ['.ts', '.tsx'],
command: 'typescript-language-server',
binaryInstalled: true,
installed: false,
},
]
expect(await run('recommend src')).toContain(
'LSP recommendations for .ts, .tsx',
)
expect(await run('recommend ts')).toContain('LSP recommendations for .ts')
expect(await run('recommend .')).toContain(
'LSP recommendations for .ts, .tsx',
)
expect(candidateCallOptions).toContainEqual(
expect.objectContaining({ extensions: ['.ts', '.tsx'] }),
)
expect(candidateCallOptions).toContainEqual(
expect.objectContaining({ extensions: ['.ts'] }),
)
})
test('does not list every marketplace candidate for a path without extensions', async () => {
candidates = [
{
pluginId: 'typescript-lsp@claude-plugins-official',
pluginName: 'typescript-lsp',
marketplaceName: 'claude-plugins-official',
isOfficial: true,
extensions: ['.ts', '.tsx'],
command: 'typescript-language-server',
binaryInstalled: true,
installed: false,
},
]
const output = await run('recommend missing/path')
expect(output).toContain('No file extensions found for "missing/path".')
expect(candidateCallOptions).toEqual([])
})
test('quotes dot path when no extensions are found', async () => {
deps.discoverWorkspaceExtensions = async () => []
const output = await run('recommend .')
expect(output).toContain('No file extensions found for ".".')
expect(output).not.toContain('for ..')
})
test('filters noisy workspace extensions and reports matched candidate extensions', async () => {
deps.discoverWorkspaceExtensions = async () => [
'.ts',
'.png',
'.woff2',
]
candidates = [
{
pluginId: 'typescript-lsp@claude-plugins-official',
pluginName: 'typescript-lsp',
marketplaceName: 'claude-plugins-official',
isOfficial: true,
extensions: ['.ts', '.tsx'],
command: 'typescript-language-server',
binaryInstalled: true,
installed: false,
},
]
const output = await run('recommend')
expect(output).toContain('LSP recommendations for .ts')
expect(output).not.toContain('.png')
expect(output).not.toContain('.woff2')
expect(candidateCallOptions).toContainEqual(
expect.objectContaining({ extensions: ['.ts'] }),
)
})
test('falls back to filesystem scanning when git cannot enumerate workspace files', async () => {
const tempDir = await mkdtemp(join(tmpdir(), 'openclaude-lsp-'))
try {
await mkdir(join(tempDir, 'src'), { recursive: true })
await writeFile(join(tempDir, 'src', 'main.ts'), 'export const x = 1\n')
await writeFile(join(tempDir, 'src', 'style.css'), '.root {}\n')
await writeFile(join(tempDir, 'logo.png'), '')
const extensions = await discoverRealWorkspaceExtensions(undefined, tempDir)
expect(extensions).toContain('.ts')
expect(extensions).toContain('.css')
expect(extensions).not.toContain('.png')
} finally {
await rm(tempDir, { recursive: true, force: true })
}
})
test('installs missing official marketplace and retries candidate lookup', async () => {
const typescriptCandidate = {
pluginId: 'typescript-lsp@claude-plugins-official',
pluginName: 'typescript-lsp',
marketplaceName: 'claude-plugins-official',
isOfficial: true,
extensions: ['.ts', '.tsx'],
command: 'typescript-language-server',
binaryInstalled: true,
installed: false,
}
let lookupCount = 0
deps.listLspPluginCandidates = async (options: unknown) => {
candidateCallOptions.push(options)
lookupCount += 1
return lookupCount === 1 ? [] : [typescriptCandidate]
}
checkAndInstallOfficialMarketplace.mockImplementationOnce(async () => ({
installed: true,
skipped: false,
}))
const output = await run('recommend src/main.ts')
expect(checkAndInstallOfficialMarketplace).toHaveBeenCalled()
expect(candidateCallOptions).toHaveLength(2)
expect(output).toContain(
'Anthropic marketplace installed for LSP recommendations',
)
expect(output).toContain('typescript-lsp@claude-plugins-official')
})
test('explains why marketplace repair could not provide candidates', async () => {
checkAndInstallOfficialMarketplace.mockImplementationOnce(async () => ({
installed: false,
skipped: true,
reason: 'policy_blocked',
}))
const output = await run('recommend src/main.ts')
expect(output).toContain('No LSP plugin candidates found for .ts')
expect(output).toContain('policy blocks it')
})
})
describe('/lsp install', () => {
test('installs plugin and refreshes active plugins in-session', async () => {
const output = await run('install typescript-lsp@claude-plugins-official')
expect(installPluginOp).toHaveBeenCalledWith(
'typescript-lsp@claude-plugins-official',
'user',
)
expect(refreshActivePlugins).toHaveBeenCalledWith(EMPTY_CONTEXT.setAppState)
expect(output).toContain(
'Installed typescript-lsp@claude-plugins-official',
)
expect(output).toContain('Activated 1 plugin LSP server')
})
test('reports install operation exceptions', async () => {
installPluginOp.mockImplementationOnce(async () => {
throw new Error('install exploded')
})
const output = await run('install typescript-lsp@claude-plugins-official')
expect(output).toContain(
'Failed to install typescript-lsp@claude-plugins-official',
)
expect(output).toContain('install exploded')
})
test('reports partial success when refresh fails after install', async () => {
refreshActivePlugins.mockImplementationOnce(async () => {
throw new Error('refresh exploded')
})
const output = await run('install typescript-lsp@claude-plugins-official')
expect(output).toContain(
'Installed typescript-lsp@claude-plugins-official',
)
expect(output).toContain('plugin refresh failed')
expect(output).toContain('refresh exploded')
})
})
describe('/lsp uninstall', () => {
test('uninstalls plugin, refreshes plugins, and reports remaining servers', async () => {
configuredServers = {
'plugin:typescript-lsp:typescript': {
command: 'typescript-language-server',
},
}
const output = await run('uninstall typescript-lsp@claude-plugins-official')
expect(uninstallPluginOp).toHaveBeenCalledWith(
'typescript-lsp@claude-plugins-official',
'user',
)
expect(refreshActivePlugins).toHaveBeenCalledWith(EMPTY_CONTEXT.setAppState)
expect(reinitializeLspServerManager).not.toHaveBeenCalled()
expect(waitForInitialization).toHaveBeenCalled()
expect(output).toContain('Uninstalled typescript-lsp@claude-plugins-official')
expect(output).toContain('1 plugin LSP server still active')
})
test('reports usage when no plugin-id given', async () => {
const output = await run('uninstall')
expect(output).toContain('Usage: /lsp uninstall')
expect(uninstallPluginOp).not.toHaveBeenCalled()
})
test('reports uninstall failure', async () => {
uninstallPluginOp.mockImplementationOnce(async () => ({
success: false,
message: 'Plugin not found',
pluginId: 'nonexistent@marketplace',
}))
const output = await run('uninstall nonexistent@marketplace')
expect(output).toContain('Failed to uninstall')
expect(output).toContain('Plugin not found')
})
test('reports uninstall operation exceptions', async () => {
uninstallPluginOp.mockImplementationOnce(async () => {
throw new Error('uninstall exploded')
})
const output = await run('uninstall typescript-lsp@claude-plugins-official')
expect(output).toContain('Failed to uninstall')
expect(output).toContain('uninstall exploded')
})
test('reports partial success when refresh fails after uninstall', async () => {
refreshActivePlugins.mockImplementationOnce(async () => {
throw new Error('refresh exploded')
})
const output = await run('uninstall typescript-lsp@claude-plugins-official')
expect(output).toContain('Uninstalled typescript-lsp@claude-plugins-official')
expect(output).toContain('plugin refresh failed')
expect(output).toContain('refresh exploded')
})
})
describe('/lsp restart', () => {
test('reinitializes and reports server count', async () => {
initializationStatus = { status: 'success' }
configuredServers = {
'plugin:typescript-lsp:typescript': {
command: 'typescript-language-server',
},
}
const output = await run('restart')
expect(reinitializeLspServerManager).toHaveBeenCalled()
expect(waitForInitialization).toHaveBeenCalled()
expect(output).toContain('LSP restarted')
expect(output).toContain('1 server configured')
})
test('refuses to restart when LSP not initialized', async () => {
initializationStatus = { status: 'not-started' }
const output = await run('restart')
expect(reinitializeLspServerManager).not.toHaveBeenCalled()
expect(output).toContain('not been initialized')
})
test('reports restart failure', async () => {
initializationStatus = { status: 'success' }
// After restart, status becomes failed
const statuses: InitializationStatus[] = [
{ status: 'success' },
{ status: 'failed', error: new Error('server crash') },
]
deps.getInitializationStatus = () => statuses.shift()!
const output = await run('restart')
expect(output).toContain('LSP restart failed')
expect(output).toContain('server crash')
// Restore original behavior
deps.getInitializationStatus = () => initializationStatus
})
})
describe('/lsp help', () => {
test('shows usage for all commands with examples', async () => {
const output = await run('help')
expect(output).toContain('/lsp status')
expect(output).toContain('/lsp recommend')
expect(output).toContain('/lsp install')
expect(output).toContain('/lsp uninstall')
expect(output).toContain('/lsp restart')
expect(output).toContain('Tip:')
})
test('shows help for unknown subcommands', async () => {
const output = await run('bogus')
expect(output).toContain('Unknown /lsp command "bogus"')
expect(output).toContain('/lsp status')
expect(output).toContain('/lsp restart')
})
})
describe('binary install hints', () => {
test('shows OS-specific install instructions for known binaries', async () => {
candidates = [
{
pluginId: 'clangd-lsp@claude-plugins-official',
pluginName: 'clangd-lsp',
marketplaceName: 'claude-plugins-official',
isOfficial: true,
extensions: ['.c', '.cpp'],
command: 'clangd',
binaryInstalled: false,
installed: false,
},
]
const output = await run('recommend src/main.cpp')
expect(output).toContain('Binary missing: clangd')
expect(output).toContain('Arch/Manjaro:')
expect(output).toContain('sudo pacman -S clang')
expect(output).toContain('Debian/Ubuntu:')
expect(output).toContain('macOS:')
expect(output).toContain('Verify:')
expect(output).toContain('clangd --version')
})
test('shows generic fallback for unknown binaries', async () => {
candidates = [
{
pluginId: 'unknown-lsp@marketplace',
pluginName: 'unknown-lsp',
marketplaceName: 'marketplace',
isOfficial: false,
extensions: ['.xyz'],
command: 'some-unknown-binary',
binaryInstalled: false,
installed: false,
},
]
const output = await run('recommend file.xyz')
expect(output).toContain('Binary missing: some-unknown-binary')
expect(output).toContain('Install some-unknown-binary and ensure it is on PATH')
expect(output).toContain('some-unknown-binary --version')
})
test('shows notes for binaries that have them', async () => {
candidates = [
{
pluginId: 'gopls-lsp@claude-plugins-official',
pluginName: 'gopls-lsp',
marketplaceName: 'claude-plugins-official',
isOfficial: true,
extensions: ['.go'],
command: 'gopls',
binaryInstalled: false,
installed: false,
},
]
const output = await run('recommend main.go')
expect(output).toContain('Notes:')
expect(output).toContain('Ensure $(go env GOPATH)/bin is on PATH')
})
})
+814
View File
@@ -0,0 +1,814 @@
import { readdir, stat } from 'fs/promises'
import { extname, join, resolve } from 'path'
import { getAllLspServers } from '../../services/lsp/config.js'
import {
getInitializationStatus,
getLspServerManager,
reinitializeLspServerManager,
waitForInitialization,
} from '../../services/lsp/manager.js'
import {
installPluginOp,
uninstallPluginOp,
} from '../../services/plugins/pluginOperations.js'
import type {
LocalCommandCall,
LocalJSXCommandContext,
} from '../../types/command.js'
import { getCwd } from '../../utils/cwd.js'
import { errorMessage } from '../../utils/errors.js'
import { execFileNoThrowWithCwd } from '../../utils/execFileNoThrow.js'
import { gitExe } from '../../utils/git.js'
import {
listLspPluginCandidates,
type LspPluginCandidate,
} from '../../utils/plugins/lspRecommendation.js'
import {
checkAndInstallOfficialMarketplace,
type OfficialMarketplaceCheckResult,
} from '../../utils/plugins/officialMarketplaceStartupCheck.js'
import { refreshActivePlugins } from '../../utils/plugins/refresh.js'
import { plural } from '../../utils/stringUtils.js'
type LspServerConfigLike = {
command?: string
args?: string[]
extensionToLanguage?: Record<string, string>
}
type LspServerInstanceLike = {
state?: string
lastError?: Error
config?: LspServerConfigLike
}
type LspServerManagerLike = {
getAllServers(): Map<string, LspServerInstanceLike>
}
type InstallPluginResult = Awaited<ReturnType<typeof installPluginOp>>
type UninstallPluginResult = Awaited<ReturnType<typeof uninstallPluginOp>>
type RefreshActivePluginsResult = Awaited<
ReturnType<typeof refreshActivePlugins>
>
export type LspCommandDeps = {
getInitializationStatus: typeof getInitializationStatus
getLspServerManager: () => LspServerManagerLike | undefined
getAllLspServers: typeof getAllLspServers
listLspPluginCandidates: typeof listLspPluginCandidates
checkAndInstallOfficialMarketplace: typeof checkAndInstallOfficialMarketplace
installPluginOp: typeof installPluginOp
uninstallPluginOp: typeof uninstallPluginOp
refreshActivePlugins: typeof refreshActivePlugins
reinitializeLspServerManager: typeof reinitializeLspServerManager
waitForInitialization: typeof waitForInitialization
discoverWorkspaceExtensions: (pathspec?: string) => Promise<string[]>
}
const DEFAULT_DEPS: LspCommandDeps = {
getInitializationStatus,
getLspServerManager,
getAllLspServers,
listLspPluginCandidates,
checkAndInstallOfficialMarketplace,
installPluginOp,
uninstallPluginOp,
refreshActivePlugins,
reinitializeLspServerManager,
waitForInitialization,
discoverWorkspaceExtensions,
}
type BinaryInstallHint = {
install?: {
archManjaro?: string
debianUbuntu?: string
macos?: string
windows?: string
generic?: string
}
verify?: string
notes?: string[]
}
const DISCOVERED_EXTENSION_IGNORE_SET = new Set([
'.eot',
'.gif',
'.ico',
'.jpeg',
'.jpg',
'.lock',
'.map',
'.otf',
'.png',
'.svg',
'.ttf',
'.webp',
'.woff',
'.woff2',
])
const DISCOVERY_DIRECTORY_IGNORE_SET = new Set([
'.cache',
'.git',
'.hg',
'.next',
'.openclaude',
'.svn',
'build',
'coverage',
'dist',
'node_modules',
'out',
'target',
'vendor',
])
const MAX_DISCOVERY_FILES = 5_000
const MAX_DISCOVERY_DEPTH = 8
const BINARY_INSTALL_HINTS: Record<string, BinaryInstallHint> = {
clangd: {
install: {
archManjaro: 'sudo pacman -S clang',
debianUbuntu: 'sudo apt install clangd',
macos: 'brew install llvm',
},
verify: 'clangd --version',
},
vtsls: {
install: {
generic: 'npm install -g @vtsls/language-server typescript',
},
verify: 'vtsls --version',
},
'typescript-language-server': {
install: {
generic: 'npm install -g typescript typescript-language-server',
},
verify: 'typescript-language-server --version',
},
'pyright-langserver': {
install: {
generic: 'npm install -g pyright',
},
verify: 'pyright-langserver --help',
},
'rust-analyzer': {
install: {
generic: 'rustup component add rust-analyzer',
},
verify: 'rust-analyzer --version',
},
gopls: {
install: {
generic: 'go install golang.org/x/tools/gopls@latest',
},
verify: 'gopls version',
notes: ['Ensure $(go env GOPATH)/bin is on PATH.'],
},
'csharp-ls': {
install: {
generic: 'dotnet tool install --global csharp-ls',
},
verify: 'csharp-ls --version',
notes: ['Ensure ~/.dotnet/tools is on PATH.'],
},
jdtls: {
install: {
macos: 'brew install jdtls',
generic: 'Install Eclipse JDT LS/jdtls and ensure jdtls is on PATH',
},
verify: 'jdtls --version',
},
'kotlin-lsp': {
install: {
macos: 'brew install JetBrains/utils/kotlin-lsp',
generic:
'Install kotlin-lsp manually and ensure kotlin-lsp is on PATH',
},
verify: 'kotlin-lsp --version',
notes: ['Requires Java 17+.'],
},
'lua-language-server': {
install: {
macos: 'brew install lua-language-server',
generic:
'Install LuaLS/lua-language-server and ensure lua-language-server is on PATH',
},
verify: 'lua-language-server --version',
},
intelephense: {
install: {
generic: 'npm install -g intelephense',
},
verify: 'intelephense --version',
},
'ruby-lsp': {
install: {
generic: 'gem install ruby-lsp',
},
verify: 'ruby-lsp --version',
},
'sourcekit-lsp': {
install: {
macos: 'Install Xcode so sourcekit-lsp is available',
generic:
'Install the Swift toolchain and ensure sourcekit-lsp is on PATH',
},
verify: 'sourcekit-lsp --help',
},
}
export const call: LocalCommandCall = (args, context) =>
runLspCommand(args, context, DEFAULT_DEPS)
export async function runLspCommand(
args: string,
context: Pick<LocalJSXCommandContext, 'setAppState'>,
deps: LspCommandDeps = DEFAULT_DEPS,
) {
const { command, rest } = parseCommand(args)
switch (command) {
case '':
case 'help':
return text(helpText())
case 'status':
return text(await renderStatus(deps))
case 'recommend':
return text(await renderRecommendations(rest, deps))
case 'install':
return text(await installLspPlugin(rest, context, deps))
case 'uninstall':
return text(await uninstallLspPlugin(rest, context, deps))
case 'restart':
return text(await restartLsp(deps))
default:
return text(`Unknown /lsp command "${command}".\n\n${helpText()}`)
}
}
function text(value: string): { type: 'text'; value: string } {
return { type: 'text', value }
}
function parseCommand(args: string): { command: string; rest: string } {
const trimmed = args.trim()
if (!trimmed) return { command: '', rest: '' }
const match = /^(\S+)(?:\s+([\s\S]*))?$/.exec(trimmed)
return {
command: match?.[1]?.toLowerCase() ?? '',
rest: match?.[2]?.trim() ?? '',
}
}
function helpText(): string {
return [
'Usage:',
' /lsp status',
' /lsp recommend [path-or-extension]',
' /lsp install <plugin-id>',
' /lsp uninstall <plugin-id>',
' /lsp restart',
'',
'Examples:',
' /lsp status',
' /lsp recommend .',
' /lsp recommend src/main.ts',
' /lsp recommend .ts',
' /lsp install typescript-lsp@claude-plugins-official',
' /lsp uninstall typescript-lsp@claude-plugins-official',
' /lsp restart',
'',
'Tip:',
' Run /lsp recommend first and copy the plugin id from the recommendation output.',
].join('\n')
}
async function renderStatus(deps: LspCommandDeps): Promise<string> {
const status = deps.getInitializationStatus()
const { servers } = await deps.getAllLspServers()
const manager = deps.getLspServerManager()
const instances = manager?.getAllServers() ?? new Map()
const lines = ['LSP status', `Initialization: ${status.status}`]
if (status.status === 'failed') {
lines.push(`Initialization error: ${status.error.message}`)
}
const serverNames = Object.keys(servers).sort()
if (serverNames.length === 0) {
lines.push('Configured plugin LSP servers: none')
return lines.join('\n')
}
lines.push(`Configured plugin LSP servers: ${serverNames.length}`)
for (const name of serverNames) {
const instance = instances.get(name)
const config = (instance?.config ?? servers[name]) as LspServerConfigLike
const state = formatServerState(instance?.state ?? 'configured')
const command = [config.command, ...(config.args ?? [])]
.filter(Boolean)
.join(' ')
const extensions = Object.keys(config.extensionToLanguage ?? {}).sort()
lines.push(`- ${name} (state: ${state})`)
if (command) lines.push(` command: ${command}`)
if (extensions.length > 0) {
lines.push(` extensions: ${extensions.join(', ')}`)
}
if (instance?.lastError) {
lines.push(` last error: ${instance.lastError.message}`)
}
}
return lines.join('\n')
}
function formatServerState(state: string): string {
if (state === 'stopped') {
return 'stopped (lazy start; starts on first LSP request)'
}
return state
}
async function renderRecommendations(
target: string,
deps: LspCommandDeps,
): Promise<string> {
const trimmedTarget = target.trim()
const extensions = await resolveRecommendationExtensions(target, deps)
if (extensions.length === 0) {
return trimmedTarget
? `No file extensions found for ${JSON.stringify(trimmedTarget)}.`
: 'No file extensions found in this workspace.'
}
const { candidates, marketplaceSetup, marketplaceSetupError } =
await listRecommendationCandidates(extensions, deps)
const scope = formatExtensionScope(extensions)
if (candidates.length === 0) {
const lines = [`No LSP plugin candidates found for ${scope}.`]
const setupMessage = renderMarketplaceSetupMessage(
marketplaceSetup,
marketplaceSetupError,
false,
)
if (setupMessage) lines.push(setupMessage)
return lines.join('\n')
}
const matchedScope = formatExtensionScope(
getCandidateMatchedExtensions(extensions, candidates),
)
const lines = [`LSP recommendations for ${matchedScope || scope}`]
const setupMessage = renderMarketplaceSetupMessage(
marketplaceSetup,
marketplaceSetupError,
true,
)
if (setupMessage) lines.push(setupMessage)
for (const candidate of candidates) {
lines.push(...renderCandidate(candidate))
}
return lines.join('\n')
}
type RecommendationCandidateLookup = {
candidates: LspPluginCandidate[]
marketplaceSetup?: OfficialMarketplaceCheckResult
marketplaceSetupError?: string
}
async function listRecommendationCandidates(
extensions: string[],
deps: LspCommandDeps,
): Promise<RecommendationCandidateLookup> {
const candidateOptions = {
extensions,
includeInstalled: true,
includeMissingBinaries: true,
}
let candidates = await deps.listLspPluginCandidates(candidateOptions)
if (candidates.length > 0) {
return { candidates }
}
let marketplaceSetup: OfficialMarketplaceCheckResult
try {
marketplaceSetup = await deps.checkAndInstallOfficialMarketplace()
} catch (error) {
return {
candidates,
marketplaceSetupError: errorMessage(error),
}
}
if (
marketplaceSetup.installed ||
marketplaceSetup.reason === 'already_installed'
) {
candidates = await deps.listLspPluginCandidates(candidateOptions)
}
return { candidates, marketplaceSetup }
}
function renderMarketplaceSetupMessage(
result: OfficialMarketplaceCheckResult | undefined,
error: string | undefined,
foundCandidates: boolean,
): string | undefined {
if (error) {
return `Anthropic marketplace setup failed: ${error}`
}
if (!result) {
return undefined
}
if (result.installed) {
return foundCandidates
? 'Anthropic marketplace installed for LSP recommendations.'
: 'Anthropic marketplace was installed, but it has no matching LSP plugin candidates for this scope.'
}
if (!result.skipped || result.reason === 'already_installed') {
return undefined
}
switch (result.reason) {
case 'policy_blocked':
return 'Anthropic marketplace is unavailable because policy blocks it.'
case 'git_unavailable':
return 'Anthropic marketplace is unavailable because git is not available.'
case 'gcs_unavailable':
return 'Anthropic marketplace download is temporarily unavailable; it will retry later.'
case 'already_attempted':
return 'Anthropic marketplace setup was already attempted and is waiting before retrying.'
case 'unknown':
default:
return 'Anthropic marketplace setup failed; run /doctor for details.'
}
}
function renderCandidate(candidate: LspPluginCandidate): string[] {
const status = [
candidate.installed ? 'installed' : 'not installed',
candidate.binaryInstalled ? 'binary: found' : 'binary: missing',
].join(', ')
const lines = [
`- ${candidate.pluginId} (${status})`,
` command: ${candidate.command}`,
` extensions: ${candidate.extensions.join(', ')}`,
]
if (candidate.description) {
lines.push(` description: ${candidate.description}`)
}
if (!candidate.binaryInstalled) {
lines.push(' next: install the language server binary')
for (const hintLine of binaryInstallHint(candidate.command).split('\n')) {
lines.push(hintLine ? ` ${hintLine}` : ' ')
}
}
if (!candidate.installed) {
lines.push(` next: /lsp install ${candidate.pluginId}`)
}
return lines
}
function binaryInstallHint(command: string): string {
const hint = BINARY_INSTALL_HINTS[command]
if (!hint) {
return [
`Binary missing: ${command}`,
'',
'Install:',
` Install ${command} and ensure it is on PATH`,
'',
'Verify:',
` ${command} --version`,
].join('\n')
}
const lines: string[] = [
`Binary missing: ${command}`,
'',
'Install:',
]
if (hint.install?.archManjaro) {
lines.push(` Arch/Manjaro: ${hint.install.archManjaro}`)
}
if (hint.install?.debianUbuntu) {
lines.push(` Debian/Ubuntu: ${hint.install.debianUbuntu}`)
}
if (hint.install?.macos) {
lines.push(` macOS: ${hint.install.macos}`)
}
if (hint.install?.windows) {
lines.push(` Windows: ${hint.install.windows}`)
}
if (hint.install?.generic) {
lines.push(` ${hint.install.generic}`)
}
if (hint.verify) {
lines.push('', 'Verify:', ` ${hint.verify}`)
}
if (hint.notes?.length) {
lines.push('', 'Notes:')
for (const note of hint.notes) {
lines.push(` - ${note}`)
}
}
return lines.join('\n')
}
async function installLspPlugin(
pluginId: string,
context: Pick<LocalJSXCommandContext, 'setAppState'>,
deps: LspCommandDeps,
): Promise<string> {
if (!pluginId) {
return 'Usage: /lsp install <plugin-id>'
}
let result: InstallPluginResult
try {
result = await deps.installPluginOp(pluginId, 'user')
} catch (error) {
return `Failed to install ${pluginId}: ${errorMessage(error)}`
}
if (!result.success) {
return `Failed to install ${pluginId}: ${result.message}`
}
const installedId = result.pluginId ?? pluginId
let refresh: RefreshActivePluginsResult
try {
refresh = await deps.refreshActivePlugins(context.setAppState)
} catch (error) {
return `Installed ${installedId}, but plugin refresh failed: ${errorMessage(
error,
)}. Run /reload-plugins after fixing the error.`
}
let message = `Installed ${installedId}. Activated ${refresh.lsp_count} ${plural(
refresh.lsp_count,
'plugin LSP server',
)}.`
if (refresh.error_count > 0) {
message += ` ${refresh.error_count} ${plural(
refresh.error_count,
'error',
)} during plugin refresh; run /doctor for details.`
}
return message
}
async function uninstallLspPlugin(
pluginId: string,
context: Pick<LocalJSXCommandContext, 'setAppState'>,
deps: LspCommandDeps,
): Promise<string> {
if (!pluginId) {
return 'Usage: /lsp uninstall <plugin-id>'
}
let result: UninstallPluginResult
try {
result = await deps.uninstallPluginOp(pluginId, 'user')
} catch (error) {
return `Failed to uninstall ${pluginId}: ${errorMessage(error)}`
}
if (!result.success) {
return `Failed to uninstall ${pluginId}: ${result.message}`
}
const uninstalledId = result.pluginId ?? pluginId
let refresh: RefreshActivePluginsResult
try {
refresh = await deps.refreshActivePlugins(context.setAppState)
} catch (error) {
return `Uninstalled ${uninstalledId}, but plugin refresh failed: ${errorMessage(error)}. Run /reload-plugins after fixing the error.`
}
await deps.waitForInitialization()
let message = `Uninstalled ${uninstalledId}. ${refresh.lsp_count} ${plural(
refresh.lsp_count,
'plugin LSP server',
)} still active.`
if (refresh.error_count > 0) {
message += ` ${refresh.error_count} ${plural(
refresh.error_count,
'error',
)} during plugin refresh.`
}
return message
}
async function restartLsp(deps: LspCommandDeps): Promise<string> {
const statusBefore = deps.getInitializationStatus()
if (statusBefore.status === 'not-started') {
return 'LSP has not been initialized. Nothing to restart.'
}
deps.reinitializeLspServerManager()
await deps.waitForInitialization()
const statusAfter = deps.getInitializationStatus()
if (statusAfter.status === 'success') {
const servers = await deps.getAllLspServers()
const count = Object.keys(servers.servers).length
return `LSP restarted. ${count} ${plural(count, 'server')} configured.`
}
if (statusAfter.status === 'failed') {
return `LSP restart failed: ${statusAfter.error.message}`
}
return 'LSP restart in progress.'
}
async function resolveRecommendationExtensions(
target: string,
deps: Pick<LspCommandDeps, 'discoverWorkspaceExtensions'>,
): Promise<string[]> {
const trimmed = target.trim()
if (!trimmed) {
return filterDiscoveredRecommendationExtensions(
await deps.discoverWorkspaceExtensions(),
)
}
if (isExtensionLiteral(trimmed)) {
return [trimmed.toLowerCase()]
}
const ext = extname(trimmed).toLowerCase()
if (ext) return [ext]
const pathExtensions = await deps.discoverWorkspaceExtensions(trimmed)
if (pathExtensions.length > 0) {
return filterDiscoveredRecommendationExtensions(pathExtensions)
}
if (/^[a-z0-9_+-]+$/i.test(trimmed)) {
return [`.${trimmed.toLowerCase()}`]
}
return []
}
function isExtensionLiteral(value: string): boolean {
return /^\.[A-Za-z0-9][A-Za-z0-9_+-]*$/.test(value)
}
function formatExtensionScope(extensions: string[]): string {
return [...extensions].sort().join(', ')
}
function getCandidateMatchedExtensions(
requestedExtensions: string[],
candidates: LspPluginCandidate[],
): string[] {
const requested = new Set(requestedExtensions)
const matched = new Set<string>()
for (const candidate of candidates) {
for (const ext of candidate.extensions) {
if (requested.has(ext)) {
matched.add(ext)
}
}
}
return Array.from(matched)
}
function filterDiscoveredRecommendationExtensions(
extensions: string[],
): string[] {
return extensions.filter(ext => !DISCOVERED_EXTENSION_IGNORE_SET.has(ext))
}
export async function discoverWorkspaceExtensions(
pathspec?: string,
cwd = getCwd(),
): Promise<string[]> {
const args = pathspec ? ['ls-files', '--', pathspec] : ['ls-files']
const result = await execFileNoThrowWithCwd(gitExe(), args, {
cwd,
timeout: 5_000,
maxBuffer: 2 * 1024 * 1024,
})
if (result.code === 0) {
const extensions = rankFileExtensions(result.stdout.split('\n'))
if (extensions.length > 0) return extensions
}
return discoverFilesystemExtensions(cwd, pathspec)
}
function rankFileExtensions(files: string[]): string[] {
const counts = new Map<string, number>()
for (const file of files) {
const ext = extname(file.trim()).toLowerCase()
if (!ext) continue
if (DISCOVERED_EXTENSION_IGNORE_SET.has(ext)) continue
counts.set(ext, (counts.get(ext) ?? 0) + 1)
}
return Array.from(counts.entries())
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, 12)
.map(([ext]) => ext)
}
async function discoverFilesystemExtensions(
cwd: string,
pathspec?: string,
): Promise<string[]> {
const root = resolve(cwd, pathspec || '.')
let rootStat
try {
rootStat = await stat(root)
} catch {
return []
}
if (rootStat.isFile()) {
return rankFileExtensions([root])
}
if (!rootStat.isDirectory()) {
return []
}
const files: string[] = []
await collectFiles(root, files, 0)
return rankFileExtensions(files)
}
async function collectFiles(
directory: string,
files: string[],
depth: number,
): Promise<void> {
if (files.length >= MAX_DISCOVERY_FILES || depth > MAX_DISCOVERY_DEPTH) {
return
}
let entries
try {
entries = await readdir(directory, { withFileTypes: true })
} catch {
return
}
entries.sort((a, b) => a.name.localeCompare(b.name))
for (const entry of entries) {
if (files.length >= MAX_DISCOVERY_FILES) {
return
}
const entryPath = join(directory, entry.name)
if (entry.isDirectory()) {
if (!DISCOVERY_DIRECTORY_IGNORE_SET.has(entry.name)) {
await collectFiles(entryPath, files, depth + 1)
}
continue
}
if (entry.isFile()) {
files.push(entryPath)
}
}
}
+275 -19
View File
@@ -1,7 +1,6 @@
import { afterEach, expect, mock, test } from 'bun:test'
import { getAdditionalModelOptionsCacheScope } from '../../services/api/providerConfig.js'
import { getAPIProvider } from '../../utils/model/providers.js'
const originalEnv = {
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
@@ -13,21 +12,49 @@ const originalEnv = {
CLAUDE_CODE_USE_FOUNDRY: process.env.CLAUDE_CODE_USE_FOUNDRY,
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
OPENAI_API_BASE: process.env.OPENAI_API_BASE,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY,
OPENAI_MODEL: process.env.OPENAI_MODEL,
ANTHROPIC_CUSTOM_HEADERS: process.env.ANTHROPIC_CUSTOM_HEADERS,
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC:
process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC,
}
async function importFreshModelModule(
suffix: string,
): Promise<typeof import('./model.js')> {
return import(`./model.js?${suffix}`) as Promise<
typeof import('./model.js')
>
}
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
afterEach(() => {
mock.restore()
process.env.CLAUDE_CODE_USE_OPENAI = originalEnv.CLAUDE_CODE_USE_OPENAI
process.env.CLAUDE_CODE_USE_GEMINI = originalEnv.CLAUDE_CODE_USE_GEMINI
process.env.CLAUDE_CODE_USE_GITHUB = originalEnv.CLAUDE_CODE_USE_GITHUB
process.env.CLAUDE_CODE_USE_MISTRAL = originalEnv.CLAUDE_CODE_USE_MISTRAL
process.env.CLAUDE_CODE_USE_BEDROCK = originalEnv.CLAUDE_CODE_USE_BEDROCK
process.env.CLAUDE_CODE_USE_VERTEX = originalEnv.CLAUDE_CODE_USE_VERTEX
process.env.CLAUDE_CODE_USE_FOUNDRY = originalEnv.CLAUDE_CODE_USE_FOUNDRY
process.env.OPENAI_BASE_URL = originalEnv.OPENAI_BASE_URL
process.env.OPENAI_API_BASE = originalEnv.OPENAI_API_BASE
process.env.OPENAI_MODEL = originalEnv.OPENAI_MODEL
restoreEnv('CLAUDE_CODE_USE_OPENAI', originalEnv.CLAUDE_CODE_USE_OPENAI)
restoreEnv('CLAUDE_CODE_USE_GEMINI', originalEnv.CLAUDE_CODE_USE_GEMINI)
restoreEnv('CLAUDE_CODE_USE_GITHUB', originalEnv.CLAUDE_CODE_USE_GITHUB)
restoreEnv('CLAUDE_CODE_USE_MISTRAL', originalEnv.CLAUDE_CODE_USE_MISTRAL)
restoreEnv('CLAUDE_CODE_USE_BEDROCK', originalEnv.CLAUDE_CODE_USE_BEDROCK)
restoreEnv('CLAUDE_CODE_USE_VERTEX', originalEnv.CLAUDE_CODE_USE_VERTEX)
restoreEnv('CLAUDE_CODE_USE_FOUNDRY', originalEnv.CLAUDE_CODE_USE_FOUNDRY)
restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL)
restoreEnv('OPENAI_API_BASE', originalEnv.OPENAI_API_BASE)
restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY)
restoreEnv('OPENROUTER_API_KEY', originalEnv.OPENROUTER_API_KEY)
restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL)
restoreEnv('ANTHROPIC_CUSTOM_HEADERS', originalEnv.ANTHROPIC_CUSTOM_HEADERS)
restoreEnv(
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
originalEnv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC,
)
})
test('opens the model picker without awaiting local model discovery refresh', async () => {
@@ -42,27 +69,256 @@ test('opens the model picker without awaiting local model discovery refresh', as
process.env.OPENAI_BASE_URL = 'http://127.0.0.1:8080/v1'
process.env.OPENAI_MODEL = 'qwen2.5-coder-7b-instruct'
let resolveDiscovery: (() => void) | undefined
const discoverOpenAICompatibleModelOptions = mock(
() =>
new Promise<void>(resolve => {
resolveDiscovery = resolve
}),
async () => {
await new Promise(resolve => setTimeout(resolve, 1_000))
return []
},
)
mock.module('../../utils/model/openaiModelDiscovery.js', () => ({
discoverOpenAICompatibleModelOptions,
}))
expect(getAdditionalModelOptionsCacheScope()).toBe('openai:http://127.0.0.1:8080/v1')
expect(
getAdditionalModelOptionsCacheScope()?.startsWith(
'openai:http://127.0.0.1:8080/v1:',
),
).toBe(true)
const { call } = await import('./model.js')
// Use a fresh module instance so per-test mocks stay local to this test.
const { call } = await importFreshModelModule('local-discovery')
const result = await Promise.race([
call(() => {}, {} as never, ''),
new Promise(resolve => setTimeout(() => resolve('timeout'), 50)),
])
resolveDiscovery?.()
expect(result).not.toBe('timeout')
})
test('opens the model picker without awaiting descriptor-backed route refresh', async () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://openrouter.ai/api/v1'
process.env.OPENAI_API_KEY = 'sk-openrouter'
delete process.env.OPENROUTER_API_KEY
process.env.OPENAI_MODEL = 'openai/gpt-5-mini'
delete process.env.CLAUDE_CODE_USE_GEMINI
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.CLAUDE_CODE_USE_MISTRAL
delete process.env.CLAUDE_CODE_USE_BEDROCK
delete process.env.CLAUDE_CODE_USE_VERTEX
delete process.env.CLAUDE_CODE_USE_FOUNDRY
delete process.env.OPENAI_API_BASE
mock.module('../../integrations/discoveryCache.js', () => ({
clearDiscoveryCache: mock(async () => {}),
getCachedModels: mock(async () => ({
models: [{ id: 'cached-qwen', apiName: 'qwen/qwen3-32b' }],
updatedAt: Date.now() - 86_400_000,
error: null,
})),
isCacheStale: mock(async () => true),
parseDurationString: (value: number | string) =>
typeof value === 'number' ? value : 86_400_000,
}))
mock.module('../../integrations/discoveryService.js', () => ({
getDiscoveryCacheKey: (
routeId: string,
options?: { apiKey?: string; baseUrl?: string; headers?: Record<string, string> },
) => `${routeId}|${options?.baseUrl ?? ''}|${options?.apiKey ?? ''}|${JSON.stringify(options?.headers ?? {})}`,
discoverModelsForRoute: mock(
() =>
new Promise(() => {
// Intentionally unresolved; refresh should happen after the picker opens.
}),
),
}))
mock.module('../../utils/providerProfiles.js', () => ({
getActiveOpenAIModelOptionsCache: () => [],
getActiveProviderProfile: () => null,
setActiveOpenAIModelOptionsCache: () => {},
}))
const { call } = await importFreshModelModule('descriptor-refresh-open')
const result = await Promise.race([
call(() => {}, {} as never, ''),
new Promise(resolve => setTimeout(() => resolve('timeout'), 50)),
])
expect(result).not.toBe('timeout')
})
test('shouldAutoRefreshRouteCatalog respects discovery refresh modes', async () => {
const { shouldAutoRefreshRouteCatalog } =
await importFreshModelModule('descriptor-refresh-modes')
expect(
shouldAutoRefreshRouteCatalog({
catalog: {
source: 'dynamic',
discovery: { kind: 'openai-compatible' },
discoveryRefreshMode: 'manual',
},
hasCachedModels: true,
staticEntryCount: 0,
stale: true,
}),
).toBe(false)
expect(
shouldAutoRefreshRouteCatalog({
catalog: {
source: 'dynamic',
discovery: { kind: 'openai-compatible' },
discoveryRefreshMode: 'on-open',
},
hasCachedModels: true,
staticEntryCount: 1,
stale: false,
}),
).toBe(true)
expect(
shouldAutoRefreshRouteCatalog({
catalog: {
source: 'dynamic',
discovery: { kind: 'openai-compatible' },
discoveryRefreshMode: 'startup',
},
hasCachedModels: true,
staticEntryCount: 0,
stale: true,
}),
).toBe(false)
})
test('/model refresh clears descriptor cache and reports updates', async () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://openrouter.ai/api/v1'
delete process.env.OPENAI_API_KEY
process.env.OPENROUTER_API_KEY = 'sk-openrouter-route'
process.env.OPENAI_MODEL = 'openai/gpt-5-mini'
delete process.env.CLAUDE_CODE_USE_GEMINI
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.CLAUDE_CODE_USE_MISTRAL
delete process.env.CLAUDE_CODE_USE_BEDROCK
delete process.env.CLAUDE_CODE_USE_VERTEX
delete process.env.CLAUDE_CODE_USE_FOUNDRY
delete process.env.OPENAI_API_BASE
const clearDiscoveryCache = mock(async () => {})
const getCachedModels = mock(async () => ({
models: [{ id: 'cached-gpt', apiName: 'openai/gpt-5-mini' }],
updatedAt: Date.now(),
error: null,
}))
const isCacheStale = mock(async () => false)
mock.module('../../integrations/discoveryCache.js', () => ({
clearDiscoveryCache,
getCachedModels,
isCacheStale,
parseDurationString: (value: number | string) =>
typeof value === 'number' ? value : 86_400_000,
}))
mock.module('../../integrations/discoveryService.js', () => ({
getDiscoveryCacheKey: (
routeId: string,
options?: { apiKey?: string; baseUrl?: string; headers?: Record<string, string> },
) => `${routeId}|${options?.baseUrl ?? ''}|${options?.apiKey ?? ''}|${JSON.stringify(options?.headers ?? {})}`,
discoverModelsForRoute: mock(async () => ({
routeId: 'openrouter',
models: [
{
id: 'openrouter-gpt-5-mini',
apiName: 'openai/gpt-5-mini',
default: true,
},
{ id: 'openrouter-qwen', apiName: 'qwen/qwen3-32b' },
],
stale: false,
error: null,
source: 'network',
})),
}))
mock.module('../../utils/providerProfiles.js', () => ({
getActiveOpenAIModelOptionsCache: () => [],
getActiveProviderProfile: () => null,
setActiveOpenAIModelOptionsCache: () => {},
}))
const messages: string[] = []
const { call } = await importFreshModelModule(
'descriptor-refresh-manual',
)
await call(
(message?: string) => {
if (message) {
messages.push(message)
}
},
{} as never,
'refresh',
)
const expectedCacheKey =
'openrouter|https://openrouter.ai/api/v1|sk-openrouter-route|{}'
expect(getCachedModels).toHaveBeenCalledWith(expectedCacheKey, 86_400_000, {
includeStale: true,
})
expect(isCacheStale).toHaveBeenCalledWith(expectedCacheKey, 86_400_000)
expect(clearDiscoveryCache).toHaveBeenCalledWith(expectedCacheKey)
expect(messages).toContain('Updated OpenRouter models.')
})
test('/model does not auto-refresh descriptor models when nonessential traffic is disabled', async () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://openrouter.ai/api/v1'
process.env.OPENAI_API_KEY = 'sk-openrouter'
delete process.env.OPENROUTER_API_KEY
process.env.OPENAI_MODEL = 'openai/gpt-5-mini'
process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1'
delete process.env.CLAUDE_CODE_USE_GEMINI
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.CLAUDE_CODE_USE_MISTRAL
delete process.env.CLAUDE_CODE_USE_BEDROCK
delete process.env.CLAUDE_CODE_USE_VERTEX
delete process.env.CLAUDE_CODE_USE_FOUNDRY
delete process.env.OPENAI_API_BASE
mock.module('../../integrations/discoveryCache.js', () => ({
clearDiscoveryCache: mock(async () => {}),
getCachedModels: mock(async () => null),
isCacheStale: mock(async () => true),
parseDurationString: (value: number | string) =>
typeof value === 'number' ? value : 86_400_000,
}))
const discoverModelsForRoute = mock(async () => {
throw new Error('unexpected descriptor discovery')
})
mock.module('../../integrations/discoveryService.js', () => ({
getDiscoveryCacheKey: (
routeId: string,
options?: { apiKey?: string; baseUrl?: string; headers?: Record<string, string> },
) => `${routeId}|${options?.baseUrl ?? ''}|${options?.apiKey ?? ''}|${JSON.stringify(options?.headers ?? {})}`,
discoverModelsForRoute,
}))
mock.module('../../utils/providerProfiles.js', () => ({
getActiveOpenAIModelOptionsCache: () => [],
getActiveProviderProfile: () => null,
setActiveOpenAIModelOptionsCache: () => {},
}))
const { call } = await importFreshModelModule('descriptor-privacy-open')
const result = await call(() => {}, {} as never, '')
expect(result).toBeTruthy()
expect(discoverModelsForRoute).not.toHaveBeenCalled()
})
File diff suppressed because it is too large Load Diff
+87 -14
View File
@@ -25,6 +25,14 @@ const ORIGINAL_CODEX_API_KEY = process.env.CODEX_API_KEY
const ORIGINAL_CHATGPT_ACCOUNT_ID = process.env.CHATGPT_ACCOUNT_ID
const ORIGINAL_CODEX_ACCOUNT_ID = process.env.CODEX_ACCOUNT_ID
async function importFreshProviderProfileModule(
suffix: string,
): Promise<typeof import('../../utils/providerProfile.js')> {
return import(`../../utils/providerProfile.js?${suffix}`) as Promise<
typeof import('../../utils/providerProfile.js')
>
}
function extractLastFrame(output: string): string {
let lastFrame: string | null = null
let cursor = 0
@@ -302,7 +310,7 @@ test('buildProfileSaveMessage maps provider fields without echoing secrets', ()
'D:/codings/Opensource/openclaude/.openclaude-profile.json',
)
expect(message).toContain('Saved OpenAI-compatible profile.')
expect(message).toContain('Saved OpenAI profile.')
expect(message).toContain('Model: gpt-4o')
expect(message).toContain('Endpoint: https://api.openai.com/v1')
expect(message).toContain('Credentials: configured')
@@ -324,6 +332,24 @@ test('buildProfileSaveMessage labels local openai-compatible profiles consistent
expect(message).toContain('Endpoint: http://127.0.0.1:8080/v1')
})
test('buildProfileSaveMessage labels descriptor-backed gateway profiles consistently', () => {
const message = buildProfileSaveMessage(
'openai',
{
OPENAI_API_KEY: 'sk-secret-12345678',
OPENAI_MODEL: 'openai/gpt-5-mini',
OPENAI_BASE_URL: 'https://openrouter.ai/api/v1',
},
'D:/codings/Opensource/openclaude/.openclaude-profile.json',
)
expect(message).toContain('Saved OpenRouter profile.')
expect(message).toContain('Model: openai/gpt-5-mini')
expect(message).toContain('Endpoint: https://openrouter.ai/api/v1')
expect(message).toContain('Credentials: configured')
expect(message).not.toContain('sk-secret-12345678')
})
test('buildProfileSaveMessage describes Gemini access token / ADC mode clearly', () => {
const message = buildProfileSaveMessage(
'gemini',
@@ -410,9 +436,8 @@ test('buildCodexProfileEnv derives oauth source from secure storage when no expl
}),
}))
// @ts-expect-error cache-busting query string for Bun module mocks
const { buildCodexProfileEnv } = await import(
'../../utils/providerProfile.js?secure-storage-codex-source'
const { buildCodexProfileEnv } = await importFreshProviderProfileModule(
'secure-storage-codex-source',
)
const env = buildCodexProfileEnv({
@@ -429,10 +454,10 @@ test('buildCodexProfileEnv derives oauth source from secure storage when no expl
})
test('explicitly declared env takes precedence over applySavedProfileToCurrentSession', async () => {
// @ts-expect-error cache-busting query string for Bun module mocks
const { applySavedProfileToCurrentSession } = await import(
'../../utils/providerProfile.js?apply-saved-profile-codex'
)
const { applySavedProfileToCurrentSession } =
await importFreshProviderProfileModule(
'apply-saved-profile-codex',
)
const processEnv: NodeJS.ProcessEnv = {
CLAUDE_CODE_USE_OPENAI: '1',
OPENAI_MODEL: 'gpt-4o',
@@ -468,11 +493,11 @@ test('explicitly declared env takes precedence over applySavedProfileToCurrentSe
expect(processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID).toBeUndefined()
})
test('explicitly declared env takes precedence over applySavedProfileToCurrentSession', async () => {
// @ts-expect-error cache-busting query string for Bun module mocks
const { applySavedProfileToCurrentSession } = await import(
'../../utils/providerProfile.js?apply-saved-profile-codex-oauth'
)
test('explicitly declared env takes precedence over applySavedProfileToCurrentSession for oauth codex profiles', async () => {
const { applySavedProfileToCurrentSession } =
await importFreshProviderProfileModule(
'apply-saved-profile-codex-oauth',
)
const processEnv: NodeJS.ProcessEnv = {
CLAUDE_CODE_USE_OPENAI: '1',
OPENAI_MODEL: 'gpt-4o',
@@ -533,6 +558,21 @@ test('buildCurrentProviderSummary labels generic local openai-compatible provide
expect(summary.endpointLabel).toBe('http://127.0.0.1:8080/v1')
})
test('buildCurrentProviderSummary recognizes descriptor-backed openai-compatible routes', () => {
const summary = buildCurrentProviderSummary({
processEnv: {
CLAUDE_CODE_USE_OPENAI: '1',
OPENAI_MODEL: 'openai/gpt-5-mini',
OPENAI_BASE_URL: 'https://openrouter.ai/api/v1',
},
persisted: null,
})
expect(summary.providerLabel).toBe('OpenRouter')
expect(summary.modelLabel).toBe('openai/gpt-5-mini')
expect(summary.endpointLabel).toBe('https://openrouter.ai/api/v1')
})
test('buildCurrentProviderSummary does not relabel local gpt-5.4 providers as Codex when custom base URL is set', () => {
const summary = buildCurrentProviderSummary({
processEnv: {
@@ -548,6 +588,39 @@ test('buildCurrentProviderSummary does not relabel local gpt-5.4 providers as Co
expect(summary.endpointLabel).toBe('http://127.0.0.1:8080/v1')
})
test('buildCurrentProviderSummary recognizes Gemini mode', () => {
const summary = buildCurrentProviderSummary({
processEnv: {
CLAUDE_CODE_USE_GEMINI: '1',
GEMINI_MODEL: 'gemini-2.5-pro',
GEMINI_BASE_URL:
'https://generativelanguage.googleapis.com/v1beta/openai',
},
persisted: null,
})
expect(summary.providerLabel).toBe('Google Gemini')
expect(summary.modelLabel).toBe('gemini-2.5-pro')
expect(summary.endpointLabel).toBe(
'https://generativelanguage.googleapis.com/v1beta/openai',
)
})
test('buildCurrentProviderSummary recognizes Mistral mode', () => {
const summary = buildCurrentProviderSummary({
processEnv: {
CLAUDE_CODE_USE_MISTRAL: '1',
MISTRAL_MODEL: 'mistral-medium-latest',
MISTRAL_BASE_URL: 'https://api.mistral.ai/v1',
},
persisted: null,
})
expect(summary.providerLabel).toBe('Mistral AI')
expect(summary.modelLabel).toBe('mistral-medium-latest')
expect(summary.endpointLabel).toBe('https://api.mistral.ai/v1')
})
test('buildCurrentProviderSummary recognizes GitHub Models mode', () => {
const summary = buildCurrentProviderSummary({
processEnv: {
@@ -574,7 +647,7 @@ test('getProviderWizardDefaults ignores poisoned current provider values', () =>
expect(defaults.openAIModel).toBe('gpt-4o')
expect(defaults.openAIBaseUrl).toBe('https://api.openai.com/v1')
expect(defaults.geminiModel).toBe('gemini-2.0-flash')
expect(defaults.geminiModel).toBe('gemini-3-flash-preview')
})
test('ProviderWizard hides Codex OAuth while running in bare mode', async () => {
+122 -41
View File
@@ -16,6 +16,12 @@ import { LoadingState } from '../../components/design-system/LoadingState.js'
import { useCodexOAuthFlow } from '../../components/useCodexOAuthFlow.js'
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
import { Box, Text } from '../../ink.js'
import { probeRouteReadiness } from '../../integrations/discoveryService.js'
import {
getProviderPresetUiMetadata,
getRouteLabel,
resolveRouteIdFromBaseUrl,
} from '../../integrations/index.js'
import {
type CodexOAuthTokens,
} from '../../services/api/codexOAuth.js'
@@ -69,7 +75,6 @@ import {
import {
getOllamaChatBaseUrl,
getLocalOpenAICompatibleProviderLabel,
probeOllamaGenerationReadiness,
type OllamaGenerationReadiness,
} from '../../utils/providerDiscovery.js'
@@ -219,6 +224,34 @@ function getSafeDisplayValue(
)
}
function getConfiguredOpenAICompatibleProviderLabel(
baseUrl: string,
options?: {
processEnv?: SecretSourceEnv
model?: string
},
): string {
const routeId = resolveRouteIdFromBaseUrl(baseUrl)
if (routeId) {
return getRouteLabel(routeId) ?? 'OpenAI-compatible'
}
const request = resolveProviderRequest({
model: options?.model,
baseUrl,
})
if (request.transport === 'codex_responses') {
return 'Codex'
}
if (isLocalProviderUrl(request.baseUrl)) {
return getLocalOpenAICompatibleProviderLabel(request.baseUrl)
}
return 'OpenAI-compatible'
}
export function getProviderWizardDefaults(
processEnv: NodeJS.ProcessEnv = process.env,
): ProviderWizardDefaults {
@@ -233,10 +266,10 @@ export function getProviderWizardDefaults(
sanitizeProviderConfigValue(processEnv.GEMINI_MODEL, secretSource) ||
DEFAULT_GEMINI_MODEL
const safeMistralModel =
sanitizeProviderConfigValue(processEnv.MISTRAL_MODEL, processEnv) ||
sanitizeProviderConfigValue(processEnv.MISTRAL_MODEL, secretSource) ||
DEFAULT_MISTRAL_MODEL
const safeMistralBaseUrl =
sanitizeProviderConfigValue(processEnv.MISTRAL_BASE_URL, processEnv) ||
sanitizeProviderConfigValue(processEnv.MISTRAL_BASE_URL, secretSource) ||
DEFAULT_MISTRAL_BASE_URL
return {
@@ -258,8 +291,9 @@ export function buildCurrentProviderSummary(options?: {
const savedProfileLabel = persisted?.profile ?? 'none'
if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_GEMINI)) {
const geminiMetadata = getProviderPresetUiMetadata('gemini', processEnv)
return {
providerLabel: 'Google Gemini',
providerLabel: geminiMetadata.label,
modelLabel: getSafeDisplayValue(
processEnv.GEMINI_MODEL ?? DEFAULT_GEMINI_MODEL,
secretSource,
@@ -273,8 +307,9 @@ export function buildCurrentProviderSummary(options?: {
}
if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_MISTRAL)) {
const mistralMetadata = getProviderPresetUiMetadata('mistral', processEnv)
return {
providerLabel: 'Mistral',
providerLabel: mistralMetadata.label,
modelLabel: getSafeDisplayValue(
processEnv.MISTRAL_MODEL ?? DEFAULT_MISTRAL_MODEL,
processEnv
@@ -310,15 +345,14 @@ export function buildCurrentProviderSummary(options?: {
baseUrl: processEnv.OPENAI_BASE_URL,
})
let providerLabel = 'OpenAI-compatible'
if (request.transport === 'codex_responses') {
providerLabel = 'Codex'
} else if (isLocalProviderUrl(request.baseUrl)) {
providerLabel = getLocalOpenAICompatibleProviderLabel(request.baseUrl)
}
return {
providerLabel,
providerLabel: getConfiguredOpenAICompatibleProviderLabel(
request.baseUrl,
{
model: processEnv.OPENAI_MODEL,
processEnv: secretSource,
},
),
modelLabel: getSafeDisplayValue(request.requestedModel, secretSource),
endpointLabel: getSafeDisplayValue(request.baseUrl, secretSource),
savedProfileLabel,
@@ -347,8 +381,10 @@ function buildSavedProfileSummary(
): SavedProfileSummary {
switch (profile) {
case 'gemini':
{
const geminiMetadata = getProviderPresetUiMetadata('gemini')
return {
providerLabel: 'Google Gemini',
providerLabel: geminiMetadata.label,
modelLabel: getSafeDisplayValue(
env.GEMINI_MODEL ?? DEFAULT_GEMINI_MODEL,
process.env,
@@ -368,9 +404,12 @@ function buildSavedProfileSummary(
? 'configured'
: undefined,
}
}
case 'mistral':
{
const mistralMetadata = getProviderPresetUiMetadata('mistral')
return {
providerLabel: 'Mistral',
providerLabel: mistralMetadata.label,
modelLabel: getSafeDisplayValue(
env.MISTRAL_MODEL ?? DEFAULT_MISTRAL_MODEL,
process.env,
@@ -386,6 +425,7 @@ function buildSavedProfileSummary(
? 'configured'
: undefined,
}
}
case 'codex':
return {
providerLabel: 'Codex',
@@ -423,9 +463,9 @@ function buildSavedProfileSummary(
const baseUrl = env.OPENAI_BASE_URL ?? DEFAULT_OPENAI_BASE_URL
return {
providerLabel: isLocalProviderUrl(baseUrl)
? getLocalOpenAICompatibleProviderLabel(baseUrl)
: 'OpenAI-compatible',
providerLabel: getConfiguredOpenAICompatibleProviderLabel(baseUrl, {
model: env.OPENAI_MODEL,
}),
modelLabel: getSafeDisplayValue(
env.OPENAI_MODEL ?? 'gpt-4o',
process.env,
@@ -626,6 +666,10 @@ function ProviderChooser({
}): React.ReactNode {
const summary = buildCurrentProviderSummary()
const canUseCodexOAuth = !isBareMode()
const ollamaMetadata = getProviderPresetUiMetadata('ollama')
const openAIMetadata = getProviderPresetUiMetadata('openai')
const geminiMetadata = getProviderPresetUiMetadata('gemini')
const mistralMetadata = getProviderPresetUiMetadata('mistral')
const helperText = canUseCodexOAuth
? 'Save a provider profile without editing environment variables first. Codex profiles backed by env, auth.json, or OpenClaude secure storage can switch this session immediately when validation succeeds.'
: 'Save a provider profile without editing environment variables first. Codex profiles backed by env or auth.json can switch this session immediately.'
@@ -637,25 +681,24 @@ function ProviderChooser({
'Prefer local Ollama when available, otherwise guide you into OpenAI-compatible setup',
},
{
label: 'Ollama',
label: ollamaMetadata.label,
value: 'ollama',
description: 'Use a local Ollama model with no API key',
description: ollamaMetadata.description,
},
{
label: 'OpenAI-compatible',
label: openAIMetadata.name,
value: 'openai',
description:
'GPT-4o, DeepSeek, OpenRouter, Groq, LM Studio, and similar APIs',
description: 'OpenAI and similar OpenAI-compatible APIs',
},
{
label: 'Gemini',
label: geminiMetadata.label,
value: 'gemini',
description: 'Use Google Gemini with API key, access token, or local ADC',
description: 'Use Gemini with API key, access token, or local ADC',
},
{
label: 'Mistral',
label: mistralMetadata.label,
value: 'mistral',
description: 'Use Mistral with API key'
description: mistralMetadata.description,
},
{
label: 'Codex',
@@ -789,7 +832,17 @@ function AutoRecommendationStep({
void (async () => {
const defaultModel = getGoalDefaultOpenAIModel(goal)
try {
const readiness = await probeOllamaGenerationReadiness()
const readiness = await probeRouteReadiness('ollama')
if (!readiness) {
if (!cancelled) {
setStatus({
state: 'error',
message: 'Ollama readiness probe is not configured for this route.',
})
}
return
}
if (readiness.state !== 'ready') {
if (!cancelled) {
setStatus({
@@ -952,7 +1005,17 @@ function OllamaModelStep({
let cancelled = false
void (async () => {
const readiness = await probeOllamaGenerationReadiness()
const readiness = await probeRouteReadiness('ollama')
if (!readiness) {
if (!cancelled) {
setStatus({
state: 'unavailable',
message: 'Ollama readiness probe is not configured for this route.',
})
}
return
}
if (readiness.state !== 'ready') {
if (!cancelled) {
setStatus({
@@ -1305,15 +1368,17 @@ export function ProviderWizard({
)
case 'openai-key':
{
const openAIMetadata = getProviderPresetUiMetadata('openai')
return (
<TextEntryDialog
resetStateKey={step.name}
title="OpenAI-compatible setup"
title={`${openAIMetadata.name} setup`}
subtitle="Step 1 of 3"
description={
process.env.OPENAI_API_KEY
? 'Enter an API key, or leave this blank to reuse the current OPENAI_API_KEY from this session.'
: 'Enter the API key for your OpenAI-compatible provider.'
? `Enter an API key, or leave this blank to reuse the current ${openAIMetadata.credentialEnvVars[0] ?? 'OPENAI_API_KEY'} from this session.`
: `Enter the API key for ${openAIMetadata.name}.`
}
initialValue=""
placeholder="sk-..."
@@ -1336,14 +1401,17 @@ export function ProviderWizard({
onCancel={() => setStep({ name: 'choose' })}
/>
)
}
case 'openai-base':
{
const openAIMetadata = getProviderPresetUiMetadata('openai')
return (
<TextEntryDialog
resetStateKey={step.name}
title="OpenAI-compatible setup"
title={`${openAIMetadata.name} setup`}
subtitle="Step 2 of 3"
description={`Optionally enter a base URL. Leave blank for ${DEFAULT_OPENAI_BASE_URL}.`}
description={`Optionally enter a base URL. Leave blank for ${openAIMetadata.baseUrl || DEFAULT_OPENAI_BASE_URL}.`}
initialValue={
defaults.openAIBaseUrl === DEFAULT_OPENAI_BASE_URL
? ''
@@ -1367,12 +1435,15 @@ export function ProviderWizard({
}
/>
)
}
case 'openai-model':
{
const openAIMetadata = getProviderPresetUiMetadata('openai')
return (
<TextEntryDialog
resetStateKey={step.name}
title="OpenAI-compatible setup"
title={`${openAIMetadata.name} setup`}
subtitle="Step 3 of 3"
description={`Enter a model name. Leave blank for ${step.defaultModel}.`}
initialValue={defaults.openAIModel ?? step.defaultModel}
@@ -1399,17 +1470,20 @@ export function ProviderWizard({
}
/>
)
}
case 'mistral-key':
{
const mistralMetadata = getProviderPresetUiMetadata('mistral')
return (
<TextEntryDialog
resetStateKey={step.name}
title="Mistral setup"
title={`${mistralMetadata.label} setup`}
subtitle="Step 1 of 3"
description={
process.env.MISTRAL_API_KEY
? 'Enter an API key, or leave this blank to reuse the current MISTRAL_API_KEY from this session.'
: 'Enter the API key for your Mistral provider.'
? `Enter an API key, or leave this blank to reuse the current ${mistralMetadata.credentialEnvVars[0] ?? 'MISTRAL_API_KEY'} from this session.`
: `Enter the API key for ${mistralMetadata.label}.`
}
initialValue=""
placeholder="..."
@@ -1432,14 +1506,17 @@ export function ProviderWizard({
onCancel={() => setStep({ name: 'choose' })}
/>
)
}
case 'mistral-base':
{
const mistralMetadata = getProviderPresetUiMetadata('mistral')
return (
<TextEntryDialog
resetStateKey={step.name}
title="Mistral setup"
title={`${mistralMetadata.label} setup`}
subtitle="Step 2 of 3"
description={`Optionally enter a base URL. Leave blank for ${DEFAULT_MISTRAL_BASE_URL}.`}
description={`Optionally enter a base URL. Leave blank for ${mistralMetadata.baseUrl || DEFAULT_MISTRAL_BASE_URL}.`}
initialValue={
defaults.mistralBaseUrl === DEFAULT_MISTRAL_BASE_URL
? ''
@@ -1463,12 +1540,15 @@ export function ProviderWizard({
}
/>
)
}
case 'mistral-model':
{
const mistralMetadata = getProviderPresetUiMetadata('mistral')
return (
<TextEntryDialog
resetStateKey={step.name}
title="Mistral setup"
title={`${mistralMetadata.label} setup`}
subtitle="Step 3 of 3"
description={`Enter a model name. Leave blank for ${step.defaultModel}.`}
initialValue={defaults.mistralModel ?? step.defaultModel}
@@ -1494,6 +1574,7 @@ export function ProviderWizard({
}
/>
)
}
case 'gemini-auth-method': {
const hasShellGeminiKey = Boolean(
+125
View File
@@ -0,0 +1,125 @@
import { describe, expect, test } from 'bun:test'
import {
getUsageDescriptor,
resolveActiveUsageId,
} from './index.js'
import type {
GatewayDescriptor,
VendorDescriptor,
} from '../../integrations/descriptors.js'
function createRegistry(options?: {
vendors?: VendorDescriptor[]
gateways?: GatewayDescriptor[]
}) {
const vendors = new Map(
(options?.vendors ?? []).map(vendor => [vendor.id, vendor] as const),
)
const gateways = new Map(
(options?.gateways ?? []).map(gateway => [gateway.id, gateway] as const),
)
return {
getVendor(id: string) {
return vendors.get(id)
},
getGateway(id: string) {
return gateways.get(id)
},
}
}
describe('getUsageDescriptor', () => {
test('resolveActiveUsageId preserves first-party and codex compatibility ids', () => {
expect(
resolveActiveUsageId(
{} as NodeJS.ProcessEnv,
{ providerCategory: 'firstParty' },
),
).toBe('firstParty')
expect(
resolveActiveUsageId(
{} as NodeJS.ProcessEnv,
{ providerCategory: 'codex' },
),
).toBe('codex')
})
test('resolveActiveUsageId keeps the descriptor route for openai-compatible gateways', () => {
expect(
resolveActiveUsageId(
{
CLAUDE_CODE_USE_OPENAI: '1',
OPENAI_BASE_URL: 'https://openrouter.ai/api/v1',
} as NodeJS.ProcessEnv,
{ providerCategory: 'openai' },
),
).toBe('openrouter')
})
test('resolves first-party usage support through the Anthropic vendor descriptor', () => {
const descriptor = getUsageDescriptor('firstParty')
expect(descriptor.supported).toBe(true)
expect(descriptor.activeLabel).toBe('Anthropic')
expect(descriptor.resolvedId).toBe('anthropic')
expect(descriptor.resolvedLabel).toBe('Anthropic')
})
test('returns neutral unsupported metadata for routes without usage support', () => {
const descriptor = getUsageDescriptor('openrouter')
expect(descriptor.supported).toBe(false)
expect(descriptor.activeLabel).toBe('OpenRouter')
expect(descriptor.resolvedId).toBe('openrouter')
expect(descriptor.resolvedLabel).toBe('OpenRouter')
})
test('follows gateway usage delegation to the delegated vendor', () => {
const anthropicVendor = {
id: 'anthropic',
label: 'Anthropic',
classification: 'anthropic',
defaultBaseUrl: 'https://api.anthropic.com',
defaultModel: 'claude-sonnet-4-6',
setup: {
requiresAuth: true,
authMode: 'api-key',
},
transportConfig: {
kind: 'anthropic-native',
},
usage: { supported: true },
} satisfies VendorDescriptor
const gateway = {
id: 'acme-gateway',
label: 'Acme Gateway',
setup: {
requiresAuth: true,
authMode: 'api-key',
},
transportConfig: {
kind: 'openai-compatible',
},
usage: {
supported: true,
delegateToVendorId: 'anthropic',
},
} satisfies GatewayDescriptor
const descriptor = getUsageDescriptor(
'acme-gateway',
createRegistry({
vendors: [anthropicVendor],
gateways: [gateway],
}),
)
expect(descriptor.supported).toBe(true)
expect(descriptor.activeLabel).toBe('Acme Gateway')
expect(descriptor.resolvedId).toBe('anthropic')
expect(descriptor.resolvedLabel).toBe('Anthropic')
})
})
+169
View File
@@ -1,4 +1,173 @@
import '../../integrations/index.js'
import {
ensureIntegrationsLoaded,
getGateway,
getVendor,
} from '../../integrations/index.js'
import { resolveActiveRouteIdFromEnv } from '../../integrations/routeMetadata.js'
import type {
GatewayDescriptor,
UsageMetadata,
VendorDescriptor,
} from '../../integrations/descriptors.js'
import type { Command } from '../../commands.js'
import type { APIProvider } from '../../utils/model/providers.js'
type UsageDescriptorTarget =
| { kind: 'vendor'; descriptor: VendorDescriptor }
| { kind: 'gateway'; descriptor: GatewayDescriptor }
type UsageDescriptorRegistry = {
getGateway: typeof getGateway
getVendor: typeof getVendor
}
export type ResolvedUsageDescriptor = {
activeId: APIProvider | string
activeKind?: UsageDescriptorTarget['kind']
activeLabel: string
resolvedId?: string
resolvedKind?: UsageDescriptorTarget['kind']
resolvedLabel: string
usage: UsageMetadata
supported: boolean
}
const DEFAULT_UNSUPPORTED_USAGE: UsageMetadata = {
supported: false,
}
const RUNTIME_USAGE_LABELS: Record<string, string> = {
firstParty: 'Anthropic',
foundry: 'Microsoft Foundry',
codex: 'Codex',
}
export function resolveActiveUsageId(
processEnv: NodeJS.ProcessEnv = process.env,
options?: {
activeProfileProvider?: string
providerCategory?: APIProvider | string
},
): APIProvider | string {
const providerCategory = options?.providerCategory
if (
providerCategory === 'firstParty' ||
providerCategory === 'foundry' ||
providerCategory === 'codex'
) {
return providerCategory
}
const routeId = resolveActiveRouteIdFromEnv(processEnv, {
activeProfileProvider: options?.activeProfileProvider,
})
return routeId ?? providerCategory ?? 'firstParty'
}
function getUsageTarget(
activeId: string,
registry: UsageDescriptorRegistry,
): UsageDescriptorTarget | undefined {
if (activeId === 'firstParty') {
const vendor = registry.getVendor('anthropic')
if (vendor) {
return { kind: 'vendor', descriptor: vendor }
}
}
const gateway = registry.getGateway(activeId)
if (gateway) {
return { kind: 'gateway', descriptor: gateway }
}
const vendor = registry.getVendor(activeId)
if (vendor) {
return { kind: 'vendor', descriptor: vendor }
}
return undefined
}
function getTargetLabel(
activeId: string,
target: UsageDescriptorTarget | undefined,
): string {
return target?.descriptor.label ?? RUNTIME_USAGE_LABELS[activeId] ?? 'this provider'
}
export function getUsageDescriptor(
activeId: APIProvider | string,
registry: UsageDescriptorRegistry = {
getGateway,
getVendor,
},
): ResolvedUsageDescriptor {
if (registry.getGateway === getGateway && registry.getVendor === getVendor) {
ensureIntegrationsLoaded()
}
const initialTarget = getUsageTarget(activeId, registry)
const activeLabel = getTargetLabel(activeId, initialTarget)
let resolvedTarget = initialTarget
let usage = initialTarget?.descriptor.usage ?? DEFAULT_UNSUPPORTED_USAGE
const seen = new Set<string>()
while (resolvedTarget) {
const currentUsage = resolvedTarget.descriptor.usage ?? DEFAULT_UNSUPPORTED_USAGE
const visitKey = `${resolvedTarget.kind}:${resolvedTarget.descriptor.id}`
if (seen.has(visitKey)) {
usage = DEFAULT_UNSUPPORTED_USAGE
break
}
seen.add(visitKey)
if (currentUsage.delegateToVendorId) {
const delegatedVendor = registry.getVendor(currentUsage.delegateToVendorId)
if (!delegatedVendor) {
usage = DEFAULT_UNSUPPORTED_USAGE
break
}
resolvedTarget = {
kind: 'vendor',
descriptor: delegatedVendor,
}
usage = delegatedVendor.usage ?? DEFAULT_UNSUPPORTED_USAGE
continue
}
if (currentUsage.delegateToGatewayId) {
const delegatedGateway = registry.getGateway(currentUsage.delegateToGatewayId)
if (!delegatedGateway) {
usage = DEFAULT_UNSUPPORTED_USAGE
break
}
resolvedTarget = {
kind: 'gateway',
descriptor: delegatedGateway,
}
usage = delegatedGateway.usage ?? DEFAULT_UNSUPPORTED_USAGE
continue
}
usage = currentUsage
break
}
return {
activeId,
activeKind: initialTarget?.kind,
activeLabel,
resolvedId: resolvedTarget?.descriptor.id,
resolvedKind: resolvedTarget?.kind,
resolvedLabel: getTargetLabel(activeId, resolvedTarget),
usage,
supported: usage.supported,
}
}
export default {
type: 'local-jsx',
+3 -2
View File
@@ -112,8 +112,9 @@ test('third-party provider branch opens the first-run provider manager', async (
)
expect(output).toContain('Set up provider')
// Use alphabetically-early sentinels so they remain visible in the
// 13-row test frame after the provider list was sorted A→Z.
// Anthropic is pinned first and the remaining presets stay near
// description order, so these sentinel labels should remain visible
// in the 13-row test frame.
expect(output).toContain('Anthropic')
expect(output).toContain('Azure OpenAI')
expect(output).toContain('DeepSeek')
@@ -0,0 +1,20 @@
import { expect, test } from 'bun:test'
import { getCostThresholdProviderLabelForProvider } from './CostThresholdProviderLabel.js'
test('getCostThresholdProviderLabel uses the active provider category for first-party sessions', () => {
expect(getCostThresholdProviderLabelForProvider('firstParty')).toBe(
'Anthropic API',
)
})
test('getCostThresholdProviderLabel keeps descriptor-era labels for mapped providers', () => {
expect(getCostThresholdProviderLabelForProvider('gemini')).toBe('Gemini API')
expect(getCostThresholdProviderLabelForProvider('bedrock')).toBe(
'AWS Bedrock',
)
})
test('getCostThresholdProviderLabel falls back safely for unmapped provider categories', () => {
expect(getCostThresholdProviderLabelForProvider('codex')).toBe('API')
})
+7 -20
View File
@@ -2,34 +2,21 @@ import React from 'react'
import { Box, Link, Text } from '../ink.js'
import { Select } from './CustomSelect/index.js'
import { Dialog } from './design-system/Dialog.js'
import { getAPIProvider } from '../utils/model/providers.js'
import { getAPIProvider, type APIProvider } from '../utils/model/providers.js'
import { getCostThresholdProviderLabelForProvider } from './CostThresholdProviderLabel.js'
type Props = {
onDone: () => void
}
function getProviderLabel(): string {
const provider = getAPIProvider()
switch (provider) {
case 'firstParty':
return 'Anthropic API'
case 'bedrock':
return 'AWS Bedrock'
case 'vertex':
return 'Google Vertex'
case 'foundry':
return 'Azure Foundry'
case 'openai':
return 'OpenAI-compatible API'
case 'gemini':
return 'Gemini API'
default:
return 'API'
}
export function getCostThresholdProviderLabel(
provider: APIProvider = getAPIProvider(),
): string {
return getCostThresholdProviderLabelForProvider(provider)
}
export function CostThresholdDialog({ onDone }: Props): React.ReactNode {
const providerLabel = getProviderLabel()
const providerLabel = getCostThresholdProviderLabel()
return (
<Dialog
title={`You've spent $5 on the ${providerLabel} this session.`}
@@ -0,0 +1,16 @@
import type { APIProvider } from '../utils/model/providers.js'
const COST_THRESHOLD_PROVIDER_LABELS: Partial<Record<APIProvider, string>> = {
firstParty: 'Anthropic API',
bedrock: 'AWS Bedrock',
vertex: 'Google Vertex',
foundry: 'Azure Foundry',
openai: 'OpenAI-compatible API',
gemini: 'Gemini API',
}
export function getCostThresholdProviderLabelForProvider(
provider: APIProvider,
): string {
return COST_THRESHOLD_PROVIDER_LABELS[provider] ?? 'API'
}
+42 -28
View File
@@ -10,7 +10,7 @@ import { useKeybindings } from '../keybindings/useKeybinding.js';
import { useAppState, useSetAppState } from '../state/AppState.js';
import { convertEffortValueToLevel, type EffortLevel, getDefaultEffortForModel, modelSupportsEffort, modelSupportsMaxEffort, resolvePickerEffortPersistence, toPersistableEffort } from '../utils/effort.js';
import { getDefaultMainLoopModel, type ModelSetting, modelDisplayString, parseUserSpecifiedModel } from '../utils/model/model.js';
import { getModelOptions } from '../utils/model/modelOptions.js';
import { getModelOptions, type ModelOption } from '../utils/model/modelOptions.js';
import { getSettingsForSource, updateSettingsForSource } from '../utils/settings/settings.js';
import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js';
import { Select } from './CustomSelect/index.js';
@@ -18,6 +18,10 @@ import { Byline } from './design-system/Byline.js';
import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js';
import { Pane } from './design-system/Pane.js';
import { effortLevelToSymbol } from './EffortIndicator.js';
export type ModelPickerDiscoveryState = {
message: string;
tone?: 'info' | 'success' | 'warning' | 'error';
};
export type Props = {
initial: string | null;
sessionModel?: ModelSetting;
@@ -34,10 +38,26 @@ export type Props = {
* install.ts) and should not leak to the user's global ~/.claude/settings.
*/
skipSettingsWrite?: boolean;
optionsOverride?: ModelOption[];
discoveryState?: ModelPickerDiscoveryState;
onRefresh?: () => void;
};
const NO_PREFERENCE = '__NO_PREFERENCE__';
function mapDiscoveryToneToColor(tone: ModelPickerDiscoveryState['tone']): 'error' | 'warning' | 'success' | 'subtle' {
switch (tone) {
case 'error':
return 'error';
case 'warning':
return 'warning';
case 'success':
return 'success';
case 'info':
default:
return 'subtle';
}
}
export function ModelPicker(t0) {
const $ = _c(82);
const $ = _c(83);
const {
initial,
sessionModel,
@@ -46,7 +66,10 @@ export function ModelPicker(t0) {
isStandaloneCommand,
showFastModeNotice,
headerText,
skipSettingsWrite
skipSettingsWrite,
optionsOverride,
discoveryState,
onRefresh
} = t0;
const setAppState = useSetAppState();
const exitState = useExitOnCtrlCDWithKeybindings();
@@ -73,7 +96,7 @@ export function ModelPicker(t0) {
} else {
t3 = $[3];
}
const modelOptions = t3;
const modelOptions = optionsOverride ?? t3;
let t4;
bb0: {
if (initial !== null && !modelOptions.some(opt => opt.value === initial)) {
@@ -200,17 +223,13 @@ export function ModelPicker(t0) {
t11 = $[31];
}
const handleCycleEffort = t11;
let t12;
if ($[32] !== handleCycleEffort) {
t12 = {
"modelPicker:decreaseEffort": () => handleCycleEffort("left"),
"modelPicker:increaseEffort": () => handleCycleEffort("right")
};
$[32] = handleCycleEffort;
$[33] = t12;
} else {
t12 = $[33];
}
const t12 = {
"modelPicker:decreaseEffort": () => handleCycleEffort("left"),
"modelPicker:increaseEffort": () => handleCycleEffort("right"),
...(onRefresh ? {
"modelPicker:refresh": () => onRefresh()
} : {})
};
let t13;
if ($[34] === Symbol.for("react.memo_cache_sentinel")) {
t13 = {
@@ -282,15 +301,9 @@ export function ModelPicker(t0) {
} else {
t18 = $[45];
}
let t19;
if ($[46] !== t17 || $[47] !== t18) {
t19 = <Box marginBottom={1} flexDirection="column">{t15}{t17}{t18}</Box>;
$[46] = t17;
$[47] = t18;
$[48] = t19;
} else {
t19 = $[48];
}
const refreshHint = onRefresh ? <ConfigurableShortcutHint action="modelPicker:refresh" context="ModelPicker" fallback="r" description="refresh models" /> : null;
const discoveryLine = discoveryState ? <Text color={mapDiscoveryToneToColor(discoveryState.tone)}>{discoveryState.message}{refreshHint ? <Text color="subtle"> {" "}· {refreshHint}</Text> : null}</Text> : refreshHint ? <Text dimColor={true}>{refreshHint}</Text> : null;
const t19 = <Box marginBottom={1} flexDirection="column">{t15}{t17}{t18}{discoveryLine}</Box>;
const t20 = onCancel ?? _temp4;
let t21;
if ($[49] !== handleFocus || $[50] !== handleSelect || $[51] !== initialFocusValue || $[52] !== initialValue || $[53] !== selectOptions || $[54] !== t20 || $[55] !== visibleCount) {
@@ -354,13 +367,14 @@ export function ModelPicker(t0) {
t26 = $[73];
}
let t27;
if ($[74] !== exitState || $[75] !== isStandaloneCommand) {
t27 = isStandaloneCommand && <Text dimColor={true} italic={true}>{exitState.pending ? <>Press {exitState.keyName} again to exit</> : <Byline><KeyboardShortcutHint shortcut="Enter" action="confirm" /><ConfigurableShortcutHint action="select:cancel" context="Select" fallback="Esc" description="exit" /></Byline>}</Text>;
if ($[74] !== exitState || $[75] !== isStandaloneCommand || $[76] !== refreshHint) {
t27 = isStandaloneCommand && <Text dimColor={true} italic={true}>{exitState.pending ? <>Press {exitState.keyName} again to exit</> : <Byline><KeyboardShortcutHint shortcut="Enter" action="confirm" />{refreshHint}<ConfigurableShortcutHint action="select:cancel" context="Select" fallback="Esc" description="exit" /></Byline>}</Text>;
$[74] = exitState;
$[75] = isStandaloneCommand;
$[76] = t27;
$[76] = refreshHint;
$[82] = t27;
} else {
t27 = $[76];
t27 = $[82];
}
let t28;
if ($[77] !== t26 || $[78] !== t27) {
+441 -43
View File
@@ -97,31 +97,31 @@ async function waitForCondition(
throw new Error('Timed out waiting for ProviderManager test condition')
}
// Provider list is sorted alphabetically by label in the preset picker, so
// reaching a given provider takes more keypresses than it used to. Keep the
// Provider list is sorted from generated preset metadata by description, with
// Codex OAuth injected into slot 7 and Custom always pinned last. Keep the
// target-by-label indirection here so these tests survive future list edits
// without further churn.
// without hardcoding raw key counts.
//
// Order matches ProviderManager.renderPresetSelection() when
// canUseCodexOAuth === true (default in mocked tests).
const PRESET_ORDER = [
'Alibaba Coding Plan',
'Alibaba Coding Plan (China)',
'Anthropic',
'Atomic Chat',
'Alibaba Coding Plan (China)',
'Alibaba Coding Plan',
'Azure OpenAI',
'Bankr',
'Codex OAuth',
'DeepSeek',
'Codex OAuth',
'Google Gemini',
'Groq',
'LM Studio',
'Atomic Chat',
'Ollama',
'MiniMax',
'Mistral',
'Mistral AI',
'Moonshot AI - API',
'Moonshot AI - Kimi Code',
'NVIDIA NIM',
'Ollama',
'OpenAI',
'OpenRouter',
'Together AI',
@@ -165,22 +165,55 @@ function mockProviderProfilesModule(options?: {
applyActiveProviderProfileFromConfig: () => {},
deleteProviderProfile: () => ({ removed: false, activeProfileId: null }),
getActiveProviderProfile: options?.getActiveProviderProfile ?? (() => null),
getProviderPresetDefaults: (preset: string) =>
preset === 'ollama'
? {
provider: 'openai',
name: 'Ollama',
baseUrl: 'http://localhost:11434/v1',
model: 'llama3.1:8b',
apiKey: '',
}
: {
provider: 'openai',
name: 'Mock provider',
baseUrl: 'http://localhost:11434/v1',
model: 'mock-model',
apiKey: '',
},
getProviderPresetDefaults: (preset: string) => {
if (preset === 'ollama') {
return {
provider: 'openai',
name: 'Ollama',
baseUrl: 'http://localhost:11434/v1',
model: 'llama3.1:8b',
apiKey: '',
}
}
if (preset === 'atomic-chat') {
return {
provider: 'openai',
name: 'Atomic Chat',
baseUrl: 'http://127.0.0.1:1337/v1',
model: 'Qwen3_5-4B_Q4_K_M',
apiKey: '',
}
}
if (preset === 'custom') {
return {
provider: 'custom',
name: 'Custom OpenAI-compatible',
baseUrl: 'http://localhost:11434/v1',
model: 'custom-model',
apiKey: '',
}
}
if (preset === 'minimax') {
return {
provider: 'minimax',
name: 'MiniMax',
baseUrl: 'https://api.minimax.io/v1',
model: 'MiniMax-M2.7',
apiKey: '',
}
}
return {
provider: 'openai',
name: 'Mock provider',
baseUrl: 'http://localhost:11434/v1',
model: 'mock-model',
apiKey: '',
}
},
getProviderProfiles: options?.getProviderProfiles ?? (() => []),
setActiveProviderProfile: options?.setActiveProviderProfile ?? (() => null),
updateProviderProfile: options?.updateProviderProfile ?? (() => null),
@@ -191,11 +224,15 @@ function mockProviderManagerDependencies(
githubSyncRead: () => string | undefined,
githubAsyncRead: () => Promise<string | undefined>,
options?: {
addProviderProfile?: (...args: unknown[]) => unknown
applySavedProfileToCurrentSession?: (...args: unknown[]) => Promise<string | null>
addProviderProfile?: (...args: any[]) => unknown
applySavedProfileToCurrentSession?: (...args: any[]) => Promise<string | null>
clearCodexCredentials?: () => { success: boolean; warning?: string }
getActiveProviderProfile?: () => unknown
getProviderProfiles?: () => unknown[]
probeRouteReadiness?: (
routeId: string,
options?: { baseUrl?: string; model?: string; timeoutMs?: number; apiKey?: string },
) => Promise<unknown>
probeOllamaGenerationReadiness?: () => Promise<{
state: 'ready' | 'unreachable' | 'no_models' | 'generation_failed'
models: Array<
@@ -213,8 +250,8 @@ function mockProviderManagerDependencies(
}>
codexSyncRead?: () => unknown
codexAsyncRead?: () => Promise<unknown>
updateProviderProfile?: (...args: unknown[]) => unknown
setActiveProviderProfile?: (...args: unknown[]) => unknown
updateProviderProfile?: (...args: any[]) => unknown
setActiveProviderProfile?: (...args: any[]) => unknown
useCodexOAuthFlow?: (options: {
onAuthenticated: (tokens: {
accessToken: string
@@ -241,12 +278,29 @@ function mockProviderManagerDependencies(
})
mock.module('../utils/providerDiscovery.js', () => ({
probeOllamaGenerationReadiness:
options?.probeOllamaGenerationReadiness ??
(async () => ({
state: 'unreachable' as const,
models: [],
})),
}))
mock.module('../integrations/discoveryService.js', () => ({
probeRouteReadiness:
options?.probeRouteReadiness ??
(async (routeId: string) => {
if (routeId === 'ollama') {
return (
options?.probeOllamaGenerationReadiness?.() ?? {
state: 'unreachable' as const,
models: [],
}
)
}
if (routeId === 'atomic-chat') {
return {
state: 'unreachable' as const,
}
}
return null
}),
}))
mock.module('../utils/githubModelsCredentials.js', () => ({
@@ -489,6 +543,168 @@ test('ProviderManager avoids first-frame false negative while stored-token looku
expect(asyncRead).toHaveBeenCalled()
})
test('ProviderManager shows API mode picker for custom OpenAI-compatible providers', async () => {
mockProviderManagerDependencies(() => undefined, async () => undefined)
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const mounted = await mountProviderManager(ProviderManager)
try {
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Provider manager'),
)
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Choose provider preset'),
)
await navigateToPreset(mounted.stdin, 'Custom')
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Create provider profile') &&
frame.includes('Provider name'),
)
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Base URL'),
)
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Default model'),
)
mounted.stdin.write('\r')
const output = await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('API mode') && frame.includes('Chat Completions'),
)
expect(output).toContain('Responses')
} finally {
await mounted.dispose()
}
})
test('ProviderManager skips advanced auth fields when adding MiniMax', async () => {
mockProviderManagerDependencies(() => undefined, async () => undefined)
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const mounted = await mountProviderManager(ProviderManager)
try {
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Provider manager'),
)
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Choose provider preset'),
)
await navigateToPreset(mounted.stdin, 'MiniMax')
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Create provider profile') &&
frame.includes('Provider name'),
)
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Base URL'),
)
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Default model'),
)
mounted.stdin.write('\r')
const output = await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('API key'),
)
expect(output).not.toContain('API mode')
expect(output).not.toContain('Auth header')
expect(output).not.toContain('Custom headers')
} finally {
await mounted.dispose()
}
})
test('ProviderManager skips advanced fields for legacy Kimi Code profiles', async () => {
const legacyKimiProfile = {
id: 'provider_legacy_kimi',
provider: 'openai',
name: 'Legacy Kimi Code',
baseUrl: 'https://api.kimi.com/coding/v1',
model: 'kimi-for-coding',
apiKey: 'sk-test',
}
mockProviderManagerDependencies(
() => undefined,
async () => undefined,
{
getProviderProfiles: () => [legacyKimiProfile],
getActiveProviderProfile: () => legacyKimiProfile,
},
)
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const mounted = await mountProviderManager(ProviderManager)
try {
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Provider manager') &&
frame.includes('Edit provider'),
)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Edit provider') &&
frame.includes('Legacy Kimi Code'),
)
await Bun.sleep(25)
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Edit provider profile') &&
frame.includes('Provider name') &&
frame.includes('Step 1 of 4'),
)
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Base URL') &&
frame.includes('Step 2 of 4'),
)
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Default model') &&
frame.includes('Step 3 of 4'),
)
mounted.stdin.write('\r')
const output = await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('API key') &&
frame.includes('Step 4 of 4'),
)
expect(output).not.toContain('API mode')
expect(output).not.toContain('Auth header')
expect(output).not.toContain('Custom headers')
} finally {
await mounted.dispose()
}
})
test('ProviderManager first-run Ollama preset auto-detects installed models', async () => {
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN
@@ -581,6 +797,129 @@ test('ProviderManager first-run Ollama preset auto-detects installed models', as
await mounted.dispose()
})
test('ProviderManager preserves the Ollama readiness message when the probe is unreachable', async () => {
const onDone = mock(() => {})
mockProviderManagerDependencies(
() => undefined,
async () => undefined,
)
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const mounted = await mountProviderManager(ProviderManager, {
mode: 'first-run',
onDone,
})
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Set up provider'),
)
await navigateToPreset(mounted.stdin, 'Ollama')
mounted.stdin.write('\r')
const messageFrame = await waitForFrameOutput(
mounted.getOutput,
frame =>
frame.includes('Could not reach Ollama at http://localhost:11434/v1.') &&
frame.includes('enter the endpoint manually'),
)
expect(messageFrame).toContain(
'Could not reach Ollama at http://localhost:11434/v1. Start Ollama first, or enter the endpoint manually.',
)
await mounted.dispose()
})
test('ProviderManager first-run Atomic Chat preset auto-detects loaded models', async () => {
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN
delete process.env.GH_TOKEN
const onDone = mock(() => {})
const addProviderProfile = mock((payload: {
provider: string
name: string
baseUrl: string
model: string
apiKey?: string
}) => ({
id: 'provider_atomic_chat',
provider: payload.provider,
name: payload.name,
baseUrl: payload.baseUrl,
model: payload.model,
apiKey: payload.apiKey,
}))
mockProviderManagerDependencies(
() => undefined,
async () => undefined,
{
addProviderProfile,
probeRouteReadiness: async routeId => {
if (routeId === 'atomic-chat') {
return {
state: 'ready' as const,
models: ['Qwen3_5-4B_Q4_K_M', 'Llama-3.1-8B-Instruct'],
}
}
return null
},
},
)
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const mounted = await mountProviderManager(ProviderManager, {
mode: 'first-run',
onDone,
})
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Set up provider'),
)
await navigateToPreset(mounted.stdin, 'Atomic Chat')
mounted.stdin.write('\r')
const modelFrame = await waitForFrameOutput(
mounted.getOutput,
frame =>
frame.includes('Choose an Atomic Chat model') &&
frame.includes('Qwen3_5-4B_Q4_K_M') &&
frame.includes('Llama-3.1-8B-Instruct'),
)
expect(modelFrame).toContain('Choose an Atomic Chat model')
expect(modelFrame).toContain('Qwen3_5-4B_Q4_K_M')
await Bun.sleep(25)
mounted.stdin.write('\r')
await waitForCondition(() => onDone.mock.calls.length > 0)
expect(addProviderProfile).toHaveBeenCalled()
expect(addProviderProfile.mock.calls[0]?.[0]).toMatchObject({
name: 'Atomic Chat',
baseUrl: 'http://127.0.0.1:1337/v1',
model: 'Qwen3_5-4B_Q4_K_M',
})
expect(onDone).toHaveBeenCalledWith(
expect.objectContaining({
action: 'saved',
message: 'Provider configured: Atomic Chat',
}),
)
await mounted.dispose()
})
test('ProviderManager first-run Codex OAuth switches the current session after login completes', async () => {
delete process.env.CLAUDE_CODE_SIMPLE
delete process.env.CLAUDE_CODE_USE_GITHUB
@@ -654,7 +993,7 @@ test('ProviderManager first-run Codex OAuth switches the current session after l
model: 'codexplan',
apiKey: '',
}),
expect.objectContaining({ makeActive: true }),
expect.objectContaining({ makeActive: false }),
)
expect(applySavedProfileToCurrentSession).toHaveBeenCalled()
expect(persistCredentials).toHaveBeenCalledWith({
@@ -1062,43 +1401,49 @@ test('ProviderManager editing an active multi-model provider keeps app state on
mounted.getOutput,
frame =>
frame.includes('Edit provider profile') &&
frame.includes('Step 1 of 7'),
frame.includes('Step 1 of 8'),
)
mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 2 of 7'),
frame => frame.includes('Step 2 of 8'),
)
mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 3 of 7'),
frame => frame.includes('Step 3 of 8'),
)
mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 4 of 7'),
frame => frame.includes('Step 4 of 8'),
)
mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 5 of 7'),
frame => frame.includes('Step 5 of 8'),
)
mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 6 of 7'),
frame => frame.includes('Step 6 of 8'),
)
mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 7 of 7'),
frame => frame.includes('Step 7 of 8'),
)
mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 8 of 8'),
)
mounted.stdin.write('\r')
@@ -1134,6 +1479,59 @@ test('ProviderManager editing an active multi-model provider keeps app state on
await mounted.dispose()
})
test('ProviderManager set-active list uses descriptor-backed provider type labels', async () => {
delete process.env.CLAUDE_CODE_SIMPLE
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN
delete process.env.GH_TOKEN
const geminiProfile = {
id: 'provider_gemini',
provider: 'gemini',
name: 'Gemini Work',
baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
model: 'gemini-2.5-pro',
apiKey: 'gm-test',
}
mockProviderManagerDependencies(
() => undefined,
async () => undefined,
{
getProviderProfiles: () => [geminiProfile],
},
)
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const mounted = await mountProviderManager(ProviderManager)
await waitForFrameOutput(
mounted.getOutput,
frame =>
frame.includes('Provider manager') &&
frame.includes('Set active provider'),
)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('\r')
const output = await waitForFrameOutput(
mounted.getOutput,
frame =>
frame.includes('Set active provider') &&
frame.includes('Gemini Work') &&
frame.includes('Gemini API'),
)
expect(output).toContain(
'Gemini API · https://generativelanguage.googleapis.com/v1beta/openai · gemini-2.5-pro',
)
await mounted.dispose()
})
test('ProviderManager resolves Codex OAuth state from async storage without sync reads in render flow', async () => {
delete process.env.CLAUDE_CODE_SIMPLE
delete process.env.CLAUDE_CODE_USE_GITHUB
+137 -163
View File
@@ -10,6 +10,10 @@ import {
readCodexCredentialsAsync,
} from '../utils/codexCredentials.js'
import { isBareMode, isEnvTruthy } from '../utils/envUtils.js'
import {
parseProfileCustomHeadersInput,
serializeProfileCustomHeaders,
} from '../utils/providerCustomHeaders.js'
import { getPrimaryModel, hasMultipleModels, parseModelList } from '../utils/providerModels.js'
import {
applySavedProfileToCurrentSession,
@@ -17,6 +21,17 @@ import {
clearPersistedCodexOAuthProfile,
createProfileFile,
} from '../utils/providerProfile.js'
import {
getProviderPresetUiMetadata,
getRouteProviderTypeLabel,
ORDERED_PROVIDER_PRESETS,
routeSupportsApiFormatSelection,
routeSupportsAuthHeaders,
routeSupportsCustomHeaders,
resolveProfileRoute,
resolveRouteIdFromBaseUrl,
} from '../integrations/index.js'
import { probeRouteReadiness } from '../integrations/discoveryService.js'
import {
addProviderProfile,
applyActiveProviderProfileFromConfig,
@@ -37,8 +52,6 @@ import {
readGithubModelsTokenAsync,
} from '../utils/githubModelsCredentials.js'
import {
probeAtomicChatReadiness,
probeOllamaGenerationReadiness,
type AtomicChatReadiness,
type OllamaGenerationReadiness,
} from '../utils/providerDiscovery.js'
@@ -89,6 +102,7 @@ type DraftField =
| 'apiFormat'
| 'authHeader'
| 'authHeaderValue'
| 'customHeaders'
type ProviderDraft = Record<DraftField, string>
@@ -165,6 +179,13 @@ const FORM_STEPS: Array<{
helpText: 'Optional. Press Enter with empty value to skip.',
optional: true,
},
{
key: 'customHeaders',
label: 'Custom headers',
placeholder: 'e.g. X-Trace: enabled; X-Team: devtools',
helpText: 'Optional. Extra non-auth request headers for providers that support them.',
optional: true,
},
]
const GITHUB_PROVIDER_ID = '__github_models__'
@@ -185,6 +206,7 @@ function toDraft(profile: ProviderProfile): ProviderDraft {
apiFormat: profile.apiFormat ?? 'chat_completions',
authHeader: profile.authHeader ?? '',
authHeaderValue: profile.authHeaderValue ?? '',
customHeaders: serializeProfileCustomHeaders(profile.customHeaders) ?? '',
}
}
@@ -198,25 +220,26 @@ function presetToDraft(preset: ProviderPreset): ProviderDraft {
apiFormat: 'chat_completions',
authHeader: '',
authHeaderValue: '',
customHeaders: '',
}
}
function profileSummary(profile: ProviderProfile, isActive: boolean): string {
const activeSuffix = isActive ? ' (active)' : ''
const keyInfo = profile.apiKey ? 'key set' : 'no key'
const providerKind =
profile.provider === 'anthropic' ? 'anthropic' : 'openai-compatible'
const routeId = resolveProfileRoute(profile.provider).routeId
const providerKind = getRouteProviderTypeLabel(routeId)
const models = parseModelList(profile.model)
const modelDisplay =
models.length <= 3
? models.join(', ')
: `${models[0]}, ${models[1]} + ${models.length - 2} more`
const modeInfo =
profile.provider === 'openai'
routeSupportsApiFormatSelection(routeId)
? ` · ${profile.apiFormat === 'responses' ? 'responses' : 'chat/completions'}`
: ''
const authInfo =
profile.provider === 'openai' && profile.authHeader
routeSupportsAuthHeaders(routeId) && profile.authHeader
? ` · ${profile.authHeader} auth`
: ''
return `${providerKind} · ${profile.baseUrl} · ${modelDisplay}${modeInfo}${authInfo} · ${keyInfo}${activeSuffix}`
@@ -231,6 +254,18 @@ function getGithubCredentialSourceFromEnv(
return 'none'
}
function resolveProviderEditorRouteId(
provider: ProviderProfile['provider'],
baseUrl?: string,
): string {
const route = resolveProfileRoute(provider).routeId
if (route !== 'openai') {
return route
}
return resolveRouteIdFromBaseUrl(baseUrl) ?? route
}
async function resolveGithubCredentialSource(
processEnv: NodeJS.ProcessEnv = process.env,
): Promise<GithubCredentialSource> {
@@ -358,7 +393,7 @@ function CodexOAuthSetup({
}, persistCredentials: (options?: { profileId?: string }) => void) => {
await onConfigured(tokens, persistCredentials)
}, [onConfigured])
useKeybinding('confirm:no', onBack, [onBack])
useKeybinding('confirm:no', onBack)
const status = useCodexOAuthFlow({
onAuthenticated: handleAuthenticated,
@@ -499,15 +534,23 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
}, [])
const formSteps = React.useMemo(
() =>
draftProvider === 'openai'
? FORM_STEPS
: FORM_STEPS.filter(step =>
step.key !== 'apiFormat' &&
step.key !== 'authHeader' &&
step.key !== 'authHeaderValue'
),
[draftProvider],
() => {
const routeId = resolveProviderEditorRouteId(draftProvider, draft.baseUrl)
const supportsCustomHeaders = routeSupportsCustomHeaders(routeId)
return FORM_STEPS.filter(step => {
if (step.key === 'apiFormat') {
return routeSupportsApiFormatSelection(routeId)
}
if (step.key === 'authHeader' || step.key === 'authHeaderValue') {
return routeSupportsAuthHeaders(routeId)
}
if (step.key === 'customHeaders') {
return supportsCustomHeaders
}
return true
})
},
[draft.baseUrl, draftProvider],
)
const currentStep = formSteps[formStepIndex] ?? formSteps[0] ?? FORM_STEPS[0]
const currentStepKey = currentStep.key
@@ -636,9 +679,19 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
setOllamaSelection({ state: 'loading' })
void (async () => {
const readiness = await probeOllamaGenerationReadiness({
const readiness = await probeRouteReadiness('ollama', {
baseUrl: draft.baseUrl,
})
if (!readiness) {
if (!cancelled) {
setOllamaSelection({
state: 'unavailable',
message: `Could not load the Ollama readiness probe for ${redactUrlForDisplay(draft.baseUrl)}. Enter the endpoint manually.`,
})
}
return
}
if (readiness.state !== 'ready') {
if (!cancelled) {
setOllamaSelection({
@@ -678,9 +731,19 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
setAtomicChatSelection({ state: 'loading' })
void (async () => {
const readiness = await probeAtomicChatReadiness({
const readiness = await probeRouteReadiness('atomic-chat', {
baseUrl: draft.baseUrl,
})
if (!readiness) {
if (!cancelled) {
setAtomicChatSelection({
state: 'unavailable',
message: `Could not load the Atomic Chat readiness probe for ${redactUrlForDisplay(draft.baseUrl)}. Enter the endpoint manually.`,
})
}
return
}
if (readiness.state !== 'ready') {
if (!cancelled) {
setAtomicChatSelection({
@@ -996,6 +1059,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
apiFormat: 'chat_completions',
authHeader: '',
authHeaderValue: '',
customHeaders: '',
}
setEditingProfileId(null)
setDraftProvider(defaults.provider ?? 'openai')
@@ -1036,6 +1100,17 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
}
function persistDraft(nextDraft: ProviderDraft = draft): void {
const parsedCustomHeaders = parseProfileCustomHeadersInput(
nextDraft.customHeaders,
)
if (parsedCustomHeaders.error) {
setErrorMessage(parsedCustomHeaders.error)
return
}
const routeId = resolveProviderEditorRouteId(draftProvider, nextDraft.baseUrl)
const supportsApiFormat = routeSupportsApiFormatSelection(routeId)
const supportsAuthHeaders = routeSupportsAuthHeaders(routeId)
const payload: ProviderProfileInput = {
provider: draftProvider,
name: nextDraft.name,
@@ -1043,21 +1118,26 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
model: nextDraft.model,
apiKey: nextDraft.apiKey,
apiFormat:
draftProvider === 'openai' && nextDraft.apiFormat === 'responses'
supportsApiFormat && nextDraft.apiFormat === 'responses'
? 'responses'
: 'chat_completions',
authHeader:
draftProvider === 'openai' && nextDraft.authHeader
supportsAuthHeaders && nextDraft.authHeader
? nextDraft.authHeader
: undefined,
authScheme:
draftProvider === 'openai' && nextDraft.authHeader
supportsAuthHeaders && nextDraft.authHeader
? (nextDraft.authHeader.toLowerCase() === 'authorization' ? 'bearer' : 'raw')
: undefined,
authHeaderValue:
draftProvider === 'openai' && nextDraft.authHeaderValue
supportsAuthHeaders && nextDraft.authHeaderValue
? nextDraft.authHeaderValue
: undefined,
customHeaders:
routeSupportsCustomHeaders(routeId) &&
Object.keys(parsedCustomHeaders.headers).length > 0
? parsedCustomHeaders.headers
: undefined,
}
const saved = editingProfileId
@@ -1317,141 +1397,31 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
function renderPresetSelection(): React.ReactNode {
const canUseCodexOAuth = !isBareMode()
// Providers sorted alphabetically by label. `Custom` is pinned to the end
// because it's the catch-all / escape hatch — users scanning the list
// should always find known providers first. `Skip for now` (first-run
// only) comes last, after Custom.
const options = [
{
value: 'dashscope-intl',
label: 'Alibaba Coding Plan',
description: 'Alibaba DashScope International endpoint',
},
{
value: 'dashscope-cn',
label: 'Alibaba Coding Plan (China)',
description: 'Alibaba DashScope China endpoint',
},
{
value: 'anthropic',
label: 'Anthropic',
description: 'Native Claude API (x-api-key auth)',
},
{
value: 'atomic-chat',
label: 'Atomic Chat',
description: 'Local Model Provider',
},
{
value: 'azure-openai',
label: 'Azure OpenAI',
description: 'Azure OpenAI endpoint (model=deployment name)',
},
{
value: 'bankr',
label: 'Bankr',
description: 'Bankr LLM Gateway (OpenAI-compatible)',
},
...(canUseCodexOAuth
? [
{
value: 'codex-oauth',
label: 'Codex OAuth',
description:
'Sign in with ChatGPT in your browser and store Codex credentials securely',
},
]
: []),
{
value: 'deepseek',
label: 'DeepSeek',
description: 'DeepSeek OpenAI-compatible endpoint',
},
{
value: 'gemini',
label: 'Google Gemini',
description: 'Gemini OpenAI-compatible endpoint',
},
{
value: 'groq',
label: 'Groq',
description: 'Groq OpenAI-compatible endpoint',
},
{
value: 'lmstudio',
label: 'LM Studio',
description: 'Local LM Studio endpoint',
},
{
value: 'minimax',
label: 'MiniMax',
description: 'MiniMax API endpoint',
},
{
value: 'mistral',
label: 'Mistral',
description: 'Mistral OpenAI-compatible endpoint',
},
{
value: 'moonshotai',
label: 'Moonshot AI - API',
description: 'Moonshot AI - API endpoint',
},
{
value: 'kimi-code',
label: 'Moonshot AI - Kimi Code',
description: 'Moonshot AI - Kimi Code Subscription endpoint',
},
{
value: 'nvidia-nim',
label: 'NVIDIA NIM',
description: 'NVIDIA NIM endpoint',
},
{
value: 'ollama',
label: 'Ollama',
description: 'Local or remote Ollama endpoint',
},
{
value: 'openai',
label: 'OpenAI',
description: 'OpenAI API with API key',
},
{
value: 'openrouter',
label: 'OpenRouter',
description: 'OpenRouter OpenAI-compatible endpoint',
},
{
value: 'together',
label: 'Together AI',
description: 'Together chat/completions endpoint',
},
{
value: 'xai',
label: 'xAI',
description: 'xAI Grok OpenAI-compatible endpoint',
},
{
value: 'zai',
label: 'Z.AI - GLM Coding Plan',
description: 'Z.AI GLM coding subscription endpoint',
},
{
value: 'custom',
label: 'Custom',
description: 'Any OpenAI-compatible provider',
},
...(mode === 'first-run'
? [
{
value: 'skip',
label: 'Skip for now',
description: 'Continue with current defaults',
},
]
: []),
]
const options: OptionWithDescription<string>[] = ORDERED_PROVIDER_PRESETS.map(preset => {
const metadata = getProviderPresetUiMetadata(preset)
return {
value: preset,
label: metadata.label,
description: metadata.description,
}
})
if (canUseCodexOAuth) {
options.splice(6, 0, {
value: 'codex-oauth',
label: 'Codex OAuth',
description:
'Sign in with ChatGPT in your browser and store Codex credentials securely',
})
}
if (mode === 'first-run') {
options.push({
value: 'skip',
label: 'Skip for now',
description: 'Continue with current defaults',
})
}
return (
<Box flexDirection="column" gap={1}>
@@ -1496,10 +1466,14 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
<Text dimColor>{currentStep.helpText}</Text>
<Text dimColor>
Provider type:{' '}
{draftProvider === 'anthropic'
? 'Anthropic native API'
: 'OpenAI-compatible API'}
{getRouteProviderTypeLabel(resolveProfileRoute(draftProvider).routeId)}
</Text>
{routeSupportsCustomHeaders(resolveProfileRoute(draftProvider).routeId) ? (
<Text dimColor>
Advanced: this provider supports custom request headers when you
need them.
</Text>
) : null}
<Text dimColor>
Step {formStepIndex + 1} of {formSteps.length}: {currentStep.label}
</Text>
@@ -1523,7 +1497,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
defaultFocusValue={
currentValue === 'responses' ? 'responses' : 'chat_completions'
}
onChange={value => handleFormSubmit(value)}
onChange={(value: string) => handleFormSubmit(value)}
onCancel={handleBackFromForm}
visibleOptionCount={2}
/>
@@ -1692,7 +1666,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
profile.id === activeProfileId
? `${profile.name} (active)`
: profile.name,
description: `${profile.provider === 'anthropic' ? 'anthropic' : 'openai-compatible'} · ${profile.baseUrl} · ${profile.model}`,
description: `${getRouteProviderTypeLabel(resolveProfileRoute(profile.provider).routeId)} · ${profile.baseUrl} · ${profile.model}`,
}))
if (includeGithub && githubProviderAvailable) {
@@ -1774,7 +1748,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
)
const saved = existing
? updateProviderProfile(existing.id, payload)
: addProviderProfile(payload, { makeActive: true })
: addProviderProfile(payload, { makeActive: false })
if (!saved) {
setErrorMessage(
+16 -16
View File
@@ -2,8 +2,13 @@ import { c as _c } from "react-compiler-runtime";
import * as React from 'react';
import { useEffect, useState } from 'react';
import { extraUsage as extraUsageCommand } from 'src/commands/extra-usage/index.js';
import {
getUsageDescriptor,
resolveActiveUsageId,
} from 'src/commands/usage/index.js';
import { formatCost } from 'src/cost-tracker.js';
import { getSubscriptionType } from 'src/utils/auth.js';
import { getActiveProviderProfile } from 'src/utils/providerProfiles.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { Box, Text } from '../../ink.js';
import { useKeybinding } from '../../keybindings/useKeybinding.js';
@@ -26,7 +31,7 @@ type LimitBarProps = {
showTimeInReset?: boolean;
extraSubtext?: string;
};
function LimitBar(t0) {
function LimitBar(t0: LimitBarProps) {
const $ = _c(34);
const {
title,
@@ -269,33 +274,28 @@ function AnthropicUsage(): React.ReactNode {
}
export function Usage(): React.ReactNode {
const provider = getAPIProvider();
const activeProfile = getActiveProviderProfile();
const usageDescriptor = getUsageDescriptor(resolveActiveUsageId(process.env, {
activeProfileProvider: activeProfile?.provider,
providerCategory: provider,
}));
if (provider === 'codex') {
return <CodexUsage />;
}
if (provider === 'minimax') {
if (usageDescriptor.resolvedId === 'minimax' && usageDescriptor.supported) {
return <MiniMaxUsage />;
}
if (provider !== 'firstParty') {
const providerLabel = {
openai: 'this OpenAI-compatible provider',
gemini: 'Google Gemini',
github: 'GitHub Models',
mistral: 'Mistral',
'nvidia-nim': 'NVIDIA NIM',
bedrock: 'AWS Bedrock',
vertex: 'Google Vertex AI',
foundry: 'Microsoft Foundry'
}[provider] ?? 'this provider';
return <UnsupportedUsage providerLabel={providerLabel} />;
if (usageDescriptor.resolvedId === 'anthropic' && usageDescriptor.supported) {
return <AnthropicUsage />;
}
return <AnthropicUsage />;
return <UnsupportedUsage providerLabel={usageDescriptor.activeLabel} />;
}
type ExtraUsageSectionProps = {
extraUsage: ExtraUsage;
maxWidth: number;
};
const EXTRA_USAGE_SECTION_TITLE = 'Extra usage';
function ExtraUsageSection(t0) {
function ExtraUsageSection(t0: ExtraUsageSectionProps) {
const $ = _c(20);
const {
extraUsage,
+21 -6
View File
@@ -1,5 +1,10 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { detectProvider } from './StartupScreen.js'
import { saveGlobalConfig } from '../utils/config.js'
import {
resetSettingsCache,
setSessionSettingsCache,
} from '../utils/settings/settingsCache.js'
const ENV_KEYS = [
'CLAUDE_CODE_USE_OPENAI',
@@ -26,9 +31,19 @@ beforeEach(() => {
originalEnv[key] = process.env[key]
delete process.env[key]
}
setSessionSettingsCache({ settings: {}, errors: [] })
saveGlobalConfig(current => ({
...current,
model: undefined,
}))
})
afterEach(() => {
resetSettingsCache()
saveGlobalConfig(current => ({
...current,
model: undefined,
}))
for (const key of ENV_KEYS) {
if (originalEnv[key] === undefined) {
delete process.env[key]
@@ -112,14 +127,14 @@ describe('detectProvider — direct vendor endpoints', () => {
expect(detectProvider().name).toBe('Moonshot AI - API')
})
test('api.mistral.ai labels as Mistral', () => {
test('api.mistral.ai labels from descriptor route metadata', () => {
setupOpenAIMode('https://api.mistral.ai/v1', 'mistral-large-latest')
expect(detectProvider().name).toBe('Mistral')
expect(detectProvider().name).toBe('Mistral AI')
})
test('api.z.ai labels as Z.AI GLM', () => {
test('api.z.ai labels from descriptor route metadata', () => {
setupOpenAIMode('https://api.z.ai/api/coding/paas/v4', 'GLM-5.1')
expect(detectProvider().name).toBe('Z.AI - GLM')
expect(detectProvider().name).toBe('Z.AI')
})
test('default OpenAI URL + gpt-4o labels as OpenAI', () => {
@@ -156,9 +171,9 @@ describe('detectProvider — rawModel fallback when URL is generic', () => {
expect(detectProvider().name).toBe('Mistral')
})
test('custom proxy + exact uppercase GLM ID falls back to Z.AI GLM', () => {
test('custom proxy + exact uppercase GLM ID stays generic without route metadata', () => {
setupOpenAIMode('https://my-proxy.internal/v1', 'GLM-5.1')
expect(detectProvider().name).toBe('Z.AI - GLM')
expect(detectProvider().name).toBe('OpenAI')
})
test('custom proxy + lowercase glm ID stays generic OpenAI', () => {
+9 -6
View File
@@ -6,10 +6,14 @@
*/
import { isLocalProviderUrl, resolveProviderRequest } from '../services/api/providerConfig.js'
import {
getRouteLabel,
resolveRouteIdFromBaseUrl,
} from '../integrations/routeMetadata.js'
import { getLocalOpenAICompatibleProviderLabel } from '../utils/providerDiscovery.js'
import { getSettings_DEPRECATED } from '../utils/settings/settings.js'
import { parseUserSpecifiedModel } from '../utils/model/model.js'
import { containsExactZaiGlmModelId, isZaiBaseUrl } from '../utils/zaiProvider.js'
import { DEFAULT_GEMINI_MODEL } from '../utils/providerProfile.js'
declare const MACRO: { VERSION: string; DISPLAY_VERSION?: string }
@@ -91,7 +95,7 @@ export function detectProvider(modelOverride?: string): { name: string; model: s
const useMistral = process.env.CLAUDE_CODE_USE_MISTRAL === '1' || process.env.CLAUDE_CODE_USE_MISTRAL === 'true'
if (useGemini) {
const model = modelOverride || process.env.GEMINI_MODEL || 'gemini-2.0-flash'
const model = modelOverride || process.env.GEMINI_MODEL || DEFAULT_GEMINI_MODEL
const baseUrl = process.env.GEMINI_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta/openai'
return { name: 'Google Gemini', model, baseUrl, isLocal: false }
}
@@ -117,6 +121,7 @@ export function detectProvider(modelOverride?: string): { name: string; model: s
})
const baseUrl = resolvedRequest.baseUrl
const isLocal = isLocalProviderUrl(baseUrl)
const routeId = resolveRouteIdFromBaseUrl(baseUrl)
let name = 'OpenAI'
// Explicit dedicated-provider env flags win.
if (process.env.NVIDIA_NIM) name = 'NVIDIA NIM'
@@ -136,10 +141,10 @@ export function detectProvider(modelOverride?: string): { name: string; model: s
else if (/nvidia/i.test(baseUrl)) name = 'NVIDIA NIM'
else if (/minimax/i.test(baseUrl)) name = 'MiniMax'
else if (/api\.kimi\.com/i.test(baseUrl)) name = 'Moonshot AI - Kimi Code'
else if (routeId && routeId !== 'openai' && routeId !== 'custom')
name = getRouteLabel(routeId) ?? name
else if (/moonshot/i.test(baseUrl)) name = 'Moonshot AI - API'
else if (/deepseek/i.test(baseUrl)) name = 'DeepSeek'
else if (/x\.ai/i.test(baseUrl)) name = 'xAI'
else if (isZaiBaseUrl(baseUrl)) name = 'Z.AI - GLM'
else if (/mistral/i.test(baseUrl)) name = 'Mistral'
// rawModel fallback — fires only when base URL is generic/custom.
else if (/nvidia/i.test(rawModel)) name = 'NVIDIA NIM'
@@ -149,8 +154,6 @@ export function detectProvider(modelOverride?: string): { name: string; model: s
else if (/\bkimi-k/i.test(rawModel) || /moonshot/i.test(rawModel))
name = 'Moonshot AI - API'
else if (/deepseek/i.test(rawModel)) name = 'DeepSeek'
else if (/grok/i.test(rawModel)) name = 'xAI'
else if (containsExactZaiGlmModelId(rawModel)) name = 'Z.AI - GLM'
else if (/mistral/i.test(rawModel)) name = 'Mistral'
else if (/llama/i.test(rawModel)) name = 'Meta Llama'
else if (/bankr/i.test(baseUrl)) name = 'Bankr'
+63 -15
View File
@@ -30,6 +30,33 @@ import type {
/** Default timeout for permission prompts (30 seconds). Reasonable for human response time. */
export const DEFAULT_PERMISSION_TIMEOUT_MS = 30000
/**
* Placeholder session_id for permission requests outside SDK session context.
* Used when createExternalCanUseTool is called without a sessionId parameter,
* indicating a standalone permission prompt (e.g., direct tool permission check
* without an active SDK session). Hosts can identify such requests by checking
* session_id === NO_SESSION_PLACEHOLDER.
*/
export const NO_SESSION_PLACEHOLDER = 'no-session'
// ============================================================================
// Logger interface for SDK surface
// ============================================================================
/**
* Logger interface for SDK permission system.
* Hosts can inject a custom logger to control warning output.
* Defaults to console.warn if no logger is provided.
*/
export interface SDKLogger {
warn(message: string): void
}
/** Default console-based logger used when no custom logger is provided. */
const defaultLogger: SDKLogger = {
warn: (message: string) => console.warn(message),
}
// ============================================================================
// Once-only resolve wrapper
// ============================================================================
@@ -180,7 +207,10 @@ export function createExternalCanUseTool(
onTimeout?: (message: SDKPermissionTimeoutMessage) => void,
// Default 30 second timeout for permission prompts - reasonable for human response time
timeoutMs: number = DEFAULT_PERMISSION_TIMEOUT_MS,
sessionId?: string,
logger?: SDKLogger,
): CanUseToolFn {
const log = logger ?? defaultLogger
return async (tool, input, toolUseContext, assistantMessage, toolUseID, forceDecision) => {
// If a forced decision was passed in, honor it
if (forceDecision) return forceDecision
@@ -211,19 +241,35 @@ export function createExternalCanUseTool(
// call it directly and await external resolution with timeout.
if (toolUseID && onPermissionRequest) {
const requestId = randomUUID()
const messageUuid = randomUUID()
onPermissionRequest({
type: 'permission_request',
request_id: requestId,
tool_name: tool.name,
tool_use_id: toolUseID,
input: input as Record<string, unknown>,
uuid: requestId,
session_id: toolUseID,
})
// Register pending permission BEFORE emitting the request so that
// a host which responds synchronously from onPermissionRequest can
// find the entry in pendingPermissionPrompts immediately.
const pendingPromise = permissionTarget.registerPendingPermission(toolUseID)
// Wrap onPermissionRequest in try-catch since it's SDK-host-provided code.
// If it throws, clean up the pending entry and deny/fallback cleanly.
try {
onPermissionRequest({
type: 'permission_request',
request_id: requestId,
tool_name: tool.name,
tool_use_id: toolUseID,
input: input as Record<string, unknown>,
uuid: messageUuid,
session_id: sessionId ?? NO_SESSION_PLACEHOLDER,
})
} catch (err) {
permissionTarget.pendingPermissionPrompts.delete(toolUseID)
const errorMessage = err instanceof Error ? err.message : 'Unknown host callback error'
return {
behavior: 'deny' as const,
message: `Tool ${tool.name} denied (onPermissionRequest callback error: ${errorMessage})`,
decisionReason: { type: 'mode' as const, mode: 'default' },
}
}
let timeoutId: ReturnType<typeof setTimeout> | undefined
const timeoutPromise = new Promise<{ timedOut: true }>(resolve => {
timeoutId = setTimeout(() => resolve({ timedOut: true }), timeoutMs)
@@ -250,11 +296,9 @@ export function createExternalCanUseTool(
tool_name: tool.name,
tool_use_id: toolUseID,
timed_out_after_ms: timeoutMs,
uuid: toolUseID,
session_id: toolUseID,
})
}
console.warn(
log.warn(
`[SDK] Permission request for tool "${tool.name}" timed out after ${timeoutMs}ms. ` +
'Denying by default. Provide a canUseTool callback or respond to permission_request ' +
'messages within the timeout window.',
@@ -385,13 +429,17 @@ let warnedDefaultPermissions = false
*/
export function createDefaultCanUseTool(
_permissionContext: ToolPermissionContext,
logger?: SDKLogger,
): CanUseToolFn {
const log = logger ?? defaultLogger
if (!warnedDefaultPermissions) {
warnedDefaultPermissions = true
console.warn(
log.warn(
'[SDK] No canUseTool or onPermissionRequest callback provided. ' +
'All tool uses will be DENIED by default. ' +
'Provide canUseTool in query options to allow specific tools.',
'Provide canUseTool in query options, e.g.: ' +
'{ canUseTool: async (name, input) => ({ behavior: "allow" }) }',
)
)
}
return async (tool, input, _toolUseContext, _assistantMessage, _toolUseID, forceDecision) => {
+51 -13
View File
@@ -33,6 +33,24 @@ export function assertValidSessionId(sessionId: string): void {
/**
* Global mutex for process.env mutations.
* Prevents race conditions when multiple queries run in parallel.
*
* **Note:** The SDK itself does not directly mutate process.env. This mutex
* is provided as a utility for SDK hosts who need to modify environment
* variables during parallel query execution (e.g., setting API keys per-query).
* Hosts must opt-in to using this mutex there is no enforcement mechanism.
*
* Example usage:
* ```typescript
* const result = await acquireEnvMutex({ timeoutMs: 1000 })
* if (result.acquired) {
* try {
* process.env.MY_API_KEY = 'key-for-this-query'
* // ... perform query ...
* } finally {
* releaseEnvMutex()
* }
* }
* ```
*/
const envMutationQueue: Array<() => void> = []
let envMutationLocked = false
@@ -120,12 +138,29 @@ export function resetEnvMutexForTesting(): void {
}
// ============================================================================
// SDK Types — camelCase public interface (matches sdk.d.ts)
// SDK Types — snake_case public interface (matches sdk.d.ts)
// ============================================================================
/**
* Permission request message emitted when a tool needs permission approval.
* Hosts can respond via respondToPermission() using the request_id.
*
* **ID Relationship:**
* - `request_id`: UUID generated per permission request, used as correlation ID
* for respondToPermission(). Passed to onPermissionRequest callback for hosts
* to identify which request they're responding to.
* - `tool_use_id`: Identifier for the specific tool use instance, passed from
* canUseTool(). Used internally for pending permission tracking and queue
* filtering. Multiple permission requests for the same tool use are rare but
* possible (e.g., retry after timeout).
* - `session_id`: SDK session identifier. When 'no-session', indicates a
* standalone permission prompt outside an SDK session flow (e.g., direct
* createExternalCanUseTool usage without session context).
* - `uuid`: Message UUID for stream correlation and transcript persistence.
*
* Hosts typically use `request_id` for responding; `tool_use_id` is useful
* for tracking state or correlating with tool_use events in the message stream.
* `session_id` enables correlation with SDK session lifecycle events.
*/
export type SDKPermissionRequestMessage = {
type: 'permission_request'
@@ -142,14 +177,16 @@ export type SDKPermissionRequestMessage = {
* Hosts can detect timeouts by checking `type === 'permission_timeout'`
* in their `for await` loop. The `tool_use_id` matches the original
* permission_request, allowing correlation.
*
* Note: `request_id` is not included in timeout messages since the request
* is no longer pending hosts cannot respond to timed-out requests.
*/
export type SDKPermissionTimeoutMessage = {
type: 'permission_timeout'
tool_name: string
tool_use_id: string
timed_out_after_ms: number
uuid: string
session_id: string
}
}
/**
@@ -196,19 +233,20 @@ export function mapMessageToSDK(msg: Record<string, unknown>): SDKMessage {
/**
* Session metadata returned by listSessions and getSessionInfo.
* Uses camelCase field names matching the public SDK contract (sdk.d.ts).
* Uses snake_case field names matching the public SDK contract (matches sdk.d.ts).
*/
export type SDKSessionInfo = {
sessionId: string
session_id: string
summary: string
lastModified: number
fileSize?: number
customTitle?: string
firstPrompt?: string
gitBranch?: string
last_modified: number
file_size?: number
custom_title?: string
first_prompt?: string
git_branch?: string
cwd?: string
tag?: string
createdAt?: number
created_at?: number
}
}
/** Options for listSessions. */
@@ -260,7 +298,7 @@ export type ForkSessionOptions = {
/** Result of forkSession. */
export type ForkSessionResult = {
/** UUID of the newly created forked session. */
sessionId: string
session_id: string
}
/**
@@ -272,7 +310,7 @@ export type SessionMessage = {
content: unknown
timestamp?: string
uuid?: string
parentUuid?: string | null
parent_uuid?: string | null
[key: string]: unknown
}
@@ -7,7 +7,6 @@ import { Text } from '../../ink.js';
import { getInitializationStatus, getLspServerManager } from '../../services/lsp/manager.js';
import { useSetAppState } from '../../state/AppState.js';
import { logForDebugging } from '../../utils/debug.js';
import { isEnvTruthy } from '../../utils/envUtils.js';
const LSP_POLL_INTERVAL_MS = 5000;
/**
@@ -17,7 +16,7 @@ const LSP_POLL_INTERVAL_MS = 5000;
*
* Also adds errors to appState.plugins.errors for /doctor display.
*
* Only active when ENABLE_LSP_TOOL is set.
* Active in normal REPL sessions. The manager itself no-ops in --bare mode.
*/
export function useLspInitializationNotification() {
const $ = _c(10);
@@ -138,5 +137,5 @@ function _temp2(e) {
return `${e.type}:${e.source}`;
}
function _temp() {
return isEnvTruthy("true");
return true;
}
+228
View File
@@ -0,0 +1,228 @@
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { describe, expect, test } from 'bun:test'
import {
generateIntegrationArtifacts,
generatedIntegrationArtifactsAreCurrent,
} from './artifactGenerator.js'
const FIXTURE_DIRS = [
'src/integrations/vendors',
'src/integrations/gateways',
'src/integrations/anthropicProxies',
'src/integrations/brands',
'src/integrations/models',
] as const
async function withFixtureRepo(
files: Record<string, string>,
callback: (repoRoot: string) => Promise<void>,
): Promise<void> {
const repoRoot = await mkdtemp(
path.join(os.tmpdir(), 'openclaude-integration-artifacts-'),
)
try {
for (const dir of FIXTURE_DIRS) {
await mkdir(path.join(repoRoot, dir), { recursive: true })
}
for (const [relativePath, content] of Object.entries(files)) {
const absolutePath = path.join(repoRoot, relativePath)
await mkdir(path.dirname(absolutePath), { recursive: true })
await writeFile(absolutePath, content, 'utf8')
}
await callback(repoRoot)
} finally {
await rm(repoRoot, { recursive: true, force: true })
}
}
describe('integration artifact generator', () => {
test('checked-in generated artifacts are current', async () => {
await expect(generatedIntegrationArtifactsAreCurrent()).resolves.toBe(true)
})
test('derives loader and preset manifest entries for a preset gateway from descriptor files', async () => {
await withFixtureRepo(
{
'src/integrations/vendors/openai.ts': `export default {
id: 'openai',
label: 'OpenAI',
classification: 'openai-compatible',
defaultBaseUrl: 'https://api.openai.com/v1',
defaultModel: 'gpt-5-mini',
setup: { requiresAuth: true, authMode: 'api-key', credentialEnvVars: ['OPENAI_API_KEY'] },
transportConfig: { kind: 'openai-compatible' },
usage: { supported: false },
}
`,
'src/integrations/gateways/acme.ts': `export default {
id: 'acme',
label: 'Acme Gateway',
defaultBaseUrl: 'https://gateway.acme.test/v1',
setup: { requiresAuth: true, authMode: 'api-key', credentialEnvVars: ['ACME_API_KEY'] },
transportConfig: { kind: 'openai-compatible' },
preset: {
id: 'acme-gateway',
description: 'Acme hosted gateway',
vendorId: 'openai',
apiKeyEnvVars: ['ACME_API_KEY'],
},
catalog: {
source: 'static',
models: [{ id: 'acme-fast', apiName: 'acme-fast' }],
},
usage: { supported: false },
}
`,
},
async repoRoot => {
const [{ content }] = await generateIntegrationArtifacts({ repoRoot })
expect(content).toContain("import gatewayAcme from '../gateways/acme.js'")
expect(content).toContain('"preset": "acme-gateway"')
expect(content).toContain('"gatewayId": "acme"')
expect(content).toContain('"routeId": "acme"')
},
)
})
test('derives loader and preset manifest entries for a direct first-party vendor from descriptor files', async () => {
await withFixtureRepo(
{
'src/integrations/vendors/acme-first-party.ts': `export default {
id: 'acme-first-party',
label: 'Acme First Party',
classification: 'openai-compatible',
defaultBaseUrl: 'https://api.acme.test/v1',
defaultModel: 'acme-fast',
setup: { requiresAuth: true, authMode: 'api-key', credentialEnvVars: ['ACME_API_KEY'] },
transportConfig: { kind: 'openai-compatible' },
preset: {
id: 'acme-direct',
description: 'Acme direct API',
apiKeyEnvVars: ['ACME_API_KEY'],
},
catalog: {
source: 'static',
models: [{ id: 'acme-fast', apiName: 'acme-fast' }],
},
usage: { supported: false },
}
`,
},
async repoRoot => {
const [{ content }] = await generateIntegrationArtifacts({ repoRoot })
expect(content).toContain(
"import vendorAcmeFirstParty from '../vendors/acme-first-party.js'",
)
expect(content).toContain('"preset": "acme-direct"')
expect(content).toContain('"routeId": "acme-first-party"')
expect(content).toContain('"vendorId": "acme-first-party"')
},
)
})
test('pins anthropic to the top, sorts the rest by description, and keeps custom at the bottom', async () => {
await withFixtureRepo(
{
'src/integrations/vendors/anthropic.ts': `export default {
id: 'anthropic',
label: 'Anthropic',
classification: 'anthropic',
defaultBaseUrl: 'https://api.anthropic.com',
defaultModel: 'claude-sonnet-4-6',
setup: { requiresAuth: true, authMode: 'api-key', credentialEnvVars: ['ANTHROPIC_API_KEY'] },
transportConfig: { kind: 'anthropic-native' },
preset: {
id: 'anthropic',
description: 'Zulu direct API',
apiKeyEnvVars: ['ANTHROPIC_API_KEY'],
},
usage: { supported: false },
}
`,
'src/integrations/vendors/openai.ts': `export default {
id: 'openai',
label: 'OpenAI',
classification: 'openai-compatible',
defaultBaseUrl: 'https://api.openai.com/v1',
defaultModel: 'gpt-5-mini',
setup: { requiresAuth: true, authMode: 'api-key', credentialEnvVars: ['OPENAI_API_KEY'] },
transportConfig: { kind: 'openai-compatible' },
usage: { supported: false },
}
`,
'src/integrations/gateways/zeta.ts': `export default {
id: 'zeta',
label: 'Zeta',
defaultBaseUrl: 'https://zeta.test/v1',
setup: { requiresAuth: true, authMode: 'api-key', credentialEnvVars: ['ZETA_API_KEY'] },
transportConfig: { kind: 'openai-compatible' },
preset: {
id: 'zeta',
description: 'Zeta 10',
vendorId: 'openai',
apiKeyEnvVars: ['ZETA_API_KEY'],
},
catalog: { source: 'static', models: [{ id: 'zeta', apiName: 'zeta', default: true }] },
usage: { supported: false },
}
`,
'src/integrations/gateways/alpha.ts': `export default {
id: 'alpha',
label: 'Alpha',
defaultBaseUrl: 'https://alpha.test/v1',
setup: { requiresAuth: true, authMode: 'api-key', credentialEnvVars: ['ALPHA_API_KEY'] },
transportConfig: { kind: 'openai-compatible' },
preset: {
id: 'alpha',
description: 'Alpha 2',
vendorId: 'openai',
apiKeyEnvVars: ['ALPHA_API_KEY'],
},
catalog: { source: 'static', models: [{ id: 'alpha', apiName: 'alpha', default: true }] },
usage: { supported: false },
}
`,
'src/integrations/gateways/custom.ts': `export default {
id: 'custom',
label: 'Custom',
setup: { requiresAuth: false, authMode: 'api-key' },
transportConfig: { kind: 'openai-compatible' },
preset: {
id: 'custom',
description: 'Any OpenAI-compatible provider',
vendorId: 'openai',
apiKeyEnvVars: ['OPENAI_API_KEY'],
fallbackBaseUrl: 'http://localhost:11434/v1',
fallbackModel: 'local-model',
},
catalog: { source: 'static', models: [] },
usage: { supported: false },
}
`,
},
async repoRoot => {
const [{ content }] = await generateIntegrationArtifacts({ repoRoot })
const orderedMatch = content.match(
/export const ORDERED_PROVIDER_PRESETS = \[\n([\s\S]*?)\n\] as const/,
)
expect(orderedMatch).not.toBeNull()
const orderedPresetIds = Array.from(
orderedMatch![1]!.matchAll(/"([^"]+)"/g),
match => match[1]!,
)
expect(orderedPresetIds).toEqual(['anthropic', 'alpha', 'zeta', 'custom'])
},
)
})
})
+460
View File
@@ -0,0 +1,460 @@
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import type {
AnthropicProxyDescriptor,
BrandDescriptor,
GatewayDescriptor,
ModelDescriptor,
ProviderPresetMetadata,
VendorDescriptor,
} from './descriptors.js'
type RouteDescriptor =
| VendorDescriptor
| GatewayDescriptor
| AnthropicProxyDescriptor
type RouteModule = {
kind: 'vendor' | 'gateway' | 'anthropic-proxy'
descriptor: RouteDescriptor
importName: string
importPath: string
}
type BrandModule = {
descriptor: BrandDescriptor
importName: string
importPath: string
}
type ModelModule = {
descriptors: ModelDescriptor[]
importName: string
importPath: string
}
type LoadedIntegrationModules = {
routeModules: RouteModule[]
brandModules: BrandModule[]
modelModules: ModelModule[]
}
type GeneratedArtifact = {
path: string
content: string
}
type GenerateIntegrationArtifactsOptions = {
repoRoot?: string
}
const PRESET_DESCRIPTION_COLLATOR = new Intl.Collator(undefined, {
numeric: true,
sensitivity: 'base',
})
const INTEGRATIONS_DIR = ['src', 'integrations'] as const
const GENERATED_DIR = [...INTEGRATIONS_DIR, 'generated'] as const
const GENERATED_ARTIFACT_PATH = [...GENERATED_DIR, 'integrationArtifacts.generated.ts'] as const
const VENDOR_DIR = 'vendors'
const GATEWAY_DIR = 'gateways'
const ANTHROPIC_PROXY_DIR = 'anthropicProxies'
const BRAND_DIR = 'brands'
const MODEL_DIR = 'models'
function normalizeLineEndings(content: string): string {
return content.replace(/\r\n/g, '\n')
}
function isDescriptorFile(fileName: string): boolean {
return (
fileName.endsWith('.ts') &&
!fileName.endsWith('.d.ts') &&
!fileName.endsWith('.test.ts') &&
!fileName.endsWith('.models.ts') &&
fileName !== 'index.ts'
)
}
function toImportIdentifier(prefix: string, fileName: string): string {
const baseName = fileName.replace(/\.ts$/, '')
const words = `${prefix}-${baseName}`
.split(/[^a-zA-Z0-9]+/)
.filter(Boolean)
const [first = 'descriptor', ...rest] = words
return [
first.toLowerCase(),
...rest.map(word => word[0]!.toUpperCase() + word.slice(1).toLowerCase()),
].join('')
}
async function loadDefaultExport<T>(absolutePath: string): Promise<T> {
const module = await import(pathToFileURL(absolutePath).href)
if (!('default' in module)) {
throw new Error(`Expected default export in ${absolutePath}`)
}
return module.default as T
}
async function loadDescriptorModules(
repoRoot: string,
): Promise<LoadedIntegrationModules> {
const integrationsRoot = path.join(repoRoot, ...INTEGRATIONS_DIR)
const routeModules: RouteModule[] = []
const brandModules: BrandModule[] = []
const modelModules: ModelModule[] = []
const routeSpecs = [
{ kind: 'vendor' as const, directory: VENDOR_DIR, prefix: 'vendor' },
{ kind: 'gateway' as const, directory: GATEWAY_DIR, prefix: 'gateway' },
{
kind: 'anthropic-proxy' as const,
directory: ANTHROPIC_PROXY_DIR,
prefix: 'anthropicProxy',
},
]
for (const spec of routeSpecs) {
const directoryPath = path.join(integrationsRoot, spec.directory)
const entries = await fs.readdir(directoryPath).catch(() => [])
const files = entries.filter(isDescriptorFile).sort()
for (const fileName of files) {
const absolutePath = path.join(directoryPath, fileName)
const descriptor = await loadDefaultExport<RouteDescriptor>(absolutePath)
routeModules.push({
kind: spec.kind,
descriptor,
importName: toImportIdentifier(spec.prefix, fileName),
importPath: `../${spec.directory}/${fileName.replace(/\.ts$/, '.js')}`,
})
}
}
const brandDirectory = path.join(integrationsRoot, BRAND_DIR)
const brandFiles = (await fs.readdir(brandDirectory)).filter(isDescriptorFile).sort()
for (const fileName of brandFiles) {
const absolutePath = path.join(brandDirectory, fileName)
brandModules.push({
descriptor: await loadDefaultExport<BrandDescriptor>(absolutePath),
importName: toImportIdentifier('brand', fileName),
importPath: `../${BRAND_DIR}/${fileName.replace(/\.ts$/, '.js')}`,
})
}
const modelDirectory = path.join(integrationsRoot, MODEL_DIR)
const modelFiles = (await fs.readdir(modelDirectory)).filter(isDescriptorFile).sort()
for (const fileName of modelFiles) {
const absolutePath = path.join(modelDirectory, fileName)
modelModules.push({
descriptors: await loadDefaultExport<ModelDescriptor[]>(absolutePath),
importName: toImportIdentifier('model', fileName),
importPath: `../${MODEL_DIR}/${fileName.replace(/\.ts$/, '.js')}`,
})
}
return { routeModules, brandModules, modelModules }
}
function toGeneratedValue(value: unknown, indent = 0): string {
const currentIndent = ' '.repeat(indent)
const nextIndent = ' '.repeat(indent + 2)
if (Array.isArray(value)) {
if (value.length === 0) {
return '[]'
}
return `[\n${value
.map(entry => `${nextIndent}${toGeneratedValue(entry, indent + 2)}`)
.join(',\n')}\n${currentIndent}]`
}
if (value && typeof value === 'object') {
const entries = Object.entries(value).filter(([, entry]) => entry !== undefined)
if (entries.length === 0) {
return '{}'
}
return `{\n${entries
.map(
([key, entry]) =>
`${nextIndent}${JSON.stringify(key)}: ${toGeneratedValue(entry, indent + 2)}`,
)
.join(',\n')}\n${currentIndent}}`
}
return JSON.stringify(value)
}
function buildPresetManifestEntry(
routeModule: RouteModule,
preset: ProviderPresetMetadata,
): Record<string, unknown> {
return {
preset: preset.id,
routeKind: routeModule.kind,
routeId: routeModule.descriptor.id,
vendorId:
routeModule.kind === 'vendor'
? routeModule.descriptor.id
: preset.vendorId,
gatewayId:
routeModule.kind === 'gateway'
? routeModule.descriptor.id
: undefined,
description: preset.description,
label: preset.label,
name: preset.name,
apiKeyEnvVars: preset.apiKeyEnvVars,
baseUrlEnvVars: preset.baseUrlEnvVars,
modelEnvVars: preset.modelEnvVars,
fallbackBaseUrl: preset.fallbackBaseUrl,
fallbackModel: preset.fallbackModel,
}
}
function compareProviderPresetEntries(
left: Record<string, unknown>,
right: Record<string, unknown>,
): number {
const leftPreset = String(left.preset)
const rightPreset = String(right.preset)
if (leftPreset === rightPreset) {
return 0
}
if (leftPreset === 'anthropic') {
return -1
}
if (rightPreset === 'anthropic') {
return 1
}
if (leftPreset === 'custom') {
return 1
}
if (rightPreset === 'custom') {
return -1
}
const descriptionDelta = PRESET_DESCRIPTION_COLLATOR.compare(
String(left.description),
String(right.description),
)
if (descriptionDelta !== 0) {
return descriptionDelta
}
return PRESET_DESCRIPTION_COLLATOR.compare(leftPreset, rightPreset)
}
function validatePresetMetadata(routeModules: RouteModule[]): void {
const presetIds = new Map<string, string>()
const routeIds = new Set(routeModules.map(routeModule => routeModule.descriptor.id))
const vendorIds = new Set(
routeModules
.filter(routeModule => routeModule.kind === 'vendor')
.map(routeModule => routeModule.descriptor.id),
)
for (const routeModule of routeModules) {
const { descriptor, kind } = routeModule
const preset = descriptor.preset
if (!preset) {
continue
}
if (!preset.id.trim()) {
throw new Error(`Route "${descriptor.id}" opted into presets with an empty preset id.`)
}
if (!preset.description.trim()) {
throw new Error(
`Route "${descriptor.id}" opted into presets without a preset description.`,
)
}
const duplicateOwner = presetIds.get(preset.id)
if (duplicateOwner) {
throw new Error(
`Duplicate preset id "${preset.id}" defined by routes "${duplicateOwner}" and "${descriptor.id}".`,
)
}
presetIds.set(preset.id, descriptor.id)
const requiresApiKey =
descriptor.setup.requiresAuth && descriptor.setup.authMode === 'api-key'
const effectiveApiKeyEnvVars =
preset.apiKeyEnvVars ?? descriptor.setup.credentialEnvVars ?? []
if (requiresApiKey && effectiveApiKeyEnvVars.length === 0) {
throw new Error(
`Preset route "${descriptor.id}" requires API-key auth but does not declare any credential env vars.`,
)
}
const hasDefaultBaseUrl =
'defaultBaseUrl' in descriptor && typeof descriptor.defaultBaseUrl === 'string'
? descriptor.defaultBaseUrl.trim().length > 0
: false
if (!hasDefaultBaseUrl && !preset.fallbackBaseUrl) {
throw new Error(
`Preset route "${descriptor.id}" must provide a defaultBaseUrl or preset.fallbackBaseUrl.`,
)
}
const defaultModelValue =
'defaultModel' in descriptor ? descriptor.defaultModel : undefined
const hasCatalogDefaultModel =
(descriptor.catalog?.models?.find(model => model.default) ??
descriptor.catalog?.models?.[0]) !== undefined
const hasDefaultModel =
typeof defaultModelValue === 'string'
? defaultModelValue.trim().length > 0
: hasCatalogDefaultModel
if (!hasDefaultModel && !preset.fallbackModel) {
throw new Error(
`Preset route "${descriptor.id}" must provide a defaultModel or preset.fallbackModel.`,
)
}
if (
defaultModelValue !== undefined &&
descriptor.catalog?.models?.some(model => model.default)
) {
throw new Error(
`Preset route "${descriptor.id}" must use defaultModel instead of catalog default flags.`,
)
}
if (kind !== 'vendor') {
if (!preset.vendorId?.trim()) {
throw new Error(
`Preset route "${descriptor.id}" must declare preset.vendorId because it is not a direct vendor.`,
)
}
if (!vendorIds.has(preset.vendorId)) {
throw new Error(
`Preset route "${descriptor.id}" references missing preset.vendorId "${preset.vendorId}".`,
)
}
}
if (!routeIds.has(descriptor.id)) {
throw new Error(`Preset route "${descriptor.id}" is not part of the known route set.`)
}
}
}
function renderIntegrationArtifacts(
loadedModules: LoadedIntegrationModules,
): string {
validatePresetMetadata(loadedModules.routeModules)
const vendorModules = loadedModules.routeModules
.filter(routeModule => routeModule.kind === 'vendor')
.sort((left, right) => left.importPath.localeCompare(right.importPath))
const gatewayModules = loadedModules.routeModules
.filter(routeModule => routeModule.kind === 'gateway')
.sort((left, right) => left.importPath.localeCompare(right.importPath))
const anthropicProxyModules = loadedModules.routeModules
.filter(routeModule => routeModule.kind === 'anthropic-proxy')
.sort((left, right) => left.importPath.localeCompare(right.importPath))
const brandModules = [...loadedModules.brandModules].sort((left, right) =>
left.importPath.localeCompare(right.importPath),
)
const modelModules = [...loadedModules.modelModules].sort((left, right) =>
left.importPath.localeCompare(right.importPath),
)
const importLines = [
...vendorModules.map(module => `import ${module.importName} from '${module.importPath}'`),
...gatewayModules.map(module => `import ${module.importName} from '${module.importPath}'`),
...anthropicProxyModules.map(
module => `import ${module.importName} from '${module.importPath}'`,
),
...brandModules.map(module => `import ${module.importName} from '${module.importPath}'`),
...modelModules.map(module => `import ${module.importName} from '${module.importPath}'`),
]
const presetManifest = loadedModules.routeModules
.filter(routeModule => routeModule.descriptor.preset)
.map(routeModule => buildPresetManifestEntry(routeModule, routeModule.descriptor.preset!))
.sort(compareProviderPresetEntries)
const orderedProviderPresets = presetManifest.map(entry => entry.preset)
const fileSections = [
'// This file is auto-generated by scripts/generate-integrations-artifacts.ts.',
'// Do not edit it by hand; update the descriptor modules and regenerate instead.',
'',
"import type { AnthropicProxyDescriptor, BrandDescriptor, GatewayDescriptor, ModelDescriptor, ProviderPresetManifestEntry, VendorDescriptor } from '../descriptors.js'",
...importLines,
'',
`export const VENDOR_DESCRIPTORS = [${vendorModules.map(module => module.importName).join(', ')}] as const satisfies readonly VendorDescriptor[]`,
`export const GATEWAY_DESCRIPTORS = [${gatewayModules.map(module => module.importName).join(', ')}] as const satisfies readonly GatewayDescriptor[]`,
`export const ANTHROPIC_PROXY_DESCRIPTORS = [${anthropicProxyModules.map(module => module.importName).join(', ')}] as const satisfies readonly AnthropicProxyDescriptor[]`,
`export const BRAND_DESCRIPTORS = [${brandModules.map(module => module.importName).join(', ')}] as const satisfies readonly BrandDescriptor[]`,
`export const MODEL_DESCRIPTOR_GROUPS = [${modelModules.map(module => module.importName).join(', ')}] as const satisfies readonly (readonly ModelDescriptor[])[]`,
'export const MODEL_DESCRIPTORS = MODEL_DESCRIPTOR_GROUPS.flat() satisfies readonly ModelDescriptor[]',
'',
`export const PROVIDER_PRESET_MANIFEST = ${toGeneratedValue(presetManifest)} as const satisfies readonly ProviderPresetManifestEntry[]`,
"export type ProviderPreset = (typeof PROVIDER_PRESET_MANIFEST)[number]['preset']",
`export const ORDERED_PROVIDER_PRESETS = ${toGeneratedValue(orderedProviderPresets)} as const`,
]
return `${fileSections.join('\n')}\n`
}
export async function generateIntegrationArtifacts(
options: GenerateIntegrationArtifactsOptions = {},
): Promise<GeneratedArtifact[]> {
const repoRoot = options.repoRoot ?? path.resolve(path.join(import.meta.dir, '..', '..'))
const loadedModules = await loadDescriptorModules(repoRoot)
const content = renderIntegrationArtifacts(loadedModules)
return [
{
path: path.join(repoRoot, ...GENERATED_ARTIFACT_PATH),
content,
},
]
}
export async function writeIntegrationArtifacts(
options: GenerateIntegrationArtifactsOptions = {},
): Promise<GeneratedArtifact[]> {
const artifacts = await generateIntegrationArtifacts(options)
await fs.mkdir(path.join(options.repoRoot ?? path.resolve(path.join(import.meta.dir, '..', '..')), ...GENERATED_DIR), {
recursive: true,
})
for (const artifact of artifacts) {
await fs.writeFile(artifact.path, artifact.content, 'utf8')
}
return artifacts
}
export async function generatedIntegrationArtifactsAreCurrent(
options: GenerateIntegrationArtifactsOptions = {},
): Promise<boolean> {
const artifacts = await generateIntegrationArtifacts(options)
for (const artifact of artifacts) {
const existing = await fs.readFile(artifact.path, 'utf8').catch(() => '')
if (normalizeLineEndings(existing) !== normalizeLineEndings(artifact.content)) {
return false
}
}
return true
}
+20
View File
@@ -0,0 +1,20 @@
import { defineBrand } from '../define.js'
export default defineBrand({
id: 'claude',
label: 'Claude',
canonicalVendorId: 'anthropic',
defaultCapabilities: {
supportsVision: true,
supportsStreaming: true,
supportsFunctionCalling: true,
supportsJsonMode: true,
supportsReasoning: true,
supportsPreciseTokenCount: false,
},
modelIds: [
'claude-sonnet-4-6',
'claude-opus-4-6',
'claude-haiku-4-5',
],
})
+21
View File
@@ -0,0 +1,21 @@
import { defineBrand } from '../define.js'
export default defineBrand({
id: 'deepseek',
label: 'DeepSeek',
canonicalVendorId: 'deepseek',
defaultCapabilities: {
supportsVision: false,
supportsStreaming: true,
supportsFunctionCalling: true,
supportsJsonMode: true,
supportsReasoning: true,
supportsPreciseTokenCount: false,
},
modelIds: [
'deepseek-chat',
'deepseek-reasoner',
'deepseek-v4-flash',
'deepseek-v4-pro',
],
})
+24
View File
@@ -0,0 +1,24 @@
import { defineBrand } from '../define.js'
export default defineBrand({
id: 'gemini',
label: 'Gemini',
canonicalVendorId: 'gemini',
defaultCapabilities: {
supportsVision: true,
supportsStreaming: true,
supportsFunctionCalling: true,
supportsJsonMode: true,
supportsReasoning: true,
supportsPreciseTokenCount: false,
},
modelIds: [
'gemini-3.1-flash-lite-preview',
'gemini-3.1-pro',
'gemini-2.5-flash',
'gemini-2.5-pro',
'gemini-2.0-flash',
'google/gemini-2.5-pro',
'google/gemini-2.0-flash',
],
})
+27
View File
@@ -0,0 +1,27 @@
import { defineBrand } from '../define.js'
export default defineBrand({
id: 'glm',
label: 'GLM',
canonicalVendorId: 'zai',
defaultCapabilities: {
supportsVision: false,
supportsStreaming: true,
supportsFunctionCalling: true,
supportsJsonMode: true,
supportsReasoning: true,
supportsPreciseTokenCount: false,
},
modelIds: [
'GLM-5.1',
'GLM-5-Turbo',
'GLM-5',
'GLM-4.7',
'GLM-4.5-Air',
'glm-5.1',
'glm-5-turbo',
'glm-5',
'glm-4.7',
'glm-4.5-air',
],
})
+38
View File
@@ -0,0 +1,38 @@
import { defineBrand } from '../define.js'
export default defineBrand({
id: 'gpt',
label: 'GPT',
canonicalVendorId: 'openai',
defaultCapabilities: {
supportsVision: true,
supportsStreaming: true,
supportsFunctionCalling: true,
supportsJsonMode: true,
supportsReasoning: false,
supportsPreciseTokenCount: true,
},
modelIds: [
'gpt-5.5',
'gpt-5.5-mini',
'gpt-5.5-nano',
'gpt-5.4',
'gpt-5.4-mini',
'gpt-5.4-nano',
'gpt-5-mini',
'gpt-4.1',
'gpt-4.1-mini',
'gpt-4.1-nano',
'gpt-4o',
'gpt-4o-mini',
'gpt-4-turbo',
'gpt-4',
'o1-preview',
'o1-mini',
'o1-pro',
'o1',
'o3-mini',
'o3',
'o4-mini',
],
})
+26
View File
@@ -0,0 +1,26 @@
import { defineBrand } from '../define.js'
export default defineBrand({
id: 'kimi',
label: 'Kimi',
canonicalVendorId: 'moonshot',
defaultCapabilities: {
supportsVision: true,
supportsStreaming: true,
supportsFunctionCalling: true,
supportsJsonMode: true,
supportsReasoning: true,
supportsPreciseTokenCount: false,
},
modelIds: [
'kimi-for-coding',
'kimi-k2.6',
'kimi-k2.5',
'kimi-k2-thinking',
'kimi-k2-instruct',
'kimi-k2',
'moonshot-v1-128k',
'moonshot-v1-32k',
'moonshot-v1-8k',
],
})
+32
View File
@@ -0,0 +1,32 @@
import { defineBrand } from '../define.js'
export default defineBrand({
id: 'llama',
label: 'Llama',
canonicalVendorId: 'openai',
defaultCapabilities: {
supportsVision: false,
supportsStreaming: true,
supportsFunctionCalling: true,
supportsJsonMode: true,
supportsReasoning: false,
supportsPreciseTokenCount: false,
},
modelIds: [
'llama-3.3-70b-versatile',
'llama-3.3-70b',
'llama-3.1-8b-instant',
'llama-3.1-8b',
'llama3.3:70b',
'llama3.2:3b',
'llama3.2:1b',
'llama3.1:8b',
'meta/llama-3.1-405b-instruct',
'meta/llama-3.1-70b-instruct',
'meta/llama-3.1-8b-instruct',
'meta/llama-3.2-90b-instruct',
'meta/llama-3.2-3b-instruct',
'meta/llama-3.2-1b-instruct',
'meta/llama-3.3-70b-instruct',
],
})
+28
View File
@@ -0,0 +1,28 @@
import { defineBrand } from '../define.js'
export default defineBrand({
id: 'minimax',
label: 'MiniMax',
canonicalVendorId: 'minimax',
defaultCapabilities: {
supportsVision: true,
supportsStreaming: true,
supportsFunctionCalling: true,
supportsJsonMode: true,
supportsReasoning: true,
supportsPreciseTokenCount: false,
},
modelIds: [
'minimax-m2',
'minimax-m2.1',
'minimax-m2.1-highspeed',
'minimax-m2.5',
'minimax-m2.5-highspeed',
'minimax-m2.7',
'minimax-m2.7-highspeed',
'minimax-text-01',
'minimax-text-01-preview',
'minimax-vision-01',
'minimax-vision-01-fast',
],
})
+24
View File
@@ -0,0 +1,24 @@
import { defineBrand } from '../define.js'
export default defineBrand({
id: 'mistral',
label: 'Mistral',
canonicalVendorId: 'openai',
defaultCapabilities: {
supportsVision: false,
supportsStreaming: true,
supportsFunctionCalling: true,
supportsJsonMode: true,
supportsReasoning: false,
supportsPreciseTokenCount: false,
},
modelIds: [
'mistral-large-latest',
'mistral-small-latest',
'devstral-latest',
'ministral-3b-latest',
'mixtral-8x7b-32768',
'codestral',
'mistral:7b',
],
})
+18
View File
@@ -0,0 +1,18 @@
import { defineBrand } from '../define.js'
export default defineBrand({
id: 'nemotron',
label: 'Nemotron',
canonicalVendorId: 'openai',
defaultCapabilities: {
supportsVision: false,
supportsStreaming: true,
supportsFunctionCalling: true,
supportsJsonMode: true,
supportsReasoning: false,
supportsPreciseTokenCount: false,
},
modelIds: [
'nvidia/llama-3.1-nemotron-70b-instruct',
],
})
@@ -0,0 +1,15 @@
import { defineBrand } from '../define.js'
export default defineBrand({
id: 'openai-compatible-alias',
label: 'OpenAI-Compatible Alias',
canonicalVendorId: 'openai',
defaultCapabilities: {
supportsVision: false,
supportsStreaming: true,
supportsFunctionCalling: true,
supportsJsonMode: true,
supportsReasoning: false,
supportsPreciseTokenCount: false,
},
})
+23
View File
@@ -0,0 +1,23 @@
import { defineBrand } from '../define.js'
export default defineBrand({
id: 'qwen',
label: 'Qwen',
canonicalVendorId: 'openai',
defaultCapabilities: {
supportsVision: true,
supportsStreaming: true,
supportsFunctionCalling: true,
supportsJsonMode: true,
supportsReasoning: true,
supportsPreciseTokenCount: false,
},
modelIds: [
'qwen3.6-plus',
'qwen3.5-plus',
'qwen3-coder-plus',
'qwen3-coder-next',
'qwen3-max',
'Qwen/Qwen3.5-9B',
],
})
+19
View File
@@ -0,0 +1,19 @@
import { defineBrand } from '../define.js'
export default defineBrand({
id: 'xai',
label: 'xAI',
canonicalVendorId: 'xai',
defaultCapabilities: {
supportsVision: true,
supportsStreaming: true,
supportsFunctionCalling: true,
supportsJsonMode: true,
supportsReasoning: true,
supportsPreciseTokenCount: false,
},
modelIds: [
'grok-4',
'grok-3',
],
})
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, test } from 'bun:test'
import './index.js'
import {
getGateway,
getVendor,
} from './index.js'
import {
PRESET_VENDOR_MAP,
gatewayIdForPreset,
routeForPreset,
vendorIdForPreset,
} from './compatibility.js'
import { resolveProfileRoute } from './profileResolver.js'
import type { ProviderPreset } from '../utils/providerProfiles.js'
const EXPECTED_PRESETS = [
'anthropic',
'openai',
'ollama',
'kimi-code',
'moonshotai',
'deepseek',
'gemini',
'mistral',
'together',
'groq',
'azure-openai',
'openrouter',
'lmstudio',
'dashscope-cn',
'dashscope-intl',
'custom',
'nvidia-nim',
'minimax',
'xai',
'zai',
'bankr',
'atomic-chat',
] as const satisfies readonly ProviderPreset[]
describe('compatibility mappings', () => {
test('cover every current provider preset exactly once', () => {
expect(PRESET_VENDOR_MAP.map(mapping => mapping.preset).sort()).toEqual(
[...EXPECTED_PRESETS].sort(),
)
expect(new Set(PRESET_VENDOR_MAP.map(mapping => mapping.preset)).size).toBe(
EXPECTED_PRESETS.length,
)
})
test('every preset resolves to an existing vendor and optional gateway', () => {
for (const preset of EXPECTED_PRESETS) {
const vendorId = vendorIdForPreset(preset)
const gatewayId = gatewayIdForPreset(preset)
const route = routeForPreset(preset)
expect(getVendor(vendorId)?.id).toBe(vendorId)
if (gatewayId) {
expect(getGateway(gatewayId)?.id).toBe(gatewayId)
}
expect(route.vendorId).toBe(vendorId)
expect(route.gatewayId).toBe(gatewayId)
expect(route.routeId).toBe(gatewayId ?? vendorId)
}
})
test('native gateway profile routes use their descriptor vendor', () => {
expect(resolveProfileRoute('bedrock')).toEqual({
vendorId: 'anthropic',
gatewayId: 'bedrock',
routeId: 'bedrock',
})
expect(resolveProfileRoute('vertex')).toEqual({
vendorId: 'anthropic',
gatewayId: 'vertex',
routeId: 'vertex',
})
})
})
+60
View File
@@ -0,0 +1,60 @@
// src/integrations/compatibility.ts
// Maps legacy preset names to descriptor-authored route ids.
// This bridge preserves backward compatibility for stored provider profiles.
import type { ProviderPresetManifestEntry } from './descriptors.js'
import {
PROVIDER_PRESET_MANIFEST,
type ProviderPreset,
} from './generated/integrationArtifacts.generated.js'
export const PRESET_VENDOR_MAP: Array<{
preset: ProviderPreset
vendorId: string
gatewayId?: string
}> = PROVIDER_PRESET_MANIFEST.map(entry => ({
preset: entry.preset,
vendorId: entry.vendorId,
gatewayId: 'gatewayId' in entry ? entry.gatewayId : undefined,
}))
const PRESET_ROUTE_MAP = new Map<ProviderPreset, ProviderPresetManifestEntry>(
PROVIDER_PRESET_MANIFEST.map(entry => [
entry.preset,
entry as ProviderPresetManifestEntry,
] as const),
)
export function isProviderPreset(value: string): value is ProviderPreset {
return PRESET_ROUTE_MAP.has(value as ProviderPreset)
}
function getPresetEntry(preset: ProviderPreset) {
const entry = PRESET_ROUTE_MAP.get(preset)
if (!entry) {
throw new Error(`Unknown preset: ${preset}`)
}
return entry
}
export function vendorIdForPreset(preset: ProviderPreset): string {
return getPresetEntry(preset).vendorId
}
export function gatewayIdForPreset(preset: ProviderPreset): string | undefined {
return getPresetEntry(preset).gatewayId
}
export function routeForPreset(preset: ProviderPreset): {
vendorId: string
gatewayId?: string
routeId: string
} {
const entry = getPresetEntry(preset)
return {
vendorId: entry.vendorId,
gatewayId: entry.gatewayId,
routeId: entry.routeId,
}
}
+36
View File
@@ -0,0 +1,36 @@
// src/integrations/define.ts
// Lightweight typed helpers for descriptor authoring.
// Contributors import these instead of registry functions or descriptor types.
import type {
AnthropicProxyDescriptor,
BrandDescriptor,
GatewayDescriptor,
ModelCatalogConfig,
ModelDescriptor,
VendorDescriptor,
} from './descriptors.js'
export function defineVendor(d: VendorDescriptor): VendorDescriptor {
return d
}
export function defineGateway(d: GatewayDescriptor): GatewayDescriptor {
return d
}
export function defineAnthropicProxy(d: AnthropicProxyDescriptor): AnthropicProxyDescriptor {
return d
}
export function defineBrand(d: BrandDescriptor): BrandDescriptor {
return d
}
export function defineModel(d: ModelDescriptor): ModelDescriptor {
return d
}
export function defineCatalog(d: ModelCatalogConfig): ModelCatalogConfig {
return d
}
+277
View File
@@ -0,0 +1,277 @@
// src/integrations/descriptors.ts
// Core descriptor types for the integration registry.
// This file contains only type definitions — no runtime logic.
export type AuthMode = 'api-key' | 'oauth' | 'adc' | 'token' | 'none'
export type TransportKind =
| 'anthropic-native'
| 'anthropic-proxy'
| 'openai-compatible'
| 'local'
| 'gemini-native'
| 'bedrock'
| 'vertex'
export type OpenAIShimTokenField = 'max_tokens' | 'max_completion_tokens'
export interface OpenAIShimTransportConfig {
headers?: Record<string, string>
supportsApiFormatSelection?: boolean
supportsAuthHeaders?: boolean
preserveReasoningContent?: boolean
requireReasoningContentOnAssistantMessages?: boolean
reasoningContentFallback?: '' | 'omit'
thinkingRequestFormat?: 'none' | 'deepseek-compatible'
maxTokensField?: OpenAIShimTokenField
removeBodyFields?: string[]
}
export interface CapabilityFlags {
supportsVision?: boolean
supportsStreaming?: boolean
supportsFunctionCalling?: boolean
supportsJsonMode?: boolean
supportsReasoning?: boolean
supportsPreciseTokenCount?: boolean
supportsEmbeddings?: boolean
}
export interface TransportConfig {
kind: TransportKind
headers?: Record<string, string>
openaiShim?: OpenAIShimTransportConfig
}
export interface CatalogTransportOverrides {
openaiShim?: Partial<OpenAIShimTransportConfig>
}
export interface CacheConfig {
supported?: boolean
maxCachedTokens?: number
cachePrefix?: string
}
export type ModelCatalogSource = 'static' | 'dynamic' | 'hybrid'
export type DurationString = `${number}m` | `${number}h` | `${number}d`
export type DiscoveryRefreshMode = 'manual' | 'on-open' | 'background-if-stale' | 'startup'
export type ReadinessProbeKind = 'ollama-generation' | 'openai-compatible-models'
export interface ModelCatalogEntry {
id: string
apiName: string
label?: string
default?: boolean
hidden?: boolean
modelDescriptorId?: string
capabilities?: CapabilityFlags
contextWindow?: number
maxOutputTokens?: number
transportOverrides?: CatalogTransportOverrides
notes?: string
}
export interface ModelCatalogConfig {
source: ModelCatalogSource
discovery?: ModelDiscoveryConfig
discoveryCacheTtl?: DurationString | number
discoveryRefreshMode?: DiscoveryRefreshMode
allowManualRefresh?: boolean
models?: ModelCatalogEntry[]
}
export type ModelDiscoveryKind = 'openai-compatible' | 'ollama' | 'custom'
export interface ModelDiscoveryConfig {
kind: ModelDiscoveryKind
path?: string
parse?: 'openai-models-list' | 'ollama-tags' | 'custom'
mapModel?: (raw: unknown) => ModelCatalogEntry | null
}
export interface SetupMetadata {
requiresAuth: boolean
authMode: AuthMode
credentialEnvVars?: string[]
setupPrompt?: string
}
export interface StartupMetadata {
autoDetectable?: boolean
probeReadiness?: ReadinessProbeKind
enablementEnvVar?: string
}
export interface UsageMetadata {
supported: boolean
delegateToVendorId?: string
delegateToGatewayId?: string
fetchModule?: string
parseModule?: string
ui?: {
showResetCountdown?: boolean
compactProgressBar?: boolean
fallbackMessage?: string
}
silentlyIgnore?: boolean
}
export interface InvalidCredentialValue {
envVar: string
value: string
message: string
}
export interface ValidationRoutingMetadata {
enablementEnvVar?: string
matchDefaultBaseUrl?: boolean
matchBaseUrlHosts?: string[]
fallbackWhenUseOpenAI?: boolean
skipWhenUseOpenAI?: boolean
}
export interface ProviderPresetMetadata {
id: string
description: string
label?: string
name?: string
vendorId?: string
apiKeyEnvVars?: string[]
baseUrlEnvVars?: string[]
modelEnvVars?: string[]
fallbackBaseUrl?: string
fallbackModel?: string
}
export type ProviderPresetRouteKind =
| 'vendor'
| 'gateway'
| 'anthropic-proxy'
export interface ProviderPresetManifestEntry {
preset: string
routeKind: ProviderPresetRouteKind
routeId: string
vendorId: string
gatewayId?: string
description: string
label?: string
name?: string
apiKeyEnvVars?: readonly string[]
baseUrlEnvVars?: readonly string[]
modelEnvVars?: readonly string[]
fallbackBaseUrl?: string
fallbackModel?: string
}
export type ValidationMetadata =
| {
routing?: ValidationRoutingMetadata
kind: 'credential-env'
credentialEnvVars: string[]
allowLocalBaseUrlWithoutCredential?: boolean
missingCredentialMessage?: string
invalidCredentialValues?: InvalidCredentialValue[]
}
| {
routing?: ValidationRoutingMetadata
kind: 'gemini-credential'
missingCredentialMessage: string
}
| {
routing?: ValidationRoutingMetadata
kind: 'github-token'
missingCredentialMessage: string
expiredCredentialMessage: string
invalidCredentialMessage: string
}
export interface VendorDescriptor {
id: string
label: string
classification: 'anthropic' | 'openai-compatible' | 'native'
defaultBaseUrl: string
defaultModel: string
requiredEnvVars?: string[]
validate?: (env: NodeJS.ProcessEnv) => string | null
setup: SetupMetadata
startup?: StartupMetadata
isFirstParty?: boolean
transportConfig: TransportConfig
catalog?: ModelCatalogConfig
usage?: UsageMetadata
validation?: ValidationMetadata
preset?: ProviderPresetMetadata
}
export interface GatewayDescriptor {
id: string
label: string
vendorId?: string
category?: 'local' | 'hosted' | 'aggregating'
defaultBaseUrl?: string
defaultModel?: string
supportsModelRouting?: boolean
setup: SetupMetadata
startup?: StartupMetadata
transportConfig: TransportConfig
catalog?: ModelCatalogConfig
usage?: UsageMetadata
validation?: ValidationMetadata
preset?: ProviderPresetMetadata
}
export interface AnthropicProxyDescriptor {
id: string
label: string
classification: 'anthropic-proxy'
defaultBaseUrl: string
defaultModel: string
requiredEnvVars?: string[]
validate?: (env: NodeJS.ProcessEnv) => string | null
setup: SetupMetadata
startup?: StartupMetadata
envVarConfig: {
authTokenEnvVar: string
baseUrlEnvVar: string
modelEnvVar?: string
}
capabilities: CapabilityFlags
transportConfig: TransportConfig
catalog?: ModelCatalogConfig
usage?: UsageMetadata
validation?: ValidationMetadata
preset?: ProviderPresetMetadata
}
export interface BrandDescriptor {
id: string
label: string
canonicalVendorId: string
defaultContextWindow?: number
defaultMaxOutputTokens?: number
defaultCapabilities: CapabilityFlags
modelIds?: string[]
}
export interface ModelDescriptor {
id: string
label: string
brandId?: string
vendorId: string
gatewayId?: string
classification: ('chat' | 'reasoning' | 'vision' | 'coding')[]
defaultModel: string
providerModelMap?: Partial<Record<string, string>>
capabilities: CapabilityFlags
contextWindow?: number
maxOutputTokens?: number
cacheConfig?: CacheConfig
}
export interface RegistryValidationResult {
valid: boolean
errors: string[]
warnings: string[]
}
+235
View File
@@ -0,0 +1,235 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import type { ModelCatalogEntry } from './descriptors.js'
import {
clearDiscoveryCache,
getCachedModels,
getDiscoveryCachePath,
isCacheStale,
parseDurationString,
recordDiscoveryError,
setCachedModels,
} from './discoveryCache.js'
import {
getFsImplementation,
setFsImplementation,
setOriginalFsImplementation,
} from '../utils/fsOperations.js'
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
let tempDir: string
function createModel(id: string): ModelCatalogEntry {
return {
id,
apiName: id,
label: id,
}
}
beforeEach(async () => {
tempDir = mkdtempSync(join(tmpdir(), 'openclaude-discovery-cache-test-'))
process.env.CLAUDE_CONFIG_DIR = tempDir
setOriginalFsImplementation()
await clearDiscoveryCache()
})
afterEach(() => {
setOriginalFsImplementation()
if (originalConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
}
rmSync(tempDir, { recursive: true, force: true })
})
describe('parseDurationString', () => {
test('parses minute, hour, day, and raw millisecond values', () => {
expect(parseDurationString('30m')).toBe(1_800_000)
expect(parseDurationString('1h')).toBe(3_600_000)
expect(parseDurationString('2d')).toBe(172_800_000)
expect(parseDurationString('5000')).toBe(5000)
expect(parseDurationString(2500)).toBe(2500)
})
test('rejects invalid duration values', () => {
expect(() => parseDurationString('')).toThrow()
expect(() => parseDurationString('-1')).toThrow()
expect(() => parseDurationString('15s')).toThrow()
})
})
describe('discovery cache storage', () => {
test('stores and returns fresh cached models, then reports stale entries', async () => {
const updatedAt = Date.now() - 250
await setCachedModels('ollama', {
models: [createModel('llama3')],
updatedAt,
})
await expect(getCachedModels('ollama', 1_000)).resolves.toEqual({
models: [createModel('llama3')],
updatedAt,
error: null,
})
await expect(isCacheStale('ollama', 1_000)).resolves.toBe(false)
await expect(getCachedModels('ollama', 100)).resolves.toBeNull()
await expect(
getCachedModels('ollama', 100, { includeStale: true }),
).resolves.toEqual({
models: [createModel('llama3')],
updatedAt,
error: null,
})
await expect(isCacheStale('ollama', 100)).resolves.toBe(true)
})
test('recordDiscoveryError preserves stale data and appends error metadata', async () => {
const updatedAt = Date.now() - 10_000
await setCachedModels('openrouter', {
models: [createModel('openai/gpt-5-mini')],
updatedAt,
})
await recordDiscoveryError('openrouter', new Error('discovery failed'))
const raw = JSON.parse(
readFileSync(getDiscoveryCachePath(), 'utf-8'),
) as {
entries: Record<
string,
{
models: ModelCatalogEntry[]
updatedAt: number | null
error: { message: string; recordedAt: number } | null
}
>
}
expect(raw.entries.openrouter.models).toEqual([
createModel('openai/gpt-5-mini'),
])
expect(raw.entries.openrouter.updatedAt).toBe(updatedAt)
expect(raw.entries.openrouter.error?.message).toBe('discovery failed')
expect(raw.entries.openrouter.error?.recordedAt).toBeNumber()
await expect(getCachedModels('openrouter', 1_000)).resolves.toBeNull()
await expect(
getCachedModels('openrouter', 1_000, { includeStale: true }),
).resolves.toEqual({
models: [createModel('openai/gpt-5-mini')],
updatedAt,
error: {
message: 'discovery failed',
recordedAt: expect.any(Number),
},
})
})
test('recordDiscoveryError stores error-only entry when no cache exists', async () => {
await recordDiscoveryError('lmstudio', 'boom')
const raw = JSON.parse(
readFileSync(getDiscoveryCachePath(), 'utf-8'),
) as {
entries: Record<
string,
{
models: ModelCatalogEntry[]
updatedAt: number | null
error: { message: string; recordedAt: number } | null
}
>
}
expect(raw.entries.lmstudio.models).toEqual([])
expect(raw.entries.lmstudio.updatedAt).toBeNull()
expect(raw.entries.lmstudio.error?.message).toBe('boom')
await expect(getCachedModels('lmstudio', 1_000)).resolves.toBeNull()
await expect(
getCachedModels('lmstudio', 1_000, { includeStale: true }),
).resolves.toEqual({
models: [],
updatedAt: null,
error: {
message: 'boom',
recordedAt: expect.any(Number),
},
})
await expect(isCacheStale('lmstudio', 1_000)).resolves.toBe(true)
})
test('clearDiscoveryCache clears one route or the full cache', async () => {
await setCachedModels('ollama', { models: [createModel('llama3')] })
await setCachedModels('atomic-chat', { models: [createModel('qwen3')] })
await clearDiscoveryCache('ollama')
await expect(getCachedModels('ollama', 60_000)).resolves.toBeNull()
await expect(getCachedModels('atomic-chat', 60_000)).resolves.not.toBeNull()
await clearDiscoveryCache()
await expect(getCachedModels('atomic-chat', 60_000)).resolves.toBeNull()
})
test('corruption fallback returns empty cache without crashing', async () => {
writeFileSync(getDiscoveryCachePath(), '{not-json', 'utf-8')
await expect(getCachedModels('ollama', 60_000)).resolves.toBeNull()
await expect(isCacheStale('ollama', 60_000)).resolves.toBe(true)
})
})
describe('discovery cache write safety', () => {
test('failed atomic rename preserves existing cache file', async () => {
await setCachedModels('ollama', { models: [createModel('llama3')] })
const originalFs = getFsImplementation()
setFsImplementation({
...originalFs,
rename: async () => {
throw new Error('simulated rename crash')
},
})
await setCachedModels('ollama', { models: [createModel('qwen3')] })
setOriginalFsImplementation()
await expect(getCachedModels('ollama', 60_000)).resolves.toEqual({
models: [createModel('llama3')],
updatedAt: expect.any(Number),
error: null,
})
})
test('concurrent writes are serialized by the discovery cache lock', async () => {
const originalFs = getFsImplementation()
let activeRenames = 0
let maxActiveRenames = 0
setFsImplementation({
...originalFs,
rename: async (oldPath: string, newPath: string) => {
activeRenames++
maxActiveRenames = Math.max(maxActiveRenames, activeRenames)
await Bun.sleep(25)
try {
await originalFs.rename(oldPath, newPath)
} finally {
activeRenames--
}
},
})
await Promise.all([
setCachedModels('ollama', { models: [createModel('llama3')] }),
setCachedModels('openrouter', { models: [createModel('openai/gpt-5-mini')] }),
setCachedModels('atomic-chat', { models: [createModel('qwen3')] }),
])
expect(maxActiveRenames).toBe(1)
})
})
+331
View File
@@ -0,0 +1,331 @@
import { randomBytes } from 'crypto'
import { open } from 'fs/promises'
import { join } from 'path'
import type { ModelCatalogEntry } from './descriptors.js'
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
import { errorMessage } from '../utils/errors.js'
import { getFsImplementation } from '../utils/fsOperations.js'
import { logForDebugging } from '../utils/debug.js'
import { logError } from '../utils/log.js'
import { jsonParse, jsonStringify } from '../utils/slowOperations.js'
export const DISCOVERY_CACHE_VERSION = 1
const MIN_MIGRATABLE_VERSION = 1
const DISCOVERY_CACHE_FILENAME = 'model-discovery-cache.json'
export type DiscoveryCacheError = {
message: string
recordedAt: number
}
export type DiscoveryCacheEntry = {
models: ModelCatalogEntry[]
updatedAt: number | null
error: DiscoveryCacheError | null
}
type PersistedDiscoveryCache = {
version: number
entries: Record<string, DiscoveryCacheEntry>
}
let discoveryCacheLockPromise: Promise<void> | null = null
export async function withDiscoveryCacheLock<T>(
fn: () => Promise<T>,
): Promise<T> {
while (discoveryCacheLockPromise) {
await discoveryCacheLockPromise
}
let releaseLock: (() => void) | undefined
discoveryCacheLockPromise = new Promise<void>(resolve => {
releaseLock = resolve
})
try {
return await fn()
} finally {
discoveryCacheLockPromise = null
releaseLock?.()
}
}
export function getDiscoveryCachePath(): string {
return join(getClaudeConfigHomeDir(), DISCOVERY_CACHE_FILENAME)
}
function getEmptyDiscoveryCache(): PersistedDiscoveryCache {
return {
version: DISCOVERY_CACHE_VERSION,
entries: {},
}
}
function normalizeDiscoveryCacheEntry(
entry: unknown,
): DiscoveryCacheEntry | null {
if (!entry || typeof entry !== 'object') {
return null
}
const candidate = entry as {
models?: unknown
updatedAt?: unknown
error?: unknown
}
if (!Array.isArray(candidate.models)) {
return null
}
const updatedAt =
candidate.updatedAt === null
? null
: typeof candidate.updatedAt === 'number' &&
Number.isFinite(candidate.updatedAt)
? candidate.updatedAt
: null
const error =
candidate.error &&
typeof candidate.error === 'object' &&
typeof (candidate.error as { message?: unknown }).message === 'string' &&
typeof (candidate.error as { recordedAt?: unknown }).recordedAt === 'number'
? {
message: (candidate.error as { message: string }).message,
recordedAt: (candidate.error as { recordedAt: number }).recordedAt,
}
: null
return {
models: candidate.models as ModelCatalogEntry[],
updatedAt,
error,
}
}
function migrateDiscoveryCache(
parsed: { version?: unknown; entries?: unknown },
): PersistedDiscoveryCache | null {
if (
typeof parsed.version !== 'number' ||
parsed.version < MIN_MIGRATABLE_VERSION ||
parsed.version > DISCOVERY_CACHE_VERSION ||
!parsed.entries ||
typeof parsed.entries !== 'object' ||
Array.isArray(parsed.entries)
) {
return null
}
const entries = Object.fromEntries(
Object.entries(parsed.entries)
.map(([routeId, entry]) => [routeId, normalizeDiscoveryCacheEntry(entry)])
.filter((value): value is [string, DiscoveryCacheEntry] => value[1] !== null),
)
return {
version: DISCOVERY_CACHE_VERSION,
entries,
}
}
async function loadDiscoveryCache(): Promise<PersistedDiscoveryCache> {
const fs = getFsImplementation()
const cachePath = getDiscoveryCachePath()
try {
const content = await fs.readFile(cachePath, { encoding: 'utf-8' })
const parsed = jsonParse(content) as {
version?: unknown
entries?: unknown
}
if (parsed.version !== DISCOVERY_CACHE_VERSION) {
const migrated = migrateDiscoveryCache(parsed)
if (!migrated) {
logForDebugging(
`Discovery cache version ${String(parsed.version)} not migratable (expected ${DISCOVERY_CACHE_VERSION}), returning empty cache`,
)
return getEmptyDiscoveryCache()
}
await saveDiscoveryCache(migrated)
return migrated
}
const migrated = migrateDiscoveryCache(parsed)
if (!migrated) {
logForDebugging(
'Discovery cache has invalid structure, returning empty cache',
)
return getEmptyDiscoveryCache()
}
return migrated
} catch (error) {
logForDebugging(`Failed to load discovery cache: ${errorMessage(error)}`)
return getEmptyDiscoveryCache()
}
}
async function saveDiscoveryCache(
cache: PersistedDiscoveryCache,
): Promise<void> {
const fs = getFsImplementation()
const cachePath = getDiscoveryCachePath()
const tempPath = `${cachePath}.${randomBytes(8).toString('hex')}.tmp`
try {
await fs.mkdir(getClaudeConfigHomeDir())
const content = jsonStringify(cache, null, 2)
const handle = await open(tempPath, 'w', 0o600)
try {
await handle.writeFile(content, { encoding: 'utf-8' })
await handle.sync()
} finally {
await handle.close()
}
await fs.rename(tempPath, cachePath)
} catch (error) {
logError(error)
try {
await fs.unlink(tempPath)
} catch {
// Ignore cleanup errors.
}
}
}
function normalizeErrorMessage(error: unknown): string {
const message = errorMessage(error).trim()
return message || 'Unknown discovery error'
}
export function parseDurationString(input: number | string): number {
if (typeof input === 'number') {
if (!Number.isFinite(input) || input < 0) {
throw new Error(`Invalid duration value: ${String(input)}`)
}
return input
}
const trimmed = input.trim()
if (!trimmed) {
throw new Error('Invalid duration value: empty string')
}
if (/^\d+$/.test(trimmed)) {
return Number(trimmed)
}
const match = trimmed.match(/^(\d+)([mhd])$/i)
if (!match) {
throw new Error(`Invalid duration value: ${input}`)
}
const value = Number(match[1])
const unit = match[2]!.toLowerCase()
const multipliers: Record<string, number> = {
m: 60_000,
h: 3_600_000,
d: 86_400_000,
}
return value * multipliers[unit]!
}
export async function getCachedModels(
routeId: string,
ttlMs: number,
options?: {
includeStale?: boolean
},
): Promise<DiscoveryCacheEntry | null> {
const cache = await loadDiscoveryCache()
const entry = cache.entries[routeId]
if (!entry) {
return null
}
if (options?.includeStale) {
return entry
}
if (entry.updatedAt === null) {
return null
}
if (Date.now() - entry.updatedAt > ttlMs) {
return null
}
return entry
}
export async function isCacheStale(
routeId: string,
ttlMs: number,
): Promise<boolean> {
const cache = await loadDiscoveryCache()
const entry = cache.entries[routeId]
if (!entry || entry.updatedAt === null) {
return true
}
return Date.now() - entry.updatedAt > ttlMs
}
export async function setCachedModels(
routeId: string,
entry: {
models: ModelCatalogEntry[]
updatedAt?: number
},
): Promise<void> {
await withDiscoveryCacheLock(async () => {
const cache = await loadDiscoveryCache()
cache.entries[routeId] = {
models: entry.models,
updatedAt: entry.updatedAt ?? Date.now(),
error: null,
}
await saveDiscoveryCache(cache)
})
}
export async function recordDiscoveryError(
routeId: string,
error: unknown,
): Promise<void> {
await withDiscoveryCacheLock(async () => {
const cache = await loadDiscoveryCache()
const currentEntry = cache.entries[routeId]
cache.entries[routeId] = {
models: currentEntry?.models ?? [],
updatedAt: currentEntry?.updatedAt ?? null,
error: {
message: normalizeErrorMessage(error),
recordedAt: Date.now(),
},
}
await saveDiscoveryCache(cache)
})
}
export async function clearDiscoveryCache(routeId?: string): Promise<void> {
await withDiscoveryCacheLock(async () => {
if (routeId) {
const cache = await loadDiscoveryCache()
delete cache.entries[routeId]
await saveDiscoveryCache(cache)
return
}
await saveDiscoveryCache(getEmptyDiscoveryCache())
})
}
+441
View File
@@ -0,0 +1,441 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { registerGateway } from './index.js'
const originalFetch = globalThis.fetch
const originalEnv = {
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY,
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
OPENAI_API_BASE: process.env.OPENAI_API_BASE,
OPENAI_MODEL: process.env.OPENAI_MODEL,
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI,
CLAUDE_CODE_USE_MISTRAL: process.env.CLAUDE_CODE_USE_MISTRAL,
CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB,
CLAUDE_CODE_USE_BEDROCK: process.env.CLAUDE_CODE_USE_BEDROCK,
CLAUDE_CODE_USE_VERTEX: process.env.CLAUDE_CODE_USE_VERTEX,
CLAUDE_CODE_USE_FOUNDRY: process.env.CLAUDE_CODE_USE_FOUNDRY,
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC:
process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC,
}
let tempDir: string
async function loadDiscoveryServiceModule() {
return import(`./discoveryService.js?ts=${Date.now()}-${Math.random()}`)
}
function setMockFetch(
implementation: typeof globalThis.fetch,
): void {
globalThis.fetch = implementation
}
function restoreEnvValue(
key: keyof typeof originalEnv,
): void {
const value = originalEnv[key]
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
function clearProviderEnv(): void {
delete process.env.OPENAI_BASE_URL
delete process.env.OPENAI_API_BASE
delete process.env.OPENAI_MODEL
delete process.env.CLAUDE_CODE_USE_OPENAI
delete process.env.CLAUDE_CODE_USE_GEMINI
delete process.env.CLAUDE_CODE_USE_MISTRAL
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.CLAUDE_CODE_USE_BEDROCK
delete process.env.CLAUDE_CODE_USE_VERTEX
delete process.env.CLAUDE_CODE_USE_FOUNDRY
}
beforeEach(() => {
mock.restore()
tempDir = mkdtempSync(join(tmpdir(), 'openclaude-discovery-service-test-'))
process.env.CLAUDE_CONFIG_DIR = tempDir
delete process.env.OPENROUTER_API_KEY
clearProviderEnv()
globalThis.fetch = originalFetch
})
afterEach(() => {
mock.restore()
globalThis.fetch = originalFetch
rmSync(tempDir, { recursive: true, force: true })
restoreEnvValue('CLAUDE_CONFIG_DIR')
restoreEnvValue('OPENROUTER_API_KEY')
restoreEnvValue('OPENAI_BASE_URL')
restoreEnvValue('OPENAI_API_BASE')
restoreEnvValue('OPENAI_MODEL')
restoreEnvValue('CLAUDE_CODE_USE_OPENAI')
restoreEnvValue('CLAUDE_CODE_USE_GEMINI')
restoreEnvValue('CLAUDE_CODE_USE_MISTRAL')
restoreEnvValue('CLAUDE_CODE_USE_GITHUB')
restoreEnvValue('CLAUDE_CODE_USE_BEDROCK')
restoreEnvValue('CLAUDE_CODE_USE_VERTEX')
restoreEnvValue('CLAUDE_CODE_USE_FOUNDRY')
restoreEnvValue('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC')
})
describe('discoverModelsForRoute', () => {
test('uses built-in openai-compatible discovery and caches results for dynamic routes', async () => {
const { discoverModelsForRoute } = await loadDiscoveryServiceModule()
let callCount = 0
setMockFetch(mock((input: string | URL | Request, init?: RequestInit) => {
callCount++
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
expect(url).toBe('http://127.0.0.1:1337/v1/models')
expect(init?.headers).toBeUndefined()
return Promise.resolve(
new Response(
JSON.stringify({
data: [{ id: 'Qwen3_5-4B_Q4_K_M' }],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
}) as unknown as typeof globalThis.fetch)
const first = await discoverModelsForRoute('atomic-chat')
const second = await discoverModelsForRoute('atomic-chat')
expect(first).toMatchObject({
routeId: 'atomic-chat',
source: 'network',
stale: false,
models: [{ id: 'Qwen3_5-4B_Q4_K_M', apiName: 'Qwen3_5-4B_Q4_K_M' }],
})
expect(second?.source).toBe('cache')
expect(callCount).toBe(1)
})
test('partitions cached discovery results by endpoint base URL', async () => {
const { discoverModelsForRoute } = await loadDiscoveryServiceModule()
let callCount = 0
setMockFetch(mock((input: string | URL | Request) => {
callCount++
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
const model = url.startsWith('http://remote-a.example/v1/')
? 'remote-a-model'
: 'remote-b-model'
return Promise.resolve(
new Response(
JSON.stringify({
data: [{ id: model }],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
}) as unknown as typeof globalThis.fetch)
const firstRemoteA = await discoverModelsForRoute('atomic-chat', {
baseUrl: 'http://remote-a.example/v1',
})
const firstRemoteB = await discoverModelsForRoute('atomic-chat', {
baseUrl: 'http://remote-b.example/v1',
})
const secondRemoteA = await discoverModelsForRoute('atomic-chat', {
baseUrl: 'http://remote-a.example/v1',
})
expect(firstRemoteA?.source).toBe('network')
expect(firstRemoteA?.models.map(model => model.apiName)).toEqual([
'remote-a-model',
])
expect(firstRemoteB?.source).toBe('network')
expect(firstRemoteB?.models.map(model => model.apiName)).toEqual([
'remote-b-model',
])
expect(secondRemoteA?.source).toBe('cache')
expect(secondRemoteA?.models.map(model => model.apiName)).toEqual([
'remote-a-model',
])
expect(callCount).toBe(2)
})
test('preserves stale cache data when refresh fails', async () => {
const { discoverModelsForRoute } = await loadDiscoveryServiceModule()
setMockFetch(mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
models: [{ name: 'llama3.1:8b', size: 1024 }],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
) as unknown as typeof globalThis.fetch)
const first = await discoverModelsForRoute('ollama', { forceRefresh: true })
expect(first?.source).toBe('network')
setMockFetch(mock(() =>
Promise.resolve(new Response('unavailable', { status: 503 })),
) as unknown as typeof globalThis.fetch)
const second = await discoverModelsForRoute('ollama', { forceRefresh: true })
expect(second).toMatchObject({
source: 'stale-cache',
stale: true,
models: [{ id: 'llama3.1:8b', apiName: 'llama3.1:8b' }],
})
expect(second?.error?.message).toContain('Discovery failed')
})
test('hybrid routes keep curated descriptor entries ahead of discovered duplicates', async () => {
const { discoverModelsForRoute } = await loadDiscoveryServiceModule()
process.env.OPENROUTER_API_KEY = 'or-key'
setMockFetch(mock((_input, init) => {
expect(init?.headers).toEqual({ Authorization: 'Bearer or-key' })
return Promise.resolve(
new Response(
JSON.stringify({
data: [
{ id: 'openai/gpt-5-mini' },
{ id: 'anthropic/claude-sonnet-4' },
],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
}) as unknown as typeof globalThis.fetch)
const result = await discoverModelsForRoute('openrouter', {
forceRefresh: true,
})
expect(result?.models.map((model: { apiName: string }) => model.apiName)).toEqual([
'openai/gpt-5-mini',
'anthropic/claude-sonnet-4',
])
expect(result?.models[0]?.label).toBe('GPT-5 Mini (via OpenRouter)')
})
test('openai-compatible discovery applies descriptor static headers with auth', async () => {
const { discoverModelsForRoute } = await loadDiscoveryServiceModule()
registerGateway({
id: 'discovery-header-test',
label: 'Discovery Header Test',
category: 'hosted',
defaultBaseUrl: 'https://discovery-header-test.example/v1',
setup: {
requiresAuth: true,
authMode: 'api-key',
credentialEnvVars: ['DISCOVERY_HEADER_TEST_API_KEY'],
},
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
headers: {
'X-Static-Client': 'openclaude',
},
},
},
catalog: {
source: 'dynamic',
discovery: {
kind: 'openai-compatible',
},
},
})
setMockFetch(mock((_input, init) => {
expect(init?.headers).toEqual({
'X-Static-Client': 'profile',
'X-Profile-Header': 'enabled',
Authorization: 'Bearer discovery-key',
})
return Promise.resolve(
new Response(
JSON.stringify({
data: [{ id: 'discovered-model' }],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
}) as unknown as typeof globalThis.fetch)
const result = await discoverModelsForRoute('discovery-header-test', {
apiKey: 'discovery-key',
headers: {
'X-Static-Client': 'profile',
'X-Profile-Header': 'enabled',
},
forceRefresh: true,
})
expect(result?.source).toBe('network')
expect(result?.models.map((model: { apiName: string }) => model.apiName)).toEqual(['discovered-model'])
})
test('skips descriptor network discovery when nonessential traffic is disabled', async () => {
process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1'
process.env.OPENROUTER_API_KEY = 'or-key'
const { discoverModelsForRoute } = await loadDiscoveryServiceModule()
setMockFetch(mock(() => {
throw new Error('unexpected model discovery request')
}) as unknown as typeof globalThis.fetch)
const result = await discoverModelsForRoute('openrouter', {
apiKey: 'privacy-test-key',
forceRefresh: true,
})
const modelNames =
result?.models.map((model: { apiName: string }) => model.apiName) ?? []
expect(['static', 'cache', 'stale-cache']).toContain(result?.source)
expect(modelNames).toContain('openai/gpt-5-mini')
expect(globalThis.fetch).not.toHaveBeenCalled()
})
test('startup refresh mode performs discovery for startup routes and then reuses cache', async () => {
const { refreshStartupDiscoveryForRoute } = await loadDiscoveryServiceModule()
let callCount = 0
setMockFetch(mock((input: string | URL | Request) => {
callCount++
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
expect(url).toBe('http://localhost:1234/v1/models')
return Promise.resolve(
new Response(
JSON.stringify({
data: [{ id: 'local-model' }],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
}) as unknown as typeof globalThis.fetch)
const first = await refreshStartupDiscoveryForRoute('lmstudio')
const second = await refreshStartupDiscoveryForRoute('lmstudio')
expect(first?.source).toBe('network')
expect(first?.models.map((model: { apiName: string }) => model.apiName)).toEqual(['local-model'])
expect(second?.source).toBe('cache')
expect(callCount).toBe(1)
})
test('refreshStartupDiscoveryForActiveRoute resolves the active startup route from env', async () => {
const { refreshStartupDiscoveryForActiveRoute } =
await loadDiscoveryServiceModule()
const startupEnv: NodeJS.ProcessEnv = {
CLAUDE_CODE_USE_OPENAI: '1',
OPENAI_BASE_URL: 'http://127.0.0.1:1234/v1',
}
setMockFetch(mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
data: [{ id: 'local-model' }],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
) as unknown as typeof globalThis.fetch)
const result = await refreshStartupDiscoveryForActiveRoute({
processEnv: startupEnv,
})
expect(result?.routeId).toBe('lmstudio')
expect(result?.source).toBe('network')
})
})
describe('probeRouteReadiness', () => {
test('drives ollama readiness through descriptor metadata', async () => {
const { probeRouteReadiness } = await loadDiscoveryServiceModule()
setMockFetch(mock((input: string | URL | Request) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
if (url.endsWith('/api/tags')) {
return Promise.resolve(
new Response(
JSON.stringify({
models: [{ name: 'llama3.1:8b', size: 1024 }],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
}
return Promise.resolve(
new Response(
JSON.stringify({
message: { role: 'assistant', content: 'OK' },
done: true,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
}) as unknown as typeof globalThis.fetch)
await expect(probeRouteReadiness('ollama')).resolves.toMatchObject({
state: 'ready',
probeModel: 'llama3.1:8b',
})
})
test('drives atomic chat readiness through descriptor metadata', async () => {
const { probeRouteReadiness } = await loadDiscoveryServiceModule()
setMockFetch(mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
data: [{ id: 'Qwen3_5-4B_Q4_K_M' }],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
) as unknown as typeof globalThis.fetch)
await expect(probeRouteReadiness('atomic-chat')).resolves.toEqual({
state: 'ready',
models: ['Qwen3_5-4B_Q4_K_M'],
})
})
})
describe('resolveDiscoveryRouteIdFromBaseUrl', () => {
test('matches descriptor-backed routes by exact default base URL', async () => {
const { resolveDiscoveryRouteIdFromBaseUrl } =
await loadDiscoveryServiceModule()
expect(
resolveDiscoveryRouteIdFromBaseUrl('http://127.0.0.1:1337/v1'),
).toBe('atomic-chat')
expect(
resolveDiscoveryRouteIdFromBaseUrl('http://localhost:1234/v1'),
).toBe('lmstudio')
})
test('falls back to local-provider heuristics for Ollama aliases', async () => {
const { resolveDiscoveryRouteIdFromBaseUrl } =
await loadDiscoveryServiceModule()
expect(
resolveDiscoveryRouteIdFromBaseUrl('http://127.0.0.1:11434/v1'),
).toBe('ollama')
})
})
+503
View File
@@ -0,0 +1,503 @@
import { createHash } from 'node:crypto'
import {
getCachedModels,
isCacheStale,
parseDurationString,
recordDiscoveryError,
setCachedModels,
type DiscoveryCacheError,
} from './discoveryCache.js'
import type {
ModelCatalogConfig,
ModelCatalogEntry,
ReadinessProbeKind,
} from './descriptors.js'
import { resolveRouteIdFromBaseUrl } from './index.js'
import {
getRouteDescriptor,
resolveActiveRouteIdFromEnv,
resolveRouteCredentialValue,
} from './routeMetadata.js'
import type {
AtomicChatReadiness,
OllamaGenerationReadiness,
} from '../utils/providerDiscovery.js'
import {
listOpenAICompatibleModels,
probeOllamaModelCatalog,
probeAtomicChatReadiness,
probeOllamaGenerationReadiness,
} from '../utils/providerDiscovery.js'
import { isEssentialTrafficOnly } from '../utils/privacyLevel.js'
export type RouteDiscoveryResult = {
routeId: string
models: ModelCatalogEntry[]
stale: boolean
error: DiscoveryCacheError | null
source: 'network' | 'cache' | 'stale-cache' | 'static' | 'error'
}
export type OpenAICompatibleReadiness =
| { state: 'unreachable' }
| { state: 'no_models' }
| { state: 'ready'; models: string[] }
export type RouteReadinessResult =
| OllamaGenerationReadiness
| AtomicChatReadiness
| OpenAICompatibleReadiness
function shouldSkipNonessentialDiscoveryTraffic(): boolean {
return (
isEssentialTrafficOnly() ||
Boolean(process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC)
)
}
function getRouteCatalog(routeId: string): ModelCatalogConfig | null {
return getRouteDescriptor(routeId)?.catalog ?? null
}
export function resolveDiscoveryRouteIdFromBaseUrl(
baseUrl?: string,
): string | null {
return resolveRouteIdFromBaseUrl(baseUrl, { requireDiscovery: true })
}
function getCatalogEntries(
routeId: string,
): ModelCatalogEntry[] {
return getRouteCatalog(routeId)?.models ?? []
}
function getDiscoveryCacheTtlMs(
routeId: string,
): number {
const ttl = getRouteCatalog(routeId)?.discoveryCacheTtl ?? 0
return typeof ttl === 'string' || typeof ttl === 'number'
? parseDurationString(ttl)
: 0
}
function normalizeDiscoveryCacheBaseUrl(
baseUrl: string | undefined,
): string {
if (!baseUrl?.trim()) {
return ''
}
try {
const parsed = new URL(baseUrl)
parsed.hash = ''
parsed.search = ''
return parsed.toString().replace(/\/+$/, '').toLowerCase()
} catch {
return baseUrl.trim().replace(/\/+$/, '').toLowerCase()
}
}
function normalizeDiscoveryCacheHeaders(
headers: Record<string, string> | undefined,
): Array<[string, string]> {
return Object.entries(headers ?? {})
.map(([name, value]) => [name.trim().toLowerCase(), value.trim()] as const)
.filter(([name, value]) => name && value)
.sort(([leftName], [rightName]) => leftName.localeCompare(rightName))
}
function hashDiscoveryCachePartition(value: unknown): string {
return createHash('sha256')
.update(JSON.stringify(value))
.digest('hex')
.slice(0, 16)
}
export function getDiscoveryCacheKey(
routeId: string,
options?: {
baseUrl?: string
apiKey?: string
headers?: Record<string, string>
},
): string {
const partition = {
baseUrl: normalizeDiscoveryCacheBaseUrl(getRouteBaseUrl(routeId, options)),
apiKeyHash: options?.apiKey?.trim()
? hashDiscoveryCachePartition(options.apiKey.trim())
: '',
headers: normalizeDiscoveryCacheHeaders(
getRouteDiscoveryHeaders(routeId, options),
),
}
return `${routeId}:${hashDiscoveryCachePartition(partition)}`
}
function getRouteBaseUrl(
routeId: string,
options?: { baseUrl?: string },
): string | undefined {
return options?.baseUrl ?? getRouteDescriptor(routeId)?.defaultBaseUrl
}
function getRouteDiscoveryApiKey(
routeId: string,
options?: { apiKey?: string },
): string | undefined {
if (options?.apiKey?.trim()) {
return options.apiKey.trim()
}
return resolveRouteCredentialValue({
routeId,
processEnv: process.env,
})
}
function getRouteDiscoveryHeaders(
routeId: string,
options?: { headers?: Record<string, string> },
): Record<string, string> | undefined {
const transportConfig = getRouteDescriptor(routeId)?.transportConfig
const headers = {
...(transportConfig?.headers ?? {}),
...(transportConfig?.openaiShim?.headers ?? {}),
...(options?.headers ?? {}),
}
return Object.keys(headers).length > 0 ? headers : undefined
}
function toDiscoveredModelEntry(modelId: string): ModelCatalogEntry {
return {
id: modelId,
apiName: modelId,
label: modelId,
}
}
function toOllamaModelEntry(model: { name: string }): ModelCatalogEntry {
return {
id: model.name,
apiName: model.name,
label: model.name,
}
}
function mergeCatalogEntries(
staticEntries: ModelCatalogEntry[],
discoveredEntries: ModelCatalogEntry[],
): ModelCatalogEntry[] {
const merged = [...staticEntries]
const existingApiNames = new Set(
staticEntries.map(entry => entry.apiName.toLowerCase()),
)
for (const entry of discoveredEntries) {
if (existingApiNames.has(entry.apiName.toLowerCase())) {
continue
}
existingApiNames.add(entry.apiName.toLowerCase())
merged.push(entry)
}
return merged
}
async function runDiscovery(
routeId: string,
options?: {
baseUrl?: string
apiKey?: string
headers?: Record<string, string>
},
): Promise<ModelCatalogEntry[] | null> {
const catalog = getRouteCatalog(routeId)
const discovery = catalog?.discovery
if (!catalog || !discovery) {
return null
}
switch (discovery.kind) {
case 'ollama': {
const result = await probeOllamaModelCatalog({
baseUrl: getRouteBaseUrl(routeId, options),
})
if (!result.reachable) {
return null
}
return result.models.map(model => toOllamaModelEntry(model))
}
case 'openai-compatible': {
const models = await listOpenAICompatibleModels({
baseUrl: getRouteBaseUrl(routeId, options),
apiKey: getRouteDiscoveryApiKey(routeId, options),
headers: getRouteDiscoveryHeaders(routeId, options),
})
return models?.map(model => toDiscoveredModelEntry(model)) ?? null
}
case 'custom':
return null
}
}
export async function discoverModelsForRoute(
routeId: string,
options?: {
baseUrl?: string
apiKey?: string
headers?: Record<string, string>
forceRefresh?: boolean
},
): Promise<RouteDiscoveryResult | null> {
const catalog = getRouteCatalog(routeId)
if (!catalog) {
return null
}
const staticEntries = getCatalogEntries(routeId)
if (!catalog.discovery) {
return {
routeId,
models: staticEntries,
stale: false,
error: null,
source: 'static',
}
}
const ttlMs = getDiscoveryCacheTtlMs(routeId)
const cacheKey = getDiscoveryCacheKey(routeId, options)
if (!options?.forceRefresh && ttlMs > 0) {
const cached = await getCachedModels(cacheKey, ttlMs)
if (cached) {
return {
routeId,
models: mergeCatalogEntries(staticEntries, cached.models),
stale: false,
error: cached.error,
source: 'cache',
}
}
}
if (shouldSkipNonessentialDiscoveryTraffic()) {
const staleEntry = await getCachedModels(cacheKey, ttlMs, {
includeStale: true,
})
if (staleEntry) {
const stale = await isCacheStale(cacheKey, ttlMs)
return {
routeId,
models: mergeCatalogEntries(staticEntries, staleEntry.models),
stale,
error: staleEntry.error,
source: stale ? 'stale-cache' : 'cache',
}
}
return {
routeId,
models: staticEntries,
stale: false,
error: null,
source: 'static',
}
}
try {
const discovered = await runDiscovery(routeId, options)
if (discovered === null) {
throw new Error(`Discovery failed for route ${routeId}`)
}
await setCachedModels(cacheKey, { models: discovered })
return {
routeId,
models: mergeCatalogEntries(staticEntries, discovered),
stale: false,
error: null,
source: 'network',
}
} catch (error) {
await recordDiscoveryError(cacheKey, error)
const staleEntry = await getCachedModels(cacheKey, ttlMs, {
includeStale: true,
})
if (staleEntry) {
return {
routeId,
models: mergeCatalogEntries(staticEntries, staleEntry.models),
stale: true,
error: staleEntry.error,
source: 'stale-cache',
}
}
return {
routeId,
models: staticEntries,
stale: false,
error: {
message: error instanceof Error ? error.message : String(error),
recordedAt: Date.now(),
},
source: 'error',
}
}
}
export async function refreshStartupDiscoveryForRoute(
routeId: string,
options?: {
baseUrl?: string
apiKey?: string
headers?: Record<string, string>
},
): Promise<RouteDiscoveryResult | null> {
const catalog = getRouteCatalog(routeId)
if (!catalog?.discovery || catalog.discoveryRefreshMode !== 'startup') {
return null
}
const ttlMs = getDiscoveryCacheTtlMs(routeId)
const cacheKey = getDiscoveryCacheKey(routeId, options)
if (ttlMs > 0) {
const cached = await getCachedModels(cacheKey, ttlMs)
if (cached) {
return {
routeId,
models: mergeCatalogEntries(getCatalogEntries(routeId), cached.models),
stale: false,
error: cached.error,
source: 'cache',
}
}
}
return discoverModelsForRoute(routeId, {
...options,
forceRefresh: true,
})
}
export async function refreshStartupDiscoveryForActiveRoute(
options?: {
processEnv?: NodeJS.ProcessEnv
activeProfileProvider?: string
baseUrl?: string
apiKey?: string
headers?: Record<string, string>
},
): Promise<RouteDiscoveryResult | null> {
const processEnv = options?.processEnv ?? process.env
const baseUrl =
options?.baseUrl ??
processEnv.OPENAI_BASE_URL ??
processEnv.OPENAI_API_BASE
const routeId =
resolveActiveRouteIdFromEnv(processEnv, {
activeProfileProvider: options?.activeProfileProvider,
}) ??
resolveRouteIdFromBaseUrl(baseUrl)
if (!routeId || routeId === 'anthropic' || routeId === 'custom') {
return null
}
return refreshStartupDiscoveryForRoute(routeId, {
baseUrl,
headers: options?.headers,
apiKey:
options?.apiKey ??
resolveRouteCredentialValue({
routeId,
baseUrl,
processEnv,
activeProfileProvider: options?.activeProfileProvider,
}),
})
}
function getReadinessProbeKind(routeId: string): ReadinessProbeKind | null {
return getRouteDescriptor(routeId)?.startup?.probeReadiness ?? null
}
export function probeRouteReadiness(
routeId: 'ollama',
options?: {
baseUrl?: string
model?: string
timeoutMs?: number
apiKey?: string
},
): Promise<OllamaGenerationReadiness | null>
export function probeRouteReadiness(
routeId: 'atomic-chat',
options?: {
baseUrl?: string
model?: string
timeoutMs?: number
apiKey?: string
},
): Promise<AtomicChatReadiness | null>
export function probeRouteReadiness(
routeId: string,
options?: {
baseUrl?: string
model?: string
timeoutMs?: number
apiKey?: string
},
): Promise<RouteReadinessResult | null>
export async function probeRouteReadiness(
routeId: string,
options?: {
baseUrl?: string
model?: string
timeoutMs?: number
apiKey?: string
},
): Promise<RouteReadinessResult | null> {
const readinessKind = getReadinessProbeKind(routeId)
if (!readinessKind) {
return null
}
switch (readinessKind) {
case 'ollama-generation':
return probeOllamaGenerationReadiness({
baseUrl: getRouteBaseUrl(routeId, options),
model: options?.model,
timeoutMs: options?.timeoutMs,
})
case 'openai-compatible-models': {
if (routeId === 'atomic-chat') {
return probeAtomicChatReadiness({
baseUrl: getRouteBaseUrl(routeId, options),
})
}
const discovered = await runDiscovery(routeId, options)
if (discovered === null) {
return { state: 'unreachable' }
}
if (discovered.length === 0) {
return { state: 'no_models' }
}
return {
state: 'ready',
models: discovered.map(entry => entry.apiName),
}
}
}
}
+39
View File
@@ -0,0 +1,39 @@
import { defineGateway } from '../define.js'
export default defineGateway({
id: 'atomic-chat',
label: 'Atomic Chat',
category: 'local',
defaultBaseUrl: 'http://127.0.0.1:1337/v1',
defaultModel: 'local-model',
supportsModelRouting: true,
setup: {
requiresAuth: false,
authMode: 'none',
},
startup: {
autoDetectable: true,
probeReadiness: 'openai-compatible-models',
},
transportConfig: {
kind: 'local',
openaiShim: {
supportsAuthHeaders: true,
maxTokensField: 'max_tokens',
},
},
preset: {
id: 'atomic-chat',
description: 'Local Model Provider',
modelEnvVars: ['OPENAI_MODEL'],
vendorId: 'openai',
},
catalog: {
source: 'dynamic',
discovery: { kind: 'openai-compatible' },
discoveryCacheTtl: '1d',
discoveryRefreshMode: 'background-if-stale',
allowManualRefresh: true,
},
usage: { supported: false },
})
+34
View File
@@ -0,0 +1,34 @@
import { defineGateway } from '../define.js'
export default defineGateway({
id: 'azure-openai',
label: 'Azure OpenAI',
category: 'hosted',
defaultBaseUrl: 'https://YOUR-RESOURCE-NAME.openai.azure.com/openai/v1',
defaultModel: 'YOUR-DEPLOYMENT-NAME',
supportsModelRouting: false,
setup: {
requiresAuth: true,
authMode: 'api-key',
credentialEnvVars: ['AZURE_OPENAI_API_KEY'],
},
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
supportsAuthHeaders: true,
},
},
preset: {
id: 'azure-openai',
description: 'Azure OpenAI endpoint (model=deployment name)',
apiKeyEnvVars: ['AZURE_OPENAI_API_KEY'],
vendorId: 'openai',
},
catalog: {
source: 'static',
models: [
{ id: 'azure-deployment', apiName: 'YOUR-DEPLOYMENT-NAME', label: 'Azure Deployment' },
],
},
usage: { supported: false },
})
+30
View File
@@ -0,0 +1,30 @@
import { defineGateway } from '../define.js'
/**
* AWS Bedrock has dedicated transport behavior that is not yet fully
* normalized into the generic descriptor model. It relies on ambient
* AWS credentials and uses a separate runtime path.
*
* Do not collapse this into generic OpenAI-compatible routing.
*/
export default defineGateway({
id: 'bedrock',
label: 'AWS Bedrock',
vendorId: 'anthropic',
category: 'hosted',
supportsModelRouting: true,
setup: {
requiresAuth: true,
authMode: 'adc',
},
transportConfig: {
kind: 'bedrock',
},
catalog: {
source: 'static',
models: [
{ id: 'bedrock-claude-opus', apiName: 'us.anthropic.claude-opus-4-6-v1', label: 'Claude Opus (Bedrock)', modelDescriptorId: 'claude-opus-4-6' },
],
},
usage: { supported: false },
})
+36
View File
@@ -0,0 +1,36 @@
import { defineGateway } from '../define.js'
export default defineGateway({
id: 'custom',
label: 'Custom OpenAI-compatible',
category: 'hosted',
defaultModel: 'llama3.1:8b',
supportsModelRouting: true,
setup: {
requiresAuth: false,
authMode: 'api-key',
},
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
supportsApiFormatSelection: true,
supportsAuthHeaders: true,
},
},
preset: {
id: 'custom',
description: 'Any OpenAI-compatible provider',
label: 'Custom',
name: 'Custom OpenAI-compatible',
apiKeyEnvVars: ['OPENAI_API_KEY'],
baseUrlEnvVars: ['OPENAI_BASE_URL', 'OPENAI_API_BASE'],
modelEnvVars: ['OPENAI_MODEL'],
fallbackBaseUrl: 'http://localhost:11434/v1',
vendorId: 'openai',
},
catalog: {
source: 'static',
models: [],
},
usage: { supported: false },
})
+34
View File
@@ -0,0 +1,34 @@
import { defineGateway } from '../define.js'
export default defineGateway({
id: 'dashscope-cn',
label: 'Alibaba Coding Plan (China)',
category: 'hosted',
defaultBaseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
defaultModel: 'qwen3.6-plus',
supportsModelRouting: true,
setup: {
requiresAuth: true,
authMode: 'api-key',
credentialEnvVars: ['DASHSCOPE_API_KEY'],
},
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
supportsAuthHeaders: true,
},
},
preset: {
id: 'dashscope-cn',
description: 'Alibaba DashScope China endpoint',
apiKeyEnvVars: ['DASHSCOPE_API_KEY'],
vendorId: 'openai',
},
catalog: {
source: 'static',
models: [
{ id: 'qwen-cn-3.6-plus', apiName: 'qwen3.6-plus', label: 'Qwen 3.6 Plus', modelDescriptorId: 'qwen3.6-plus' },
],
},
usage: { supported: false },
})
@@ -0,0 +1,34 @@
import { defineGateway } from '../define.js'
export default defineGateway({
id: 'dashscope-intl',
label: 'Alibaba Coding Plan',
category: 'hosted',
defaultBaseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
defaultModel: 'qwen3.6-plus',
supportsModelRouting: true,
setup: {
requiresAuth: true,
authMode: 'api-key',
credentialEnvVars: ['DASHSCOPE_API_KEY'],
},
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
supportsAuthHeaders: true,
},
},
preset: {
id: 'dashscope-intl',
description: 'Alibaba DashScope International endpoint',
apiKeyEnvVars: ['DASHSCOPE_API_KEY'],
vendorId: 'openai',
},
catalog: {
source: 'static',
models: [
{ id: 'qwen-intl-3.6-plus', apiName: 'qwen3.6-plus', label: 'Qwen 3.6 Plus', modelDescriptorId: 'qwen3.6-plus' },
],
},
usage: { supported: false },
})
+53
View File
@@ -0,0 +1,53 @@
import { defineGateway } from '../define.js'
/**
* GitHub Copilot has a special native-Claude path for Claude models.
* When the model string contains "claude-", the runtime routes through
* the native Anthropic path instead of the OpenAI shim to enable prompt
* caching. This exception is handled in openaiShim.ts and providers.ts
* and must be preserved during migration.
*
* @see src/utils/model/providers.ts isGithubNativeAnthropicMode()
* @see src/services/api/openaiShim.ts getGithubEndpointType()
*/
export default defineGateway({
id: 'github',
label: 'GitHub Copilot',
vendorId: 'openai',
category: 'hosted',
defaultBaseUrl: 'https://api.githubcopilot.com',
supportsModelRouting: true,
setup: {
requiresAuth: true,
authMode: 'token',
credentialEnvVars: ['GITHUB_TOKEN', 'GH_TOKEN'],
},
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
supportsAuthHeaders: true,
maxTokensField: 'max_tokens',
},
},
validation: {
kind: 'github-token',
routing: {
enablementEnvVar: 'CLAUDE_CODE_USE_GITHUB',
skipWhenUseOpenAI: true,
},
missingCredentialMessage:
'GitHub Copilot authentication required.\nRun /onboard-github in the CLI to sign in with your GitHub account.\nThis will store your OAuth token securely and enable Copilot models.',
expiredCredentialMessage:
'GitHub Copilot token has expired.\nRun /onboard-github to sign in again and get a fresh token.',
invalidCredentialMessage:
'GitHub Copilot token is invalid or corrupted.\nRun /onboard-github to sign in again with your GitHub account.',
},
catalog: {
source: 'static',
models: [
{ id: 'github-claude-sonnet', apiName: 'claude-sonnet-4-6', label: 'Claude Sonnet (GitHub)', modelDescriptorId: 'claude-sonnet-4-6' },
{ id: 'github-gpt-4o', apiName: 'gpt-4o', label: 'GPT-4o (GitHub)', modelDescriptorId: 'gpt-4o' },
],
},
usage: { supported: false },
})
+34
View File
@@ -0,0 +1,34 @@
import { defineGateway } from '../define.js'
export default defineGateway({
id: 'groq',
label: 'Groq',
category: 'aggregating',
defaultBaseUrl: 'https://api.groq.com/openai/v1',
defaultModel: 'llama-3.3-70b-versatile',
supportsModelRouting: true,
setup: {
requiresAuth: true,
authMode: 'api-key',
credentialEnvVars: ['GROQ_API_KEY'],
},
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
supportsAuthHeaders: true,
},
},
preset: {
id: 'groq',
description: 'Groq OpenAI-compatible endpoint',
apiKeyEnvVars: ['GROQ_API_KEY'],
vendorId: 'openai',
},
catalog: {
source: 'static',
models: [
{ id: 'groq-llama-3.3-70b', apiName: 'llama-3.3-70b-versatile', label: 'Llama 3.3 70B', modelDescriptorId: 'llama-3.3-70b-versatile' },
],
},
usage: { supported: false },
})
+40
View File
@@ -0,0 +1,40 @@
import { defineGateway } from '../define.js'
export default defineGateway({
id: 'kimi-code',
label: 'Moonshot AI - Kimi Code',
category: 'hosted',
defaultBaseUrl: 'https://api.kimi.com/coding/v1',
defaultModel: 'kimi-for-coding',
supportsModelRouting: true,
setup: {
requiresAuth: true,
authMode: 'api-key',
credentialEnvVars: ['KIMI_API_KEY'],
},
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
supportsApiFormatSelection: false,
supportsAuthHeaders: false,
preserveReasoningContent: true,
requireReasoningContentOnAssistantMessages: true,
reasoningContentFallback: '',
maxTokensField: 'max_tokens',
removeBodyFields: ['store'],
},
},
preset: {
id: 'kimi-code',
description: 'Moonshot AI - Kimi Code Subscription endpoint',
apiKeyEnvVars: ['KIMI_API_KEY'],
vendorId: 'openai',
},
catalog: {
source: 'static',
models: [
{ id: 'kimi-for-coding', apiName: 'kimi-for-coding', label: 'Kimi for Coding', modelDescriptorId: 'kimi-for-coding' },
],
},
usage: { supported: false },
})
+39
View File
@@ -0,0 +1,39 @@
import { defineGateway } from '../define.js'
export default defineGateway({
id: 'lmstudio',
label: 'LM Studio',
category: 'local',
defaultBaseUrl: 'http://localhost:1234/v1',
defaultModel: 'local-model',
supportsModelRouting: true,
setup: {
requiresAuth: false,
authMode: 'none',
},
startup: {
autoDetectable: true,
probeReadiness: 'openai-compatible-models',
},
transportConfig: {
kind: 'local',
openaiShim: {
supportsAuthHeaders: true,
maxTokensField: 'max_tokens',
},
},
preset: {
id: 'lmstudio',
description: 'Local LM Studio endpoint',
modelEnvVars: ['OPENAI_MODEL'],
vendorId: 'openai',
},
catalog: {
source: 'dynamic',
discovery: { kind: 'openai-compatible' },
discoveryCacheTtl: '1d',
discoveryRefreshMode: 'startup',
allowManualRefresh: true,
},
usage: { supported: false },
})
+54
View File
@@ -0,0 +1,54 @@
import { defineGateway } from '../define.js'
/**
* Mistral has dedicated runtime provider behavior that is not yet fully
* normalized. It currently routes through the OpenAI shim but with
* provider-specific handling. Do not collapse into generic openai-compatible
* without preserving the existing Mistral-specific branches.
*
* @see src/utils/model/providers.ts getAPIProvider() returns 'mistral'
* @see src/services/api/openaiShim.ts Mistral-specific shim branches
*/
export default defineGateway({
id: 'mistral',
label: 'Mistral AI',
category: 'hosted',
defaultBaseUrl: 'https://api.mistral.ai/v1',
defaultModel: 'devstral-latest',
supportsModelRouting: true,
setup: {
requiresAuth: true,
authMode: 'api-key',
credentialEnvVars: ['MISTRAL_API_KEY'],
},
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
supportsAuthHeaders: true,
maxTokensField: 'max_tokens',
removeBodyFields: ['store'],
},
},
preset: {
id: 'mistral',
description: 'Mistral OpenAI-compatible endpoint',
apiKeyEnvVars: ['MISTRAL_API_KEY'],
vendorId: 'openai',
},
validation: {
kind: 'credential-env',
routing: {
enablementEnvVar: 'CLAUDE_CODE_USE_MISTRAL',
},
credentialEnvVars: ['MISTRAL_API_KEY'],
missingCredentialMessage:
'MISTRAL_API_KEY is required when CLAUDE_CODE_USE_MISTRAL=1.',
},
catalog: {
source: 'static',
models: [
{ id: 'mistral-devstral', apiName: 'devstral-latest', label: 'Devstral Latest', modelDescriptorId: 'devstral-latest' },
],
},
usage: { supported: false },
})
+44
View File
@@ -0,0 +1,44 @@
import { defineGateway } from '../define.js'
export default defineGateway({
id: 'nvidia-nim',
label: 'NVIDIA NIM',
category: 'hosted',
defaultBaseUrl: 'https://integrate.api.nvidia.com/v1',
defaultModel: 'nvidia/llama-3.1-nemotron-70b-instruct',
supportsModelRouting: true,
setup: {
requiresAuth: true,
authMode: 'api-key',
credentialEnvVars: ['NVIDIA_API_KEY'],
},
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
supportsAuthHeaders: true,
},
},
preset: {
id: 'nvidia-nim',
description: 'NVIDIA NIM endpoint',
apiKeyEnvVars: ['NVIDIA_API_KEY'],
vendorId: 'openai',
},
validation: {
kind: 'credential-env',
credentialEnvVars: ['NVIDIA_API_KEY'],
missingCredentialMessage:
'NVIDIA_API_KEY is required when using NVIDIA NIM.',
routing: {
enablementEnvVar: 'NVIDIA_NIM',
matchDefaultBaseUrl: true,
},
},
catalog: {
source: 'static',
models: [
{ id: 'nvidia-llama-3.1-nemotron-70b', apiName: 'nvidia/llama-3.1-nemotron-70b-instruct', label: 'Llama 3.1 Nemotron 70B', modelDescriptorId: 'nvidia/llama-3.1-nemotron-70b-instruct' },
],
},
usage: { supported: false },
})
+39
View File
@@ -0,0 +1,39 @@
import { defineGateway } from '../define.js'
export default defineGateway({
id: 'ollama',
label: 'Ollama',
category: 'local',
defaultBaseUrl: 'http://localhost:11434/v1',
defaultModel: 'llama3.1:8b',
supportsModelRouting: true,
setup: {
requiresAuth: false,
authMode: 'none',
},
startup: {
autoDetectable: true,
probeReadiness: 'ollama-generation',
},
transportConfig: {
kind: 'local',
openaiShim: {
supportsAuthHeaders: true,
maxTokensField: 'max_tokens',
},
},
preset: {
id: 'ollama',
description: 'Local or remote Ollama endpoint',
modelEnvVars: ['OPENAI_MODEL'],
vendorId: 'openai',
},
catalog: {
source: 'dynamic',
discovery: { kind: 'ollama' },
discoveryCacheTtl: '1d',
discoveryRefreshMode: 'background-if-stale',
allowManualRefresh: true,
},
usage: { supported: false },
})
+41
View File
@@ -0,0 +1,41 @@
import { defineGateway } from '../define.js'
export default defineGateway({
id: 'openrouter',
label: 'OpenRouter',
category: 'aggregating',
defaultBaseUrl: 'https://openrouter.ai/api/v1',
defaultModel: 'openai/gpt-5-mini',
supportsModelRouting: true,
setup: {
requiresAuth: true,
authMode: 'api-key',
credentialEnvVars: ['OPENROUTER_API_KEY'],
},
startup: {
probeReadiness: 'openai-compatible-models',
},
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
supportsAuthHeaders: true,
},
},
preset: {
id: 'openrouter',
description: 'OpenRouter OpenAI-compatible endpoint',
apiKeyEnvVars: ['OPENROUTER_API_KEY'],
vendorId: 'openai',
},
catalog: {
source: 'hybrid',
discovery: { kind: 'openai-compatible' },
discoveryCacheTtl: '1d',
discoveryRefreshMode: 'background-if-stale',
allowManualRefresh: true,
models: [
{ id: 'openrouter-gpt-5-mini', apiName: 'openai/gpt-5-mini', label: 'GPT-5 Mini (via OpenRouter)', modelDescriptorId: 'gpt-5-mini' },
],
},
usage: { supported: false },
})
+34
View File
@@ -0,0 +1,34 @@
import { defineGateway } from '../define.js'
export default defineGateway({
id: 'together',
label: 'Together AI',
category: 'aggregating',
defaultBaseUrl: 'https://api.together.xyz/v1',
defaultModel: 'Qwen/Qwen3.5-9B',
supportsModelRouting: true,
setup: {
requiresAuth: true,
authMode: 'api-key',
credentialEnvVars: ['TOGETHER_API_KEY'],
},
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
supportsAuthHeaders: true,
},
},
preset: {
id: 'together',
description: 'Together chat/completions endpoint',
apiKeyEnvVars: ['TOGETHER_API_KEY'],
vendorId: 'openai',
},
catalog: {
source: 'static',
models: [
{ id: 'together-qwen-3.5-9b', apiName: 'Qwen/Qwen3.5-9B', label: 'Qwen 3.5 9B', modelDescriptorId: 'Qwen/Qwen3.5-9B' },
],
},
usage: { supported: false },
})
+30
View File
@@ -0,0 +1,30 @@
import { defineGateway } from '../define.js'
/**
* Google Vertex AI has dedicated transport behavior that is not yet fully
* normalized into the generic descriptor model. It relies on ambient GCP
* credentials and uses a separate runtime path.
*
* Do not collapse this into generic OpenAI-compatible routing.
*/
export default defineGateway({
id: 'vertex',
label: 'Google Vertex AI',
vendorId: 'anthropic',
category: 'hosted',
supportsModelRouting: true,
setup: {
requiresAuth: true,
authMode: 'adc',
},
transportConfig: {
kind: 'vertex',
},
catalog: {
source: 'static',
models: [
{ id: 'vertex-claude-opus', apiName: 'claude-opus-4-6', label: 'Claude Opus (Vertex)', modelDescriptorId: 'claude-opus-4-6' },
],
},
usage: { supported: false },
})
@@ -0,0 +1,353 @@
// This file is auto-generated by scripts/generate-integrations-artifacts.ts.
// Do not edit it by hand; update the descriptor modules and regenerate instead.
import type { AnthropicProxyDescriptor, BrandDescriptor, GatewayDescriptor, ModelDescriptor, ProviderPresetManifestEntry, VendorDescriptor } from '../descriptors.js'
import vendorAnthropic from '../vendors/anthropic.js'
import vendorBankr from '../vendors/bankr.js'
import vendorDeepseek from '../vendors/deepseek.js'
import vendorGemini from '../vendors/gemini.js'
import vendorMinimax from '../vendors/minimax.js'
import vendorMoonshot from '../vendors/moonshot.js'
import vendorOpenai from '../vendors/openai.js'
import vendorXai from '../vendors/xai.js'
import vendorZai from '../vendors/zai.js'
import gatewayAtomicChat from '../gateways/atomic-chat.js'
import gatewayAzureOpenai from '../gateways/azure-openai.js'
import gatewayBedrock from '../gateways/bedrock.js'
import gatewayCustom from '../gateways/custom.js'
import gatewayDashscopeCn from '../gateways/dashscope-cn.js'
import gatewayDashscopeIntl from '../gateways/dashscope-intl.js'
import gatewayGithub from '../gateways/github.js'
import gatewayGroq from '../gateways/groq.js'
import gatewayKimiCode from '../gateways/kimi-code.js'
import gatewayLmstudio from '../gateways/lmstudio.js'
import gatewayMistral from '../gateways/mistral.js'
import gatewayNvidiaNim from '../gateways/nvidia-nim.js'
import gatewayOllama from '../gateways/ollama.js'
import gatewayOpenrouter from '../gateways/openrouter.js'
import gatewayTogether from '../gateways/together.js'
import gatewayVertex from '../gateways/vertex.js'
import brandClaude from '../brands/claude.js'
import brandDeepseek from '../brands/deepseek.js'
import brandGemini from '../brands/gemini.js'
import brandGlm from '../brands/glm.js'
import brandGpt from '../brands/gpt.js'
import brandKimi from '../brands/kimi.js'
import brandLlama from '../brands/llama.js'
import brandMinimax from '../brands/minimax.js'
import brandMistral from '../brands/mistral.js'
import brandNemotron from '../brands/nemotron.js'
import brandOpenaiCompatibleAlias from '../brands/openai-compatible-alias.js'
import brandQwen from '../brands/qwen.js'
import brandXai from '../brands/xai.js'
import modelClaude from '../models/claude.js'
import modelDeepseek from '../models/deepseek.js'
import modelGemini from '../models/gemini.js'
import modelGlm from '../models/glm.js'
import modelGpt from '../models/gpt.js'
import modelKimi from '../models/kimi.js'
import modelLlama from '../models/llama.js'
import modelMinimax from '../models/minimax.js'
import modelMistral from '../models/mistral.js'
import modelNemotron from '../models/nemotron.js'
import modelOpenaiCompatibleAlias from '../models/openai-compatible-alias.js'
import modelQwen from '../models/qwen.js'
import modelXai from '../models/xai.js'
export const VENDOR_DESCRIPTORS = [vendorAnthropic, vendorBankr, vendorDeepseek, vendorGemini, vendorMinimax, vendorMoonshot, vendorOpenai, vendorXai, vendorZai] as const satisfies readonly VendorDescriptor[]
export const GATEWAY_DESCRIPTORS = [gatewayAtomicChat, gatewayAzureOpenai, gatewayBedrock, gatewayCustom, gatewayDashscopeCn, gatewayDashscopeIntl, gatewayGithub, gatewayGroq, gatewayKimiCode, gatewayLmstudio, gatewayMistral, gatewayNvidiaNim, gatewayOllama, gatewayOpenrouter, gatewayTogether, gatewayVertex] as const satisfies readonly GatewayDescriptor[]
export const ANTHROPIC_PROXY_DESCRIPTORS = [] as const satisfies readonly AnthropicProxyDescriptor[]
export const BRAND_DESCRIPTORS = [brandClaude, brandDeepseek, brandGemini, brandGlm, brandGpt, brandKimi, brandLlama, brandMinimax, brandMistral, brandNemotron, brandOpenaiCompatibleAlias, brandQwen, brandXai] as const satisfies readonly BrandDescriptor[]
export const MODEL_DESCRIPTOR_GROUPS = [modelClaude, modelDeepseek, modelGemini, modelGlm, modelGpt, modelKimi, modelLlama, modelMinimax, modelMistral, modelNemotron, modelOpenaiCompatibleAlias, modelQwen, modelXai] as const satisfies readonly (readonly ModelDescriptor[])[]
export const MODEL_DESCRIPTORS = MODEL_DESCRIPTOR_GROUPS.flat() satisfies readonly ModelDescriptor[]
export const PROVIDER_PRESET_MANIFEST = [
{
"preset": "anthropic",
"routeKind": "vendor",
"routeId": "anthropic",
"vendorId": "anthropic",
"description": "Native Claude API (x-api-key auth)",
"apiKeyEnvVars": [
"ANTHROPIC_API_KEY"
],
"baseUrlEnvVars": [
"ANTHROPIC_BASE_URL"
],
"modelEnvVars": [
"ANTHROPIC_MODEL"
]
},
{
"preset": "dashscope-cn",
"routeKind": "gateway",
"routeId": "dashscope-cn",
"vendorId": "openai",
"gatewayId": "dashscope-cn",
"description": "Alibaba DashScope China endpoint",
"apiKeyEnvVars": [
"DASHSCOPE_API_KEY"
]
},
{
"preset": "dashscope-intl",
"routeKind": "gateway",
"routeId": "dashscope-intl",
"vendorId": "openai",
"gatewayId": "dashscope-intl",
"description": "Alibaba DashScope International endpoint",
"apiKeyEnvVars": [
"DASHSCOPE_API_KEY"
]
},
{
"preset": "azure-openai",
"routeKind": "gateway",
"routeId": "azure-openai",
"vendorId": "openai",
"gatewayId": "azure-openai",
"description": "Azure OpenAI endpoint (model=deployment name)",
"apiKeyEnvVars": [
"AZURE_OPENAI_API_KEY"
]
},
{
"preset": "bankr",
"routeKind": "vendor",
"routeId": "bankr",
"vendorId": "bankr",
"description": "Bankr LLM Gateway (OpenAI-compatible)",
"apiKeyEnvVars": [
"BNKR_API_KEY"
],
"modelEnvVars": [
"BANKR_MODEL",
"OPENAI_MODEL"
]
},
{
"preset": "deepseek",
"routeKind": "vendor",
"routeId": "deepseek",
"vendorId": "deepseek",
"description": "DeepSeek OpenAI-compatible endpoint",
"apiKeyEnvVars": [
"DEEPSEEK_API_KEY"
]
},
{
"preset": "gemini",
"routeKind": "vendor",
"routeId": "gemini",
"vendorId": "gemini",
"description": "Gemini OpenAI-compatible endpoint",
"apiKeyEnvVars": [
"GEMINI_API_KEY"
]
},
{
"preset": "groq",
"routeKind": "gateway",
"routeId": "groq",
"vendorId": "openai",
"gatewayId": "groq",
"description": "Groq OpenAI-compatible endpoint",
"apiKeyEnvVars": [
"GROQ_API_KEY"
]
},
{
"preset": "lmstudio",
"routeKind": "gateway",
"routeId": "lmstudio",
"vendorId": "openai",
"gatewayId": "lmstudio",
"description": "Local LM Studio endpoint",
"modelEnvVars": [
"OPENAI_MODEL"
]
},
{
"preset": "atomic-chat",
"routeKind": "gateway",
"routeId": "atomic-chat",
"vendorId": "openai",
"gatewayId": "atomic-chat",
"description": "Local Model Provider",
"modelEnvVars": [
"OPENAI_MODEL"
]
},
{
"preset": "ollama",
"routeKind": "gateway",
"routeId": "ollama",
"vendorId": "openai",
"gatewayId": "ollama",
"description": "Local or remote Ollama endpoint",
"modelEnvVars": [
"OPENAI_MODEL"
]
},
{
"preset": "minimax",
"routeKind": "vendor",
"routeId": "minimax",
"vendorId": "minimax",
"description": "MiniMax API endpoint",
"apiKeyEnvVars": [
"MINIMAX_API_KEY"
]
},
{
"preset": "mistral",
"routeKind": "gateway",
"routeId": "mistral",
"vendorId": "openai",
"gatewayId": "mistral",
"description": "Mistral OpenAI-compatible endpoint",
"apiKeyEnvVars": [
"MISTRAL_API_KEY"
]
},
{
"preset": "moonshotai",
"routeKind": "vendor",
"routeId": "moonshot",
"vendorId": "moonshot",
"description": "Moonshot AI - API endpoint",
"label": "Moonshot AI - API",
"name": "Moonshot AI - API",
"apiKeyEnvVars": [
"MOONSHOT_API_KEY"
]
},
{
"preset": "kimi-code",
"routeKind": "gateway",
"routeId": "kimi-code",
"vendorId": "openai",
"gatewayId": "kimi-code",
"description": "Moonshot AI - Kimi Code Subscription endpoint",
"apiKeyEnvVars": [
"KIMI_API_KEY"
]
},
{
"preset": "nvidia-nim",
"routeKind": "gateway",
"routeId": "nvidia-nim",
"vendorId": "openai",
"gatewayId": "nvidia-nim",
"description": "NVIDIA NIM endpoint",
"apiKeyEnvVars": [
"NVIDIA_API_KEY"
]
},
{
"preset": "openai",
"routeKind": "vendor",
"routeId": "openai",
"vendorId": "openai",
"description": "OpenAI API with API key",
"apiKeyEnvVars": [
"OPENAI_API_KEY"
]
},
{
"preset": "openrouter",
"routeKind": "gateway",
"routeId": "openrouter",
"vendorId": "openai",
"gatewayId": "openrouter",
"description": "OpenRouter OpenAI-compatible endpoint",
"apiKeyEnvVars": [
"OPENROUTER_API_KEY"
]
},
{
"preset": "together",
"routeKind": "gateway",
"routeId": "together",
"vendorId": "openai",
"gatewayId": "together",
"description": "Together chat/completions endpoint",
"apiKeyEnvVars": [
"TOGETHER_API_KEY"
]
},
{
"preset": "xai",
"routeKind": "vendor",
"routeId": "xai",
"vendorId": "xai",
"description": "xAI Grok OpenAI-compatible endpoint",
"apiKeyEnvVars": [
"XAI_API_KEY"
],
"modelEnvVars": [
"OPENAI_MODEL"
]
},
{
"preset": "zai",
"routeKind": "vendor",
"routeId": "zai",
"vendorId": "zai",
"description": "Z.AI GLM coding subscription endpoint",
"label": "Z.AI - GLM Coding Plan",
"name": "Z.AI - GLM Coding Plan",
"apiKeyEnvVars": [
"OPENAI_API_KEY"
],
"modelEnvVars": [
"OPENAI_MODEL"
]
},
{
"preset": "custom",
"routeKind": "gateway",
"routeId": "custom",
"vendorId": "openai",
"gatewayId": "custom",
"description": "Any OpenAI-compatible provider",
"label": "Custom",
"name": "Custom OpenAI-compatible",
"apiKeyEnvVars": [
"OPENAI_API_KEY"
],
"baseUrlEnvVars": [
"OPENAI_BASE_URL",
"OPENAI_API_BASE"
],
"modelEnvVars": [
"OPENAI_MODEL"
],
"fallbackBaseUrl": "http://localhost:11434/v1"
}
] as const satisfies readonly ProviderPresetManifestEntry[]
export type ProviderPreset = (typeof PROVIDER_PRESET_MANIFEST)[number]['preset']
export const ORDERED_PROVIDER_PRESETS = [
"anthropic",
"dashscope-cn",
"dashscope-intl",
"azure-openai",
"bankr",
"deepseek",
"gemini",
"groq",
"lmstudio",
"atomic-chat",
"ollama",
"minimax",
"mistral",
"moonshotai",
"kimi-code",
"nvidia-nim",
"openai",
"openrouter",
"together",
"xai",
"zai",
"custom"
] as const
+100
View File
@@ -0,0 +1,100 @@
// src/integrations/index.test.ts
// Integration test: validates the full registry after loading all descriptors.
import { describe, expect, test } from 'bun:test'
import {
getBrandsForVendor,
getAllGateways,
getAllVendors,
getCatalogEntriesForRoute,
getModel,
getModelsForVendor,
routeSupportsApiFormatSelection,
routeSupportsAuthHeaders,
routeSupportsCustomHeaders,
validateIntegrationRegistry,
} from './index.js'
describe('loaded registry validation', () => {
test('registry is valid after loading all descriptors', () => {
const result = validateIntegrationRegistry()
expect(result.valid).toBe(true)
expect(result.errors).toHaveLength(0)
})
test('MiniMax has shared brand and model descriptors wired to its route catalog', () => {
expect(getBrandsForVendor('minimax').map(brand => brand.id)).toContain(
'minimax',
)
expect(getModelsForVendor('minimax').map(model => model.id)).toContain(
'minimax-m2.7',
)
expect(
getCatalogEntriesForRoute('minimax').every(entry =>
Boolean(entry.modelDescriptorId),
),
).toBe(true)
expect(routeSupportsApiFormatSelection('minimax')).toBe(false)
expect(routeSupportsAuthHeaders('minimax')).toBe(false)
expect(routeSupportsCustomHeaders('minimax')).toBe(false)
})
test('route catalogs do not duplicate defaultModel with catalog default flags', () => {
const routes = [...getAllVendors(), ...getAllGateways()]
expect(
routes.flatMap(route =>
(route.catalog?.models ?? [])
.filter(model => model.default)
.map(model => `${route.id}:${model.id}`),
),
).toEqual([])
})
test('static gateway catalog entries use shared model descriptors when known', () => {
const descriptorOptionalEntries = new Set([
'azure-openai:azure-deployment',
])
const missingDescriptors = getAllGateways().flatMap(gateway =>
(gateway.catalog?.models ?? [])
.filter(entry => !descriptorOptionalEntries.has(`${gateway.id}:${entry.id}`))
.filter(entry => !entry.modelDescriptorId)
.map(entry => `${gateway.id}:${entry.id}`),
)
expect(missingDescriptors).toEqual([])
})
test('gateway defaultModel values are present in their static catalog', () => {
const dynamicCatalogRoutes = new Set([
'atomic-chat',
'custom',
'lmstudio',
'ollama',
])
const missingDefaults = getAllGateways()
.filter(gateway => gateway.defaultModel)
.filter(gateway => !dynamicCatalogRoutes.has(gateway.id))
.filter(gateway => {
const defaultModel = gateway.defaultModel?.trim()
return !(gateway.catalog?.models ?? []).some(
entry =>
entry.apiName === defaultModel ||
entry.modelDescriptorId === defaultModel,
)
})
.map(gateway => `${gateway.id}:${gateway.defaultModel}`)
expect(missingDefaults).toEqual([])
})
test('gateway modelDescriptorId references have model metadata', () => {
const missingModels = getAllGateways().flatMap(gateway =>
(gateway.catalog?.models ?? [])
.filter(entry => entry.modelDescriptorId)
.filter(entry => !getModel(entry.modelDescriptorId!))
.map(entry => `${gateway.id}:${entry.id}:${entry.modelDescriptorId}`),
)
expect(missingModels).toEqual([])
})
})
+125
View File
@@ -0,0 +1,125 @@
// src/integrations/index.ts
// Single loader entrypoint for descriptor modules.
// Runtime and tests must import this file before reading registry state.
import type { AnthropicProxyDescriptor } from './descriptors.js'
import {
ANTHROPIC_PROXY_DESCRIPTORS,
BRAND_DESCRIPTORS,
GATEWAY_DESCRIPTORS,
MODEL_DESCRIPTOR_GROUPS,
VENDOR_DESCRIPTORS,
type ProviderPreset,
} from './generated/integrationArtifacts.generated.js'
import {
getAllAnthropicProxies,
getAllBrands,
getAllGateways,
getAllModels,
getAllVendors,
getAnthropicProxy,
getBrand,
getBrandsForVendor,
getCatalogEntriesForRoute,
getCatalogForGateway,
getCatalogForVendor,
getGateway,
getModel,
getModelsForBrand,
getModelsForGateway,
getModelsForVendor,
getVendor,
registerAnthropicProxy,
registerBrand,
registerGateway,
registerModel,
registerVendor,
validateIntegrationRegistry,
_clearRegistryForTesting,
} from './registry.js'
export function ensureIntegrationsLoaded(): void {
for (const vendor of VENDOR_DESCRIPTORS) {
if (!getVendor(vendor.id)) {
registerVendor(vendor)
}
}
for (const gateway of GATEWAY_DESCRIPTORS) {
if (!getGateway(gateway.id)) {
registerGateway(gateway)
}
}
for (const anthropicProxy of ANTHROPIC_PROXY_DESCRIPTORS as unknown as AnthropicProxyDescriptor[]) {
if (!getAnthropicProxy(anthropicProxy.id)) {
registerAnthropicProxy(anthropicProxy)
}
}
for (const brand of BRAND_DESCRIPTORS) {
if (!getBrand(brand.id)) {
registerBrand(brand)
}
}
for (const modelGroup of MODEL_DESCRIPTOR_GROUPS) {
for (const model of modelGroup) {
if (!getModel(model.id)) {
registerModel(model)
}
}
}
}
ensureIntegrationsLoaded()
export {
registerBrand,
registerVendor,
registerGateway,
registerAnthropicProxy,
registerModel,
getBrand,
getVendor,
getGateway,
getAnthropicProxy,
getModel,
getAllBrands,
getAllVendors,
getAllGateways,
getAllAnthropicProxies,
getAllModels,
getCatalogForGateway,
getCatalogForVendor,
getCatalogEntriesForRoute,
getModelsForBrand,
getModelsForGateway,
getModelsForVendor,
getBrandsForVendor,
validateIntegrationRegistry,
_clearRegistryForTesting,
}
export { routeForPreset, vendorIdForPreset, gatewayIdForPreset } from './compatibility.js'
export { resolveProfileRoute } from './profileResolver.js'
export type { ResolvedProfileRoute } from './profileResolver.js'
export type { ProviderPreset }
export { PROVIDER_PRESET_MANIFEST } from './generated/integrationArtifacts.generated.js'
export {
getRouteDefaultBaseUrl,
getRouteDefaultModel,
getRouteDescriptor,
getRouteLabel,
getRouteProviderTypeLabel,
getTransportKindForRoute,
resolveActiveRouteIdFromEnv,
resolveRouteIdFromBaseUrl,
routeSupportsApiFormatSelection,
routeSupportsAuthHeaders,
routeSupportsCustomHeaders,
} from './routeMetadata.js'
export {
getProviderPresetUiMetadata,
ORDERED_PROVIDER_PRESETS,
} from './providerUiMetadata.js'
+58
View File
@@ -0,0 +1,58 @@
import { defineModel } from '../define.js'
export default [
defineModel({
id: 'claude-sonnet-4-6',
label: 'Claude Sonnet 4.6',
brandId: 'claude',
vendorId: 'anthropic',
classification: ['chat', 'reasoning', 'vision', 'coding'],
defaultModel: 'claude-sonnet-4-6',
capabilities: {
supportsVision: true,
supportsStreaming: true,
supportsFunctionCalling: true,
supportsJsonMode: true,
supportsReasoning: true,
supportsPreciseTokenCount: false,
},
contextWindow: 200_000,
maxOutputTokens: 8192,
}),
defineModel({
id: 'claude-opus-4-6',
label: 'Claude Opus 4.6',
brandId: 'claude',
vendorId: 'anthropic',
classification: ['chat', 'reasoning', 'vision', 'coding'],
defaultModel: 'claude-opus-4-6',
capabilities: {
supportsVision: true,
supportsStreaming: true,
supportsFunctionCalling: true,
supportsJsonMode: true,
supportsReasoning: true,
supportsPreciseTokenCount: false,
},
contextWindow: 200_000,
maxOutputTokens: 8192,
}),
defineModel({
id: 'claude-haiku-4-5',
label: 'Claude Haiku 4.5',
brandId: 'claude',
vendorId: 'anthropic',
classification: ['chat', 'vision'],
defaultModel: 'claude-haiku-4-5',
capabilities: {
supportsVision: true,
supportsStreaming: true,
supportsFunctionCalling: true,
supportsJsonMode: true,
supportsReasoning: false,
supportsPreciseTokenCount: false,
},
contextWindow: 144_000,
maxOutputTokens: 8192,
}),
]
+76
View File
@@ -0,0 +1,76 @@
import { defineModel } from '../define.js'
export default [
defineModel({
id: 'deepseek-chat',
label: 'DeepSeek Chat',
brandId: 'deepseek',
vendorId: 'deepseek',
classification: ['chat', 'coding'],
defaultModel: 'deepseek-chat',
capabilities: {
supportsVision: false,
supportsStreaming: true,
supportsFunctionCalling: true,
supportsJsonMode: true,
supportsReasoning: false,
supportsPreciseTokenCount: false,
},
contextWindow: 128_000,
maxOutputTokens: 8_192,
}),
defineModel({
id: 'deepseek-reasoner',
label: 'DeepSeek Reasoner',
brandId: 'deepseek',
vendorId: 'deepseek',
classification: ['chat', 'reasoning', 'coding'],
defaultModel: 'deepseek-reasoner',
capabilities: {
supportsVision: false,
supportsStreaming: true,
supportsFunctionCalling: true,
supportsJsonMode: true,
supportsReasoning: true,
supportsPreciseTokenCount: false,
},
contextWindow: 128_000,
maxOutputTokens: 65_536,
}),
defineModel({
id: 'deepseek-v4-flash',
label: 'DeepSeek V4 Flash',
brandId: 'deepseek',
vendorId: 'deepseek',
classification: ['chat', 'coding'],
defaultModel: 'deepseek-v4-flash',
capabilities: {
supportsVision: false,
supportsStreaming: true,
supportsFunctionCalling: true,
supportsJsonMode: true,
supportsReasoning: false,
supportsPreciseTokenCount: false,
},
contextWindow: 1_048_576,
maxOutputTokens: 65_536,
}),
defineModel({
id: 'deepseek-v4-pro',
label: 'DeepSeek V4 Pro',
brandId: 'deepseek',
vendorId: 'deepseek',
classification: ['chat', 'reasoning', 'coding'],
defaultModel: 'deepseek-v4-pro',
capabilities: {
supportsVision: false,
supportsStreaming: true,
supportsFunctionCalling: true,
supportsJsonMode: true,
supportsReasoning: true,
supportsPreciseTokenCount: false,
},
contextWindow: 1_048_576,
maxOutputTokens: 65_536,
}),
]
+34
View File
@@ -0,0 +1,34 @@
import { defineModel } from '../define.js'
const geminiCapabilities = {
supportsVision: true,
supportsStreaming: true,
supportsFunctionCalling: true,
supportsJsonMode: true,
supportsReasoning: true,
supportsPreciseTokenCount: false,
}
function geminiModel(id: string, label: string, maxOutputTokens: number) {
return defineModel({
id,
label,
brandId: 'gemini',
vendorId: 'gemini',
classification: ['chat', 'reasoning', 'vision', 'coding'],
defaultModel: id,
capabilities: geminiCapabilities,
contextWindow: 1_048_576,
maxOutputTokens,
})
}
export default [
geminiModel('gemini-3.1-flash-lite-preview', 'Gemini 3.1 Flash Lite Preview', 65_536),
geminiModel('gemini-3.1-pro', 'Gemini 3.1 Pro', 65_536),
geminiModel('gemini-2.5-flash', 'Gemini 2.5 Flash', 65_536),
geminiModel('gemini-2.5-pro', 'Gemini 2.5 Pro', 65_536),
geminiModel('gemini-2.0-flash', 'Gemini 2.0 Flash', 8_192),
geminiModel('google/gemini-2.5-pro', 'Google Gemini 2.5 Pro', 65_536),
geminiModel('google/gemini-2.0-flash', 'Google Gemini 2.0 Flash', 8_192),
]
+42
View File
@@ -0,0 +1,42 @@
import { defineModel } from '../define.js'
const glmCapabilities = {
supportsVision: false,
supportsStreaming: true,
supportsFunctionCalling: true,
supportsJsonMode: true,
supportsReasoning: true,
supportsPreciseTokenCount: false,
}
function glmModel(
id: string,
label: string,
contextWindow: number,
maxOutputTokens: number,
) {
return defineModel({
id,
label,
brandId: 'glm',
vendorId: 'zai',
classification: ['chat', 'reasoning', 'coding'],
defaultModel: id,
capabilities: glmCapabilities,
contextWindow,
maxOutputTokens,
})
}
export default [
glmModel('GLM-5.1', 'GLM-5.1', 202_752, 131_072),
glmModel('GLM-5-Turbo', 'GLM-5-Turbo', 202_752, 131_072),
glmModel('GLM-5', 'GLM-5', 202_752, 131_072),
glmModel('GLM-4.7', 'GLM-4.7', 202_752, 131_072),
glmModel('GLM-4.5-Air', 'GLM-4.5-Air', 128_000, 65_536),
glmModel('glm-5.1', 'GLM 5.1', 202_752, 16_384),
glmModel('glm-5-turbo', 'GLM 5 Turbo', 202_752, 16_384),
glmModel('glm-5', 'GLM 5', 202_752, 16_384),
glmModel('glm-4.7', 'GLM 4.7', 202_752, 16_384),
glmModel('glm-4.5-air', 'GLM 4.5 Air', 128_000, 16_384),
]

Some files were not shown because too many files have changed in this diff Show More