mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-06-01 15:27:41 +00:00
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:
@@ -1,5 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
web
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
|
||||
+4
-4
@@ -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=
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
{
|
||||
".": "0.7.0"
|
||||
".": "0.8.0"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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'`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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}`)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
+746
-261
File diff suppressed because it is too large
Load Diff
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'])
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
})
|
||||
@@ -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',
|
||||
],
|
||||
})
|
||||
@@ -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',
|
||||
],
|
||||
})
|
||||
@@ -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',
|
||||
],
|
||||
})
|
||||
@@ -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',
|
||||
],
|
||||
})
|
||||
@@ -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',
|
||||
],
|
||||
})
|
||||
@@ -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',
|
||||
],
|
||||
})
|
||||
@@ -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',
|
||||
],
|
||||
})
|
||||
@@ -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',
|
||||
],
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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',
|
||||
],
|
||||
})
|
||||
@@ -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',
|
||||
],
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
})
|
||||
@@ -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 },
|
||||
})
|
||||
@@ -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 },
|
||||
})
|
||||
@@ -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 },
|
||||
})
|
||||
@@ -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 },
|
||||
})
|
||||
@@ -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 },
|
||||
})
|
||||
@@ -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 },
|
||||
})
|
||||
@@ -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 },
|
||||
})
|
||||
@@ -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 },
|
||||
})
|
||||
@@ -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 },
|
||||
})
|
||||
@@ -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 },
|
||||
})
|
||||
@@ -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 },
|
||||
})
|
||||
@@ -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 },
|
||||
})
|
||||
@@ -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 },
|
||||
})
|
||||
@@ -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
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
@@ -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,
|
||||
}),
|
||||
]
|
||||
@@ -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,
|
||||
}),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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
Reference in New Issue
Block a user