Providers as data; add Ollama and Custom endpoints

Every gateway provider is now an entry in providers.json - endpoint and auth
template, how its model list is read, how it is probed before a switch, what
setup asks, which doctor checks apply, and its title, colour and logo -
installed next to the presets and read by all three consumers: the bash CLI
(through cm-json.py), the Windows script, and the bar widget (through
health.json). anthropic stays built in; it is the native login, not a gateway.

Behaviour that differs in kind stays in code, chosen by name from the entry:
catalogue parsers (openrouter, lmstudio, ollama, openai, static), probe rules
(always, lenient, local), and named doctor checks. A provider that reuses them
is an entry and a default preset, with no code. The widget draws providers
from health.json, so a new one needs no QML change and no shell restart.

The existing three are unchanged in behaviour: their blank presets come out
byte-identical from the file, and setup, doctor, models and the picker run the
same checks through the generic paths.

Ollama: local server on :11434, placeholder token, one model for every tier,
models from /api/tags. doctor reads the context each loaded model actually runs
with (/api/ps) and its maximum (/api/show), because Ollama defaults to 4096
tokens unless OLLAMA_CONTEXT_LENGTH is set and silently truncates past it. A
bare model name matches its :latest tag.

Custom: any Anthropic-compatible endpoint. Ships with no address and is refused
until it has one; key optional; models from /v1/models when the endpoint has a
list, and a lenient probe so a proxy without one is not blocked.

Also: preflight (and Set-ClaudeMode on Windows) refuses a preset with no
server address; the server form, setup and set-auth use each provider's own
default URL, key name and placeholder token instead of LM Studio's; the
Windows build gains the no-models and no-address guards it never had.
Tested on Linux against fake Ollama/Custom servers, and on Windows 5.1 in a
USERPROFILE sandbox on winbox.

1.12.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
smoido
2026-09-15 01:20:33 +03:00
co-authored by Claude Opus 5
parent a54f7bcb55
commit b5824c6611
15 changed files with 1482 additions and 480 deletions
+104 -4
View File
@@ -1,7 +1,8 @@
# claude-mode
Switch Claude Code system-wide between **Anthropic**, **OpenRouter**, **Z.AI**, and a
local **LM Studio** server — with named per-tier model presets.
Switch Claude Code system-wide between **Anthropic**, **OpenRouter**, **Z.AI**, a
local **LM Studio** or **Ollama** server, and any **custom** Anthropic-compatible
endpoint — with named per-tier model presets.
One write to `~/.claude/settings.json` covers the CLI, the VS Code extension, and
the desktop app. Restart Claude Code afterwards — nothing else. Windows
@@ -14,12 +15,16 @@ claude-mode anthropic # subscription login
claude-mode openrouter # remote gateway (preset: default)
claude-mode zai # Z.AI GLM coding plan (preset: zai)
claude-mode lmstudio # local server (preset: lmstudio)
claude-mode ollama # local server (preset: ollama)
claude-mode custom # your own endpoint (preset: custom)
```
**Contents:** [Install](#install) · [First run](#first-run) · [The interactive
menu](#the-interactive-menu) · [Commands](#commands) · [Presets](#presets-shipped) ·
[Context windows](#context-windows-and-early-auto-compaction) · [Z.AI](#zai-mode) ·
[LM Studio](#lm-studio-mode) · [Live sessions](#live-sessions) ·
[LM Studio](#lm-studio-mode) · [Ollama](#ollama-mode) · [Custom
endpoints](#custom-endpoints) · [Adding a provider](#adding-a-provider) ·
[Live sessions](#live-sessions) ·
[Troubleshooting](#troubleshooting) · [Design decisions](#design-decisions) ·
[Layout](#layout) · [Linux / Omarchy](#linux--omarchy) · [The bar
widget](#the-omarchy-bar-widget) · [Uninstall](#uninstall)
@@ -177,7 +182,9 @@ claude-mode status active mode, preset, model map
claude-mode anthropic native login (deletes all managed keys)
claude-mode openrouter [preset] default preset: default
claude-mode zai [preset] default preset: zai (alias: z.ai, z-ai)
claude-mode lmstudio [preset] default preset: lmstudio
claude-mode lmstudio [preset] default preset: lmstudio (alias: lm-studio)
claude-mode ollama [preset] default preset: ollama
claude-mode custom [preset] default preset: custom
claude-mode presets list presets (* = active)
claude-mode preset show <name>
@@ -401,6 +408,98 @@ like every other credential — a real key on a public address is a real key.
Nothing here changes the shipped presets unless you ask it to; the local default
stays exactly as it was.
## Ollama mode
Ollama serves Anthropic's Messages API itself, at `/v1/messages`, so nothing sits
in between. `claude-mode setup ollama` asks where the server is (it ships on
`http://127.0.0.1:11434`), whether it needs a key, and picks one model for every
tier from what the server has pulled. One model for all four is the right shape
for a local server: it holds one in memory at a time, and mapping tiers to
different models just means paying the load cost on every tier change. The token
is a placeholder, `ollama`, which Ollama requires but does not check.
### The context window is set on the server
This is the part that bites. Ollama sizes the context on the server, not per
request: **4096 tokens** unless `ollama serve` runs with `OLLAMA_CONTEXT_LENGTH`,
and anything past it is cut off without an error. Claude Code's system prompt
alone is most of that. Ollama recommends 64k or more for Claude Code, and the
shipped preset declares 65,536 — but `contextTokens` only tells Claude Code what
to expect. It cannot change what the server does.
```bash
OLLAMA_CONTEXT_LENGTH=65536 ollama serve
# or, for the systemd service: systemctl edit ollama
# [Service]
# Environment=OLLAMA_CONTEXT_LENGTH=65536
```
`claude-mode doctor` reads what each loaded model is actually running with
(`/api/ps`) and the model's own maximum (`/api/show`), and says so when either is
below the preset. When nothing is loaded it cannot see the server's setting, and
says that instead of guessing.
Also worth knowing:
- A bare name like `qwen3-coder` is `qwen3-coder:latest` to Ollama; both work.
- Ollama does not implement `count_tokens` or prompt caching, so long sessions
redo more work than they would on a hosted provider. Nothing breaks.
- To reach it from another machine, serve it with `OLLAMA_HOST=0.0.0.0` and
point the preset there: `claude-mode preset url ollama http://192.168.1.40:11434`.
## Custom endpoints
For anything else that speaks Anthropic's Messages API: a LiteLLM or Vercel
gateway, vLLM, llama.cpp's server, a company proxy. The `custom` preset ships
with no address — there is no sensible one to guess — and a switch to it is
refused until it has one.
```bash
claude-mode setup custom # asks for all of the below
# or by hand:
claude-mode preset url custom https://llm.example.com
claude-mode preset auth custom key custom # the key lives in the vault as 'custom'
claude-mode set-key custom
claude-mode preset set custom opus <model-id> # ...and each other tier
```
Several endpoints are several presets, each with its own address and key name:
`claude-mode preset new work --provider custom --blank`, then
`claude-mode preset auth work key work`.
The model list comes from `/v1/models` when the endpoint has one (OpenAI's shape
or Anthropic's). Plenty of proxies serve Messages and nothing else. That is fine:
the check before a switch only wants something to answer, and model ids can be
typed by hand. The cost guard still applies. A custom endpoint in front of
Anthropic's own models needs `"allowAnthropicModels": true` in the preset, which
keeps it deliberate.
## Adding a provider
Every gateway provider is an entry in [`providers.json`](providers.json), installed
next to the presets and read by the CLI, the bar widget (through `health.json`)
and the Windows build. Anthropic is not in it: it is the native login, not a
gateway. An entry holds:
| field | what it says |
|---|---|
| `id`, `aliases`, `title`, `label`, `blurb`, `color` | names and how the CLI, menu and panel show it |
| `defaultPreset`, `preset` | the preset `claude-mode <id>` picks, and the template for a blank one (URL, auth, context, extra env) |
| `server` | whether the address is editable, how it is probed before a switch (`always`, `lenient` for proxies, `local` only when it is on this machine), setup hint |
| `catalogue.kind` | how its model list is read: `openrouter`, `lmstudio`, `ollama`, `openai` (`/v1/models`) or `static` (a list kept in the entry) |
| `setup` | whether a key is required or optional, where to get one, and one model for every tier or one per tier |
| `doctor` | which checks apply: `catalogue-models`, `openrouter-key`, `guardrail`, `message-check`, `ollama-context`, `lmstudio-templates` (Windows) |
| `logo`, `logoScale` | a single-path 24×24 SVG mark, and its optical size correction |
A provider that reuses those kinds is an entry plus a default preset in
`presets/`, and no code. A hosted Anthropic-compatible coding plan — Kimi,
MiniMax, DeepSeek — is an entry shaped like `zai`, with its own URL and a
`static` or `openai` catalogue. Only a genuinely new kind of behaviour, such as
a catalogue format none of the parsers read, needs code: a parser in
`cm-json.py`, and a branch in `provider_catalogue` (bash) and
`Get-ProviderCatalogue` (Windows). The bar widget picks new providers up from
`health.json` — no QML change, and no shell restart.
## Live sessions
### Why a switch breaks a running session
@@ -719,6 +818,7 @@ Windows:
```
~/.claude-mode/
claude-mode.ps1 main script
providers.json what each gateway provider is (replaced on every install)
state.json mode, active preset, and the exact env keys last written
presets/*.json provider + model maps
vault/*.cred DPAPI-encrypted keys (openrouter, zai, ...)
+1 -1
View File
@@ -1 +1 @@
1.11.0
1.12.0
+360 -187
View File
@@ -92,22 +92,60 @@ $script:BaseManagedEnvKeys = @(
$script:Tiers = @('opus', 'sonnet', 'haiku', 'fable')
$script:Version = '0.0.0'
try { $__v = Join-Path $PSScriptRoot 'VERSION'; if (Test-Path $__v) { $script:Version = (Get-Content $__v -Raw).Trim() } } catch { }
$script:Modes = @('anthropic', 'openrouter', 'zai', 'lmstudio')
$script:NodeExe = $null # resolved lazily by Format-JsonPretty
$script:ModeLabel = @{
'anthropic' = 'Anthropic - your subscription login, no gateway'
'openrouter' = 'OpenRouter - remote, pay-per-token, any vendor'
'zai' = 'Z.AI - GLM coding plan'
'lmstudio' = 'LM Studio - local server, offline, free'
# ---------------------------------------------------------------------------
# Providers
#
# Every gateway provider is an entry in providers.json, next to this script and
# shared with the POSIX build: endpoint, auth, how its model list is fetched,
# which doctor checks apply. This script only knows the *kinds* of behaviour
# (Get-ProviderCatalogue, Test-PresetCatalogue) and picks one by name from the
# entry, so a provider that reuses them needs no change here. anthropic is
# built in: it is the native login, not a gateway.
# ---------------------------------------------------------------------------
$script:ProvidersPath = Join-Path $PSScriptRoot 'providers.json'
$script:Providers = @()
try {
$__pj = Get-Content -LiteralPath $script:ProvidersPath -Raw -Encoding UTF8 | ConvertFrom-Json
$script:Providers = @($__pj.providers | Where-Object { $_.id })
} catch { }
if ($script:Providers.Count -eq 0) {
Write-Host " FAIL providers.json missing or unreadable at $($script:ProvidersPath) - re-run install.ps1" -ForegroundColor Red
exit 1
}
$script:Modes = @('anthropic') + @($script:Providers | ForEach-Object { [string]$_.id })
$script:ModeLabel = @{ 'anthropic' = 'Anthropic - your subscription login, no gateway' }
# `claude-mode <provider>` with no preset named uses this one. Deliberately a
# fixed choice rather than "most recently used", so the command is predictable.
$script:ProviderDefaultPreset = @{
'openrouter' = 'default'
'zai' = 'zai'
'lmstudio' = 'lmstudio'
$script:ProviderDefaultPreset = @{}
foreach ($__p in $script:Providers) {
$script:ModeLabel[[string]$__p.id] = [string]$__p.label
$script:ProviderDefaultPreset[[string]$__p.id] = $(if ($__p.defaultPreset) { [string]$__p.defaultPreset } else { [string]$__p.id })
}
function Get-Provider {
param([string] $Id)
return ($script:Providers | Where-Object { $_.id -eq $Id } | Select-Object -First 1)
}
# An id or an alias (z.ai, z-ai) to the provider id; $null if neither.
function Resolve-ProviderId {
param([string] $Word)
$w = ([string]$Word).ToLower()
foreach ($p in $script:Providers) {
if ([string]$p.id -eq $w -or (@($p.aliases) -contains $w)) { return [string]$p.id }
}
return $null
}
function Test-ProviderDoctor {
param([string] $Id, [string] $Check)
$p = Get-Provider $Id
return [bool]($p -and (@($p.doctor) -contains $Check))
}
# ---------------------------------------------------------------------------
@@ -121,11 +159,12 @@ function Write-Head ($m) { Write-Host ""; Write-Host $m -ForegroundColor Cyan }
# Each mode gets an identity colour, reused for its menu row, its banner tagline
# and its status line - so "which mode am I in" is answerable at a glance.
$script:ModeColor = @{
'anthropic' = 'Magenta'
'openrouter' = 'Cyan'
'zai' = 'Green'
'lmstudio' = 'Yellow'
$script:ModeColor = @{ 'anthropic' = 'Magenta' }
$__colors = @{ cyan = 'Cyan'; green = 'Green'; yellow = 'Yellow'; magenta = 'Magenta'
white = 'White'; gray = 'Gray'; dkcyan = 'DarkCyan'; red = 'Red' }
foreach ($__p in $script:Providers) {
$__c = $__colors[[string]$__p.color]
$script:ModeColor[[string]$__p.id] = $(if ($__c) { $__c } else { 'Gray' })
}
# Deliberately ASCII-only. This file is read by Windows PowerShell 5.1, which
@@ -162,15 +201,19 @@ function Show-Banner {
function Show-Usage {
@'
claude-mode - switch Claude Code between Anthropic, OpenRouter, Z.AI, LM Studio
claude-mode - switch Claude Code between Anthropic and gateway providers
claude-mode interactive menu
claude-mode status active mode, preset, model map
claude-mode anthropic native login/subscription (clears all gateway config)
claude-mode openrouter [preset] remote gateway (default preset: default)
claude-mode zai [preset] Z.AI GLM coding plan (default preset: zai)
claude-mode lmstudio [preset] local LM Studio (default preset: lmstudio)
'@ | Write-Host
# One line per provider in providers.json, so a new one documents itself.
foreach ($p in $script:Providers) {
$desc = ([string]$p.label) -replace '^[^-]*-\s*', ''
Write-Host (" claude-mode {0,-26} {1} (default preset: {2})" -f "$($p.id) [preset]", $desc, $script:ProviderDefaultPreset[[string]$p.id])
}
@'
claude-mode presets list presets
claude-mode preset show <name>
@@ -474,7 +517,7 @@ function Clear-ManagedSettings {
function Set-ClaudeMode {
param(
[ValidateSet('anthropic', 'openrouter', 'zai', 'lmstudio')] [string] $Mode,
[string] $Mode,
[string] $PresetName
)
@@ -483,6 +526,9 @@ function Set-ClaudeMode {
New-Item -ItemType Directory -Path $script:SettingsDir -Force | Out-Null
}
# Any provider in providers.json, rather than a fixed ValidateSet.
if ($Mode -ne 'anthropic' -and -not (Get-Provider $Mode)) { throw "unknown mode '$Mode'" }
$settings = Read-JsonFile $script:Settings
if ($null -eq $settings) { $settings = [ordered]@{} }
@@ -500,6 +546,21 @@ function Set-ClaudeMode {
$models = $preset['models']
if ($null -eq $models) { throw "preset '$PresetName' has no 'models' block" }
# A custom endpoint ships with no address, since there is no sensible
# one to guess. Switching to it would point every session at nothing.
if ([string]::IsNullOrWhiteSpace([string]$preset['baseUrl'])) {
throw "preset '$PresetName' has no server address - set baseUrl in $(Get-PresetPath $PresetName)"
}
# Every tier empty - a fresh blank preset - would switch cleanly and
# leave Claude Code asking for its own default Anthropic models: billed
# at full price through OpenRouter, refused by the other providers.
$anyTier = $false
foreach ($tier in $script:Tiers) { if (-not [string]::IsNullOrWhiteSpace([string]$models[$tier])) { $anyTier = $true } }
if (-not $anyTier) {
throw "preset '$PresetName' has no models set - set a tier first: claude-mode preset set $PresetName <tier> <model-id>"
}
# Cost guard. Gateways resell Anthropic models at full list price, with no
# subscription discount - routing a tier there is almost never intended
# and is expensive enough to be worth blocking outright. Opt in per
@@ -566,7 +627,9 @@ function Set-ClaudeMode {
}
$settings['apiKeyHelper'] = Get-HelperCommandLine
} else {
$tok = 'lmstudio'
# The provider's own placeholder (lmstudio, ollama), not LM Studio's.
$pv = Get-Provider ([string]$preset['provider'])
$tok = $(if ($pv -and $pv.preset.auth.token) { [string]$pv.preset.auth.token } else { 'lmstudio' })
if ($auth.Contains('token') -and $auth['token']) { $tok = [string]$auth['token'] }
$envBlock['ANTHROPIC_AUTH_TOKEN'] = $tok
}
@@ -666,7 +729,9 @@ function Test-OpenRouterGuardrail {
# nothing actionable to print for them.
function Show-GuardrailStatus {
param([string] $Mode, [string] $Key)
if ($Mode -ne 'openrouter' -or -not $Key) { return }
# An OpenRouter feature, flagged per provider rather than by name.
$pv = Get-Provider $Mode
if (-not ($pv -and $pv.guardrail) -or -not $Key) { return }
$label = ' guardrail '
$state = Test-OpenRouterGuardrail -Key $Key
@@ -1054,6 +1119,202 @@ function Test-LmStudioTemplate {
return $rep
}
# The credential a preset would send: its vault key, or its inline token.
function Get-PresetToken {
param($Preset)
$auth = Get-PresetAuth $Preset
if ([string]$auth['mode'] -eq 'vault') {
$ref = $(if ($auth.Contains('keyRef') -and $auth['keyRef']) { [string]$auth['keyRef'] } else { 'openrouter' })
return (Get-VaultKey $ref)
}
return [string]$auth['token']
}
# One catalogue fetch for any provider, by its catalogue.kind in providers.json.
# Every row comes back in one shape - Id, Ctx, State, InM, OutM, Note - so the
# callers (models, the picker, doctor) never branch on the provider itself.
function Get-ProviderCatalogue {
param($Preset)
$prov = Get-Provider ([string]$Preset['provider'])
if (-not $prov) { return @() }
$base = ([string]$Preset['baseUrl']).TrimEnd('/')
switch ([string]$prov.catalogue.kind) {
'openrouter' {
return @((Invoke-RestMethod -Uri 'https://openrouter.ai/api/v1/models' -TimeoutSec 30).data | ForEach-Object {
[pscustomobject]@{
Id = $_.id; Ctx = $_.context_length; State = $null; Note = ''
InM = $(if ($_.pricing -and $_.pricing.prompt) { [math]::Round([double]$_.pricing.prompt * 1e6, 3) } else { $null })
OutM = $(if ($_.pricing -and $_.pricing.completion) { [math]::Round([double]$_.pricing.completion * 1e6, 3) } else { $null })
}
})
}
'lmstudio' {
return @(Get-LmStudioModels $base | ForEach-Object {
[pscustomobject]@{ Id = $_.Id; Ctx = $_.Ctx; State = $_.State; InM = $null; OutM = $null; Note = '' }
})
}
'ollama' {
$h = @{}
$tok = Get-PresetToken $Preset
if ($tok) { $h['Authorization'] = "Bearer $tok" }
return @((Invoke-RestMethod -Uri "$base/api/tags" -Headers $h -TimeoutSec 10).models | ForEach-Object {
$d = $_.details
$note = $(if ($d) { (@($d.parameter_size, $d.quantization_level) | Where-Object { $_ }) -join ' ' } else { '' })
[pscustomobject]@{ Id = $_.name; Ctx = $null; State = $null; InM = $null; OutM = $null; Note = $note }
})
}
'openai' {
# Both header styles: a proxy in front of Anthropic wants x-api-key,
# one in front of anything else wants Bearer.
$h = @{ 'anthropic-version' = '2023-06-01' }
$tok = Get-PresetToken $Preset
if ($tok) { $h['Authorization'] = "Bearer $tok"; $h['x-api-key'] = $tok }
return @((Invoke-RestMethod -Uri "$base/v1/models" -Headers $h -TimeoutSec 15).data | ForEach-Object {
[pscustomobject]@{ Id = $_.id; Ctx = $null; State = $null; InM = $null; OutM = $null; Note = '' }
})
}
'static' {
return @($prov.catalogue.static | ForEach-Object {
[pscustomobject]@{ Id = $_.id; Ctx = $null; State = $null; InM = $null; OutM = $null; Note = [string]$_.note }
})
}
}
return @()
}
# The preset's model ids against what the provider actually offers. A server
# provider that does not answer is a failure; a hosted catalogue that cannot be
# fetched is only a warning, since the endpoint may be fine regardless.
function Test-PresetCatalogue {
param([string] $Mode, $Preset)
$prov = Get-Provider $Mode
$kind = [string]$prov.catalogue.kind
$title = [string]$prov.title
$base = ([string]$Preset['baseUrl']).TrimEnd('/')
$server = [bool]$prov.server.editable
$cat = @()
try { $cat = @(Get-ProviderCatalogue $Preset) } catch { $cat = @() }
if ($cat.Count -eq 0) {
if ($server -and [string]$prov.server.probe -eq 'lenient') {
Write-Warn2 "$title at $base lists no models - fine for a proxy, but the ids below cannot be checked"
} elseif ($server) {
$start = [string]$prov.server.start
Write-Err2 ("$title not reachable at $base" + $(if ($start) { " - $start" } else { '' }))
} else {
Write-Warn2 "could not fetch the $title model list"
}
return
}
if ($server) { Write-Ok "$title reachable at $base ($($cat.Count) models)" }
$declared = $(if ($Preset.Contains('contextTokens') -and $Preset['contextTokens']) { [int]$Preset['contextTokens'] } else { 0 })
$tpl = $(if (Test-ProviderDoctor $Mode 'lmstudio-templates') { Get-LmStudioTemplateReport } else { @() })
$warned = @{} # a one-for-all preset names one model four times; say things about it once
foreach ($t in $script:Tiers) {
if (-not ($Preset['models'].Contains($t) -and $Preset['models'][$t])) { continue }
$id = [string]$Preset['models'][$t]
$m = $cat | Where-Object { $_.Id -eq $id } | Select-Object -First 1
# Ollama lists every model with its tag; a bare name means :latest.
if (-not $m -and $kind -eq 'ollama' -and $id -notlike '*:*') {
$m = $cat | Where-Object { $_.Id -eq "${id}:latest" } | Select-Object -First 1
}
if (-not $m) {
# A fixed list is documentation, not the provider's word.
if ($kind -eq 'static') { Write-Ok ("{0,-6} {1} (not in the documented list)" -f $t, $id) }
else { Write-Err2 "$t model NOT available from ${title}: $id" }
continue
}
switch ($kind) {
'lmstudio' {
if ($m.State -eq 'loaded') { Write-Ok ("{0,-6} {1} [loaded, ctx {2}]" -f $t, $id, $m.Ctx) }
else { Write-Ok ("{0,-6} {1} [{2} - LM Studio will JIT-load it on first request, ctx {3}]" -f $t, $id, $m.State, $m.Ctx) }
if (-not $warned.ContainsKey($id)) {
$warned[$id] = $true
if ($m.Ctx -and [int]$m.Ctx -lt 25000) { Write-Warn2 "$id context is $($m.Ctx); LM Studio recommends >25k for Claude Code." }
if ($declared -and $m.Ctx -and [int]$m.Ctx -lt $declared) {
Write-Warn2 ("declared contextTokens {0:N0} exceeds {1}'s {2:N0} - lower it." -f $declared, $id, [int]$m.Ctx)
}
$short = ($id -split '/')[-1].ToLower()
$risk = $tpl | Where-Object { $_.Key -eq $short } | Select-Object -First 1
if ($risk -and $risk.Assertions.Count -gt 0) {
Write-Warn2 "$id chat template hard-asserts message order ($($risk.Assertions -join '; '))."
Write-Warn2 " This can surface as: [Server Error] 'Unable to generate parser for this template'."
Write-Warn2 " If you hit that, switch to a model without this flag - see 'claude-mode models'."
}
}
}
'openrouter' { Write-Ok ("{0,-6} {1} [ctx {2:N0}]" -f $t, $id, [int]$m.Ctx) }
'ollama' { Write-Ok ("{0,-6} {1} [{2}]" -f $t, $id, $m.Note) }
default { Write-Ok ("{0,-6} {1}" -f $t, $id) }
}
}
if ($kind -eq 'openrouter') { Test-ContextWindow -Preset $Preset -Catalogue $cat }
elseif ($declared) { Write-Ok ("declared context window: {0:N0} tokens" -f $declared) }
else { Write-Warn2 'preset has no contextTokens - Claude Code will guess a small window and auto-compact early.' }
}
# Ollama sets the context window on the server, not per request from Claude
# Code: 4096 tokens unless `ollama serve` runs with OLLAMA_CONTEXT_LENGTH, and
# anything past it is cut off without an error. contextTokens only tells Claude
# Code what to expect, so say what the server is actually running where that
# can be seen, and what to set where it cannot.
function Test-OllamaContext {
param($Preset)
if (-not ($Preset.Contains('contextTokens') -and $Preset['contextTokens'])) { return }
$declared = [int]$Preset['contextTokens']
$base = ([string]$Preset['baseUrl']).TrimEnd('/')
$ids = @()
foreach ($t in $script:Tiers) {
$v = [string]$Preset['models'][$t]
if ($v -and $ids -notcontains $v) { $ids += $v }
}
$ps = $null
try { $ps = Invoke-RestMethod -Uri "$base/api/ps" -TimeoutSec 5 } catch { }
$seen = $false; $short = $false
foreach ($id in $ids) {
try {
$show = Invoke-RestMethod -Uri "$base/api/show" -Method Post -ContentType 'application/json' `
-Body (@{ model = $id } | ConvertTo-Json) -TimeoutSec 8
$max = $null
if ($show.model_info) {
foreach ($prop in $show.model_info.PSObject.Properties) {
if ($prop.Name -like '*.context_length') { $max = [int]$prop.Value; break }
}
}
if ($max -and $max -lt $declared) { Write-Warn2 "$id supports at most $max tokens, below the declared $declared - lower contextTokens" }
} catch { }
$want = @($id)
if ($id -notlike '*:*') { $want += "${id}:latest" }
$live = $null
if ($ps -and $ps.models) {
foreach ($m in $ps.models) {
if ((($want -contains [string]$m.name) -or ($want -contains [string]$m.model)) -and $m.context_length) {
$live = [int]$m.context_length; break
}
}
}
if ($null -eq $live) { continue }
$seen = $true
if ($live -lt $declared) {
$short = $true
Write-Warn2 "$id is loaded with a $live-token context, below the declared $declared - requests past it are cut off"
} else {
Write-Ok "$id is loaded with a $live-token context"
}
}
if (-not $seen) {
Write-Warn2 "none of these models is loaded, so the server's context window cannot be checked"
Write-Host " Ollama defaults to 4096 tokens; run it with OLLAMA_CONTEXT_LENGTH=$declared or requests past that are cut off silently"
} elseif ($short) {
Write-Host " restart it with OLLAMA_CONTEXT_LENGTH=$declared (or lower contextTokens to match)"
}
}
# CLAUDE_CODE_MAX_CONTEXT_TOKENS is a single global value, but each tier can
# point at a model with a different window. Declaring more context than a tier's
# model actually has means requests on that tier can overflow, so name the
@@ -1073,10 +1334,10 @@ function Test-ContextWindow {
foreach ($t in $script:Tiers) {
if (-not $Preset['models'].Contains($t)) { continue }
$id = [string]$Preset['models'][$t]
$m = $Catalogue | Where-Object { $_.id -eq $id } | Select-Object -First 1
if (-not $m) { continue }
if ([int]$m.context_length -lt $declared) {
Write-Warn2 ("{0} model {1} only has {2:N0} ctx, below the declared {3:N0}." -f $t, $id, $m.context_length, $declared)
$m = $Catalogue | Where-Object { $_.Id -eq $id } | Select-Object -First 1
if (-not $m -or -not $m.Ctx) { continue }
if ([int]$m.Ctx -lt $declared) {
Write-Warn2 ("{0} model {1} only has {2:N0} ctx, below the declared {3:N0}." -f $t, $id, [int]$m.Ctx, $declared)
if ($t -eq 'haiku') {
Write-Warn2 ' haiku only runs short background tasks, so this is usually harmless.'
} else {
@@ -1251,45 +1512,37 @@ function Invoke-Models {
$state = Get-State
$mode = [string]$state['mode']
if ($mode -eq 'lmstudio') {
$preset = Get-Preset ([string]$state['preset'])
Write-Head "models installed in LM Studio at $($preset['baseUrl'])"
$tpl = Get-LmStudioTemplateReport
Get-LmStudioModels ([string]$preset['baseUrl']) |
Where-Object { -not $Filter -or $_.Id -like "*$Filter*" } |
Sort-Object Id |
ForEach-Object {
$short = ($_.Id -split '/')[-1].ToLower()
# On anthropic, OpenRouter's catalogue is the one worth browsing, as before.
$preset = $(if ($mode -ne 'anthropic') { Get-Preset ([string]$state['preset']) } else { [ordered]@{ provider = 'openrouter'; baseUrl = '' } })
$prov = Get-Provider ([string]$preset['provider'])
$kind = [string]$prov.catalogue.kind
switch ($kind) {
'static' { Write-Head "$($prov.title) models (from its docs - no public catalogue endpoint)" }
'openrouter' { Write-Head 'fetching https://openrouter.ai/api/v1/models ...' }
default { Write-Head "models on the $($prov.title) server at $($preset['baseUrl'])" }
}
$tpl = $(if ($kind -eq 'lmstudio') { Get-LmStudioTemplateReport } else { @() })
$rows = foreach ($m in (Get-ProviderCatalogue $preset | Sort-Object Id)) {
if ($Filter -and $m.Id -notlike "*$Filter*") { continue }
if ($kind -eq 'openrouter') {
[pscustomobject]@{ Id = $m.Id; Ctx = $m.Ctx; 'In/M$' = $m.InM; 'Out/M$' = $m.OutM }
} elseif ($kind -eq 'lmstudio') {
$short = ($m.Id -split '/')[-1].ToLower()
$t = $tpl | Where-Object { $_.Key -eq $short } | Select-Object -First 1
$flag = ''
if ($t -and $t.Assertions.Count -gt 0) { $flag = 'TEMPLATE RISK' }
[pscustomobject]@{ Id = $_.Id; State = $_.State; Ctx = $_.Ctx; Note = $flag }
} | Format-Table -AutoSize
$flag = $(if ($t -and $t.Assertions.Count -gt 0) { 'TEMPLATE RISK' } else { '' })
[pscustomobject]@{ Id = $m.Id; State = $m.State; Ctx = $m.Ctx; Note = $flag }
} else {
[pscustomobject]@{ Id = $m.Id; Note = $m.Note }
}
}
$rows | Format-Table -AutoSize
if ($kind -eq 'lmstudio') {
Write-Host " 'TEMPLATE RISK' = the model's chat template hard-asserts message order,"
Write-Host " which can break tool-call parser generation. Prefer an unflagged model."
return
}
if ($mode -eq 'zai') {
Write-Head 'Z.AI GLM models (from Z.AI docs - no public catalogue endpoint)'
@('glm-5.3 - flagship coding model (opus/sonnet tier)',
'glm-4.7 - fast/cheap tier (haiku tier)') | ForEach-Object { Write-Host " $_" }
Write-Host ' Full list: https://docs.z.ai/devpack/tool/claude'
return
}
Write-Head 'fetching https://openrouter.ai/api/v1/models ...'
$resp = Invoke-RestMethod -Uri 'https://openrouter.ai/api/v1/models' -Method Get -TimeoutSec 30
$rows = foreach ($m in $resp.data) {
if ($Filter -and $m.id -notlike "*$Filter*") { continue }
[pscustomobject]@{
Id = $m.id
Ctx = $m.context_length
'In/M$' = if ($m.pricing -and $m.pricing.prompt) { [math]::Round([double]$m.pricing.prompt * 1e6, 3) } else { $null }
'Out/M$' = if ($m.pricing -and $m.pricing.completion) { [math]::Round([double]$m.pricing.completion * 1e6, 3) } else { $null }
}
}
$rows | Sort-Object Id | Format-Table -AutoSize
if ($kind -eq 'static' -and $prov.catalogue.docs) { Write-Host " Full list: $($prov.catalogue.docs)" }
}
function Invoke-Doctor {
@@ -1363,7 +1616,7 @@ function Invoke-Doctor {
}
}
if ($key -and $mode -eq 'openrouter') {
if ($key -and (Test-ProviderDoctor $mode 'openrouter-key')) {
try {
$r = Invoke-RestMethod -Uri 'https://openrouter.ai/api/v1/key' -Headers @{ Authorization = "Bearer $key" } -TimeoutSec 20
Write-Ok "OpenRouter key valid (label: $($r.data.label))"
@@ -1382,7 +1635,7 @@ function Invoke-Doctor {
Show-GuardrailStatus -Mode $mode -Key $key
}
if ($key -and $mode -eq 'zai') {
if ($key -and (Test-ProviderDoctor $mode 'message-check')) {
# No key-info endpoint; the cheapest real check is a 1-token
# message against the Anthropic-compatible surface.
try {
@@ -1394,80 +1647,20 @@ function Invoke-Doctor {
[void](Invoke-RestMethod -Uri "$base/v1/messages" -Method Post -Body $body `
-ContentType 'application/json' -TimeoutSec 45 `
-Headers @{ 'x-api-key' = $key; 'Authorization' = "Bearer $key"; 'anthropic-version' = '2023-06-01' })
Write-Ok "Z.AI endpoint accepted the key ($base/v1/messages)"
Write-Ok "$((Get-Provider $mode).title) endpoint accepted the key ($base/v1/messages)"
} catch {
$detail = ''
if ($_.ErrorDetails) { $detail = ($_.ErrorDetails.Message -replace '\s+', ' ') }
Write-Err2 "Z.AI request failed: $($_.Exception.Message) $detail"
Write-Err2 "$((Get-Provider $mode).title) request failed: $($_.Exception.Message) $detail"
}
}
} else {
Write-Ok "inline token '$($auth['token'])' (no secret in settings.json)"
}
if ($mode -eq 'lmstudio') {
$catalogue = $null
try {
$catalogue = Get-LmStudioModels $base
Write-Ok "LM Studio server reachable at $base ($($catalogue.Count) models installed)"
} catch {
Write-Err2 "LM Studio not reachable at $base - start the server (LM Studio > Developer > Start Server)"
}
if ($catalogue) {
$wanted = @()
foreach ($t in $script:Tiers) {
if ($preset['models'].Contains($t)) { $wanted += [string]$preset['models'][$t] }
}
if ($preset.Contains('subagentModel')) { $wanted += [string]$preset['subagentModel'] }
foreach ($id in ($wanted | Sort-Object -Unique)) {
$m = $catalogue | Where-Object { $_.Id -eq $id } | Select-Object -First 1
if (-not $m) {
Write-Err2 "model NOT installed in LM Studio: $id (run 'claude-mode models' for valid ids)"
continue
}
if ($m.State -eq 'loaded') { Write-Ok "$id [loaded, ctx $($m.Ctx)]" }
else { Write-Ok "$id [$($m.State) - LM Studio will JIT-load it on first request, ctx $($m.Ctx)]" }
if ($m.Ctx -and [int]$m.Ctx -lt 25000) {
Write-Warn2 "$id context is $($m.Ctx); LM Studio recommends >25k for Claude Code."
}
if ($preset.Contains('contextTokens') -and $preset['contextTokens'] -and $m.Ctx) {
$declared = [int]$preset['contextTokens']
if ([int]$m.Ctx -lt $declared) {
Write-Warn2 ("declared contextTokens {0:N0} exceeds {1}'s {2:N0} - lower it." -f $declared, $id, $m.Ctx)
} else {
Write-Ok ("declared context window: {0:N0} tokens" -f $declared)
}
} elseif (-not $preset.Contains('contextTokens')) {
Write-Warn2 'preset has no contextTokens - Claude Code will guess a small window and auto-compact early.'
}
$tpl = Test-LmStudioTemplate $id
if ($tpl -and $tpl.Assertions.Count -gt 0) {
Write-Warn2 "$id chat template hard-asserts message order ($($tpl.Assertions -join '; '))."
Write-Warn2 " This can surface as: [Server Error] 'Unable to generate parser for this template'."
Write-Warn2 " If you hit that, switch to a model without this flag - see 'claude-mode models'."
}
}
}
} elseif ($mode -eq 'openrouter') {
try {
$cat = (Invoke-RestMethod -Uri 'https://openrouter.ai/api/v1/models' -TimeoutSec 30).data
foreach ($t in $script:Tiers) {
if (-not $preset['models'].Contains($t)) { continue }
$id = [string]$preset['models'][$t]
$m = $cat | Where-Object { $_.id -eq $id } | Select-Object -First 1
if ($m) { Write-Ok ("{0,-6} {1} [ctx {2:N0}]" -f $t, $id, $m.context_length) }
else { Write-Err2 "$t model NOT available from this provider: $id" }
}
Test-ContextWindow -Preset $preset -Catalogue $cat
} catch {
Write-Warn2 "could not verify model ids: $($_.Exception.Message)"
}
}
# Which checks run is the provider's call, by name, in providers.json.
if (Test-ProviderDoctor $mode 'catalogue-models') { Test-PresetCatalogue -Mode $mode -Preset $preset }
if (Test-ProviderDoctor $mode 'ollama-context') { Test-OllamaContext -Preset $preset }
}
Write-Host ''
@@ -1879,37 +2072,26 @@ function Get-ModelChoices {
if ($script:ModelCatalogueCache.ContainsKey($cacheKey)) { return $script:ModelCatalogueCache[$cacheKey] }
$out = @()
$kind = [string](Get-Provider $provider).catalogue.kind
$tpl = $(if ($kind -eq 'lmstudio') { Get-LmStudioTemplateReport } else { @() })
if ($provider -eq 'lmstudio') {
$models = @(Get-LmStudioModels ([string]$Preset['baseUrl']))
$tpl = Get-LmStudioTemplateReport
foreach ($m in ($models | Sort-Object Id)) {
foreach ($m in (Get-ProviderCatalogue $Preset | Sort-Object Id)) {
$flag = ''
if ($kind -eq 'openrouter') {
$det = @(("context {0:N0} `$$($m.InM) in / `$$($m.OutM) out per 1M tokens" -f [int]$m.Ctx))
} elseif ($kind -eq 'lmstudio') {
$det = @("state: $($m.State) max context: $($m.Ctx)")
$short = ($m.Id -split '/')[-1].ToLower()
$risk = $tpl | Where-Object { $_.Key -eq $short } | Select-Object -First 1
$flag = ''
$det = @("state: $($m.State) max context: $($m.Ctx)")
if ($risk -and $risk.Assertions.Count -gt 0) {
$flag = ' [TEMPLATE RISK]'
$det += 'chat template asserts message order - can break tool calls'
}
} else {
$det = @([string]$m.Note)
}
$out += [pscustomobject]@{ Id = $m.Id; Key = $m.Id; Label = ($m.Id + $flag); Detail = $det }
}
}
elseif ($provider -eq 'openrouter') {
$cat = (Invoke-RestMethod -Uri 'https://openrouter.ai/api/v1/models' -TimeoutSec 30).data
foreach ($m in ($cat | Sort-Object id)) {
$inp = if ($m.pricing -and $m.pricing.prompt) { [math]::Round([double]$m.pricing.prompt * 1e6, 3) } else { $null }
$outp= if ($m.pricing -and $m.pricing.completion) { [math]::Round([double]$m.pricing.completion * 1e6, 3) } else { $null }
$det = @(("context {0:N0} `$$inp in / `$$outp out per 1M tokens" -f [int]$m.context_length))
$out += [pscustomobject]@{ Id = $m.id; Key = $m.id; Label = $m.id; Detail = $det }
}
}
elseif ($provider -eq 'zai') {
# Z.AI publishes no public catalogue endpoint; these are the documented
# coding-plan models. Anything else can still be typed manually.
$out += [pscustomobject]@{ Id = 'glm-5.3'; Key = 'glm-5.3'; Label = 'glm-5.3'; Detail = @('flagship coding model - opus/sonnet tier') }
$out += [pscustomobject]@{ Id = 'glm-4.7'; Key = 'glm-4.7'; Label = 'glm-4.7'; Detail = @('fast/cheap tier - haiku') }
}
$script:ModelCatalogueCache[$cacheKey] = $out
return $out
@@ -1951,36 +2133,30 @@ function Read-ModelId {
function New-PresetScaffold {
param([string] $Provider)
# Built from the provider's own template in providers.json; the POSIX
# build's `cm-json.py scaffold` produces the same thing from the same file.
$p = Get-Provider $Provider
if (-not $p) { throw "unknown provider '$Provider'" }
$tpl = $p.preset
$base = [ordered]@{
provider = $Provider
description = 'new preset'
}
switch ($Provider) {
'openrouter' {
$base['baseUrl'] = 'https://openrouter.ai/api'
$base['auth'] = [ordered]@{ mode = 'vault'; keyRef = 'openrouter' }
}
'zai' {
$base['baseUrl'] = 'https://api.z.ai/api/anthropic'
$base['auth'] = [ordered]@{ mode = 'vault'; keyRef = 'zai' }
}
'lmstudio' {
$base['baseUrl'] = 'http://127.0.0.1:1234'
$base['auth'] = [ordered]@{ mode = 'literal'; token = 'lmstudio' }
}
}
$base['baseUrl'] = [string]$tpl.baseUrl
$auth = [ordered]@{}
if ($tpl.auth) { foreach ($prop in $tpl.auth.PSObject.Properties) { $auth[$prop.Name] = $prop.Value } }
else { $auth['mode'] = 'vault'; $auth['keyRef'] = $Provider }
$base['auth'] = $auth
$base['models'] = [ordered]@{ opus = ''; sonnet = ''; haiku = ''; fable = '' }
$base['subagentModel'] = 'inherit'
$base['gatewayModelDiscovery'] = ($Provider -eq 'openrouter')
$base['contextTokens'] = if ($Provider -eq 'lmstudio') { 262144 } else { 1000000 }
if ($Provider -eq 'lmstudio') { $base['extraEnv'] = [ordered]@{ CLAUDE_CODE_ATTRIBUTION_HEADER = '0' } }
if ($Provider -eq 'zai') {
$base['extraEnv'] = [ordered]@{
API_TIMEOUT_MS = '3000000'
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1'
}
$base['gatewayModelDiscovery'] = [bool]$tpl.gatewayModelDiscovery
$base['contextTokens'] = $(if ($tpl.contextTokens) { [int]$tpl.contextTokens } else { 200000 })
if ($tpl.extraEnv) {
$extra = [ordered]@{}
foreach ($prop in $tpl.extraEnv.PSObject.Properties) { $extra[$prop.Name] = [string]$prop.Value }
$base['extraEnv'] = $extra
}
return $base
}
@@ -2186,25 +2362,12 @@ Initialize-Root
try {
$cmd = $Command.ToLower()
if ($cmd -eq 'z.ai' -or $cmd -eq 'z-ai') { $cmd = 'zai' }
switch ($cmd) {
'' { Invoke-Menu }
'menu' { Invoke-Menu }
'status' { Invoke-Status }
'anthropic' { Set-ClaudeMode -Mode 'anthropic' -PresetName '' }
'openrouter' {
$req = if ($Rest -and $Rest.Count -ge 1) { $Rest[0] } else { $null }
Set-ClaudeMode -Mode 'openrouter' -PresetName (Resolve-PresetForProvider 'openrouter' $req)
}
'zai' {
$req = if ($Rest -and $Rest.Count -ge 1) { $Rest[0] } else { $null }
Set-ClaudeMode -Mode 'zai' -PresetName (Resolve-PresetForProvider 'zai' $req)
}
'lmstudio' {
$req = if ($Rest -and $Rest.Count -ge 1) { $Rest[0] } else { $null }
Set-ClaudeMode -Mode 'lmstudio' -PresetName (Resolve-PresetForProvider 'lmstudio' $req)
}
'presets' { Invoke-Presets }
'preset' { Invoke-PresetCmd -Argv $Rest }
'set-key' {
@@ -2226,7 +2389,17 @@ try {
'help' { Show-Usage }
'--help' { Show-Usage }
'-h' { Show-Usage }
default { Write-Err2 "unknown command '$Command'"; Show-Usage; exit 1 }
default {
# Any provider in providers.json, by id or alias, is a mode. Last,
# so a provider can never shadow a real command.
$prov = Resolve-ProviderId $cmd
if ($prov) {
$req = if ($Rest -and $Rest.Count -ge 1) { $Rest[0] } else { $null }
Set-ClaudeMode -Mode $prov -PresetName (Resolve-PresetForProvider $prov $req)
} else {
Write-Err2 "unknown command '$Command'"; Show-Usage; exit 1
}
}
}
} catch {
Write-Err2 $_.Exception.Message
+7 -1
View File
@@ -91,7 +91,13 @@ if (Test-Path -LiteralPath (Join-Path $src 'VERSION')) {
}
Copy-Item -LiteralPath (Join-Path $src 'bin\claude-key-helper.ps1') -Destination "$root\bin" -Force
Copy-Item -LiteralPath (Join-Path $src 'bin\claude-key-helper.cmd') -Destination "$root\bin" -Force
Write-Host ' ok copied claude-mode.ps1 + key helper' -ForegroundColor Green
# The table of providers claude-mode can switch to. Always replaced, unlike
# presets: it is the tool's own definition of each provider, not user data.
if (-not (Test-Path -LiteralPath (Join-Path $src 'providers.json'))) {
throw 'providers.json missing from the payload - claude-mode cannot run without it'
}
Copy-Item -LiteralPath (Join-Path $src 'providers.json') -Destination $root -Force
Write-Host ' ok copied claude-mode.ps1 + key helper + providers.json' -ForegroundColor Green
# --- 3. presets -------------------------------------------------------------
foreach ($p in Get-ChildItem -LiteralPath (Join-Path $src 'presets') -Filter '*.json') {
+345 -195
View File
@@ -32,7 +32,9 @@ JSON="$CM_BIN/cm-json.py"
CM_FORCE=0
CM_SAME_ENDPOINT=0 # set by reapply_if_active for a tier-only edit
MODES=(anthropic openrouter zai lmstudio)
# anthropic is built in; every gateway provider is appended from providers.json
# at startup (see cm_providers_load).
MODES=(anthropic)
TIERS=(opus sonnet haiku fable)
# ---------------------------------------------------------------------------
@@ -219,22 +221,21 @@ err() { printf ' %sFAIL%s %s\n' "$C_BOLD$C_RED" "$C_RESET" "$*" >&2; }
head_() { printf '\n%s%s%s\n' "$C_BOLD$C_CYAN" "$*" "$C_RESET"; }
mode_color() {
case "$1" in
anthropic) printf '%s' "$C_MAGENTA" ;;
openrouter) printf '%s' "$C_CYAN" ;;
zai) printf '%s' "$C_GREEN" ;;
lmstudio) printf '%s' "$C_YELLOW" ;;
[ "$1" = anthropic ] && { printf '%s' "$C_MAGENTA"; return; }
case "$(prov_field "$1" 5)" in
cyan) printf '%s' "$C_CYAN" ;;
green) printf '%s' "$C_GREEN" ;;
yellow) printf '%s' "$C_YELLOW" ;;
magenta) printf '%s' "$C_MAGENTA" ;;
white) printf '%s' "$C_WHITE" ;;
dkcyan) printf '%s' "$C_DKCYAN" ;;
*) printf '%s' "$C_GRAY" ;;
esac
}
mode_label() {
case "$1" in
anthropic) printf 'Anthropic - your subscription login, no gateway' ;;
openrouter) printf 'OpenRouter - remote, pay-per-token, any vendor' ;;
zai) printf 'Z.AI - GLM coding plan' ;;
lmstudio) printf 'LM Studio - local server, offline, free' ;;
esac
[ "$1" = anthropic ] && { printf 'Anthropic - your subscription login, no gateway'; return; }
prov_field "$1" 4
}
# Pure ASCII on purpose - renders identically in every terminal and locale.
@@ -254,15 +255,20 @@ show_banner() {
usage() {
cat <<'EOF'
claude-mode - switch Claude Code between Anthropic, OpenRouter, Z.AI, LM Studio
claude-mode - switch Claude Code between Anthropic and gateway providers
claude-mode interactive menu
claude-mode status active mode, preset, model map
claude-mode anthropic native login (clears all gateway config)
claude-mode openrouter [preset] remote gateway (default: default)
claude-mode zai [preset] Z.AI GLM coding plan (default: zai)
claude-mode lmstudio [preset] local LM Studio (default: lmstudio)
EOF
# One line per provider in providers.json, so a new one documents itself.
local id
for id in $(provider_ids); do
printf ' claude-mode %-26s %s (default: %s)\n' "$id [preset]" \
"$(prov_field "$id" 4 | sed 's/^[^-]*- //')" "$(default_preset_for "$id")"
done
cat <<'EOF'
claude-mode presets list presets
claude-mode preset show <name>
@@ -350,6 +356,66 @@ tsv_find() {
tsv_field() { printf '%s' "$1" | cut -f"$2"; }
# ---------------------------------------------------------------------------
# Providers
#
# Every gateway provider is an entry in providers.json; this script only knows
# the *kinds* of behaviour (how a catalogue is fetched, how a server is probed)
# and picks one by name from the entry. The table is read once, in the main
# shell before dispatch - read inside a $( ) it would be re-read on every
# lookup. Columns are cm-json.py's PROVIDER_TSV:
#
# 1 id 2 aliases 3 title 4 label 5 color 6 defaultPreset
# 7 serverEditable 8 probe 9 probePaths 10 catalogueKind 11 perServer
# 12 setupKey 13 setupModels 14 keyUrl 15 guardrail 16 doctor
# 17 defaultKeyRef 18 literalToken 19 defaultBaseUrl 20 serverHint
# 21 serverStart
#
# Always read with cut, never `IFS=$'\t' read`: tab counts as whitespace to
# read, so an empty column (most providers have no aliases) would vanish and
# shift every column after it.
# ---------------------------------------------------------------------------
CM_PROVIDERS_TSV=''
cm_providers_load() {
CM_PROVIDERS_TSV="$("$PY" "$JSON" provider-tsv)" || {
echo "claude-mode: could not read providers.json (next to $CM_BIN)" >&2; exit 1; }
local id
for id in $(provider_ids); do MODES+=("$id"); done
}
provider_ids() { printf '%s\n' "$CM_PROVIDERS_TSV" | cut -f1; }
prov_field() {
local row
row="$(printf '%s\n' "$CM_PROVIDERS_TSV" | tsv_find "$1")" || return 1
tsv_field "$row" "$2"
}
# An id or an alias (z.ai, z-ai) to the provider id; non-zero if neither.
provider_resolve() {
local w line
w="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
[ -n "$w" ] || return 1
while IFS= read -r line; do
[ -n "$line" ] || continue
if [ "${line%%$'\t'*}" = "$w" ]; then printf '%s\n' "$w"; return 0; fi
case ",$(tsv_field "$line" 2)," in
*",$w,"*) printf '%s\n' "${line%%$'\t'*}"; return 0 ;;
esac
done <<EOF_PROVIDERS
$CM_PROVIDERS_TSV
EOF_PROVIDERS
return 1
}
# Whether a provider's entry lists a named doctor check.
doctor_has() {
case ",$(prov_field "$1" 16)," in *",$2,"*) return 0 ;; esac
return 1
}
term_cols() {
local c=''
if command -v tput >/dev/null 2>&1; then c="$(tput cols 2>/dev/null)"; fi
@@ -367,13 +433,7 @@ term_cols() {
# The existence test is what lets a stale choice degrade instead of break.
# cm-json.py's BUILTIN_DEFAULT_PRESET and the widget's Modes.DEFAULT_PRESET
# mirror the built-in names.
builtin_default_preset() {
case "$1" in
openrouter) printf 'default' ;;
zai) printf 'zai' ;;
lmstudio) printf 'lmstudio' ;;
esac
}
builtin_default_preset() { prov_field "$1" 6; }
default_preset_for() {
local chosen
@@ -452,11 +512,11 @@ cm_probe_timeout() { cm_url_is_local "$1" && printf '4' || printf '10'; }
# The distinction matters because the remedies are opposites: `refused` means go
# and start the server, `auth` means the server is fine and the key is not.
cm_probe_server() {
local base="${1%/}" token="${2:-}" t code ep
local base="${1%/}" token="${2:-}" paths="${3:-/api/v0/models,/v1/models}" t code ep
command -v curl >/dev/null 2>&1 || { printf 'skip'; return 0; }
t="$(cm_probe_timeout "$base")"
for ep in /api/v0/models /v1/models; do
for ep in $(printf '%s' "$paths" | tr ',' ' '); do
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time "$t" \
${token:+-H "Authorization: Bearer $token"} \
"$base$ep" 2>/dev/null)"
@@ -521,6 +581,15 @@ cm_preflight() {
base="$(jget "$pf" baseUrl)"; CM_PF_BASEURL="$base"
auth_mode="$(jget "$pf" auth.mode)"; [ -z "$auth_mode" ] && auth_mode=vault
# A custom endpoint ships with no address, since there is no sensible one
# to guess; switching to it would point every session at nothing.
if [ -z "$base" ]; then
cm_pf_set 'no-url' "Preset '$preset' has no server address" \
"$(prov_field "$mode" 3) needs to know where the server is before anything can be sent to it." \
"claude-mode preset url $preset <base-url>" 'set-url'
return 1
fi
if [ "$auth_mode" = "vault" ]; then
key_ref="$(jget "$pf" auth.keyRef)"; [ -z "$key_ref" ] && key_ref=openrouter
CM_PF_KEYREF="$key_ref"
@@ -538,17 +607,21 @@ cm_preflight() {
fi
fi
# LM Studio is checked wherever it is; other providers only when they are
# pointed at this machine. A public gateway that is briefly unreachable is
# the network's problem and not worth blocking a config change over.
if [ -n "$base" ] && { [ "$mode" = "lmstudio" ] || cm_url_is_local "$base"; }; then
# A server provider is checked wherever it is (probe "always", or
# "lenient" for a proxy that may list no models); a public gateway only
# when it has been pointed at this machine (probe "local"). A public
# gateway that is briefly unreachable is the network's problem and not
# worth blocking a config change over.
local probe_rule title
probe_rule="$(prov_field "$mode" 8)"; title="$(prov_field "$mode" 3)"
if [ "$probe_rule" = always ] || [ "$probe_rule" = lenient ] || cm_url_is_local "$base"; then
local token='' probe where
if [ "$auth_mode" = "vault" ]; then
token="$(cm_vault_get "$key_ref" 2>/dev/null || true)"
else
token="$(jget "$pf" auth.token)"
fi
probe="$(cm_probe_server "$base" "$token")"
probe="$(cm_probe_server "$base" "$token" "$(prov_field "$mode" 9)")"
cm_url_is_local "$base" && where='on this machine' || where='at that address'
case "$probe" in
@@ -560,13 +633,17 @@ cm_preflight() {
"claude-mode set-key $key_ref" 'set-key'
else
cm_pf_set 'server-auth' 'The server wants an API key' \
"$base is running but is refusing an unauthenticated request. This preset is set to send LM Studio's placeholder token, which only works on a server with authentication switched off." \
"$base is running but is refusing an unauthenticated request. This preset is set to send $title's placeholder token, which only works on a server with authentication switched off." \
"claude-mode preset auth $preset key" 'needs-key'
fi
return 1 ;;
notfound)
cm_pf_set 'server-wrong' 'That address answered, but not as LM Studio' \
"Something is listening at $base, but neither /api/v0/models nor /v1/models is there. Check the port, or whether a proxy in front of it is rewriting the path." \
# A proxy in front of a Messages API often serves no model
# list at all; for one of those, something answering is
# as much as can be checked.
[ "$probe_rule" = lenient ] && return 0
cm_pf_set 'server-wrong' "That address answered, but not as $title" \
"Something is listening at $base, but $(prov_field "$mode" 9 | sed 's/,/ and /g') is not there. Check the port, or whether a proxy in front of it is rewriting the path." \
"claude-mode preset url $preset <base-url>" 'set-url'
return 1 ;;
*)
@@ -582,14 +659,11 @@ cm_preflight() {
cmd_preflight() {
local mode="${1:-}" preset="${2:-}" p
case "$mode" in
anthropic) ;;
openrouter|zai|lmstudio)
if [ "$mode" != anthropic ]; then
mode="$(provider_resolve "$mode")" || { err "unknown mode '${1:-}'"; return 1; }
p="$(resolve_preset "$mode" "$preset" 2>/dev/null)" || p="$preset"
preset="$p" ;;
z.ai|z-ai) mode=zai; p="$(resolve_preset zai "$preset" 2>/dev/null)" || p="$preset"; preset="$p" ;;
*) err "unknown mode '$mode'"; return 1 ;;
esac
preset="$p"
fi
if cm_preflight "$mode" "$preset"; then
"$PY" "$JSON" preflight-json ok "$mode" "$preset" '' '' '' '' '' '' ''
@@ -1046,7 +1120,8 @@ write_health() {
# nothing actionable to print for them.
show_guardrail_status() {
local mode="$1" key="$2" code
[ "$mode" = "openrouter" ] || return 0
# An OpenRouter feature, flagged per provider rather than by name.
[ "$(prov_field "$mode" 15)" = 1 ] || return 0
[ -n "$key" ] || return 0
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 25 \
@@ -1187,26 +1262,41 @@ cm_cache_catalogue() {
"$PY" "$JSON" cache-models "$CM_MODELS_CACHE" "$1" "$2" "${3:-}" >/dev/null 2>&1 || true
}
or_catalogue() {
local out rc
out="$(curl -fsS --max-time 30 https://openrouter.ai/api/v1/models 2>/dev/null | "$PY" "$JSON" or-models 2>/dev/null)"
# One catalogue fetch, for any provider: fetched according to its
# catalogue.kind, parsed to TSV, and left in the cache for the panel. Prints
# the TSV; the status says whether the fetch itself worked. TSV columns by kind:
#
# openrouter id ctx $in $out lmstudio id state ctx
# ollama id params quant family openai id
# static id note (a list kept in providers.json)
provider_catalogue() {
local pid="$1" base="${2%/}" token="${3:-}" out rc
case "$(prov_field "$pid" 10)" in
openrouter)
out="$(curl -fsS --max-time 30 https://openrouter.ai/api/v1/models 2>/dev/null \
| "$PY" "$JSON" or-models 2>/dev/null)" ;;
lmstudio)
out="$(curl -fsS --max-time 10 ${token:+-H "Authorization: Bearer $token"} \
"$base/api/v0/models" 2>/dev/null | "$PY" "$JSON" lms-models 2>/dev/null)" ;;
ollama)
out="$(curl -fsS --max-time 10 ${token:+-H "Authorization: Bearer $token"} \
"$base/api/tags" 2>/dev/null | "$PY" "$JSON" ollama-models 2>/dev/null)" ;;
openai)
# Both header styles: a proxy in front of Anthropic wants
# x-api-key, one in front of anything else wants Bearer.
out="$(curl -fsS --max-time 15 ${token:+-H "Authorization: Bearer $token"} \
${token:+-H "x-api-key: $token"} -H 'anthropic-version: 2023-06-01' \
"$base/v1/models" 2>/dev/null | "$PY" "$JSON" openai-models 2>/dev/null)" ;;
static)
out="$("$PY" "$JSON" provider-static "$pid" 2>/dev/null)" ;;
*) return 1 ;;
esac
rc=$?
cm_cache_catalogue openrouter "$([ "$rc" -eq 0 ] && echo 1 || echo 0)" <<<"$out"
cm_cache_catalogue "$pid" "$([ "$rc" -eq 0 ] && echo 1 || echo 0)" "$base" <<<"$out"
[ -n "$out" ] && printf '%s\n' "$out"
return "$rc"
}
# Z.AI publishes no catalogue endpoint, so this list comes from its docs - and
# this is the one place it is kept.
zai_catalogue() {
local out
out="$(printf '%s\t%s\n' \
glm-5.3 'flagship coding model - opus/sonnet tier' \
glm-4.7 'fast/cheap tier - haiku')"
cm_cache_catalogue zai 1 <<<"$out"
printf '%s\n' "$out"
}
# The credential a preset would send. Empty when there is nothing to send.
cm_preset_token() {
local pf="$1" am ref
@@ -1219,19 +1309,11 @@ cm_preset_token() {
fi
}
# The token is not optional decoration. An LM Studio server with authentication
# switched on answers /api/v0/models with 401 like anything else, so without it
# the catalogue comes back empty and every caller silently believes the server
# has no models installed - on exactly the setups that need the list most.
lms_catalogue() {
local base="${1%/}" token="${2:-}" out rc
out="$(curl -fsS --max-time 10 ${token:+-H "Authorization: Bearer $token"} \
"$base/api/v0/models" 2>/dev/null | "$PY" "$JSON" lms-models 2>/dev/null)"
rc=$?
cm_cache_catalogue lmstudio "$([ "$rc" -eq 0 ] && echo 1 || echo 0)" "$base" <<<"$out"
[ -n "$out" ] && printf '%s\n' "$out"
return "$rc"
}
# The token passed to provider_catalogue is not optional decoration. A local
# server with authentication switched on answers its model list with 401 like
# anything else, so without it the catalogue comes back empty and every caller
# silently believes the server has no models - on exactly the setups that need
# the list most.
# ---------------------------------------------------------------------------
# Commands
@@ -1325,18 +1407,16 @@ cmd_models() {
provider=openrouter # on anthropic, the catalogue worth browsing
fi
local quiet=$(( refresh || as_json ))
case "$provider" in
lmstudio)
[ "$quiet" -eq 1 ] || head_ "models installed in LM Studio at $base"
tsv="$(lms_catalogue "$base" "$(cm_preset_token "$pf")")"; rc=$? ;;
zai)
[ "$quiet" -eq 1 ] || head_ 'Z.AI GLM models (from Z.AI docs - no public catalogue endpoint)'
tsv="$(zai_catalogue)"; rc=$? ;;
*)
[ "$quiet" -eq 1 ] || head_ 'fetching https://openrouter.ai/api/v1/models ...'
tsv="$(or_catalogue)"; rc=$? ;;
local quiet=$(( refresh || as_json )) kind title
kind="$(prov_field "$provider" 10)"; title="$(prov_field "$provider" 3)"
if [ "$quiet" -eq 0 ]; then
case "$kind" in
static) head_ "$title models (from its docs - no public catalogue endpoint)" ;;
openrouter) head_ 'fetching https://openrouter.ai/api/v1/models ...' ;;
*) head_ "models on the $title server at $base" ;;
esac
fi
tsv="$(provider_catalogue "$provider" "$base" "$(cm_preset_token "$pf")")"; rc=$?
if [ "$as_json" -eq 1 ]; then
jget "$CM_MODELS_CACHE" "providers.$provider"
@@ -1346,6 +1426,10 @@ cmd_models() {
if [ "$refresh" -eq 1 ]; then
if [ "$rc" -ne 0 ]; then
err "could not fetch the $provider catalogue${base:+ from $base}; the cached list, if any, is kept"
# Not necessarily a fault: plenty of proxies serve Messages and
# nothing else.
[ "$(prov_field "$provider" 8)" = lenient ] && \
say 'some endpoints serve no model list at all - model ids can still be typed by hand'
return 1
fi
ok "cached $(printf '%s\n' "$tsv" | grep -c .) $provider model(s)"
@@ -1357,10 +1441,12 @@ cmd_models() {
while IFS=$'\t' read -r id c2 c3 c4; do
[ -n "$id" ] || continue
[ -z "$filter" ] || case "$id" in *"$filter"*) ;; *) continue ;; esac
case "$provider" in
case "$kind" in
lmstudio) printf ' %-58s %-11s %s\n' "$id" "$c2" "$c3" ;;
zai) say "$(printf '%-8s - %s' "$id" "$c2")" ;;
*) printf ' %-52s %10s $%-8s $%s\n' "$id" "$c2" "$c3" "$c4" ;;
static) say "$(printf '%-8s - %s' "$id" "$c2")" ;;
ollama) printf ' %-44s %-8s %s\n' "$id" "$c2" "$c3" ;;
openrouter) printf ' %-52s %10s $%-8s $%s\n' "$id" "$c2" "$c3" "$c4" ;;
*) printf ' %s\n' "$id" ;;
esac
done <<<"$tsv"
}
@@ -1399,6 +1485,106 @@ cmd_set_key() {
printf '%s' "$secret" | cm_vault_set "$ref" && ok "stored key '$ref' via $(cm_vault_backend_label)"
}
# The preset's model ids against what the provider actually offers. A server
# provider that does not answer is a failure; a hosted catalogue that cannot be
# fetched is only a warning, since the endpoint may be fine regardless.
doctor_catalogue() {
local mode="$1" pf="$2" base="$3" kind title cat t id row declared c2 c3
kind="$(prov_field "$mode" 10)"; title="$(prov_field "$mode" 3)"
declared="$(jget "$pf" contextTokens)"
cat="$(provider_catalogue "$mode" "$base" "$(cm_preset_token "$pf")")"
if [ -z "$cat" ]; then
if [ "$(prov_field "$mode" 7)" = 1 ]; then
local start; start="$(prov_field "$mode" 21)"
if [ "$(prov_field "$mode" 8)" = lenient ]; then
warn "$title at $base lists no models - fine for a proxy, but the ids below cannot be checked"
else
err "$title not reachable at $base${start:+ - $start}"
fi
else
warn "could not fetch the $title model list"
fi
return 0
fi
[ "$(prov_field "$mode" 7)" = 1 ] && ok "$title reachable at $base ($(printf '%s\n' "$cat" | grep -c .) models)"
for t in "${TIERS[@]}"; do
id="$(jget "$pf" "models.$t")"; [ -n "$id" ] || continue
row="$(printf '%s\n' "$cat" | tsv_find "$id")" || row=''
# Ollama lists every model with its tag, and a bare name means
# `:latest` - `qwen3-coder` is served as `qwen3-coder:latest`.
if [ -z "$row" ] && [ "$kind" = ollama ]; then
case "$id" in *:*) ;; *) row="$(printf '%s\n' "$cat" | tsv_find "$id:latest")" || row='' ;; esac
fi
if [ -z "$row" ]; then
# A fixed list is documentation, not the provider's word: an id
# missing from it may still be served.
if [ "$kind" = static ]; then ok "$(printf '%-6s %s (not in the documented list)' "$t" "$id")"
else err "$t model NOT available from $title: $id"; fi
continue
fi
c2="$(tsv_field "$row" 2)"; c3="$(tsv_field "$row" 3)"
case "$kind" in
openrouter) ok "$(printf '%-6s %s [ctx %s]' "$t" "$id" "$c2")"; doctor_ctx_vs "$t" "$c2" "$declared" ;;
lmstudio) ok "$(printf '%-6s %s [%s, ctx %s]' "$t" "$id" "$c2" "$c3")"; doctor_ctx_vs "$t" "$c3" "$declared" ;;
ollama) ok "$(printf '%-6s %s [%s %s]' "$t" "$id" "$c2" "$c3")" ;;
*) ok "$(printf '%-6s %s' "$t" "$id")" ;;
esac
done
[ -n "$declared" ] && ok "declared context window: $declared tokens" \
|| warn 'preset has no contextTokens - Claude Code will guess a small window and auto-compact early'
}
doctor_ctx_vs() {
local t="$1" ctx="$2" declared="$3"
{ [ -n "$declared" ] && [ -n "$ctx" ] && [ "$ctx" -lt "$declared" ]; } 2>/dev/null || return 0
if [ "$t" = haiku ]; then
warn "$t model has $ctx ctx, below the declared $declared - harmless, haiku runs short background tasks"
else
warn "$t model has $ctx ctx, below the declared $declared - this tier can overflow"
fi
}
# Ollama sets the context window on the server, not per request from Claude
# Code: 4096 tokens unless `ollama serve` runs with OLLAMA_CONTEXT_LENGTH, and
# anything past it is cut off without an error. contextTokens in a preset only
# tells Claude Code what to expect - it cannot change what the server does - so
# say what the server is actually running where that can be seen, and what to
# set where it cannot.
doctor_ollama_context() {
local pf="$1" base="$2" declared ids id max live ps seen=0 short=0 t body
declared="$(jget "$pf" contextTokens)"; [ -n "$declared" ] || return 0
ids="$(for t in "${TIERS[@]}"; do jget "$pf" "models.$t"; done | sort -u | grep . || true)"
[ -n "$ids" ] || return 0
ps="$(curl -fsS --max-time 5 "$base/api/ps" 2>/dev/null)"
while IFS= read -r id; do
[ -n "$id" ] || continue
body="$("$PY" -c 'import json,sys; print(json.dumps({"model": sys.argv[1]}))' "$id")"
max="$(curl -fsS --max-time 8 -X POST -H 'content-type: application/json' -d "$body" \
"$base/api/show" 2>/dev/null | "$PY" "$JSON" ollama-ctx show 2>/dev/null)"
if [ -n "$max" ] && [ "$max" -lt "$declared" ] 2>/dev/null; then
warn "$id supports at most $max tokens, below the declared $declared - lower contextTokens"
fi
live="$(printf '%s' "$ps" | "$PY" "$JSON" ollama-ctx ps "$id" 2>/dev/null)"
[ -n "$live" ] || continue
seen=1
if [ "$live" -lt "$declared" ] 2>/dev/null; then
short=1
warn "$id is loaded with a $live-token context, below the declared $declared - requests past it are cut off"
else
ok "$id is loaded with a $live-token context"
fi
done <<EOF_IDS
$ids
EOF_IDS
if [ "$seen" -eq 0 ]; then
warn "none of these models is loaded, so the server's context window cannot be checked"
say "Ollama defaults to 4096 tokens; run it with OLLAMA_CONTEXT_LENGTH=$declared or requests past that are cut off silently"
elif [ "$short" -eq 1 ]; then
say "restart it with OLLAMA_CONTEXT_LENGTH=$declared (or lower contextTokens to match)"
fi
}
cmd_doctor() {
local mode preset pf
mode="$(state_mode)"; preset="$(state_preset)"; pf="$(preset_path "$preset")"
@@ -1456,7 +1642,7 @@ cmd_doctor() {
else err 'apiKeyHelper produced no output'; fi
fi
if [ -n "$key" ] && [ "$mode" = "openrouter" ]; then
if [ -n "$key" ] && doctor_has "$mode" openrouter-key; then
local kinfo
kinfo="$(curl -fsS --max-time 20 -H "Authorization: Bearer $key" https://openrouter.ai/api/v1/key 2>/dev/null)"
if [ -n "$kinfo" ]; then
@@ -1474,60 +1660,24 @@ else: print(' ok spend %.2f of %.2f limit (%s), %.2f remaining' % (use, lim,
show_guardrail_status "$mode" "$key"
fi
if [ -n "$key" ] && [ "$mode" = "zai" ]; then
# No key-info endpoint: the cheapest real check is a 1-token
# message against the Anthropic-compatible surface itself.
if [ -n "$key" ] && doctor_has "$mode" message-check; then
if curl -fsS --max-time 45 -X POST "$base/v1/messages" \
-H 'content-type: application/json' -H "x-api-key: $key" \
-H "authorization: Bearer $key" -H 'anthropic-version: 2023-06-01' \
-d "{\"model\":\"$(jget "$pf" models.haiku)\",\"max_tokens\":1,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}" >/dev/null 2>&1; then
ok "Z.AI endpoint accepted the key ($base/v1/messages)"
ok "$(prov_field "$mode" 3) endpoint accepted the key ($base/v1/messages)"
else
err 'Z.AI request failed'
err "$(prov_field "$mode" 3) request failed"
fi
fi
else
ok "inline token '$(jget "$pf" auth.token)' (no secret in settings.json)"
fi
if [ "$mode" = "lmstudio" ]; then
local cat; cat="$(lms_catalogue "$base" "$(cm_preset_token "$pf")")"
if [ -n "$cat" ]; then
ok "LM Studio reachable at $base ($(printf '%s\n' "$cat" | wc -l | tr -d ' ') models installed)"
local t id row st ctx
for t in "${TIERS[@]}"; do
id="$(jget "$pf" "models.$t")"; [ -n "$id" ] || continue
row="$(printf '%s\n' "$cat" | tsv_find "$id")" || row=''
if [ -z "$row" ]; then err "$t model NOT installed in LM Studio: $id"; continue; fi
st="$(tsv_field "$row" 2)"; ctx="$(tsv_field "$row" 3)"
ok "$t $id [$st, ctx $ctx]"
done
else
err "LM Studio not reachable at $base - start the server (Developer > Start Server)"
fi
elif [ "$mode" = "openrouter" ]; then
local cat; cat="$(or_catalogue)"
if [ -n "$cat" ]; then
local t id row ctx declared
declared="$(jget "$pf" contextTokens)"
for t in "${TIERS[@]}"; do
id="$(jget "$pf" "models.$t")"; [ -n "$id" ] || continue
row="$(printf '%s\n' "$cat" | tsv_find "$id")" || row=''
if [ -z "$row" ]; then err "$t model NOT available from OpenRouter: $id"; continue; fi
ctx="$(tsv_field "$row" 2)"
ok "$(printf '%-6s %s [ctx %s]' "$t" "$id" "$ctx")"
if [ -n "$declared" ] && [ -n "$ctx" ] && [ "$ctx" -lt "$declared" ] 2>/dev/null; then
if [ "$t" = "haiku" ]; then
warn "$t model has $ctx ctx, below the declared $declared - harmless, haiku runs short background tasks"
else
warn "$t model has $ctx ctx, below the declared $declared - this tier can overflow"
fi
fi
done
[ -n "$declared" ] && ok "declared context window: $declared tokens" \
|| warn 'preset has no contextTokens - Claude Code will guess a small window and auto-compact early'
else
warn 'could not fetch the OpenRouter catalogue'
fi
fi
doctor_has "$mode" catalogue-models && doctor_catalogue "$mode" "$pf" "$base"
doctor_has "$mode" ollama-context && doctor_ollama_context "$pf" "$base"
fi
printf '\n'
@@ -1569,17 +1719,18 @@ cmd_preset() {
a="$1"; shift
case "$a" in
--provider)
[ $# -gt 0 ] || { err '--provider needs openrouter, zai or lmstudio'; return 1; }
[ $# -gt 0 ] || { err "--provider needs one of: $(provider_ids | tr '\n' ' ')"; return 1; }
provider="$1"; shift ;;
--blank) blank=1 ;;
-*) err "unknown option '$a'"; return 1 ;;
*) from="$a" ;;
esac
done
case "$provider" in
''|openrouter|zai|lmstudio) ;;
*) err "unknown provider '$provider' - openrouter, zai or lmstudio"; return 1 ;;
esac
if [ -n "$provider" ]; then
local want="$provider"
provider="$(provider_resolve "$want")" || {
err "unknown provider '$want' - one of: $(provider_ids | tr '\n' ' ')"; return 1; }
fi
if [ "$blank" -eq 1 ]; then
[ -n "$provider" ] || { err '--blank needs --provider to know which template'; return 1; }
@@ -1652,7 +1803,7 @@ cmd_preset() {
local prov="$name" target="${3:-}" p cur got
if [ -z "$prov" ]; then
head_ 'default preset per provider'
for p in openrouter zai lmstudio; do
for p in $(provider_ids); do
cur="$(jget "$CM_DEFAULTS" "$p")"
if [ -n "$cur" ] && [ -f "$(preset_path "$cur")" ]; then
printf ' %-11s %s (chosen)\n' "$p" "$cur"
@@ -1671,10 +1822,8 @@ cmd_preset() {
printf '\n %sclaude-mode preset default <provider> <name> (or --clear)%s\n' "$C_DIM" "$C_RESET"
return 0
fi
case "$prov" in
openrouter|zai|lmstudio) ;;
*) err "unknown provider '$prov' - openrouter, zai or lmstudio"; return 1 ;;
esac
prov="$(provider_resolve "$name")" || {
err "unknown provider '$name' - one of: $(provider_ids | tr '\n' ' ')"; return 1; }
if [ -z "$target" ]; then
resolve_preset "$prov" && printf '\n'
return
@@ -1722,9 +1871,12 @@ cmd_preset() {
reapply_if_active "$name"
;;
auth)
local amode="${3:-}" ref="${4:-lmstudio}"
local amode="${3:-}" ref="${4:-}"
[ -n "$name" ] && [ -n "$amode" ] || { err 'usage: claude-mode preset auth <name> none|key [keyRef]'; return 1; }
[ -f "$(preset_path "$name")" ] || { err "preset '$name' not found"; return 1; }
# The provider's own slot name by default: lmstudio, ollama, custom.
[ -n "$ref" ] || ref="$(prov_field "$(jget "$(preset_path "$name")" provider)" 17)"
[ -n "$ref" ] || ref=lmstudio
"$PY" "$JSON" set-auth "$(preset_path "$name")" "$amode" "$ref" >/dev/null || return 1
if [ "$amode" = "key" ]; then
ok "$name : auth -> vault key '$ref'"
@@ -2012,27 +2164,18 @@ ui_pick_model() {
ui_reset_items
ui_add_item '<type an id manually>' 'enter any model id by hand'
local ids=() id ctx a b st
case "$provider" in
openrouter)
while IFS=$'\t' read -r id ctx a b; do
local ids=() id ctx a b st kind
kind="$(prov_field "$provider" 10)"
while IFS=$'\t' read -r id a b st; do
[ -n "$id" ] || continue
ids+=("$id"); ui_add_item "$id" "context $ctx \$$a in / \$$b out per 1M"
done < <(or_catalogue)
;;
lmstudio)
while IFS=$'\t' read -r id st ctx; do
[ -n "$id" ] || continue
ids+=("$id"); ui_add_item "$id" "state: $st max context: $ctx"
done < <(lms_catalogue "$base" "$(cm_preset_token "$pf")")
;;
zai)
while IFS=$'\t' read -r id a; do
[ -n "$id" ] || continue
ids+=("$id"); ui_add_item "$id" "$a"
done < <(zai_catalogue)
;;
ids+=("$id")
case "$kind" in
openrouter) ui_add_item "$id" "context $a \$$b in / \$$st out per 1M" ;;
lmstudio) ui_add_item "$id" "state: $a max context: $b" ;;
ollama) ui_add_item "$id" "$a $b" ;;
*) ui_add_item "$id" "$a" ;;
esac
done < <(provider_catalogue "$provider" "$base" "$(cm_preset_token "$pf")")
if [ "${#ids[@]}" -gt 0 ]; then
ui_filter_select "model for '$tier'" "current: $current" || return 1
@@ -2083,7 +2226,7 @@ ui_edit_preset() {
ui_new_preset() {
ui_reset_items
local provs=(openrouter zai lmstudio) p
local provs=($(provider_ids)) p
for p in "${provs[@]}"; do ui_add_item "$p" "$(mode_label "$p")"; done
ui_select 'new preset - which provider' '' || return
local provider="${provs[$UI_SEL]}"
@@ -2254,7 +2397,7 @@ setup_models() {
return 0
fi
if [ "$provider" = "lmstudio" ]; then
if [ "$(prov_field "$provider" 13)" = one-for-all ]; then
# One model for every tier is the normal shape for a local server: it
# has one loaded at a time, and mapping tiers to different models just
# means paying the load cost on every tier change.
@@ -2263,7 +2406,7 @@ setup_models() {
while IFS=$'\t' read -r id st ctx; do
[ -n "$id" ] || continue
ids+=("$id")
done < <(lms_catalogue "$base" "$(cm_preset_token "$pf")")
done < <(provider_catalogue "$provider" "$base" "$(cm_preset_token "$pf")")
if [ "${#ids[@]}" -eq 0 ]; then
warn 'the server returned no models; type an id by hand instead'
@@ -2292,40 +2435,48 @@ setup_models() {
done
}
setup_lmstudio_server() {
local pf="$1" name="$2" url probe token
url="$(jget "$pf" baseUrl)"; [ -n "$url" ] || url='http://127.0.0.1:1234'
# Where a server provider (LM Studio, Ollama, a custom endpoint) lives, and
# whether it wants a key. None of them has to be on this machine.
setup_server() {
local pf="$1" name="$2" mode="$3" url probe token title hint start
title="$(prov_field "$mode" 3)"; hint="$(prov_field "$mode" 20)"; start="$(prov_field "$mode" 21)"
url="$(jget "$pf" baseUrl)"; [ -n "$url" ] || url="$(prov_field "$mode" 19)"
printf '\n'
say 'LM Studio does not have to be on this machine - a LAN address or'
say 'anything reachable through a tunnel or proxy works just as well.'
[ -n "$hint" ] && say "$hint"
url="$(ask_value 'server base URL' "$url")"
[ -n "$url" ] || { err 'a server address is needed'; return 1; }
"$PY" "$JSON" set-url "$pf" "$url" >/dev/null || return 1
ok "baseUrl -> $url"
printf '\n'
if ask_yes 'does that server require an API key?'; then
local ref; ref="$(ask_value 'key name to store it under' 'lmstudio')"
local ref; ref="$(ask_value 'key name to store it under' "$(prov_field "$mode" 17)")"
"$PY" "$JSON" set-auth "$pf" key "$ref" >/dev/null
ok "auth -> vault key '$ref'"
cm_vault_has "$ref" || cmd_set_key "$ref"
token="$(cm_vault_get "$ref" 2>/dev/null || true)"
else
"$PY" "$JSON" set-auth "$pf" none >/dev/null
ok 'auth -> none (inline placeholder token)'
token='lmstudio'
token="$(prov_field "$mode" 18)"
ok "auth -> none (inline placeholder token '$token')"
fi
printf '\n'
say "checking $url ..."
probe="$(cm_probe_server "$url" "$token")"
probe="$(cm_probe_server "$url" "$token" "$(prov_field "$mode" 9)")"
case "$probe" in
ok) ok 'server answered' ;;
auth) err 'the server refused that credential'; return 1 ;;
notfound) err 'something answered there, but not an LM Studio API' ; return 1 ;;
notfound)
if [ "$(prov_field "$mode" 8)" = lenient ]; then
warn "something answered, but it lists no models - fine for a proxy; model ids are typed by hand"
else
err "something answered there, but not a $title API"; return 1
fi ;;
skip) warn 'curl is missing, so the server was not checked' ;;
*) err 'nothing answered at that address'
say 'start the server and run: claude-mode setup lmstudio'
say "${start:+$start, then }run: claude-mode setup $mode"
return 1 ;;
esac
return 0
@@ -2339,10 +2490,8 @@ cmd_setup() {
head_ 'setup: anthropic'
ok 'nothing to configure - it uses your existing Claude login'
return 0 ;;
openrouter|zai|lmstudio) ;;
z.ai|z-ai) mode=zai ;;
'') err 'usage: claude-mode setup <mode>'; return 1 ;;
*) err "unknown mode '$mode'"; return 1 ;;
*) mode="$(provider_resolve "$mode")" || { err "unknown mode '${1:-}'"; return 1; } ;;
esac
name="$(resolve_preset "$mode" "${2:-}")" || return 1
@@ -2357,23 +2506,19 @@ cmd_setup() {
head_ "setup: $mode / preset '$name'"
say "$(mode_label "$mode")"
case "$mode" in
openrouter)
# A server provider is asked where it is and whether it wants a key; a
# hosted one always wants a key, and the entry says where to get one.
if [ "$(prov_field "$mode" 7)" = 1 ]; then
setup_server "$pf" "$name" "$mode" || return 1
else
local ref url
ref="$(jget "$pf" auth.keyRef)"; [ -n "$ref" ] || ref="$(prov_field "$mode" 17)"
url="$(prov_field "$mode" 14)"
printf '\n'
setup_key openrouter 'OpenRouter'
setup_models "$pf" openrouter "$name"
;;
zai)
printf '\n'
say 'get a key from https://z.ai/manage-apikey/apikey-list'
setup_key zai 'Z.AI'
setup_models "$pf" zai "$name"
;;
lmstudio)
setup_lmstudio_server "$pf" "$name" || return 1
setup_models "$pf" lmstudio "$name"
;;
esac
[ -n "$url" ] && say "get a key from $url"
setup_key "$ref" "$(prov_field "$mode" 3)"
fi
setup_models "$pf" "$mode" "$name"
mark_configured "$pf"
printf '\n'
@@ -2785,6 +2930,7 @@ else:
command -v "$PY" >/dev/null 2>&1 || { echo "claude-mode: $PY not found (set CLAUDE_MODE_PYTHON)" >&2; exit 1; }
init_root
cm_providers_load
# --force is global and may appear anywhere; strip it before the mode arguments
# are positional-matched below.
@@ -2803,10 +2949,6 @@ case "$cmd" in
''|menu) ui_menu ;;
status) cmd_status ;;
anthropic) set_mode anthropic ;;
openrouter|zai|lmstudio)
p="$(resolve_preset "$cmd" "${1:-}")" || exit 1
set_mode "$cmd" "$p" ;;
z.ai|z-ai) p="$(resolve_preset zai "${1:-}")" || exit 1; set_mode zai "$p" ;;
presets) cmd_presets ;;
preset) cmd_preset "$@" ;;
set-key)
@@ -2878,5 +3020,13 @@ case "$cmd" in
for a in "$@"; do [ "$a" = "--all" ] && scope=all; done
cmd_repair "$scope" ;;
help|--help|-h) usage ;;
*) err "unknown command '$cmd'"; usage; exit 1 ;;
*)
# Any provider in providers.json, by id or alias, is a mode.
# Last, so a provider can never shadow a real command.
if _prov="$(provider_resolve "$cmd")"; then
p="$(resolve_preset "$_prov" "${1:-}")" || exit 1
set_mode "$_prov" "$p"
else
err "unknown command '$cmd'"; usage; exit 1
fi ;;
esac
+252 -37
View File
@@ -20,6 +20,10 @@ Subcommands:
preset-rename <dir> <old> <new> <state> [defaults] rename, repointing state + default
auth-of <preset> "mode<TAB>keyRef"; fails if missing
set-default <defaults> <provider> [name] choose (or clear) a provider default
provider-tsv | provider-resolve <word> | provider-get <id> <path> | provider-static <id>
read providers.json
ollama-models | openai-models catalogue parsers (JSON on stdin)
ollama-ctx show | ps <model> context lengths from Ollama (stdin)
"""
import glob
@@ -63,9 +67,162 @@ BASE_MANAGED = [
TIERS = ["opus", "sonnet", "haiku", "fable"]
# Mirrors builtin_default_preset() in the CLI: what `claude-mode <provider>`
# picks when no preset is named and none has been chosen with `preset default`.
BUILTIN_DEFAULT_PRESET = {"openrouter": "default", "zai": "zai", "lmstudio": "lmstudio"}
# ---------------------------------------------------------------------------
# Providers
#
# Every gateway provider is described in providers.json - endpoint, auth, how
# its model list is fetched, how setup runs, which doctor checks apply, how it
# is drawn - so adding one that reuses an existing behaviour is an entry there
# rather than code in four places. What differs in *kind* (parsing a catalogue
# format, probing a server) stays in code, picked by name from the entry.
#
# anthropic is not in it: it is the native login, not a gateway.
#
# The file sits one level above this script in both layouts - the repository
# (linux/cm-json.py) and an install (~/.claude-mode/bin/cm-json.py) - so no
# path has to be passed around. CM_PROVIDERS overrides it, for tests.
# ---------------------------------------------------------------------------
PROVIDERS_PATH = os.environ.get("CM_PROVIDERS") or os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "providers.json")
_PROVIDERS = None
def providers():
global _PROVIDERS
if _PROVIDERS is None:
if not os.path.exists(PROVIDERS_PATH):
raise SystemExit("providers.json not found at %s - re-run the installer" % PROVIDERS_PATH)
data = load(PROVIDERS_PATH, {})
items = data.get("providers") if isinstance(data, dict) else None
_PROVIDERS = [p for p in (items or []) if isinstance(p, dict) and p.get("id")]
return _PROVIDERS
def provider(pid):
for p in providers():
if p["id"] == pid:
return p
return None
def _dig(node, dotted, default=None):
for part in dotted.split("."):
if isinstance(node, dict) and part in node:
node = node[part]
else:
return default
return node
def catalogue_kind(pid):
return _dig(provider(pid) or {}, "catalogue.kind", "")
def per_server_catalogue(pid):
"""A catalogue that belongs to one server rather than to the provider -
two LM Studio presets can point at two machines with different models."""
return bool(_dig(provider(pid) or {}, "catalogue.perServer", False))
def builtin_default_preset():
"""What `claude-mode <provider>` picks when no preset is named and none has
been chosen. Mirrored by builtin_default_preset() in the CLI."""
return {p["id"]: p.get("defaultPreset") or p["id"] for p in providers()}
# Column order of `provider-tsv`, which the shell reads once per run and looks
# rows up in (bash 3.2 on macOS has no associative arrays). Append only: the
# shell addresses these by number.
PROVIDER_TSV = [
("id", lambda p: p["id"]),
("aliases", lambda p: ",".join(p.get("aliases") or [])),
("title", lambda p: p.get("title") or p["id"]),
("label", lambda p: p.get("label") or p.get("title") or p["id"]),
("color", lambda p: p.get("color") or "gray"),
("defaultPreset", lambda p: p.get("defaultPreset") or p["id"]),
("serverEditable", lambda p: "1" if _dig(p, "server.editable") else "0"),
("probe", lambda p: _dig(p, "server.probe", "none")),
("probePaths", lambda p: ",".join(_dig(p, "server.paths", []) or [])),
("catalogueKind", lambda p: _dig(p, "catalogue.kind", "")),
("perServer", lambda p: "1" if _dig(p, "catalogue.perServer") else "0"),
("setupKey", lambda p: _dig(p, "setup.key", "required")),
("setupModels", lambda p: _dig(p, "setup.models", "per-tier")),
("keyUrl", lambda p: _dig(p, "setup.keyUrl", "")),
("guardrail", lambda p: "1" if p.get("guardrail") else "0"),
("doctor", lambda p: ",".join(p.get("doctor") or [])),
("defaultKeyRef", lambda p: _dig(p, "preset.auth.keyRef") or p["id"]),
("literalToken", lambda p: _dig(p, "preset.auth.token") or p["id"]),
("defaultBaseUrl", lambda p: _dig(p, "preset.baseUrl", "")),
("serverHint", lambda p: _dig(p, "server.hint", "")),
("serverStart", lambda p: _dig(p, "server.start", "")),
]
def cmd_provider_static(argv):
"""provider-static <id> - a fixed catalogue from providers.json as id<TAB>note."""
for m in _dig(provider(argv[0]) or {}, "catalogue.static", []) or []:
if isinstance(m, dict) and m.get("id"):
print("%s\t%s" % (m["id"], m.get("note", "")))
def cmd_ollama_ctx(argv):
"""ollama-ctx show | ps <model> (the matching Ollama response on stdin)
show: the model's own maximum, from /api/show's model_info
"<architecture>.context_length".
ps: the context a loaded model is actually running with, from /api/ps -
which is the number that matters, because Ollama sets it on the
server and cuts anything longer off without saying so.
Prints nothing when the answer is not there to read.
"""
try:
data = json.loads(sys.stdin.read() or "{}")
except ValueError:
return
if argv[0] == "show":
info = data.get("model_info") or {}
for k, v in info.items():
if k.endswith(".context_length") and isinstance(v, int):
print(v)
return
elif argv[0] == "ps" and len(argv) > 1:
# A bare name is `:latest` to Ollama, and /api/ps reports the tag.
want = {argv[1]} | ({argv[1] + ":latest"} if ":" not in argv[1] else set())
for m in data.get("models") or []:
if want & {m.get("name"), m.get("model")} and isinstance(m.get("context_length"), int):
print(m["context_length"])
return
def cmd_provider_tsv(argv):
"""provider-tsv - one tab-separated row per provider, columns as PROVIDER_TSV."""
for p in providers():
print("\t".join(str(fn(p)).replace("\t", " ") for _, fn in PROVIDER_TSV))
def cmd_provider_resolve(argv):
"""provider-resolve <word> - the provider id for an id or alias, else exit 1."""
word = (argv[0] if argv else "").strip().lower()
for p in providers():
if word == p["id"] or word in [a.lower() for a in (p.get("aliases") or [])]:
print(p["id"])
return
sys.exit(1)
def cmd_provider_get(argv):
"""provider-get <id> <dotted.path> - one value from a provider entry."""
p = provider(argv[0])
if p is None:
sys.exit(1)
val = _dig(p, argv[1])
if isinstance(val, (dict, list)):
print(json.dumps(val))
elif isinstance(val, bool):
print("true" if val else "false")
elif val is not None:
print(val)
def load(path, default=None):
@@ -229,31 +386,20 @@ def cmd_set_tier(argv):
def cmd_scaffold(argv):
provider = argv[0]
base = {"provider": provider, "description": "new preset"}
if provider == "openrouter":
base["baseUrl"] = "https://openrouter.ai/api"
base["auth"] = {"mode": "vault", "keyRef": "openrouter"}
elif provider == "zai":
base["baseUrl"] = "https://api.z.ai/api/anthropic"
base["auth"] = {"mode": "vault", "keyRef": "zai"}
elif provider == "lmstudio":
base["baseUrl"] = "http://127.0.0.1:1234"
base["auth"] = {"mode": "literal", "token": "lmstudio"}
else:
raise SystemExit("unknown provider '%s'" % provider)
"""scaffold <provider> - a blank preset built from the provider's template."""
p = provider(argv[0])
if p is None:
raise SystemExit("unknown provider '%s'" % argv[0])
tpl = p.get("preset") or {}
base = {"provider": p["id"], "description": "new preset"}
base["baseUrl"] = tpl.get("baseUrl", "")
base["auth"] = dict(tpl.get("auth") or {"mode": "vault", "keyRef": p["id"]})
base["models"] = {t: "" for t in TIERS}
base["subagentModel"] = "inherit"
base["gatewayModelDiscovery"] = provider == "openrouter"
base["contextTokens"] = 262144 if provider == "lmstudio" else 1000000
if provider == "lmstudio":
base["extraEnv"] = {"CLAUDE_CODE_ATTRIBUTION_HEADER": "0"}
if provider == "zai":
base["extraEnv"] = {
"API_TIMEOUT_MS": "3000000",
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
}
base["gatewayModelDiscovery"] = bool(tpl.get("gatewayModelDiscovery"))
base["contextTokens"] = int(tpl.get("contextTokens") or 200000)
if tpl.get("extraEnv"):
base["extraEnv"] = dict(tpl["extraEnv"])
print(json.dumps(base, indent=2))
@@ -416,6 +562,8 @@ def cmd_cache_models(argv):
"""
path, provider, ok = argv[0], argv[1], argv[2] == "1"
base = argv[3].rstrip("/") if len(argv) > 3 else ""
kind = catalogue_kind(provider)
per_server = per_server_catalogue(provider)
def num(s, cast):
try:
@@ -423,6 +571,8 @@ def cmd_cache_models(argv):
except (TypeError, ValueError):
return None
# The TSV columns are whatever that kind's parser prints (or-models,
# lms-models, ollama-models, openai-models, or a static list's id/note).
rows = []
for line in sys.stdin.read().splitlines():
parts = line.split("\t")
@@ -430,17 +580,20 @@ def cmd_cache_models(argv):
if not mid:
continue
m = {"id": mid}
if provider == "openrouter":
if kind == "openrouter":
ctx, pin, pout = (parts[1:] + ["", "", ""])[:3]
m["contextTokens"] = num(ctx, int)
m["priceIn"] = num(pin, float)
m["priceOut"] = num(pout, float)
elif provider == "lmstudio":
elif kind == "lmstudio":
state, ctx = (parts[1:] + ["", ""])[:2]
m["state"] = state
m["contextTokens"] = num(ctx, int)
else:
m["note"] = parts[1] if len(parts) > 1 else ""
elif kind == "ollama":
params, quant = (parts[1:] + ["", ""])[:2]
m["note"] = " ".join(x for x in (params, quant) if x and x != "-")
elif len(parts) > 1 and parts[1]:
m["note"] = parts[1]
rows.append(m)
try:
@@ -458,11 +611,11 @@ def cmd_cache_models(argv):
__import__("datetime").timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
if ok:
node = {"fetchedAt": now, "ok": True, "models": rows}
if provider == "lmstudio":
if per_server:
node["baseUrl"] = base
else:
# A list from a different LM Studio server is not stale, it is wrong.
if provider == "lmstudio" and node.get("baseUrl", "") != base:
# A list from a different server is not stale, it is wrong.
if per_server and node.get("baseUrl", "") != base:
node = {"baseUrl": base, "models": []}
node["ok"] = False
node["failedAt"] = now
@@ -479,6 +632,35 @@ def cmd_lms_models(argv):
print("%s\t%s\t%s" % (m.get("id", ""), m.get("state", "unknown"), m.get("max_context_length", "")))
def cmd_ollama_models(argv):
"""Ollama /api/tags on stdin -> 'id<TAB>parameters<TAB>quantization<TAB>family'.
The id is `name` (e.g. qwen3-coder:30b), which is what the Anthropic
endpoint accepts as a model. /api/tags carries no context length - that is
a server setting (OLLAMA_CONTEXT_LENGTH), not a property of the model file.
"""
data = json.loads(sys.stdin.read())
for m in sorted(data.get("models") or [], key=lambda x: x.get("name", "")):
d = m.get("details") or {}
# "-" rather than empty: bash's `read` with a tab IFS merges empty
# fields, which would slide the later columns left.
print("%s\t%s\t%s\t%s" % (m.get("name") or m.get("model", ""), d.get("parameter_size") or "-",
d.get("quantization_level") or "-", d.get("family") or "-"))
def cmd_openai_models(argv):
"""A `GET /v1/models` list on stdin -> one id per line.
Covers both the OpenAI shape and Anthropic's own ({"data": [{"id": ...}]}),
which is what a proxy in front of either tends to serve.
"""
data = json.loads(sys.stdin.read())
items = data.get("data") if isinstance(data, dict) else data
for m in sorted((items or []), key=lambda x: (x or {}).get("id", "")):
if isinstance(m, dict) and m.get("id"):
print(m["id"])
# A model id carrying a bracket suffix - e.g. `claude-fable-5[1m]` - is Claude
# Code's extended-context marker. It belongs to Anthropic's 1M models and no
# gateway recognises it. A session that had it can carry the tag onto a new id
@@ -586,7 +768,8 @@ def cmd_health(argv):
"schema": 1,
"tool": "claude-mode",
"version": version or "0.0.0",
"updatedAt": __import__("datetime").datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"),
"updatedAt": __import__("datetime").datetime.now(
__import__("datetime").timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"os": "posix",
"mode": mode,
"preset": "" if mode == "anthropic" else preset,
@@ -662,7 +845,7 @@ def cmd_health(argv):
if not isinstance(chosen_raw, dict):
chosen_raw = {}
effective, chosen = {}, {}
for prov, builtin in BUILTIN_DEFAULT_PRESET.items():
for prov, builtin in builtin_default_preset().items():
pick = chosen_raw.get(prov)
if pick and pick in names:
effective[prov] = chosen[prov] = pick
@@ -674,6 +857,25 @@ def cmd_health(argv):
h["defaultPresetFor"] = effective
h["defaultPresetChosen"] = chosen
# The providers as the widget needs them, in menu order: how to draw each
# one and which panel features apply. With this published, a provider
# added to providers.json shows up in the bar without touching its QML -
# or restarting the shell, which a change to Modes.js would need.
h["providers"] = [{
"id": p["id"],
"title": p.get("title") or p["id"],
"blurb": p.get("blurb", ""),
"logo": p.get("logo", ""),
"logoScale": float(p.get("logoScale") or 1.0),
"glyph": p.get("glyph", ""),
"serverEditable": bool(_dig(p, "server.editable")),
"serverHint": _dig(p, "server.hint", ""),
"defaultBaseUrl": _dig(p, "preset.baseUrl", ""),
"perServerCatalogue": bool(_dig(p, "catalogue.perServer")),
"defaultKeyRef": _dig(p, "preset.auth.keyRef") or p["id"],
"keyOptional": _dig(p, "setup.key", "required") != "required",
} for p in providers()]
save(os.path.join(root, "health.json"), h)
@@ -760,10 +962,16 @@ def cmd_set_auth(argv):
never sees it either way.
"""
path, mode = argv[0], argv[1]
key_ref = argv[2] if len(argv) > 2 and argv[2] else "lmstudio"
p = load(path)
# The provider's own names, not LM Studio's: an Ollama preset switched to
# "none" sends Ollama's placeholder, and one switched to "key" defaults to
# a vault slot named after its provider.
pid = p.get("provider", "")
key_ref = argv[2] if len(argv) > 2 and argv[2] else (
_dig(provider(pid) or {}, "preset.auth.keyRef") or pid or "lmstudio")
if mode == "none":
p["auth"] = {"mode": "literal", "token": "lmstudio"}
token = _dig(provider(pid) or {}, "preset.auth.token") or pid or "lmstudio"
p["auth"] = {"mode": "literal", "token": token}
elif mode == "key":
p["auth"] = {"mode": "vault", "keyRef": key_ref}
else:
@@ -1279,6 +1487,13 @@ COMMANDS = {
"strip-tags": cmd_strip_tags,
"or-models": cmd_or_models,
"lms-models": cmd_lms_models,
"ollama-models": cmd_ollama_models,
"openai-models": cmd_openai_models,
"provider-tsv": cmd_provider_tsv,
"provider-resolve": cmd_provider_resolve,
"provider-get": cmd_provider_get,
"provider-static": cmd_provider_static,
"ollama-ctx": cmd_ollama_ctx,
"cache-models": cmd_cache_models,
"apply": cmd_apply,
"summary": cmd_summary,
+14
View File
@@ -82,6 +82,20 @@ if [ -f "$VERSION_SRC" ]; then
green "version $(tr -d '[:space:]' < "$ROOT/VERSION")"
fi
# --- providers (shared with the Windows build) -----------------------------
# Always replaced, unlike presets: it is the tool's own table of what each
# provider is, not something a user edits. cm-json.py looks for it one level
# above bin/.
PROVIDERS_SRC="$SRC/../providers.json"
[ -f "$PROVIDERS_SRC" ] || PROVIDERS_SRC="$SRC/providers.json"
if [ -f "$PROVIDERS_SRC" ]; then
install -m 0644 "$PROVIDERS_SRC" "$ROOT/providers.json"
green "providers: $("$PY" -c 'import json,sys; print(", ".join(p["id"] for p in json.load(open(sys.argv[1]))["providers"]))' "$ROOT/providers.json")"
else
fail 'providers.json missing from the payload - claude-mode cannot run without it'
exit 1
fi
# --- presets (shared with the Windows build) -------------------------------
PRESET_SRC="$SRC/../presets"
[ -d "$PRESET_SRC" ] || PRESET_SRC="$SRC/presets"
+36 -4
View File
@@ -42,6 +42,38 @@ BarWidget {
}
readonly property bool known: mode !== ""
// ---- Providers
//
// claude-mode publishes its providers in health.json - how to draw each one
// and which panel features it has - so one added to providers.json appears
// here without this file or Modes.js changing (and Modes.js cannot change
// without a shell restart). Modes.js stays the fallback: for anthropic,
// which is the native login and never in the list, and for a health.json
// written before the list existed. The panel asks through these, too.
readonly property var providerList: health && health.providers ? health.providers : []
readonly property var modeOrder: {
if (providerList.length === 0) return Modes.ORDER
var out = ["anthropic"]
for (var i = 0; i < providerList.length; i++) out.push(String(providerList[i].id))
return out
}
function providerInfo(m) {
for (var i = 0; i < providerList.length; i++) {
if (String(providerList[i].id) === m) return providerList[i]
}
return null
}
function modeTitle(m) { var p = providerInfo(m); return p && p.title ? String(p.title) : Modes.title(m) }
function modeBlurb(m) { var p = providerInfo(m); return p ? String(p.blurb || "") : Modes.blurb(m) }
function modeLogo(m) { var p = providerInfo(m); return p ? String(p.logo || "") : Modes.logo(m) }
function modeLogoScale(m) { var p = providerInfo(m); return p && p.logoScale ? Number(p.logoScale) : Modes.logoScale(m) }
function modeGlyph(m) {
var p = providerInfo(m)
if (p && p.glyph) return String.fromCodePoint(parseInt(String(p.glyph), 16))
return Modes.glyph(m)
}
// Measured against the neighbours: every stock bar glyph in this shell paints
// 11px of ink from a 13px font. These marks fill their box rather than
// carrying a font's internal padding, so the box itself has to be the smaller
@@ -54,8 +86,8 @@ BarWidget {
var n = Math.round(Number(root.setting("iconSize", Style.bar.iconFont)))
return n % 2 === 0 ? n + 1 : n
}
readonly property string logo: Modes.logo(mode)
readonly property string glyph: Modes.glyph(mode)
readonly property string logo: modeLogo(mode)
readonly property string glyph: modeGlyph(mode)
readonly property string label: Modes.shortLabel(mode, preset)
// A gateway mode is spending money or leaning on a local server; native
@@ -226,7 +258,7 @@ BarWidget {
id: icon
anchors.fill: parent
pathData: root.logo
opticalScale: Modes.logoScale(root.mode)
opticalScale: root.modeLogoScale(root.mode)
color: root.activeColor
iconSize: root.iconPx
}
@@ -282,7 +314,7 @@ BarWidget {
function tooltipLabel() {
if (!root.known) return "claude-mode: not installed"
var lines = [Modes.title(root.mode)]
var lines = [root.modeTitle(root.mode)]
if (root.preset !== "") lines.push("preset " + root.preset)
var models = root.health && root.health.models ? root.health.models : null
if (models && models.opus) lines.push("opus " + models.opus)
+7 -4
View File
@@ -1,9 +1,12 @@
.pragma library
// The four modes claude-mode understands, in the order the CLI menu lists
// them. Kept here rather than in either QML file because the bar widget needs
// the glyph and the panel needs the prose, and a second copy of this table is
// exactly the sort of thing that drifts.
// Fallback presentation only. claude-mode publishes its providers - titles,
// blurbs, logos, scales, order - in health.json from providers.json, and the
// widget reads them from there (BarWidget.providerInfo). What remains here is
// anthropic, which is the native login and never in that list, and the
// original three gateways for a health.json written before the list existed.
// Nothing new belongs in this file: it is a .pragma library, cached until the
// shell restarts.
// Material Design icons from the Nerd Font patch set, written as codepoints
// rather than literals so they survive any editor or transport that is not
+63 -37
View File
@@ -72,18 +72,37 @@ Panel {
function cli(args) { return [root.cmRoot + "/bin/claude-mode"].concat(args) }
function parseJson(t) { try { return JSON.parse(String(t || "")) } catch (e) { return null } }
// ---- Server settings (LM Studio)
// ---- Providers, as the widget publishes them
//
// LM Studio ships listening on loopback, but it does not have to be there: it
// can be another machine on the LAN, or something reached through a tunnel or
// a reverse proxy, and it can have authentication switched on. All three are
// The widget owns the lookup, and the fallback to Modes.js for anthropic and
// for an older health.json. This side only asks, so nothing here needs to
// know which providers exist.
readonly property var modeOrder: widget ? widget.modeOrder : Modes.ORDER
function pInfo(m) { return widget ? widget.providerInfo(m) : null }
function pTitle(m) { return widget ? widget.modeTitle(m) : "" }
function pBlurb(m) { return widget ? widget.modeBlurb(m) : "" }
function pLogo(m) { return widget ? widget.modeLogo(m) : "" }
function pLogoScale(m) { return widget ? widget.modeLogoScale(m) : 1.0 }
function pGlyph(m) { return widget ? widget.modeGlyph(m) : "" }
// Before providers were published only LM Studio had either.
function serverEditable(m) { var p = root.pInfo(m); return p ? p.serverEditable === true : m === "lmstudio" }
function perServerCatalogue(m) { var p = root.pInfo(m); return p ? p.perServerCatalogue === true : m === "lmstudio" }
// ---- Server settings
//
// A server provider (LM Studio, Ollama, a custom endpoint) ships pointed at
// its usual local address, but it does not have to be there: it can be
// another machine on the LAN, or something reached through a tunnel or a
// reverse proxy, and it can have authentication switched on. All of that is
// just a baseUrl and an auth block in the preset, so this edits those two
// rather than pretending the local default is the only shape.
property string serverPreset: ""
property string serverProvider: ""
property string serverUrl: ""
property bool serverNeedsKey: false
property string serverKeyRef: "lmstudio"
readonly property string serverLocalDefault: "http://127.0.0.1:1234"
property string serverKeyRef: ""
property string serverDefault: "" // the provider's usual address; "" for custom
property string serverHint: ""
function presetEntry(name) {
var all = (health && health.presets) ? health.presets : []
@@ -99,9 +118,15 @@ Panel {
var e = root.presetEntry(presetName)
root.serverReturn = from || ""
root.serverPreset = presetName
root.serverUrl = e && e.baseUrl ? String(e.baseUrl) : root.serverLocalDefault
root.serverProvider = e ? String(e.provider) : ""
var p = root.pInfo(root.serverProvider)
root.serverDefault = p ? String(p.defaultBaseUrl || "") : "http://127.0.0.1:1234"
root.serverHint = p && p.serverHint ? String(p.serverHint)
: "The server does not have to be on this machine. Point this at a LAN address, or anything reachable through a tunnel or proxy."
root.serverUrl = e && e.baseUrl ? String(e.baseUrl) : root.serverDefault
root.serverNeedsKey = !!(e && String(e.authMode) === "vault")
root.serverKeyRef = e && e.keyRef ? String(e.keyRef) : "lmstudio"
root.serverKeyRef = e && e.keyRef ? String(e.keyRef)
: (p && p.defaultKeyRef ? String(p.defaultKeyRef) : (root.serverProvider || "lmstudio"))
root.blocker = null
root.stage = "server"
}
@@ -408,7 +433,7 @@ Panel {
var all = widget && widget.modelsCache && widget.modelsCache.providers ? widget.modelsCache.providers : null
var n = all && root.editProvider !== "" ? all[root.editProvider] : null
if (!n) return null
if (root.editProvider === "lmstudio") {
if (root.perServerCatalogue(root.editProvider)) {
var want = root.editEntry && root.editEntry.baseUrl ? String(root.editEntry.baseUrl).replace(/\/+$/, "") : ""
if (String(n.baseUrl || "") !== want) return null
}
@@ -797,17 +822,17 @@ Panel {
BrandIcon {
anchors.centerIn: parent
visible: Modes.logo(root.mode) !== ""
pathData: Modes.logo(root.mode)
opticalScale: Modes.logoScale(root.mode)
visible: root.pLogo(root.mode) !== ""
pathData: root.pLogo(root.mode)
opticalScale: root.pLogoScale(root.mode)
color: heroGlyph.tint
iconSize: Style.font.display
}
Text {
anchors.centerIn: parent
visible: Modes.logo(root.mode) === ""
text: Modes.glyph(root.mode)
visible: root.pLogo(root.mode) === ""
text: root.pGlyph(root.mode)
color: heroGlyph.tint
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.font.display
@@ -822,7 +847,7 @@ Panel {
Text {
width: parent.width
elide: Text.ElideRight
text: Modes.title(root.mode)
text: root.pTitle(root.mode)
color: Color.popups.text
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(17)
@@ -831,7 +856,7 @@ Panel {
Text {
width: parent.width
elide: Text.ElideRight
text: root.preset !== "" ? "preset · " + root.preset : Modes.blurb(root.mode)
text: root.preset !== "" ? "preset · " + root.preset : root.pBlurb(root.mode)
color: Color.muted
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(11)
@@ -857,7 +882,7 @@ Panel {
spacing: Style.space(1)
Repeater {
model: root.stage === "list" ? Modes.ORDER : []
model: root.stage === "list" ? root.modeOrder : []
Column {
id: modeEntry
@@ -890,8 +915,8 @@ Panel {
anchors.left: parent.left
anchors.leftMargin: Style.space(3)
anchors.verticalCenter: parent.verticalCenter
pathData: Modes.logo(modeEntry.thisMode)
opticalScale: Modes.logoScale(modeEntry.thisMode)
pathData: root.pLogo(modeEntry.thisMode)
opticalScale: root.pLogoScale(modeEntry.thisMode)
color: modeEntry.isCurrent ? Color.accent : Color.popups.text
iconSize: Style.space(16)
}
@@ -905,7 +930,7 @@ Panel {
spacing: 0
Text {
text: Modes.title(modeEntry.thisMode)
text: root.pTitle(modeEntry.thisMode)
color: Color.popups.text
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.font.body
@@ -914,7 +939,7 @@ Panel {
Text {
width: parent.width
text: Modes.blurb(modeEntry.thisMode)
text: root.pBlurb(modeEntry.thisMode)
color: Color.muted
elide: Text.ElideRight
font.family: root.bar ? root.bar.fontFamily : Style.font.family
@@ -1080,7 +1105,7 @@ Panel {
anchors.left: parent.left
anchors.leftMargin: Style.space(30)
anchors.verticalCenter: parent.verticalCenter
text: "New " + Modes.title(modeEntry.thisMode) + " preset…"
text: "New " + root.pTitle(modeEntry.thisMode) + " preset…"
color: newPresetArea.containsMouse ? Color.accent : Color.muted
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(11)
@@ -1146,7 +1171,7 @@ Panel {
spacing: Style.space(7)
PillButton {
label: "Set up " + Modes.title(root.pendingMode) + "…"
label: "Set up " + root.pTitle(root.pendingMode) + "…"
primary: true
visible: root.blocker && String(root.blocker.remedyKind) === "setup"
onTriggered: root.runSetup()
@@ -1172,7 +1197,7 @@ Panel {
PillButton {
label: "Server settings…"
primary: root.blocker && ["start-server", "set-url", "needs-key"].indexOf(String(root.blocker.remedyKind)) >= 0
visible: root.pendingMode === "lmstudio"
visible: root.serverEditable(root.pendingMode)
&& !(root.blocker && String(root.blocker.remedyKind) === "setup")
onTriggered: root.openServerSettings(root.pendingPreset)
}
@@ -1187,7 +1212,7 @@ Panel {
}
// ---- Where the LM Studio server is, and whether it needs a key.
// ---- Where a server provider is, and whether it needs a key.
Column {
width: parent.width
visible: root.stage === "server"
@@ -1205,8 +1230,7 @@ Panel {
Text {
width: parent.width
text: "LM Studio does not have to be on this machine. Point this at a "
+ "LAN address, or anything reachable through a tunnel or proxy."
text: root.serverHint
color: Color.muted
wrapMode: Text.WordWrap
lineHeight: 1.2
@@ -1218,7 +1242,7 @@ Panel {
id: urlField
width: parent.width
text: root.serverUrl
placeholderText: root.serverLocalDefault
placeholderText: root.serverDefault !== "" ? root.serverDefault : "https://…"
foreground: Color.popups.text
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(11)
@@ -1232,9 +1256,11 @@ Panel {
width: parent.width
spacing: Style.space(7)
// A custom endpoint has no usual address to go back to.
PillButton {
label: "Use local default"
onTriggered: { root.serverUrl = root.serverLocalDefault; urlField.text = root.serverLocalDefault }
visible: root.serverDefault !== ""
onTriggered: { root.serverUrl = root.serverDefault; urlField.text = root.serverDefault }
}
}
@@ -1271,7 +1297,7 @@ Panel {
width: parent.width
text: root.serverNeedsKey
? "Kept in the vault as '" + root.serverKeyRef + "', never in settings.json."
: "Sends LM Studio's placeholder token, which is not a secret."
: "Sends " + root.pTitle(root.serverProvider) + "'s placeholder token, which is not a secret."
color: Color.muted
wrapMode: Text.WordWrap
font.family: root.bar ? root.bar.fontFamily : Style.font.family
@@ -1317,7 +1343,7 @@ Panel {
Text {
width: parent.width
text: "'" + root.editPreset + "' · " + Modes.title(root.editProvider)
text: "'" + root.editPreset + "' · " + root.pTitle(root.editProvider)
color: Color.popups.text
elide: Text.ElideRight
font.family: root.bar ? root.bar.fontFamily : Style.font.family
@@ -1434,7 +1460,7 @@ Panel {
PillButton {
label: "Server settings…"
visible: root.editProvider === "lmstudio"
visible: root.serverEditable(root.editProvider)
onTriggered: root.openServerSettings(root.editPreset, "preset")
}
PillButton { label: "Make default"; visible: !root.editIsDefault; onTriggered: root.setDefault(true) }
@@ -1470,7 +1496,7 @@ Panel {
Text {
width: parent.width
text: "New " + Modes.title(root.newProvider) + " preset"
text: "New " + root.pTitle(root.newProvider) + " preset"
color: Color.popups.text
elide: Text.ElideRight
font.family: root.bar ? root.bar.fontFamily : Style.font.family
@@ -1649,8 +1675,8 @@ Panel {
Text {
width: parent.width
visible: parent.lastOne
text: "It is the only " + Modes.title(root.editProvider) + " preset, so "
+ Modes.title(root.editProvider) + " will have nothing to switch to until you create another."
text: "It is the only " + root.pTitle(root.editProvider) + " preset, so "
+ root.pTitle(root.editProvider) + " will have nothing to switch to until you create another."
color: root.urgentColor
wrapMode: Text.WordWrap
font.family: root.bar ? root.bar.fontFamily : Style.font.family
@@ -1826,7 +1852,7 @@ Panel {
visible: root.modelOptions.length === 0
text: root.catalogueNode && root.catalogueNode.ok === false
? "The last fetch failed and no list is cached. Type an id, or try fetching again."
: "No model list cached for " + Modes.title(root.editProvider) + " yet. Type an id, or fetch the list."
: "No model list cached for " + root.pTitle(root.editProvider) + " yet. Type an id, or fetch the list."
color: Color.muted
wrapMode: Text.WordWrap
font.family: root.bar ? root.bar.fontFamily : Style.font.family
@@ -1892,7 +1918,7 @@ Panel {
// they fail rather than carry on.
Text {
width: parent.width
text: "They keep pointing at " + Modes.title(root.mode) + ", but their key is "
text: "They keep pointing at " + root.pTitle(root.mode) + ", but their key is "
+ "re-fetched on a timer and will switch under them. Worse, if one takes a "
+ "reply from the new provider first, that provider's message-id format "
+ "goes into its transcript and Anthropic will refuse to resume it at all."
+1 -1
View File
@@ -2,7 +2,7 @@
"schemaVersion": 1,
"id": "smoido.claude-mode",
"name": "Claude Mode",
"version": "1.11.0",
"version": "1.12.0",
"author": "smoido",
"description": "Which provider Claude Code is pointed at, and a one-click switch between them",
"kinds": ["bar-widget"],
+19
View File
@@ -0,0 +1,19 @@
{
"provider": "custom",
"description": "Any Anthropic-compatible endpoint. Setup asks where it is and whether it needs a key.",
"baseUrl": "",
"auth": {
"mode": "vault",
"keyRef": "custom"
},
"models": {
"opus": "",
"sonnet": "",
"haiku": "",
"fable": ""
},
"subagentModel": "inherit",
"gatewayModelDiscovery": false,
"contextTokens": 200000,
"configured": false
}
+22
View File
@@ -0,0 +1,22 @@
{
"provider": "ollama",
"description": "Local Ollama server. Setup picks the model from whatever the server has pulled.",
"baseUrl": "http://127.0.0.1:11434",
"auth": {
"mode": "literal",
"token": "ollama"
},
"models": {
"opus": "qwen3-coder",
"sonnet": "qwen3-coder",
"haiku": "qwen3-coder",
"fable": "qwen3-coder"
},
"subagentModel": "inherit",
"gatewayModelDiscovery": false,
"extraEnv": {
"CLAUDE_CODE_ATTRIBUTION_HEADER": "0"
},
"contextTokens": 65536,
"configured": false
}
+240
View File
@@ -0,0 +1,240 @@
{
"_comment": [
"Every gateway provider claude-mode can switch to. anthropic is not here: it is the native login.",
"Read by linux/cm-json.py (CLI + bar widget, via health.json) and claude-mode.ps1 (Windows).",
"Behaviour that differs in kind is chosen by name: catalogue.kind (openrouter | lmstudio | ollama | openai | static),",
"server.probe (always | lenient | local), setup.key (required | optional), setup.models (per-tier | one-for-all),",
"and the doctor checks. A new provider that reuses those is an entry here and a preset in presets/.",
"Logos are single-path 24x24 SVG marks; logoScale corrects apparent size (see Modes.js)."
],
"providers": [
{
"id": "openrouter",
"aliases": [],
"title": "OpenRouter",
"label": "OpenRouter - remote, pay-per-token, any vendor",
"blurb": "Remote gateway, pay per token, any vendor.",
"color": "cyan",
"defaultPreset": "default",
"preset": {
"baseUrl": "https://openrouter.ai/api",
"auth": {
"mode": "vault",
"keyRef": "openrouter"
},
"contextTokens": 1000000,
"gatewayModelDiscovery": true
},
"server": {
"editable": false,
"probe": "local"
},
"catalogue": {
"kind": "openrouter",
"url": "https://openrouter.ai/api/v1/models"
},
"setup": {
"key": "required",
"models": "per-tier",
"keyUrl": "https://openrouter.ai/settings/keys"
},
"guardrail": true,
"doctor": [
"openrouter-key",
"guardrail",
"catalogue-models"
],
"logo": "M16.778 1.844v1.919q-.569-.026-1.138-.032-.708-.008-1.415.037c-1.93.126-4.023.728-6.149 2.237-2.911 2.066-2.731 1.95-4.14 2.75-.396.223-1.342.574-2.185.798-.841.225-1.753.333-1.751.333v4.229s.768.108 1.61.333c.842.224 1.789.575 2.185.799 1.41.798 1.228.683 4.14 2.75 2.126 1.509 4.22 2.11 6.148 2.236.88.058 1.716.041 2.555.005v1.918l7.222-4.168-7.222-4.17v2.176c-.86.038-1.611.065-2.278.021-1.364-.09-2.417-.357-3.979-1.465-2.244-1.593-2.866-2.027-3.68-2.508.889-.518 1.449-.906 3.822-2.59 1.56-1.109 2.614-1.377 3.978-1.466.667-.044 1.418-.017 2.278.02v2.176L24 6.014Z",
"logoScale": 1.12,
"glyph": "F0469"
},
{
"id": "zai",
"aliases": [
"z.ai",
"z-ai"
],
"title": "Z.AI",
"label": "Z.AI - GLM coding plan",
"blurb": "GLM coding plan on Z.AI's Anthropic endpoint.",
"color": "green",
"defaultPreset": "zai",
"preset": {
"baseUrl": "https://api.z.ai/api/anthropic",
"auth": {
"mode": "vault",
"keyRef": "zai"
},
"contextTokens": 1000000,
"gatewayModelDiscovery": false,
"extraEnv": {
"API_TIMEOUT_MS": "3000000",
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"
}
},
"server": {
"editable": false,
"probe": "local"
},
"catalogue": {
"kind": "static",
"docs": "https://docs.z.ai/devpack/tool/claude",
"static": [
{
"id": "glm-5.3",
"note": "flagship coding model - opus/sonnet tier"
},
{
"id": "glm-4.7",
"note": "fast/cheap tier - haiku"
}
]
},
"setup": {
"key": "required",
"models": "per-tier",
"keyUrl": "https://z.ai/manage-apikey/apikey-list"
},
"doctor": [
"message-check"
],
"logo": "M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z",
"logoScale": 1.07,
"glyph": "F015F"
},
{
"id": "lmstudio",
"aliases": [
"lm-studio"
],
"title": "LM Studio",
"label": "LM Studio - local server, offline, free",
"blurb": "Local LM Studio server. Offline and free.",
"color": "yellow",
"defaultPreset": "lmstudio",
"preset": {
"baseUrl": "http://127.0.0.1:1234",
"auth": {
"mode": "literal",
"token": "lmstudio"
},
"contextTokens": 262144,
"gatewayModelDiscovery": false,
"extraEnv": {
"CLAUDE_CODE_ATTRIBUTION_HEADER": "0"
}
},
"server": {
"editable": true,
"probe": "always",
"paths": [
"/api/v0/models",
"/v1/models"
],
"hint": "LM Studio does not have to be on this machine. Point this at a LAN address, or anything reachable through a tunnel or proxy.",
"start": "start the server (Developer > Start Server)"
},
"catalogue": {
"kind": "lmstudio",
"perServer": true
},
"setup": {
"key": "optional",
"models": "one-for-all"
},
"doctor": [
"catalogue-models",
"lmstudio-templates"
],
"logo": "M14.025 0c3.492 0 5.237 0 6.571.68a6.24 6.24 0 0 1 2.725 2.724C24 4.738 24 6.484 24 9.975v4.05c0 3.492 0 5.237-.68 6.571a6.24 6.24 0 0 1-2.724 2.725c-1.334.679-3.08.679-6.571.679h-4.05c-3.492 0-5.237 0-6.571-.68A6.24 6.24 0 0 1 .68 20.597C0 19.262 0 17.516 0 14.025v-4.05c0-3.492 0-5.237.68-6.571A6.23 6.23 0 0 1 3.404.68C4.738 0 6.484 0 9.975 0zM7.688 16.313a1.313 1.313 0 0 0 0 2.625h11.625a1.313 1.313 0 0 0 0-2.625zm-3-3.75a1.313 1.313 0 0 0 0 2.624h11.625a1.313 1.313 0 0 0 0-2.624zm3-3.75a1.313 1.313 0 0 0 0 2.624h11.625a1.313 1.313 0 0 0 0-2.624zm-3-3.75a1.313 1.313 0 0 0 0 2.625h11.625a1.313 1.313 0 0 0 0-2.625z",
"logoScale": 0.86,
"glyph": "F048B"
},
{
"id": "ollama",
"aliases": [],
"title": "Ollama",
"label": "Ollama - local server, offline, free",
"blurb": "Local Ollama server. Offline and free.",
"color": "white",
"defaultPreset": "ollama",
"preset": {
"baseUrl": "http://127.0.0.1:11434",
"auth": {
"mode": "literal",
"token": "ollama"
},
"contextTokens": 65536,
"gatewayModelDiscovery": false,
"extraEnv": {
"CLAUDE_CODE_ATTRIBUTION_HEADER": "0"
}
},
"server": {
"editable": true,
"probe": "always",
"paths": [
"/api/tags"
],
"hint": "Ollama does not have to be on this machine. Serve it with OLLAMA_HOST=0.0.0.0 to reach it over the LAN.",
"start": "start it with: ollama serve"
},
"catalogue": {
"kind": "ollama",
"perServer": true
},
"setup": {
"key": "optional",
"models": "one-for-all"
},
"doctor": [
"catalogue-models",
"ollama-context"
],
"logo": "M16.361 10.26a.894.894 0 0 0-.558.47l-.072.148.001.207c0 .193.004.217.059.353.076.193.152.312.291.448.24.238.51.3.872.205a.86.86 0 0 0 .517-.436.752.752 0 0 0 .08-.498c-.064-.453-.33-.782-.724-.897a1.06 1.06 0 0 0-.466 0zm-9.203.005c-.305.096-.533.32-.65.639a1.187 1.187 0 0 0-.06.52c.057.309.31.59.598.667.362.095.632.033.872-.205.14-.136.215-.255.291-.448.055-.136.059-.16.059-.353l.001-.207-.072-.148a.894.894 0 0 0-.565-.472 1.02 1.02 0 0 0-.474.007Zm4.184 2c-.131.071-.223.25-.195.383.031.143.157.288.353.407.105.063.112.072.117.136.004.038-.01.146-.029.243-.02.094-.036.194-.036.222.002.074.07.195.143.253.064.052.076.054.255.059.164.005.198.001.264-.03.169-.082.212-.234.15-.525-.052-.243-.042-.28.087-.355.137-.08.281-.219.324-.314a.365.365 0 0 0-.175-.48.394.394 0 0 0-.181-.033c-.126 0-.207.03-.355.124l-.085.053-.053-.032c-.219-.13-.259-.145-.391-.143a.396.396 0 0 0-.193.032zm.39-2.195c-.373.036-.475.05-.654.086-.291.06-.68.195-.951.328-.94.46-1.589 1.226-1.787 2.114-.04.176-.045.234-.045.53 0 .294.005.357.043.524.264 1.16 1.332 2.017 2.714 2.173.3.033 1.596.033 1.896 0 1.11-.125 2.064-.727 2.493-1.571.114-.226.169-.372.22-.602.039-.167.044-.23.044-.523 0-.297-.005-.355-.045-.531-.288-1.29-1.539-2.304-3.072-2.497a6.873 6.873 0 0 0-.855-.031zm.645.937a3.283 3.283 0 0 1 1.44.514c.223.148.537.458.671.662.166.251.26.508.303.82.02.143.01.251-.043.482-.08.345-.332.705-.672.957a3.115 3.115 0 0 1-.689.348c-.382.122-.632.144-1.525.138-.582-.006-.686-.01-.853-.042-.57-.107-1.022-.334-1.35-.68-.264-.28-.385-.535-.45-.946-.03-.192.025-.509.137-.776.136-.326.488-.73.836-.963.403-.269.934-.46 1.422-.512.187-.02.586-.02.773-.002zm-5.503-11a1.653 1.653 0 0 0-.683.298C5.617.74 5.173 1.666 4.985 2.819c-.07.436-.119 1.04-.119 1.503 0 .544.064 1.24.155 1.721.02.107.031.202.023.208a8.12 8.12 0 0 1-.187.152 5.324 5.324 0 0 0-.949 1.02 5.49 5.49 0 0 0-.94 2.339 6.625 6.625 0 0 0-.023 1.357c.091.78.325 1.438.727 2.04l.13.195-.037.064c-.269.452-.498 1.105-.605 1.732-.084.496-.095.629-.095 1.294 0 .67.009.803.088 1.266.095.555.288 1.143.503 1.534.071.128.243.393.264.407.007.003-.014.067-.046.141a7.405 7.405 0 0 0-.548 1.873c-.062.417-.071.552-.071.991 0 .56.031.832.148 1.279L3.42 24h1.478l-.05-.091c-.297-.552-.325-1.575-.068-2.597.117-.472.25-.819.498-1.296l.148-.29v-.177c0-.165-.003-.184-.057-.293a.915.915 0 0 0-.194-.25 1.74 1.74 0 0 1-.385-.543c-.424-.92-.506-2.286-.208-3.451.124-.486.329-.918.544-1.154a.787.787 0 0 0 .223-.531c0-.195-.07-.355-.224-.522a3.136 3.136 0 0 1-.817-1.729c-.14-.96.114-2.005.69-2.834.563-.814 1.353-1.336 2.237-1.475.199-.033.57-.028.776.01.226.04.367.028.512-.041.179-.085.268-.19.374-.431.093-.215.165-.333.36-.576.234-.29.46-.489.822-.729.413-.27.884-.467 1.352-.561.17-.035.25-.04.569-.04.319 0 .398.005.569.04a4.07 4.07 0 0 1 1.914.997c.117.109.398.457.488.602.034.057.095.177.132.267.105.241.195.346.374.43.14.068.286.082.503.045.343-.058.607-.053.943.016 1.144.23 2.14 1.173 2.581 2.437.385 1.108.276 2.267-.296 3.153-.097.15-.193.27-.333.419-.301.322-.301.722-.001 1.053.493.539.801 1.866.708 3.036-.062.772-.26 1.463-.533 1.854a2.096 2.096 0 0 1-.224.258.916.916 0 0 0-.194.25c-.054.109-.057.128-.057.293v.178l.148.29c.248.476.38.823.498 1.295.253 1.008.231 2.01-.059 2.581a.845.845 0 0 0-.044.098c0 .006.329.009.732.009h.73l.02-.074.036-.134c.019-.076.057-.3.088-.516.029-.217.029-1.016 0-1.258-.11-.875-.295-1.57-.597-2.226-.032-.074-.053-.138-.046-.141.008-.005.057-.074.108-.152.376-.569.607-1.284.724-2.228.031-.26.031-1.378 0-1.628-.083-.645-.182-1.082-.348-1.525a6.083 6.083 0 0 0-.329-.7l-.038-.064.131-.194c.402-.604.636-1.262.727-2.04a6.625 6.625 0 0 0-.024-1.358 5.512 5.512 0 0 0-.939-2.339 5.325 5.325 0 0 0-.95-1.02 8.097 8.097 0 0 1-.186-.152.692.692 0 0 1 .023-.208c.208-1.087.201-2.443-.017-3.503-.19-.924-.535-1.658-.98-2.082-.354-.338-.716-.482-1.15-.455-.996.059-1.8 1.205-2.116 3.01a6.805 6.805 0 0 0-.097.726c0 .036-.007.066-.015.066a.96.96 0 0 1-.149-.078A4.857 4.857 0 0 0 12 3.03c-.832 0-1.687.243-2.456.698a.958.958 0 0 1-.148.078c-.008 0-.015-.03-.015-.066a6.71 6.71 0 0 0-.097-.725C8.997 1.392 8.337.319 7.46.048a2.096 2.096 0 0 0-.585-.041Zm.293 1.402c.248.197.523.759.682 1.388.03.113.06.244.069.292.007.047.026.152.041.233.067.365.098.76.102 1.24l.002.475-.12.175-.118.178h-.278c-.324 0-.646.041-.954.124l-.238.06c-.033.007-.038-.003-.057-.144a8.438 8.438 0 0 1 .016-2.323c.124-.788.413-1.501.696-1.711.067-.05.079-.049.157.013zm9.825-.012c.17.126.358.46.498.888.28.854.36 2.028.212 3.145-.019.14-.024.151-.057.144l-.238-.06a3.693 3.693 0 0 0-.954-.124h-.278l-.119-.178-.119-.175.002-.474c.004-.669.066-1.19.214-1.772.157-.623.434-1.185.68-1.382.078-.062.09-.063.159-.012z",
"logoScale": 1.0,
"glyph": ""
},
{
"id": "custom",
"aliases": [],
"title": "Custom",
"label": "Custom - any Anthropic-compatible endpoint",
"blurb": "Your own endpoint: a proxy, gateway or self-hosted server.",
"color": "gray",
"defaultPreset": "custom",
"preset": {
"baseUrl": "",
"auth": {
"mode": "vault",
"keyRef": "custom"
},
"contextTokens": 200000,
"gatewayModelDiscovery": false
},
"server": {
"editable": true,
"probe": "lenient",
"paths": [
"/v1/models"
],
"hint": "Any server that speaks Anthropic's Messages API: a LiteLLM or Vercel gateway, vLLM, llama.cpp, a company proxy.",
"start": ""
},
"catalogue": {
"kind": "openai",
"perServer": true
},
"setup": {
"key": "optional",
"models": "per-tier"
},
"doctor": [
"catalogue-models"
],
"logo": "M9 3.5 1 12l8 8.5 2-2L4.8 12 11 5.5zm6 0-2 2 6.2 6.5-6.2 6.5 2 2 8-8.5z",
"logoScale": 1.09,
"glyph": ""
}
]
}
+3 -1
View File
@@ -33,6 +33,7 @@ Copy-Item (Join-Path $root 'claude-mode.ps1') $stage -Force
Copy-Item (Join-Path $root 'install.ps1') $stage -Force
Copy-Item (Join-Path $root 'profile-snippet.ps1') $stage -Force
Copy-Item (Join-Path $root 'VERSION') $stage -Force
Copy-Item (Join-Path $root 'providers.json') $stage -Force
Get-ChildItem (Join-Path $root 'bin') -File | ForEach-Object { Copy-Item $_.FullName (Join-Path $stage 'bin') -Force }
Get-ChildItem (Join-Path $root 'presets') -File | ForEach-Object { Copy-Item $_.FullName (Join-Path $stage 'presets') -Force }
@@ -81,6 +82,7 @@ New-Item -ItemType Directory -Path (Join-Path $posixStage 'presets') -Force | Ou
Get-ChildItem (Join-Path $root 'linux') -File | ForEach-Object { Copy-Item $_.FullName (Join-Path $posixStage 'linux') -Force }
Get-ChildItem (Join-Path $root 'presets') -File | ForEach-Object { Copy-Item $_.FullName (Join-Path $posixStage 'presets') -Force }
Copy-Item (Join-Path $root 'VERSION') $posixStage -Force
Copy-Item (Join-Path $root 'providers.json') $posixStage -Force
# Shell scripts authored on Windows carry CRLF, which makes the kernel reject
# the "#!/usr/bin/env bash" line. Normalise before packaging.
@@ -92,7 +94,7 @@ Get-ChildItem (Join-Path $posixStage 'linux') -File | ForEach-Object {
$tarName = "claude-mode-$version-posix.tar.gz"
$tarPath = Join-Path $outDir $tarName
if (Test-Path -LiteralPath $tarPath) { Remove-Item -LiteralPath $tarPath -Force }
& tar.exe -czf $tarPath -C $posixStage 'linux' 'presets' 'VERSION'
& tar.exe -czf $tarPath -C $posixStage 'linux' 'presets' 'VERSION' 'providers.json'
if ($LASTEXITCODE -ne 0) { throw 'tar failed - cannot build the POSIX package' }
$shaPosix = (Get-FileHash -LiteralPath $tarPath -Algorithm SHA256).Hash.ToLower()