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 # claude-mode
Switch Claude Code system-wide between **Anthropic**, **OpenRouter**, **Z.AI**, and a Switch Claude Code system-wide between **Anthropic**, **OpenRouter**, **Z.AI**, a
local **LM Studio** server — with named per-tier model presets. 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 One write to `~/.claude/settings.json` covers the CLI, the VS Code extension, and
the desktop app. Restart Claude Code afterwards — nothing else. Windows 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 openrouter # remote gateway (preset: default)
claude-mode zai # Z.AI GLM coding plan (preset: zai) claude-mode zai # Z.AI GLM coding plan (preset: zai)
claude-mode lmstudio # local server (preset: lmstudio) 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 **Contents:** [Install](#install) · [First run](#first-run) · [The interactive
menu](#the-interactive-menu) · [Commands](#commands) · [Presets](#presets-shipped) · menu](#the-interactive-menu) · [Commands](#commands) · [Presets](#presets-shipped) ·
[Context windows](#context-windows-and-early-auto-compaction) · [Z.AI](#zai-mode) · [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) · [Troubleshooting](#troubleshooting) · [Design decisions](#design-decisions) ·
[Layout](#layout) · [Linux / Omarchy](#linux--omarchy) · [The bar [Layout](#layout) · [Linux / Omarchy](#linux--omarchy) · [The bar
widget](#the-omarchy-bar-widget) · [Uninstall](#uninstall) 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 anthropic native login (deletes all managed keys)
claude-mode openrouter [preset] default preset: default claude-mode openrouter [preset] default preset: default
claude-mode zai [preset] default preset: zai (alias: z.ai, z-ai) 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 presets list presets (* = active)
claude-mode preset show <name> 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 Nothing here changes the shipped presets unless you ask it to; the local default
stays exactly as it was. 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 ## Live sessions
### Why a switch breaks a running session ### Why a switch breaks a running session
@@ -719,6 +818,7 @@ Windows:
``` ```
~/.claude-mode/ ~/.claude-mode/
claude-mode.ps1 main script 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 state.json mode, active preset, and the exact env keys last written
presets/*.json provider + model maps presets/*.json provider + model maps
vault/*.cred DPAPI-encrypted keys (openrouter, zai, ...) 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:Tiers = @('opus', 'sonnet', 'haiku', 'fable')
$script:Version = '0.0.0' $script:Version = '0.0.0'
try { $__v = Join-Path $PSScriptRoot 'VERSION'; if (Test-Path $__v) { $script:Version = (Get-Content $__v -Raw).Trim() } } catch { } 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:NodeExe = $null # resolved lazily by Format-JsonPretty
$script:ModeLabel = @{ # ---------------------------------------------------------------------------
'anthropic' = 'Anthropic - your subscription login, no gateway' # Providers
'openrouter' = 'OpenRouter - remote, pay-per-token, any vendor' #
'zai' = 'Z.AI - GLM coding plan' # Every gateway provider is an entry in providers.json, next to this script and
'lmstudio' = 'LM Studio - local server, offline, free' # 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 # `claude-mode <provider>` with no preset named uses this one. Deliberately a
# fixed choice rather than "most recently used", so the command is predictable. # fixed choice rather than "most recently used", so the command is predictable.
$script:ProviderDefaultPreset = @{ $script:ProviderDefaultPreset = @{}
'openrouter' = 'default' foreach ($__p in $script:Providers) {
'zai' = 'zai' $script:ModeLabel[[string]$__p.id] = [string]$__p.label
'lmstudio' = 'lmstudio' $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 # 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. # and its status line - so "which mode am I in" is answerable at a glance.
$script:ModeColor = @{ $script:ModeColor = @{ 'anthropic' = 'Magenta' }
'anthropic' = 'Magenta' $__colors = @{ cyan = 'Cyan'; green = 'Green'; yellow = 'Yellow'; magenta = 'Magenta'
'openrouter' = 'Cyan' white = 'White'; gray = 'Gray'; dkcyan = 'DarkCyan'; red = 'Red' }
'zai' = 'Green' foreach ($__p in $script:Providers) {
'lmstudio' = 'Yellow' $__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 # Deliberately ASCII-only. This file is read by Windows PowerShell 5.1, which
@@ -162,15 +201,19 @@ function Show-Banner {
function Show-Usage { 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 interactive menu
claude-mode status active mode, preset, model map claude-mode status active mode, preset, model map
claude-mode anthropic native login/subscription (clears all gateway config) claude-mode anthropic native login/subscription (clears all gateway config)
claude-mode openrouter [preset] remote gateway (default preset: default) '@ | Write-Host
claude-mode zai [preset] Z.AI GLM coding plan (default preset: zai) # One line per provider in providers.json, so a new one documents itself.
claude-mode lmstudio [preset] local LM Studio (default preset: lmstudio) 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 presets list presets
claude-mode preset show <name> claude-mode preset show <name>
@@ -474,7 +517,7 @@ function Clear-ManagedSettings {
function Set-ClaudeMode { function Set-ClaudeMode {
param( param(
[ValidateSet('anthropic', 'openrouter', 'zai', 'lmstudio')] [string] $Mode, [string] $Mode,
[string] $PresetName [string] $PresetName
) )
@@ -483,6 +526,9 @@ function Set-ClaudeMode {
New-Item -ItemType Directory -Path $script:SettingsDir -Force | Out-Null 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 $settings = Read-JsonFile $script:Settings
if ($null -eq $settings) { $settings = [ordered]@{} } if ($null -eq $settings) { $settings = [ordered]@{} }
@@ -500,6 +546,21 @@ function Set-ClaudeMode {
$models = $preset['models'] $models = $preset['models']
if ($null -eq $models) { throw "preset '$PresetName' has no 'models' block" } 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 # Cost guard. Gateways resell Anthropic models at full list price, with no
# subscription discount - routing a tier there is almost never intended # subscription discount - routing a tier there is almost never intended
# and is expensive enough to be worth blocking outright. Opt in per # and is expensive enough to be worth blocking outright. Opt in per
@@ -566,7 +627,9 @@ function Set-ClaudeMode {
} }
$settings['apiKeyHelper'] = Get-HelperCommandLine $settings['apiKeyHelper'] = Get-HelperCommandLine
} else { } 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'] } if ($auth.Contains('token') -and $auth['token']) { $tok = [string]$auth['token'] }
$envBlock['ANTHROPIC_AUTH_TOKEN'] = $tok $envBlock['ANTHROPIC_AUTH_TOKEN'] = $tok
} }
@@ -666,7 +729,9 @@ function Test-OpenRouterGuardrail {
# nothing actionable to print for them. # nothing actionable to print for them.
function Show-GuardrailStatus { function Show-GuardrailStatus {
param([string] $Mode, [string] $Key) 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 ' $label = ' guardrail '
$state = Test-OpenRouterGuardrail -Key $Key $state = Test-OpenRouterGuardrail -Key $Key
@@ -1054,6 +1119,202 @@ function Test-LmStudioTemplate {
return $rep 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 # 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 # 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 # 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) { foreach ($t in $script:Tiers) {
if (-not $Preset['models'].Contains($t)) { continue } if (-not $Preset['models'].Contains($t)) { continue }
$id = [string]$Preset['models'][$t] $id = [string]$Preset['models'][$t]
$m = $Catalogue | Where-Object { $_.id -eq $id } | Select-Object -First 1 $m = $Catalogue | Where-Object { $_.Id -eq $id } | Select-Object -First 1
if (-not $m) { continue } if (-not $m -or -not $m.Ctx) { continue }
if ([int]$m.context_length -lt $declared) { if ([int]$m.Ctx -lt $declared) {
Write-Warn2 ("{0} model {1} only has {2:N0} ctx, below the declared {3:N0}." -f $t, $id, $m.context_length, $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') { if ($t -eq 'haiku') {
Write-Warn2 ' haiku only runs short background tasks, so this is usually harmless.' Write-Warn2 ' haiku only runs short background tasks, so this is usually harmless.'
} else { } else {
@@ -1251,45 +1512,37 @@ function Invoke-Models {
$state = Get-State $state = Get-State
$mode = [string]$state['mode'] $mode = [string]$state['mode']
if ($mode -eq 'lmstudio') { # On anthropic, OpenRouter's catalogue is the one worth browsing, as before.
$preset = Get-Preset ([string]$state['preset']) $preset = $(if ($mode -ne 'anthropic') { Get-Preset ([string]$state['preset']) } else { [ordered]@{ provider = 'openrouter'; baseUrl = '' } })
Write-Head "models installed in LM Studio at $($preset['baseUrl'])" $prov = Get-Provider ([string]$preset['provider'])
$tpl = Get-LmStudioTemplateReport $kind = [string]$prov.catalogue.kind
Get-LmStudioModels ([string]$preset['baseUrl']) | switch ($kind) {
Where-Object { -not $Filter -or $_.Id -like "*$Filter*" } | 'static' { Write-Head "$($prov.title) models (from its docs - no public catalogue endpoint)" }
Sort-Object Id | 'openrouter' { Write-Head 'fetching https://openrouter.ai/api/v1/models ...' }
ForEach-Object { default { Write-Head "models on the $($prov.title) server at $($preset['baseUrl'])" }
$short = ($_.Id -split '/')[-1].ToLower() }
$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 $t = $tpl | Where-Object { $_.Key -eq $short } | Select-Object -First 1
$flag = '' $flag = $(if ($t -and $t.Assertions.Count -gt 0) { 'TEMPLATE RISK' } else { '' })
if ($t -and $t.Assertions.Count -gt 0) { $flag = 'TEMPLATE RISK' } [pscustomobject]@{ Id = $m.Id; State = $m.State; Ctx = $m.Ctx; Note = $flag }
[pscustomobject]@{ Id = $_.Id; State = $_.State; Ctx = $_.Ctx; Note = $flag } } else {
} | Format-Table -AutoSize [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 " 'TEMPLATE RISK' = the model's chat template hard-asserts message order,"
Write-Host " which can break tool-call parser generation. Prefer an unflagged model." Write-Host " which can break tool-call parser generation. Prefer an unflagged model."
return
} }
if ($kind -eq 'static' -and $prov.catalogue.docs) { Write-Host " Full list: $($prov.catalogue.docs)" }
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
} }
function Invoke-Doctor { 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 { try {
$r = Invoke-RestMethod -Uri 'https://openrouter.ai/api/v1/key' -Headers @{ Authorization = "Bearer $key" } -TimeoutSec 20 $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))" Write-Ok "OpenRouter key valid (label: $($r.data.label))"
@@ -1382,7 +1635,7 @@ function Invoke-Doctor {
Show-GuardrailStatus -Mode $mode -Key $key 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 # No key-info endpoint; the cheapest real check is a 1-token
# message against the Anthropic-compatible surface. # message against the Anthropic-compatible surface.
try { try {
@@ -1394,80 +1647,20 @@ function Invoke-Doctor {
[void](Invoke-RestMethod -Uri "$base/v1/messages" -Method Post -Body $body ` [void](Invoke-RestMethod -Uri "$base/v1/messages" -Method Post -Body $body `
-ContentType 'application/json' -TimeoutSec 45 ` -ContentType 'application/json' -TimeoutSec 45 `
-Headers @{ 'x-api-key' = $key; 'Authorization' = "Bearer $key"; 'anthropic-version' = '2023-06-01' }) -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 { } catch {
$detail = '' $detail = ''
if ($_.ErrorDetails) { $detail = ($_.ErrorDetails.Message -replace '\s+', ' ') } 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 { } else {
Write-Ok "inline token '$($auth['token'])' (no secret in settings.json)" Write-Ok "inline token '$($auth['token'])' (no secret in settings.json)"
} }
if ($mode -eq 'lmstudio') { # Which checks run is the provider's call, by name, in providers.json.
$catalogue = $null if (Test-ProviderDoctor $mode 'catalogue-models') { Test-PresetCatalogue -Mode $mode -Preset $preset }
try { if (Test-ProviderDoctor $mode 'ollama-context') { Test-OllamaContext -Preset $preset }
$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)"
}
}
} }
Write-Host '' Write-Host ''
@@ -1879,37 +2072,26 @@ function Get-ModelChoices {
if ($script:ModelCatalogueCache.ContainsKey($cacheKey)) { return $script:ModelCatalogueCache[$cacheKey] } if ($script:ModelCatalogueCache.ContainsKey($cacheKey)) { return $script:ModelCatalogueCache[$cacheKey] }
$out = @() $out = @()
$kind = [string](Get-Provider $provider).catalogue.kind
$tpl = $(if ($kind -eq 'lmstudio') { Get-LmStudioTemplateReport } else { @() })
if ($provider -eq 'lmstudio') { foreach ($m in (Get-ProviderCatalogue $Preset | Sort-Object Id)) {
$models = @(Get-LmStudioModels ([string]$Preset['baseUrl'])) $flag = ''
$tpl = Get-LmStudioTemplateReport if ($kind -eq 'openrouter') {
foreach ($m in ($models | Sort-Object Id)) { $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() $short = ($m.Id -split '/')[-1].ToLower()
$risk = $tpl | Where-Object { $_.Key -eq $short } | Select-Object -First 1 $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) { if ($risk -and $risk.Assertions.Count -gt 0) {
$flag = ' [TEMPLATE RISK]' $flag = ' [TEMPLATE RISK]'
$det += 'chat template asserts message order - can break tool calls' $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 } $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 $script:ModelCatalogueCache[$cacheKey] = $out
return $out return $out
@@ -1951,36 +2133,30 @@ function Read-ModelId {
function New-PresetScaffold { function New-PresetScaffold {
param([string] $Provider) 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]@{ $base = [ordered]@{
provider = $Provider provider = $Provider
description = 'new preset' description = 'new preset'
} }
$base['baseUrl'] = [string]$tpl.baseUrl
switch ($Provider) { $auth = [ordered]@{}
'openrouter' { if ($tpl.auth) { foreach ($prop in $tpl.auth.PSObject.Properties) { $auth[$prop.Name] = $prop.Value } }
$base['baseUrl'] = 'https://openrouter.ai/api' else { $auth['mode'] = 'vault'; $auth['keyRef'] = $Provider }
$base['auth'] = [ordered]@{ mode = 'vault'; keyRef = 'openrouter' } $base['auth'] = $auth
}
'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['models'] = [ordered]@{ opus = ''; sonnet = ''; haiku = ''; fable = '' } $base['models'] = [ordered]@{ opus = ''; sonnet = ''; haiku = ''; fable = '' }
$base['subagentModel'] = 'inherit' $base['subagentModel'] = 'inherit'
$base['gatewayModelDiscovery'] = ($Provider -eq 'openrouter') $base['gatewayModelDiscovery'] = [bool]$tpl.gatewayModelDiscovery
$base['contextTokens'] = if ($Provider -eq 'lmstudio') { 262144 } else { 1000000 } $base['contextTokens'] = $(if ($tpl.contextTokens) { [int]$tpl.contextTokens } else { 200000 })
if ($Provider -eq 'lmstudio') { $base['extraEnv'] = [ordered]@{ CLAUDE_CODE_ATTRIBUTION_HEADER = '0' } } if ($tpl.extraEnv) {
if ($Provider -eq 'zai') { $extra = [ordered]@{}
$base['extraEnv'] = [ordered]@{ foreach ($prop in $tpl.extraEnv.PSObject.Properties) { $extra[$prop.Name] = [string]$prop.Value }
API_TIMEOUT_MS = '3000000' $base['extraEnv'] = $extra
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1'
}
} }
return $base return $base
} }
@@ -2186,25 +2362,12 @@ Initialize-Root
try { try {
$cmd = $Command.ToLower() $cmd = $Command.ToLower()
if ($cmd -eq 'z.ai' -or $cmd -eq 'z-ai') { $cmd = 'zai' }
switch ($cmd) { switch ($cmd) {
'' { Invoke-Menu } '' { Invoke-Menu }
'menu' { Invoke-Menu } 'menu' { Invoke-Menu }
'status' { Invoke-Status } 'status' { Invoke-Status }
'anthropic' { Set-ClaudeMode -Mode 'anthropic' -PresetName '' } '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 } 'presets' { Invoke-Presets }
'preset' { Invoke-PresetCmd -Argv $Rest } 'preset' { Invoke-PresetCmd -Argv $Rest }
'set-key' { 'set-key' {
@@ -2226,7 +2389,17 @@ try {
'help' { Show-Usage } 'help' { Show-Usage }
'--help' { Show-Usage } '--help' { Show-Usage }
'-h' { 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 { } catch {
Write-Err2 $_.Exception.Message 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.ps1') -Destination "$root\bin" -Force
Copy-Item -LiteralPath (Join-Path $src 'bin\claude-key-helper.cmd') -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 ------------------------------------------------------------- # --- 3. presets -------------------------------------------------------------
foreach ($p in Get-ChildItem -LiteralPath (Join-Path $src 'presets') -Filter '*.json') { 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_FORCE=0
CM_SAME_ENDPOINT=0 # set by reapply_if_active for a tier-only edit 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) 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"; } head_() { printf '\n%s%s%s\n' "$C_BOLD$C_CYAN" "$*" "$C_RESET"; }
mode_color() { mode_color() {
case "$1" in [ "$1" = anthropic ] && { printf '%s' "$C_MAGENTA"; return; }
anthropic) printf '%s' "$C_MAGENTA" ;; case "$(prov_field "$1" 5)" in
openrouter) printf '%s' "$C_CYAN" ;; cyan) printf '%s' "$C_CYAN" ;;
zai) printf '%s' "$C_GREEN" ;; green) printf '%s' "$C_GREEN" ;;
lmstudio) printf '%s' "$C_YELLOW" ;; yellow) printf '%s' "$C_YELLOW" ;;
magenta) printf '%s' "$C_MAGENTA" ;;
white) printf '%s' "$C_WHITE" ;;
dkcyan) printf '%s' "$C_DKCYAN" ;;
*) printf '%s' "$C_GRAY" ;; *) printf '%s' "$C_GRAY" ;;
esac esac
} }
mode_label() { mode_label() {
case "$1" in [ "$1" = anthropic ] && { printf 'Anthropic - your subscription login, no gateway'; return; }
anthropic) printf 'Anthropic - your subscription login, no gateway' ;; prov_field "$1" 4
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
} }
# Pure ASCII on purpose - renders identically in every terminal and locale. # Pure ASCII on purpose - renders identically in every terminal and locale.
@@ -254,15 +255,20 @@ show_banner() {
usage() { usage() {
cat <<'EOF' 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 interactive menu
claude-mode status active mode, preset, model map claude-mode status active mode, preset, model map
claude-mode anthropic native login (clears all gateway config) claude-mode anthropic native login (clears all gateway config)
claude-mode openrouter [preset] remote gateway (default: default) EOF
claude-mode zai [preset] Z.AI GLM coding plan (default: zai) # One line per provider in providers.json, so a new one documents itself.
claude-mode lmstudio [preset] local LM Studio (default: lmstudio) 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 presets list presets
claude-mode preset show <name> claude-mode preset show <name>
@@ -350,6 +356,66 @@ tsv_find() {
tsv_field() { printf '%s' "$1" | cut -f"$2"; } 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() { term_cols() {
local c='' local c=''
if command -v tput >/dev/null 2>&1; then c="$(tput cols 2>/dev/null)"; fi 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. # 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 # cm-json.py's BUILTIN_DEFAULT_PRESET and the widget's Modes.DEFAULT_PRESET
# mirror the built-in names. # mirror the built-in names.
builtin_default_preset() { builtin_default_preset() { prov_field "$1" 6; }
case "$1" in
openrouter) printf 'default' ;;
zai) printf 'zai' ;;
lmstudio) printf 'lmstudio' ;;
esac
}
default_preset_for() { default_preset_for() {
local chosen 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 # 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. # and start the server, `auth` means the server is fine and the key is not.
cm_probe_server() { 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; } command -v curl >/dev/null 2>&1 || { printf 'skip'; return 0; }
t="$(cm_probe_timeout "$base")" 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" \ code="$(curl -s -o /dev/null -w '%{http_code}' --max-time "$t" \
${token:+-H "Authorization: Bearer $token"} \ ${token:+-H "Authorization: Bearer $token"} \
"$base$ep" 2>/dev/null)" "$base$ep" 2>/dev/null)"
@@ -521,6 +581,15 @@ cm_preflight() {
base="$(jget "$pf" baseUrl)"; CM_PF_BASEURL="$base" base="$(jget "$pf" baseUrl)"; CM_PF_BASEURL="$base"
auth_mode="$(jget "$pf" auth.mode)"; [ -z "$auth_mode" ] && auth_mode=vault 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 if [ "$auth_mode" = "vault" ]; then
key_ref="$(jget "$pf" auth.keyRef)"; [ -z "$key_ref" ] && key_ref=openrouter key_ref="$(jget "$pf" auth.keyRef)"; [ -z "$key_ref" ] && key_ref=openrouter
CM_PF_KEYREF="$key_ref" CM_PF_KEYREF="$key_ref"
@@ -538,17 +607,21 @@ cm_preflight() {
fi fi
fi fi
# LM Studio is checked wherever it is; other providers only when they are # A server provider is checked wherever it is (probe "always", or
# pointed at this machine. A public gateway that is briefly unreachable is # "lenient" for a proxy that may list no models); a public gateway only
# the network's problem and not worth blocking a config change over. # when it has been pointed at this machine (probe "local"). A public
if [ -n "$base" ] && { [ "$mode" = "lmstudio" ] || cm_url_is_local "$base"; }; then # 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 local token='' probe where
if [ "$auth_mode" = "vault" ]; then if [ "$auth_mode" = "vault" ]; then
token="$(cm_vault_get "$key_ref" 2>/dev/null || true)" token="$(cm_vault_get "$key_ref" 2>/dev/null || true)"
else else
token="$(jget "$pf" auth.token)" token="$(jget "$pf" auth.token)"
fi 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' cm_url_is_local "$base" && where='on this machine' || where='at that address'
case "$probe" in case "$probe" in
@@ -560,13 +633,17 @@ cm_preflight() {
"claude-mode set-key $key_ref" 'set-key' "claude-mode set-key $key_ref" 'set-key'
else else
cm_pf_set 'server-auth' 'The server wants an API key' \ 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' "claude-mode preset auth $preset key" 'needs-key'
fi fi
return 1 ;; return 1 ;;
notfound) notfound)
cm_pf_set 'server-wrong' 'That address answered, but not as LM Studio' \ # A proxy in front of a Messages API often serves no model
"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." \ # 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' "claude-mode preset url $preset <base-url>" 'set-url'
return 1 ;; return 1 ;;
*) *)
@@ -582,14 +659,11 @@ cm_preflight() {
cmd_preflight() { cmd_preflight() {
local mode="${1:-}" preset="${2:-}" p local mode="${1:-}" preset="${2:-}" p
case "$mode" in if [ "$mode" != anthropic ]; then
anthropic) ;; mode="$(provider_resolve "$mode")" || { err "unknown mode '${1:-}'"; return 1; }
openrouter|zai|lmstudio)
p="$(resolve_preset "$mode" "$preset" 2>/dev/null)" || p="$preset" p="$(resolve_preset "$mode" "$preset" 2>/dev/null)" || p="$preset"
preset="$p" ;; preset="$p"
z.ai|z-ai) mode=zai; p="$(resolve_preset zai "$preset" 2>/dev/null)" || p="$preset"; preset="$p" ;; fi
*) err "unknown mode '$mode'"; return 1 ;;
esac
if cm_preflight "$mode" "$preset"; then if cm_preflight "$mode" "$preset"; then
"$PY" "$JSON" preflight-json ok "$mode" "$preset" '' '' '' '' '' '' '' "$PY" "$JSON" preflight-json ok "$mode" "$preset" '' '' '' '' '' '' ''
@@ -1046,7 +1120,8 @@ write_health() {
# nothing actionable to print for them. # nothing actionable to print for them.
show_guardrail_status() { show_guardrail_status() {
local mode="$1" key="$2" code 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 [ -n "$key" ] || return 0
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 25 \ 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 "$PY" "$JSON" cache-models "$CM_MODELS_CACHE" "$1" "$2" "${3:-}" >/dev/null 2>&1 || true
} }
or_catalogue() { # One catalogue fetch, for any provider: fetched according to its
local out rc # catalogue.kind, parsed to TSV, and left in the cache for the panel. Prints
out="$(curl -fsS --max-time 30 https://openrouter.ai/api/v1/models 2>/dev/null | "$PY" "$JSON" or-models 2>/dev/null)" # 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=$? 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" [ -n "$out" ] && printf '%s\n' "$out"
return "$rc" 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. # The credential a preset would send. Empty when there is nothing to send.
cm_preset_token() { cm_preset_token() {
local pf="$1" am ref local pf="$1" am ref
@@ -1219,19 +1309,11 @@ cm_preset_token() {
fi fi
} }
# The token is not optional decoration. An LM Studio server with authentication # The token passed to provider_catalogue is not optional decoration. A local
# switched on answers /api/v0/models with 401 like anything else, so without it # server with authentication switched on answers its model list with 401 like
# the catalogue comes back empty and every caller silently believes the server # anything else, so without it the catalogue comes back empty and every caller
# has no models installed - on exactly the setups that need the list most. # silently believes the server has no models - on exactly the setups that need
lms_catalogue() { # the list most.
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"
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Commands # Commands
@@ -1325,18 +1407,16 @@ cmd_models() {
provider=openrouter # on anthropic, the catalogue worth browsing provider=openrouter # on anthropic, the catalogue worth browsing
fi fi
local quiet=$(( refresh || as_json )) local quiet=$(( refresh || as_json )) kind title
case "$provider" in kind="$(prov_field "$provider" 10)"; title="$(prov_field "$provider" 3)"
lmstudio) if [ "$quiet" -eq 0 ]; then
[ "$quiet" -eq 1 ] || head_ "models installed in LM Studio at $base" case "$kind" in
tsv="$(lms_catalogue "$base" "$(cm_preset_token "$pf")")"; rc=$? ;; static) head_ "$title models (from its docs - no public catalogue endpoint)" ;;
zai) openrouter) head_ 'fetching https://openrouter.ai/api/v1/models ...' ;;
[ "$quiet" -eq 1 ] || head_ 'Z.AI GLM models (from Z.AI docs - no public catalogue endpoint)' *) head_ "models on the $title server at $base" ;;
tsv="$(zai_catalogue)"; rc=$? ;;
*)
[ "$quiet" -eq 1 ] || head_ 'fetching https://openrouter.ai/api/v1/models ...'
tsv="$(or_catalogue)"; rc=$? ;;
esac esac
fi
tsv="$(provider_catalogue "$provider" "$base" "$(cm_preset_token "$pf")")"; rc=$?
if [ "$as_json" -eq 1 ]; then if [ "$as_json" -eq 1 ]; then
jget "$CM_MODELS_CACHE" "providers.$provider" jget "$CM_MODELS_CACHE" "providers.$provider"
@@ -1346,6 +1426,10 @@ cmd_models() {
if [ "$refresh" -eq 1 ]; then if [ "$refresh" -eq 1 ]; then
if [ "$rc" -ne 0 ]; then if [ "$rc" -ne 0 ]; then
err "could not fetch the $provider catalogue${base:+ from $base}; the cached list, if any, is kept" 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 return 1
fi fi
ok "cached $(printf '%s\n' "$tsv" | grep -c .) $provider model(s)" 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 while IFS=$'\t' read -r id c2 c3 c4; do
[ -n "$id" ] || continue [ -n "$id" ] || continue
[ -z "$filter" ] || case "$id" in *"$filter"*) ;; *) continue ;; esac [ -z "$filter" ] || case "$id" in *"$filter"*) ;; *) continue ;; esac
case "$provider" in case "$kind" in
lmstudio) printf ' %-58s %-11s %s\n' "$id" "$c2" "$c3" ;; lmstudio) printf ' %-58s %-11s %s\n' "$id" "$c2" "$c3" ;;
zai) say "$(printf '%-8s - %s' "$id" "$c2")" ;; static) say "$(printf '%-8s - %s' "$id" "$c2")" ;;
*) printf ' %-52s %10s $%-8s $%s\n' "$id" "$c2" "$c3" "$c4" ;; 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 esac
done <<<"$tsv" 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)" 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() { cmd_doctor() {
local mode preset pf local mode preset pf
mode="$(state_mode)"; preset="$(state_preset)"; pf="$(preset_path "$preset")" mode="$(state_mode)"; preset="$(state_preset)"; pf="$(preset_path "$preset")"
@@ -1456,7 +1642,7 @@ cmd_doctor() {
else err 'apiKeyHelper produced no output'; fi else err 'apiKeyHelper produced no output'; fi
fi fi
if [ -n "$key" ] && [ "$mode" = "openrouter" ]; then if [ -n "$key" ] && doctor_has "$mode" openrouter-key; then
local kinfo local kinfo
kinfo="$(curl -fsS --max-time 20 -H "Authorization: Bearer $key" https://openrouter.ai/api/v1/key 2>/dev/null)" kinfo="$(curl -fsS --max-time 20 -H "Authorization: Bearer $key" https://openrouter.ai/api/v1/key 2>/dev/null)"
if [ -n "$kinfo" ]; then 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" show_guardrail_status "$mode" "$key"
fi 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" \ if curl -fsS --max-time 45 -X POST "$base/v1/messages" \
-H 'content-type: application/json' -H "x-api-key: $key" \ -H 'content-type: application/json' -H "x-api-key: $key" \
-H "authorization: Bearer $key" -H 'anthropic-version: 2023-06-01' \ -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 -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 else
err 'Z.AI request failed' err "$(prov_field "$mode" 3) request failed"
fi fi
fi fi
else else
ok "inline token '$(jget "$pf" auth.token)' (no secret in settings.json)" ok "inline token '$(jget "$pf" auth.token)' (no secret in settings.json)"
fi fi
if [ "$mode" = "lmstudio" ]; then doctor_has "$mode" catalogue-models && doctor_catalogue "$mode" "$pf" "$base"
local cat; cat="$(lms_catalogue "$base" "$(cm_preset_token "$pf")")" doctor_has "$mode" ollama-context && doctor_ollama_context "$pf" "$base"
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
fi fi
printf '\n' printf '\n'
@@ -1569,17 +1719,18 @@ cmd_preset() {
a="$1"; shift a="$1"; shift
case "$a" in case "$a" in
--provider) --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 ;; provider="$1"; shift ;;
--blank) blank=1 ;; --blank) blank=1 ;;
-*) err "unknown option '$a'"; return 1 ;; -*) err "unknown option '$a'"; return 1 ;;
*) from="$a" ;; *) from="$a" ;;
esac esac
done done
case "$provider" in if [ -n "$provider" ]; then
''|openrouter|zai|lmstudio) ;; local want="$provider"
*) err "unknown provider '$provider' - openrouter, zai or lmstudio"; return 1 ;; provider="$(provider_resolve "$want")" || {
esac err "unknown provider '$want' - one of: $(provider_ids | tr '\n' ' ')"; return 1; }
fi
if [ "$blank" -eq 1 ]; then if [ "$blank" -eq 1 ]; then
[ -n "$provider" ] || { err '--blank needs --provider to know which template'; return 1; } [ -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 local prov="$name" target="${3:-}" p cur got
if [ -z "$prov" ]; then if [ -z "$prov" ]; then
head_ 'default preset per provider' head_ 'default preset per provider'
for p in openrouter zai lmstudio; do for p in $(provider_ids); do
cur="$(jget "$CM_DEFAULTS" "$p")" cur="$(jget "$CM_DEFAULTS" "$p")"
if [ -n "$cur" ] && [ -f "$(preset_path "$cur")" ]; then if [ -n "$cur" ] && [ -f "$(preset_path "$cur")" ]; then
printf ' %-11s %s (chosen)\n' "$p" "$cur" 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" printf '\n %sclaude-mode preset default <provider> <name> (or --clear)%s\n' "$C_DIM" "$C_RESET"
return 0 return 0
fi fi
case "$prov" in prov="$(provider_resolve "$name")" || {
openrouter|zai|lmstudio) ;; err "unknown provider '$name' - one of: $(provider_ids | tr '\n' ' ')"; return 1; }
*) err "unknown provider '$prov' - openrouter, zai or lmstudio"; return 1 ;;
esac
if [ -z "$target" ]; then if [ -z "$target" ]; then
resolve_preset "$prov" && printf '\n' resolve_preset "$prov" && printf '\n'
return return
@@ -1722,9 +1871,12 @@ cmd_preset() {
reapply_if_active "$name" reapply_if_active "$name"
;; ;;
auth) 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; } [ -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; } [ -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 "$PY" "$JSON" set-auth "$(preset_path "$name")" "$amode" "$ref" >/dev/null || return 1
if [ "$amode" = "key" ]; then if [ "$amode" = "key" ]; then
ok "$name : auth -> vault key '$ref'" ok "$name : auth -> vault key '$ref'"
@@ -2012,27 +2164,18 @@ ui_pick_model() {
ui_reset_items ui_reset_items
ui_add_item '<type an id manually>' 'enter any model id by hand' ui_add_item '<type an id manually>' 'enter any model id by hand'
local ids=() id ctx a b st local ids=() id ctx a b st kind
case "$provider" in kind="$(prov_field "$provider" 10)"
openrouter) while IFS=$'\t' read -r id a b st; do
while IFS=$'\t' read -r id ctx a b; do
[ -n "$id" ] || continue [ -n "$id" ] || continue
ids+=("$id"); ui_add_item "$id" "context $ctx \$$a in / \$$b out per 1M" ids+=("$id")
done < <(or_catalogue) case "$kind" in
;; openrouter) ui_add_item "$id" "context $a \$$b in / \$$st out per 1M" ;;
lmstudio) lmstudio) ui_add_item "$id" "state: $a max context: $b" ;;
while IFS=$'\t' read -r id st ctx; do ollama) ui_add_item "$id" "$a $b" ;;
[ -n "$id" ] || continue *) ui_add_item "$id" "$a" ;;
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)
;;
esac esac
done < <(provider_catalogue "$provider" "$base" "$(cm_preset_token "$pf")")
if [ "${#ids[@]}" -gt 0 ]; then if [ "${#ids[@]}" -gt 0 ]; then
ui_filter_select "model for '$tier'" "current: $current" || return 1 ui_filter_select "model for '$tier'" "current: $current" || return 1
@@ -2083,7 +2226,7 @@ ui_edit_preset() {
ui_new_preset() { ui_new_preset() {
ui_reset_items 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 for p in "${provs[@]}"; do ui_add_item "$p" "$(mode_label "$p")"; done
ui_select 'new preset - which provider' '' || return ui_select 'new preset - which provider' '' || return
local provider="${provs[$UI_SEL]}" local provider="${provs[$UI_SEL]}"
@@ -2254,7 +2397,7 @@ setup_models() {
return 0 return 0
fi 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 # 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 # has one loaded at a time, and mapping tiers to different models just
# means paying the load cost on every tier change. # means paying the load cost on every tier change.
@@ -2263,7 +2406,7 @@ setup_models() {
while IFS=$'\t' read -r id st ctx; do while IFS=$'\t' read -r id st ctx; do
[ -n "$id" ] || continue [ -n "$id" ] || continue
ids+=("$id") ids+=("$id")
done < <(lms_catalogue "$base" "$(cm_preset_token "$pf")") done < <(provider_catalogue "$provider" "$base" "$(cm_preset_token "$pf")")
if [ "${#ids[@]}" -eq 0 ]; then if [ "${#ids[@]}" -eq 0 ]; then
warn 'the server returned no models; type an id by hand instead' warn 'the server returned no models; type an id by hand instead'
@@ -2292,40 +2435,48 @@ setup_models() {
done done
} }
setup_lmstudio_server() { # Where a server provider (LM Studio, Ollama, a custom endpoint) lives, and
local pf="$1" name="$2" url probe token # whether it wants a key. None of them has to be on this machine.
url="$(jget "$pf" baseUrl)"; [ -n "$url" ] || url='http://127.0.0.1:1234' 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' printf '\n'
say 'LM Studio does not have to be on this machine - a LAN address or' [ -n "$hint" ] && say "$hint"
say 'anything reachable through a tunnel or proxy works just as well.'
url="$(ask_value 'server base URL' "$url")" 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 "$PY" "$JSON" set-url "$pf" "$url" >/dev/null || return 1
ok "baseUrl -> $url" ok "baseUrl -> $url"
printf '\n' printf '\n'
if ask_yes 'does that server require an API key?'; then 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 "$PY" "$JSON" set-auth "$pf" key "$ref" >/dev/null
ok "auth -> vault key '$ref'" ok "auth -> vault key '$ref'"
cm_vault_has "$ref" || cmd_set_key "$ref" cm_vault_has "$ref" || cmd_set_key "$ref"
token="$(cm_vault_get "$ref" 2>/dev/null || true)" token="$(cm_vault_get "$ref" 2>/dev/null || true)"
else else
"$PY" "$JSON" set-auth "$pf" none >/dev/null "$PY" "$JSON" set-auth "$pf" none >/dev/null
ok 'auth -> none (inline placeholder token)' token="$(prov_field "$mode" 18)"
token='lmstudio' ok "auth -> none (inline placeholder token '$token')"
fi fi
printf '\n' printf '\n'
say "checking $url ..." say "checking $url ..."
probe="$(cm_probe_server "$url" "$token")" probe="$(cm_probe_server "$url" "$token" "$(prov_field "$mode" 9)")"
case "$probe" in case "$probe" in
ok) ok 'server answered' ;; ok) ok 'server answered' ;;
auth) err 'the server refused that credential'; return 1 ;; 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' ;; skip) warn 'curl is missing, so the server was not checked' ;;
*) err 'nothing answered at that address' *) 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 ;; return 1 ;;
esac esac
return 0 return 0
@@ -2339,10 +2490,8 @@ cmd_setup() {
head_ 'setup: anthropic' head_ 'setup: anthropic'
ok 'nothing to configure - it uses your existing Claude login' ok 'nothing to configure - it uses your existing Claude login'
return 0 ;; return 0 ;;
openrouter|zai|lmstudio) ;;
z.ai|z-ai) mode=zai ;;
'') err 'usage: claude-mode setup <mode>'; return 1 ;; '') 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 esac
name="$(resolve_preset "$mode" "${2:-}")" || return 1 name="$(resolve_preset "$mode" "${2:-}")" || return 1
@@ -2357,23 +2506,19 @@ cmd_setup() {
head_ "setup: $mode / preset '$name'" head_ "setup: $mode / preset '$name'"
say "$(mode_label "$mode")" say "$(mode_label "$mode")"
case "$mode" in # A server provider is asked where it is and whether it wants a key; a
openrouter) # 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' printf '\n'
setup_key openrouter 'OpenRouter' [ -n "$url" ] && say "get a key from $url"
setup_models "$pf" openrouter "$name" setup_key "$ref" "$(prov_field "$mode" 3)"
;; fi
zai) setup_models "$pf" "$mode" "$name"
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
mark_configured "$pf" mark_configured "$pf"
printf '\n' 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; } command -v "$PY" >/dev/null 2>&1 || { echo "claude-mode: $PY not found (set CLAUDE_MODE_PYTHON)" >&2; exit 1; }
init_root init_root
cm_providers_load
# --force is global and may appear anywhere; strip it before the mode arguments # --force is global and may appear anywhere; strip it before the mode arguments
# are positional-matched below. # are positional-matched below.
@@ -2803,10 +2949,6 @@ case "$cmd" in
''|menu) ui_menu ;; ''|menu) ui_menu ;;
status) cmd_status ;; status) cmd_status ;;
anthropic) set_mode anthropic ;; 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 ;; presets) cmd_presets ;;
preset) cmd_preset "$@" ;; preset) cmd_preset "$@" ;;
set-key) set-key)
@@ -2878,5 +3020,13 @@ case "$cmd" in
for a in "$@"; do [ "$a" = "--all" ] && scope=all; done for a in "$@"; do [ "$a" = "--all" ] && scope=all; done
cmd_repair "$scope" ;; cmd_repair "$scope" ;;
help|--help|-h) usage ;; 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 esac
+252 -37
View File
@@ -20,6 +20,10 @@ Subcommands:
preset-rename <dir> <old> <new> <state> [defaults] rename, repointing state + default preset-rename <dir> <old> <new> <state> [defaults] rename, repointing state + default
auth-of <preset> "mode<TAB>keyRef"; fails if missing auth-of <preset> "mode<TAB>keyRef"; fails if missing
set-default <defaults> <provider> [name] choose (or clear) a provider default 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 import glob
@@ -63,9 +67,162 @@ BASE_MANAGED = [
TIERS = ["opus", "sonnet", "haiku", "fable"] 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`. # Providers
BUILTIN_DEFAULT_PRESET = {"openrouter": "default", "zai": "zai", "lmstudio": "lmstudio"} #
# 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): def load(path, default=None):
@@ -229,31 +386,20 @@ def cmd_set_tier(argv):
def cmd_scaffold(argv): def cmd_scaffold(argv):
provider = argv[0] """scaffold <provider> - a blank preset built from the provider's template."""
base = {"provider": provider, "description": "new preset"} p = provider(argv[0])
if provider == "openrouter": if p is None:
base["baseUrl"] = "https://openrouter.ai/api" raise SystemExit("unknown provider '%s'" % argv[0])
base["auth"] = {"mode": "vault", "keyRef": "openrouter"} tpl = p.get("preset") or {}
elif provider == "zai": base = {"provider": p["id"], "description": "new preset"}
base["baseUrl"] = "https://api.z.ai/api/anthropic" base["baseUrl"] = tpl.get("baseUrl", "")
base["auth"] = {"mode": "vault", "keyRef": "zai"} base["auth"] = dict(tpl.get("auth") or {"mode": "vault", "keyRef": p["id"]})
elif provider == "lmstudio":
base["baseUrl"] = "http://127.0.0.1:1234"
base["auth"] = {"mode": "literal", "token": "lmstudio"}
else:
raise SystemExit("unknown provider '%s'" % provider)
base["models"] = {t: "" for t in TIERS} base["models"] = {t: "" for t in TIERS}
base["subagentModel"] = "inherit" base["subagentModel"] = "inherit"
base["gatewayModelDiscovery"] = provider == "openrouter" base["gatewayModelDiscovery"] = bool(tpl.get("gatewayModelDiscovery"))
base["contextTokens"] = 262144 if provider == "lmstudio" else 1000000 base["contextTokens"] = int(tpl.get("contextTokens") or 200000)
if provider == "lmstudio": if tpl.get("extraEnv"):
base["extraEnv"] = {"CLAUDE_CODE_ATTRIBUTION_HEADER": "0"} base["extraEnv"] = dict(tpl["extraEnv"])
if provider == "zai":
base["extraEnv"] = {
"API_TIMEOUT_MS": "3000000",
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
}
print(json.dumps(base, indent=2)) 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" path, provider, ok = argv[0], argv[1], argv[2] == "1"
base = argv[3].rstrip("/") if len(argv) > 3 else "" base = argv[3].rstrip("/") if len(argv) > 3 else ""
kind = catalogue_kind(provider)
per_server = per_server_catalogue(provider)
def num(s, cast): def num(s, cast):
try: try:
@@ -423,6 +571,8 @@ def cmd_cache_models(argv):
except (TypeError, ValueError): except (TypeError, ValueError):
return None 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 = [] rows = []
for line in sys.stdin.read().splitlines(): for line in sys.stdin.read().splitlines():
parts = line.split("\t") parts = line.split("\t")
@@ -430,17 +580,20 @@ def cmd_cache_models(argv):
if not mid: if not mid:
continue continue
m = {"id": mid} m = {"id": mid}
if provider == "openrouter": if kind == "openrouter":
ctx, pin, pout = (parts[1:] + ["", "", ""])[:3] ctx, pin, pout = (parts[1:] + ["", "", ""])[:3]
m["contextTokens"] = num(ctx, int) m["contextTokens"] = num(ctx, int)
m["priceIn"] = num(pin, float) m["priceIn"] = num(pin, float)
m["priceOut"] = num(pout, float) m["priceOut"] = num(pout, float)
elif provider == "lmstudio": elif kind == "lmstudio":
state, ctx = (parts[1:] + ["", ""])[:2] state, ctx = (parts[1:] + ["", ""])[:2]
m["state"] = state m["state"] = state
m["contextTokens"] = num(ctx, int) m["contextTokens"] = num(ctx, int)
else: elif kind == "ollama":
m["note"] = parts[1] if len(parts) > 1 else "" 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) rows.append(m)
try: try:
@@ -458,11 +611,11 @@ def cmd_cache_models(argv):
__import__("datetime").timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") __import__("datetime").timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
if ok: if ok:
node = {"fetchedAt": now, "ok": True, "models": rows} node = {"fetchedAt": now, "ok": True, "models": rows}
if provider == "lmstudio": if per_server:
node["baseUrl"] = base node["baseUrl"] = base
else: else:
# A list from a different LM Studio server is not stale, it is wrong. # A list from a different server is not stale, it is wrong.
if provider == "lmstudio" and node.get("baseUrl", "") != base: if per_server and node.get("baseUrl", "") != base:
node = {"baseUrl": base, "models": []} node = {"baseUrl": base, "models": []}
node["ok"] = False node["ok"] = False
node["failedAt"] = now 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", ""))) 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 # 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 # 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 # 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, "schema": 1,
"tool": "claude-mode", "tool": "claude-mode",
"version": version or "0.0.0", "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", "os": "posix",
"mode": mode, "mode": mode,
"preset": "" if mode == "anthropic" else preset, "preset": "" if mode == "anthropic" else preset,
@@ -662,7 +845,7 @@ def cmd_health(argv):
if not isinstance(chosen_raw, dict): if not isinstance(chosen_raw, dict):
chosen_raw = {} chosen_raw = {}
effective, chosen = {}, {} effective, chosen = {}, {}
for prov, builtin in BUILTIN_DEFAULT_PRESET.items(): for prov, builtin in builtin_default_preset().items():
pick = chosen_raw.get(prov) pick = chosen_raw.get(prov)
if pick and pick in names: if pick and pick in names:
effective[prov] = chosen[prov] = pick effective[prov] = chosen[prov] = pick
@@ -674,6 +857,25 @@ def cmd_health(argv):
h["defaultPresetFor"] = effective h["defaultPresetFor"] = effective
h["defaultPresetChosen"] = chosen 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) save(os.path.join(root, "health.json"), h)
@@ -760,10 +962,16 @@ def cmd_set_auth(argv):
never sees it either way. never sees it either way.
""" """
path, mode = argv[0], argv[1] path, mode = argv[0], argv[1]
key_ref = argv[2] if len(argv) > 2 and argv[2] else "lmstudio"
p = load(path) 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": 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": elif mode == "key":
p["auth"] = {"mode": "vault", "keyRef": key_ref} p["auth"] = {"mode": "vault", "keyRef": key_ref}
else: else:
@@ -1279,6 +1487,13 @@ COMMANDS = {
"strip-tags": cmd_strip_tags, "strip-tags": cmd_strip_tags,
"or-models": cmd_or_models, "or-models": cmd_or_models,
"lms-models": cmd_lms_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, "cache-models": cmd_cache_models,
"apply": cmd_apply, "apply": cmd_apply,
"summary": cmd_summary, "summary": cmd_summary,
+14
View File
@@ -82,6 +82,20 @@ if [ -f "$VERSION_SRC" ]; then
green "version $(tr -d '[:space:]' < "$ROOT/VERSION")" green "version $(tr -d '[:space:]' < "$ROOT/VERSION")"
fi 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) ------------------------------- # --- presets (shared with the Windows build) -------------------------------
PRESET_SRC="$SRC/../presets" PRESET_SRC="$SRC/../presets"
[ -d "$PRESET_SRC" ] || 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 !== "" 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 // 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 // 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 // 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))) var n = Math.round(Number(root.setting("iconSize", Style.bar.iconFont)))
return n % 2 === 0 ? n + 1 : n return n % 2 === 0 ? n + 1 : n
} }
readonly property string logo: Modes.logo(mode) readonly property string logo: modeLogo(mode)
readonly property string glyph: Modes.glyph(mode) readonly property string glyph: modeGlyph(mode)
readonly property string label: Modes.shortLabel(mode, preset) readonly property string label: Modes.shortLabel(mode, preset)
// A gateway mode is spending money or leaning on a local server; native // A gateway mode is spending money or leaning on a local server; native
@@ -226,7 +258,7 @@ BarWidget {
id: icon id: icon
anchors.fill: parent anchors.fill: parent
pathData: root.logo pathData: root.logo
opticalScale: Modes.logoScale(root.mode) opticalScale: root.modeLogoScale(root.mode)
color: root.activeColor color: root.activeColor
iconSize: root.iconPx iconSize: root.iconPx
} }
@@ -282,7 +314,7 @@ BarWidget {
function tooltipLabel() { function tooltipLabel() {
if (!root.known) return "claude-mode: not installed" 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) if (root.preset !== "") lines.push("preset " + root.preset)
var models = root.health && root.health.models ? root.health.models : null var models = root.health && root.health.models ? root.health.models : null
if (models && models.opus) lines.push("opus " + models.opus) if (models && models.opus) lines.push("opus " + models.opus)
+7 -4
View File
@@ -1,9 +1,12 @@
.pragma library .pragma library
// The four modes claude-mode understands, in the order the CLI menu lists // Fallback presentation only. claude-mode publishes its providers - titles,
// them. Kept here rather than in either QML file because the bar widget needs // blurbs, logos, scales, order - in health.json from providers.json, and the
// the glyph and the panel needs the prose, and a second copy of this table is // widget reads them from there (BarWidget.providerInfo). What remains here is
// exactly the sort of thing that drifts. // 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 // 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 // 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 cli(args) { return [root.cmRoot + "/bin/claude-mode"].concat(args) }
function parseJson(t) { try { return JSON.parse(String(t || "")) } catch (e) { return null } } 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 // The widget owns the lookup, and the fallback to Modes.js for anthropic and
// can be another machine on the LAN, or something reached through a tunnel or // for an older health.json. This side only asks, so nothing here needs to
// a reverse proxy, and it can have authentication switched on. All three are // 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 // 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. // rather than pretending the local default is the only shape.
property string serverPreset: "" property string serverPreset: ""
property string serverProvider: ""
property string serverUrl: "" property string serverUrl: ""
property bool serverNeedsKey: false property bool serverNeedsKey: false
property string serverKeyRef: "lmstudio" property string serverKeyRef: ""
readonly property string serverLocalDefault: "http://127.0.0.1:1234" property string serverDefault: "" // the provider's usual address; "" for custom
property string serverHint: ""
function presetEntry(name) { function presetEntry(name) {
var all = (health && health.presets) ? health.presets : [] var all = (health && health.presets) ? health.presets : []
@@ -99,9 +118,15 @@ Panel {
var e = root.presetEntry(presetName) var e = root.presetEntry(presetName)
root.serverReturn = from || "" root.serverReturn = from || ""
root.serverPreset = presetName 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.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.blocker = null
root.stage = "server" root.stage = "server"
} }
@@ -408,7 +433,7 @@ Panel {
var all = widget && widget.modelsCache && widget.modelsCache.providers ? widget.modelsCache.providers : null var all = widget && widget.modelsCache && widget.modelsCache.providers ? widget.modelsCache.providers : null
var n = all && root.editProvider !== "" ? all[root.editProvider] : null var n = all && root.editProvider !== "" ? all[root.editProvider] : null
if (!n) return 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(/\/+$/, "") : "" var want = root.editEntry && root.editEntry.baseUrl ? String(root.editEntry.baseUrl).replace(/\/+$/, "") : ""
if (String(n.baseUrl || "") !== want) return null if (String(n.baseUrl || "") !== want) return null
} }
@@ -797,17 +822,17 @@ Panel {
BrandIcon { BrandIcon {
anchors.centerIn: parent anchors.centerIn: parent
visible: Modes.logo(root.mode) !== "" visible: root.pLogo(root.mode) !== ""
pathData: Modes.logo(root.mode) pathData: root.pLogo(root.mode)
opticalScale: Modes.logoScale(root.mode) opticalScale: root.pLogoScale(root.mode)
color: heroGlyph.tint color: heroGlyph.tint
iconSize: Style.font.display iconSize: Style.font.display
} }
Text { Text {
anchors.centerIn: parent anchors.centerIn: parent
visible: Modes.logo(root.mode) === "" visible: root.pLogo(root.mode) === ""
text: Modes.glyph(root.mode) text: root.pGlyph(root.mode)
color: heroGlyph.tint color: heroGlyph.tint
font.family: root.bar ? root.bar.fontFamily : Style.font.family font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.font.display font.pixelSize: Style.font.display
@@ -822,7 +847,7 @@ Panel {
Text { Text {
width: parent.width width: parent.width
elide: Text.ElideRight elide: Text.ElideRight
text: Modes.title(root.mode) text: root.pTitle(root.mode)
color: Color.popups.text color: Color.popups.text
font.family: root.bar ? root.bar.fontFamily : Style.font.family font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(17) font.pixelSize: Style.space(17)
@@ -831,7 +856,7 @@ Panel {
Text { Text {
width: parent.width width: parent.width
elide: Text.ElideRight 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 color: Color.muted
font.family: root.bar ? root.bar.fontFamily : Style.font.family font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(11) font.pixelSize: Style.space(11)
@@ -857,7 +882,7 @@ Panel {
spacing: Style.space(1) spacing: Style.space(1)
Repeater { Repeater {
model: root.stage === "list" ? Modes.ORDER : [] model: root.stage === "list" ? root.modeOrder : []
Column { Column {
id: modeEntry id: modeEntry
@@ -890,8 +915,8 @@ Panel {
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: Style.space(3) anchors.leftMargin: Style.space(3)
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
pathData: Modes.logo(modeEntry.thisMode) pathData: root.pLogo(modeEntry.thisMode)
opticalScale: Modes.logoScale(modeEntry.thisMode) opticalScale: root.pLogoScale(modeEntry.thisMode)
color: modeEntry.isCurrent ? Color.accent : Color.popups.text color: modeEntry.isCurrent ? Color.accent : Color.popups.text
iconSize: Style.space(16) iconSize: Style.space(16)
} }
@@ -905,7 +930,7 @@ Panel {
spacing: 0 spacing: 0
Text { Text {
text: Modes.title(modeEntry.thisMode) text: root.pTitle(modeEntry.thisMode)
color: Color.popups.text color: Color.popups.text
font.family: root.bar ? root.bar.fontFamily : Style.font.family font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.font.body font.pixelSize: Style.font.body
@@ -914,7 +939,7 @@ Panel {
Text { Text {
width: parent.width width: parent.width
text: Modes.blurb(modeEntry.thisMode) text: root.pBlurb(modeEntry.thisMode)
color: Color.muted color: Color.muted
elide: Text.ElideRight elide: Text.ElideRight
font.family: root.bar ? root.bar.fontFamily : Style.font.family font.family: root.bar ? root.bar.fontFamily : Style.font.family
@@ -1080,7 +1105,7 @@ Panel {
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: Style.space(30) anchors.leftMargin: Style.space(30)
anchors.verticalCenter: parent.verticalCenter 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 color: newPresetArea.containsMouse ? Color.accent : Color.muted
font.family: root.bar ? root.bar.fontFamily : Style.font.family font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(11) font.pixelSize: Style.space(11)
@@ -1146,7 +1171,7 @@ Panel {
spacing: Style.space(7) spacing: Style.space(7)
PillButton { PillButton {
label: "Set up " + Modes.title(root.pendingMode) + "…" label: "Set up " + root.pTitle(root.pendingMode) + "…"
primary: true primary: true
visible: root.blocker && String(root.blocker.remedyKind) === "setup" visible: root.blocker && String(root.blocker.remedyKind) === "setup"
onTriggered: root.runSetup() onTriggered: root.runSetup()
@@ -1172,7 +1197,7 @@ Panel {
PillButton { PillButton {
label: "Server settings…" label: "Server settings…"
primary: root.blocker && ["start-server", "set-url", "needs-key"].indexOf(String(root.blocker.remedyKind)) >= 0 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") && !(root.blocker && String(root.blocker.remedyKind) === "setup")
onTriggered: root.openServerSettings(root.pendingPreset) 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 { Column {
width: parent.width width: parent.width
visible: root.stage === "server" visible: root.stage === "server"
@@ -1205,8 +1230,7 @@ Panel {
Text { Text {
width: parent.width width: parent.width
text: "LM Studio does not have to be on this machine. Point this at a " text: root.serverHint
+ "LAN address, or anything reachable through a tunnel or proxy."
color: Color.muted color: Color.muted
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
lineHeight: 1.2 lineHeight: 1.2
@@ -1218,7 +1242,7 @@ Panel {
id: urlField id: urlField
width: parent.width width: parent.width
text: root.serverUrl text: root.serverUrl
placeholderText: root.serverLocalDefault placeholderText: root.serverDefault !== "" ? root.serverDefault : "https://…"
foreground: Color.popups.text foreground: Color.popups.text
font.family: root.bar ? root.bar.fontFamily : Style.font.family font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(11) font.pixelSize: Style.space(11)
@@ -1232,9 +1256,11 @@ Panel {
width: parent.width width: parent.width
spacing: Style.space(7) spacing: Style.space(7)
// A custom endpoint has no usual address to go back to.
PillButton { PillButton {
label: "Use local default" 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 width: parent.width
text: root.serverNeedsKey text: root.serverNeedsKey
? "Kept in the vault as '" + root.serverKeyRef + "', never in settings.json." ? "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 color: Color.muted
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
font.family: root.bar ? root.bar.fontFamily : Style.font.family font.family: root.bar ? root.bar.fontFamily : Style.font.family
@@ -1317,7 +1343,7 @@ Panel {
Text { Text {
width: parent.width width: parent.width
text: "'" + root.editPreset + "' · " + Modes.title(root.editProvider) text: "'" + root.editPreset + "' · " + root.pTitle(root.editProvider)
color: Color.popups.text color: Color.popups.text
elide: Text.ElideRight elide: Text.ElideRight
font.family: root.bar ? root.bar.fontFamily : Style.font.family font.family: root.bar ? root.bar.fontFamily : Style.font.family
@@ -1434,7 +1460,7 @@ Panel {
PillButton { PillButton {
label: "Server settings…" label: "Server settings…"
visible: root.editProvider === "lmstudio" visible: root.serverEditable(root.editProvider)
onTriggered: root.openServerSettings(root.editPreset, "preset") onTriggered: root.openServerSettings(root.editPreset, "preset")
} }
PillButton { label: "Make default"; visible: !root.editIsDefault; onTriggered: root.setDefault(true) } PillButton { label: "Make default"; visible: !root.editIsDefault; onTriggered: root.setDefault(true) }
@@ -1470,7 +1496,7 @@ Panel {
Text { Text {
width: parent.width width: parent.width
text: "New " + Modes.title(root.newProvider) + " preset" text: "New " + root.pTitle(root.newProvider) + " preset"
color: Color.popups.text color: Color.popups.text
elide: Text.ElideRight elide: Text.ElideRight
font.family: root.bar ? root.bar.fontFamily : Style.font.family font.family: root.bar ? root.bar.fontFamily : Style.font.family
@@ -1649,8 +1675,8 @@ Panel {
Text { Text {
width: parent.width width: parent.width
visible: parent.lastOne visible: parent.lastOne
text: "It is the only " + Modes.title(root.editProvider) + " preset, so " text: "It is the only " + root.pTitle(root.editProvider) + " preset, so "
+ Modes.title(root.editProvider) + " will have nothing to switch to until you create another." + root.pTitle(root.editProvider) + " will have nothing to switch to until you create another."
color: root.urgentColor color: root.urgentColor
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
font.family: root.bar ? root.bar.fontFamily : Style.font.family font.family: root.bar ? root.bar.fontFamily : Style.font.family
@@ -1826,7 +1852,7 @@ Panel {
visible: root.modelOptions.length === 0 visible: root.modelOptions.length === 0
text: root.catalogueNode && root.catalogueNode.ok === false text: root.catalogueNode && root.catalogueNode.ok === false
? "The last fetch failed and no list is cached. Type an id, or try fetching again." ? "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 color: Color.muted
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
font.family: root.bar ? root.bar.fontFamily : Style.font.family font.family: root.bar ? root.bar.fontFamily : Style.font.family
@@ -1892,7 +1918,7 @@ Panel {
// they fail rather than carry on. // they fail rather than carry on.
Text { Text {
width: parent.width 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 " + "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 " + "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." + "goes into its transcript and Anthropic will refuse to resume it at all."
+1 -1
View File
@@ -2,7 +2,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "smoido.claude-mode", "id": "smoido.claude-mode",
"name": "Claude Mode", "name": "Claude Mode",
"version": "1.11.0", "version": "1.12.0",
"author": "smoido", "author": "smoido",
"description": "Which provider Claude Code is pointed at, and a one-click switch between them", "description": "Which provider Claude Code is pointed at, and a one-click switch between them",
"kinds": ["bar-widget"], "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 'install.ps1') $stage -Force
Copy-Item (Join-Path $root 'profile-snippet.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 '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 '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 } 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 '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 } 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 'VERSION') $posixStage -Force
Copy-Item (Join-Path $root 'providers.json') $posixStage -Force
# Shell scripts authored on Windows carry CRLF, which makes the kernel reject # Shell scripts authored on Windows carry CRLF, which makes the kernel reject
# the "#!/usr/bin/env bash" line. Normalise before packaging. # 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" $tarName = "claude-mode-$version-posix.tar.gz"
$tarPath = Join-Path $outDir $tarName $tarPath = Join-Path $outDir $tarName
if (Test-Path -LiteralPath $tarPath) { Remove-Item -LiteralPath $tarPath -Force } 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' } if ($LASTEXITCODE -ne 0) { throw 'tar failed - cannot build the POSIX package' }
$shaPosix = (Get-FileHash -LiteralPath $tarPath -Algorithm SHA256).Hash.ToLower() $shaPosix = (Get-FileHash -LiteralPath $tarPath -Algorithm SHA256).Hash.ToLower()