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
+352 -202
View File
@@ -32,7 +32,9 @@ JSON="$CM_BIN/cm-json.py"
CM_FORCE=0
CM_SAME_ENDPOINT=0 # set by reapply_if_active for a tier-only edit
MODES=(anthropic openrouter zai lmstudio)
# anthropic is built in; every gateway provider is appended from providers.json
# at startup (see cm_providers_load).
MODES=(anthropic)
TIERS=(opus sonnet haiku fable)
# ---------------------------------------------------------------------------
@@ -219,22 +221,21 @@ err() { printf ' %sFAIL%s %s\n' "$C_BOLD$C_RED" "$C_RESET" "$*" >&2; }
head_() { printf '\n%s%s%s\n' "$C_BOLD$C_CYAN" "$*" "$C_RESET"; }
mode_color() {
case "$1" in
anthropic) printf '%s' "$C_MAGENTA" ;;
openrouter) printf '%s' "$C_CYAN" ;;
zai) printf '%s' "$C_GREEN" ;;
lmstudio) printf '%s' "$C_YELLOW" ;;
*) printf '%s' "$C_GRAY" ;;
[ "$1" = anthropic ] && { printf '%s' "$C_MAGENTA"; return; }
case "$(prov_field "$1" 5)" in
cyan) printf '%s' "$C_CYAN" ;;
green) printf '%s' "$C_GREEN" ;;
yellow) printf '%s' "$C_YELLOW" ;;
magenta) printf '%s' "$C_MAGENTA" ;;
white) printf '%s' "$C_WHITE" ;;
dkcyan) printf '%s' "$C_DKCYAN" ;;
*) printf '%s' "$C_GRAY" ;;
esac
}
mode_label() {
case "$1" in
anthropic) printf 'Anthropic - your subscription login, no gateway' ;;
openrouter) printf 'OpenRouter - remote, pay-per-token, any vendor' ;;
zai) printf 'Z.AI - GLM coding plan' ;;
lmstudio) printf 'LM Studio - local server, offline, free' ;;
esac
[ "$1" = anthropic ] && { printf 'Anthropic - your subscription login, no gateway'; return; }
prov_field "$1" 4
}
# Pure ASCII on purpose - renders identically in every terminal and locale.
@@ -254,15 +255,20 @@ show_banner() {
usage() {
cat <<'EOF'
claude-mode - switch Claude Code between Anthropic, OpenRouter, Z.AI, LM Studio
claude-mode - switch Claude Code between Anthropic and gateway providers
claude-mode interactive menu
claude-mode status active mode, preset, model map
claude-mode anthropic native login (clears all gateway config)
claude-mode openrouter [preset] remote gateway (default: default)
claude-mode zai [preset] Z.AI GLM coding plan (default: zai)
claude-mode lmstudio [preset] local LM Studio (default: lmstudio)
EOF
# One line per provider in providers.json, so a new one documents itself.
local id
for id in $(provider_ids); do
printf ' claude-mode %-26s %s (default: %s)\n' "$id [preset]" \
"$(prov_field "$id" 4 | sed 's/^[^-]*- //')" "$(default_preset_for "$id")"
done
cat <<'EOF'
claude-mode presets list presets
claude-mode preset show <name>
@@ -350,6 +356,66 @@ tsv_find() {
tsv_field() { printf '%s' "$1" | cut -f"$2"; }
# ---------------------------------------------------------------------------
# Providers
#
# Every gateway provider is an entry in providers.json; this script only knows
# the *kinds* of behaviour (how a catalogue is fetched, how a server is probed)
# and picks one by name from the entry. The table is read once, in the main
# shell before dispatch - read inside a $( ) it would be re-read on every
# lookup. Columns are cm-json.py's PROVIDER_TSV:
#
# 1 id 2 aliases 3 title 4 label 5 color 6 defaultPreset
# 7 serverEditable 8 probe 9 probePaths 10 catalogueKind 11 perServer
# 12 setupKey 13 setupModels 14 keyUrl 15 guardrail 16 doctor
# 17 defaultKeyRef 18 literalToken 19 defaultBaseUrl 20 serverHint
# 21 serverStart
#
# Always read with cut, never `IFS=$'\t' read`: tab counts as whitespace to
# read, so an empty column (most providers have no aliases) would vanish and
# shift every column after it.
# ---------------------------------------------------------------------------
CM_PROVIDERS_TSV=''
cm_providers_load() {
CM_PROVIDERS_TSV="$("$PY" "$JSON" provider-tsv)" || {
echo "claude-mode: could not read providers.json (next to $CM_BIN)" >&2; exit 1; }
local id
for id in $(provider_ids); do MODES+=("$id"); done
}
provider_ids() { printf '%s\n' "$CM_PROVIDERS_TSV" | cut -f1; }
prov_field() {
local row
row="$(printf '%s\n' "$CM_PROVIDERS_TSV" | tsv_find "$1")" || return 1
tsv_field "$row" "$2"
}
# An id or an alias (z.ai, z-ai) to the provider id; non-zero if neither.
provider_resolve() {
local w line
w="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
[ -n "$w" ] || return 1
while IFS= read -r line; do
[ -n "$line" ] || continue
if [ "${line%%$'\t'*}" = "$w" ]; then printf '%s\n' "$w"; return 0; fi
case ",$(tsv_field "$line" 2)," in
*",$w,"*) printf '%s\n' "${line%%$'\t'*}"; return 0 ;;
esac
done <<EOF_PROVIDERS
$CM_PROVIDERS_TSV
EOF_PROVIDERS
return 1
}
# Whether a provider's entry lists a named doctor check.
doctor_has() {
case ",$(prov_field "$1" 16)," in *",$2,"*) return 0 ;; esac
return 1
}
term_cols() {
local c=''
if command -v tput >/dev/null 2>&1; then c="$(tput cols 2>/dev/null)"; fi
@@ -367,13 +433,7 @@ term_cols() {
# The existence test is what lets a stale choice degrade instead of break.
# cm-json.py's BUILTIN_DEFAULT_PRESET and the widget's Modes.DEFAULT_PRESET
# mirror the built-in names.
builtin_default_preset() {
case "$1" in
openrouter) printf 'default' ;;
zai) printf 'zai' ;;
lmstudio) printf 'lmstudio' ;;
esac
}
builtin_default_preset() { prov_field "$1" 6; }
default_preset_for() {
local chosen
@@ -452,11 +512,11 @@ cm_probe_timeout() { cm_url_is_local "$1" && printf '4' || printf '10'; }
# The distinction matters because the remedies are opposites: `refused` means go
# and start the server, `auth` means the server is fine and the key is not.
cm_probe_server() {
local base="${1%/}" token="${2:-}" t code ep
local base="${1%/}" token="${2:-}" paths="${3:-/api/v0/models,/v1/models}" t code ep
command -v curl >/dev/null 2>&1 || { printf 'skip'; return 0; }
t="$(cm_probe_timeout "$base")"
for ep in /api/v0/models /v1/models; do
for ep in $(printf '%s' "$paths" | tr ',' ' '); do
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time "$t" \
${token:+-H "Authorization: Bearer $token"} \
"$base$ep" 2>/dev/null)"
@@ -521,6 +581,15 @@ cm_preflight() {
base="$(jget "$pf" baseUrl)"; CM_PF_BASEURL="$base"
auth_mode="$(jget "$pf" auth.mode)"; [ -z "$auth_mode" ] && auth_mode=vault
# A custom endpoint ships with no address, since there is no sensible one
# to guess; switching to it would point every session at nothing.
if [ -z "$base" ]; then
cm_pf_set 'no-url' "Preset '$preset' has no server address" \
"$(prov_field "$mode" 3) needs to know where the server is before anything can be sent to it." \
"claude-mode preset url $preset <base-url>" 'set-url'
return 1
fi
if [ "$auth_mode" = "vault" ]; then
key_ref="$(jget "$pf" auth.keyRef)"; [ -z "$key_ref" ] && key_ref=openrouter
CM_PF_KEYREF="$key_ref"
@@ -538,17 +607,21 @@ cm_preflight() {
fi
fi
# LM Studio is checked wherever it is; other providers only when they are
# pointed at this machine. A public gateway that is briefly unreachable is
# the network's problem and not worth blocking a config change over.
if [ -n "$base" ] && { [ "$mode" = "lmstudio" ] || cm_url_is_local "$base"; }; then
# A server provider is checked wherever it is (probe "always", or
# "lenient" for a proxy that may list no models); a public gateway only
# when it has been pointed at this machine (probe "local"). A public
# gateway that is briefly unreachable is the network's problem and not
# worth blocking a config change over.
local probe_rule title
probe_rule="$(prov_field "$mode" 8)"; title="$(prov_field "$mode" 3)"
if [ "$probe_rule" = always ] || [ "$probe_rule" = lenient ] || cm_url_is_local "$base"; then
local token='' probe where
if [ "$auth_mode" = "vault" ]; then
token="$(cm_vault_get "$key_ref" 2>/dev/null || true)"
else
token="$(jget "$pf" auth.token)"
fi
probe="$(cm_probe_server "$base" "$token")"
probe="$(cm_probe_server "$base" "$token" "$(prov_field "$mode" 9)")"
cm_url_is_local "$base" && where='on this machine' || where='at that address'
case "$probe" in
@@ -560,13 +633,17 @@ cm_preflight() {
"claude-mode set-key $key_ref" 'set-key'
else
cm_pf_set 'server-auth' 'The server wants an API key' \
"$base is running but is refusing an unauthenticated request. This preset is set to send LM Studio's placeholder token, which only works on a server with authentication switched off." \
"$base is running but is refusing an unauthenticated request. This preset is set to send $title's placeholder token, which only works on a server with authentication switched off." \
"claude-mode preset auth $preset key" 'needs-key'
fi
return 1 ;;
notfound)
cm_pf_set 'server-wrong' 'That address answered, but not as LM Studio' \
"Something is listening at $base, but neither /api/v0/models nor /v1/models is there. Check the port, or whether a proxy in front of it is rewriting the path." \
# A proxy in front of a Messages API often serves no model
# list at all; for one of those, something answering is
# as much as can be checked.
[ "$probe_rule" = lenient ] && return 0
cm_pf_set 'server-wrong' "That address answered, but not as $title" \
"Something is listening at $base, but $(prov_field "$mode" 9 | sed 's/,/ and /g') is not there. Check the port, or whether a proxy in front of it is rewriting the path." \
"claude-mode preset url $preset <base-url>" 'set-url'
return 1 ;;
*)
@@ -582,14 +659,11 @@ cm_preflight() {
cmd_preflight() {
local mode="${1:-}" preset="${2:-}" p
case "$mode" in
anthropic) ;;
openrouter|zai|lmstudio)
p="$(resolve_preset "$mode" "$preset" 2>/dev/null)" || p="$preset"
preset="$p" ;;
z.ai|z-ai) mode=zai; p="$(resolve_preset zai "$preset" 2>/dev/null)" || p="$preset"; preset="$p" ;;
*) err "unknown mode '$mode'"; return 1 ;;
esac
if [ "$mode" != anthropic ]; then
mode="$(provider_resolve "$mode")" || { err "unknown mode '${1:-}'"; return 1; }
p="$(resolve_preset "$mode" "$preset" 2>/dev/null)" || p="$preset"
preset="$p"
fi
if cm_preflight "$mode" "$preset"; then
"$PY" "$JSON" preflight-json ok "$mode" "$preset" '' '' '' '' '' '' ''
@@ -1046,7 +1120,8 @@ write_health() {
# nothing actionable to print for them.
show_guardrail_status() {
local mode="$1" key="$2" code
[ "$mode" = "openrouter" ] || return 0
# An OpenRouter feature, flagged per provider rather than by name.
[ "$(prov_field "$mode" 15)" = 1 ] || return 0
[ -n "$key" ] || return 0
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 25 \
@@ -1187,26 +1262,41 @@ cm_cache_catalogue() {
"$PY" "$JSON" cache-models "$CM_MODELS_CACHE" "$1" "$2" "${3:-}" >/dev/null 2>&1 || true
}
or_catalogue() {
local out rc
out="$(curl -fsS --max-time 30 https://openrouter.ai/api/v1/models 2>/dev/null | "$PY" "$JSON" or-models 2>/dev/null)"
# One catalogue fetch, for any provider: fetched according to its
# catalogue.kind, parsed to TSV, and left in the cache for the panel. Prints
# the TSV; the status says whether the fetch itself worked. TSV columns by kind:
#
# openrouter id ctx $in $out lmstudio id state ctx
# ollama id params quant family openai id
# static id note (a list kept in providers.json)
provider_catalogue() {
local pid="$1" base="${2%/}" token="${3:-}" out rc
case "$(prov_field "$pid" 10)" in
openrouter)
out="$(curl -fsS --max-time 30 https://openrouter.ai/api/v1/models 2>/dev/null \
| "$PY" "$JSON" or-models 2>/dev/null)" ;;
lmstudio)
out="$(curl -fsS --max-time 10 ${token:+-H "Authorization: Bearer $token"} \
"$base/api/v0/models" 2>/dev/null | "$PY" "$JSON" lms-models 2>/dev/null)" ;;
ollama)
out="$(curl -fsS --max-time 10 ${token:+-H "Authorization: Bearer $token"} \
"$base/api/tags" 2>/dev/null | "$PY" "$JSON" ollama-models 2>/dev/null)" ;;
openai)
# Both header styles: a proxy in front of Anthropic wants
# x-api-key, one in front of anything else wants Bearer.
out="$(curl -fsS --max-time 15 ${token:+-H "Authorization: Bearer $token"} \
${token:+-H "x-api-key: $token"} -H 'anthropic-version: 2023-06-01' \
"$base/v1/models" 2>/dev/null | "$PY" "$JSON" openai-models 2>/dev/null)" ;;
static)
out="$("$PY" "$JSON" provider-static "$pid" 2>/dev/null)" ;;
*) return 1 ;;
esac
rc=$?
cm_cache_catalogue openrouter "$([ "$rc" -eq 0 ] && echo 1 || echo 0)" <<<"$out"
cm_cache_catalogue "$pid" "$([ "$rc" -eq 0 ] && echo 1 || echo 0)" "$base" <<<"$out"
[ -n "$out" ] && printf '%s\n' "$out"
return "$rc"
}
# Z.AI publishes no catalogue endpoint, so this list comes from its docs - and
# this is the one place it is kept.
zai_catalogue() {
local out
out="$(printf '%s\t%s\n' \
glm-5.3 'flagship coding model - opus/sonnet tier' \
glm-4.7 'fast/cheap tier - haiku')"
cm_cache_catalogue zai 1 <<<"$out"
printf '%s\n' "$out"
}
# The credential a preset would send. Empty when there is nothing to send.
cm_preset_token() {
local pf="$1" am ref
@@ -1219,19 +1309,11 @@ cm_preset_token() {
fi
}
# The token is not optional decoration. An LM Studio server with authentication
# switched on answers /api/v0/models with 401 like anything else, so without it
# the catalogue comes back empty and every caller silently believes the server
# has no models installed - on exactly the setups that need the list most.
lms_catalogue() {
local base="${1%/}" token="${2:-}" out rc
out="$(curl -fsS --max-time 10 ${token:+-H "Authorization: Bearer $token"} \
"$base/api/v0/models" 2>/dev/null | "$PY" "$JSON" lms-models 2>/dev/null)"
rc=$?
cm_cache_catalogue lmstudio "$([ "$rc" -eq 0 ] && echo 1 || echo 0)" "$base" <<<"$out"
[ -n "$out" ] && printf '%s\n' "$out"
return "$rc"
}
# The token passed to provider_catalogue is not optional decoration. A local
# server with authentication switched on answers its model list with 401 like
# anything else, so without it the catalogue comes back empty and every caller
# silently believes the server has no models - on exactly the setups that need
# the list most.
# ---------------------------------------------------------------------------
# Commands
@@ -1325,18 +1407,16 @@ cmd_models() {
provider=openrouter # on anthropic, the catalogue worth browsing
fi
local quiet=$(( refresh || as_json ))
case "$provider" in
lmstudio)
[ "$quiet" -eq 1 ] || head_ "models installed in LM Studio at $base"
tsv="$(lms_catalogue "$base" "$(cm_preset_token "$pf")")"; rc=$? ;;
zai)
[ "$quiet" -eq 1 ] || head_ 'Z.AI GLM models (from Z.AI docs - no public catalogue endpoint)'
tsv="$(zai_catalogue)"; rc=$? ;;
*)
[ "$quiet" -eq 1 ] || head_ 'fetching https://openrouter.ai/api/v1/models ...'
tsv="$(or_catalogue)"; rc=$? ;;
esac
local quiet=$(( refresh || as_json )) kind title
kind="$(prov_field "$provider" 10)"; title="$(prov_field "$provider" 3)"
if [ "$quiet" -eq 0 ]; then
case "$kind" in
static) head_ "$title models (from its docs - no public catalogue endpoint)" ;;
openrouter) head_ 'fetching https://openrouter.ai/api/v1/models ...' ;;
*) head_ "models on the $title server at $base" ;;
esac
fi
tsv="$(provider_catalogue "$provider" "$base" "$(cm_preset_token "$pf")")"; rc=$?
if [ "$as_json" -eq 1 ]; then
jget "$CM_MODELS_CACHE" "providers.$provider"
@@ -1346,6 +1426,10 @@ cmd_models() {
if [ "$refresh" -eq 1 ]; then
if [ "$rc" -ne 0 ]; then
err "could not fetch the $provider catalogue${base:+ from $base}; the cached list, if any, is kept"
# Not necessarily a fault: plenty of proxies serve Messages and
# nothing else.
[ "$(prov_field "$provider" 8)" = lenient ] && \
say 'some endpoints serve no model list at all - model ids can still be typed by hand'
return 1
fi
ok "cached $(printf '%s\n' "$tsv" | grep -c .) $provider model(s)"
@@ -1357,10 +1441,12 @@ cmd_models() {
while IFS=$'\t' read -r id c2 c3 c4; do
[ -n "$id" ] || continue
[ -z "$filter" ] || case "$id" in *"$filter"*) ;; *) continue ;; esac
case "$provider" in
lmstudio) printf ' %-58s %-11s %s\n' "$id" "$c2" "$c3" ;;
zai) say "$(printf '%-8s - %s' "$id" "$c2")" ;;
*) printf ' %-52s %10s $%-8s $%s\n' "$id" "$c2" "$c3" "$c4" ;;
case "$kind" in
lmstudio) printf ' %-58s %-11s %s\n' "$id" "$c2" "$c3" ;;
static) say "$(printf '%-8s - %s' "$id" "$c2")" ;;
ollama) printf ' %-44s %-8s %s\n' "$id" "$c2" "$c3" ;;
openrouter) printf ' %-52s %10s $%-8s $%s\n' "$id" "$c2" "$c3" "$c4" ;;
*) printf ' %s\n' "$id" ;;
esac
done <<<"$tsv"
}
@@ -1399,6 +1485,106 @@ cmd_set_key() {
printf '%s' "$secret" | cm_vault_set "$ref" && ok "stored key '$ref' via $(cm_vault_backend_label)"
}
# The preset's model ids against what the provider actually offers. A server
# provider that does not answer is a failure; a hosted catalogue that cannot be
# fetched is only a warning, since the endpoint may be fine regardless.
doctor_catalogue() {
local mode="$1" pf="$2" base="$3" kind title cat t id row declared c2 c3
kind="$(prov_field "$mode" 10)"; title="$(prov_field "$mode" 3)"
declared="$(jget "$pf" contextTokens)"
cat="$(provider_catalogue "$mode" "$base" "$(cm_preset_token "$pf")")"
if [ -z "$cat" ]; then
if [ "$(prov_field "$mode" 7)" = 1 ]; then
local start; start="$(prov_field "$mode" 21)"
if [ "$(prov_field "$mode" 8)" = lenient ]; then
warn "$title at $base lists no models - fine for a proxy, but the ids below cannot be checked"
else
err "$title not reachable at $base${start:+ - $start}"
fi
else
warn "could not fetch the $title model list"
fi
return 0
fi
[ "$(prov_field "$mode" 7)" = 1 ] && ok "$title reachable at $base ($(printf '%s\n' "$cat" | grep -c .) models)"
for t in "${TIERS[@]}"; do
id="$(jget "$pf" "models.$t")"; [ -n "$id" ] || continue
row="$(printf '%s\n' "$cat" | tsv_find "$id")" || row=''
# Ollama lists every model with its tag, and a bare name means
# `:latest` - `qwen3-coder` is served as `qwen3-coder:latest`.
if [ -z "$row" ] && [ "$kind" = ollama ]; then
case "$id" in *:*) ;; *) row="$(printf '%s\n' "$cat" | tsv_find "$id:latest")" || row='' ;; esac
fi
if [ -z "$row" ]; then
# A fixed list is documentation, not the provider's word: an id
# missing from it may still be served.
if [ "$kind" = static ]; then ok "$(printf '%-6s %s (not in the documented list)' "$t" "$id")"
else err "$t model NOT available from $title: $id"; fi
continue
fi
c2="$(tsv_field "$row" 2)"; c3="$(tsv_field "$row" 3)"
case "$kind" in
openrouter) ok "$(printf '%-6s %s [ctx %s]' "$t" "$id" "$c2")"; doctor_ctx_vs "$t" "$c2" "$declared" ;;
lmstudio) ok "$(printf '%-6s %s [%s, ctx %s]' "$t" "$id" "$c2" "$c3")"; doctor_ctx_vs "$t" "$c3" "$declared" ;;
ollama) ok "$(printf '%-6s %s [%s %s]' "$t" "$id" "$c2" "$c3")" ;;
*) ok "$(printf '%-6s %s' "$t" "$id")" ;;
esac
done
[ -n "$declared" ] && ok "declared context window: $declared tokens" \
|| warn 'preset has no contextTokens - Claude Code will guess a small window and auto-compact early'
}
doctor_ctx_vs() {
local t="$1" ctx="$2" declared="$3"
{ [ -n "$declared" ] && [ -n "$ctx" ] && [ "$ctx" -lt "$declared" ]; } 2>/dev/null || return 0
if [ "$t" = haiku ]; then
warn "$t model has $ctx ctx, below the declared $declared - harmless, haiku runs short background tasks"
else
warn "$t model has $ctx ctx, below the declared $declared - this tier can overflow"
fi
}
# Ollama sets the context window on the server, not per request from Claude
# Code: 4096 tokens unless `ollama serve` runs with OLLAMA_CONTEXT_LENGTH, and
# anything past it is cut off without an error. contextTokens in a preset only
# tells Claude Code what to expect - it cannot change what the server does - so
# say what the server is actually running where that can be seen, and what to
# set where it cannot.
doctor_ollama_context() {
local pf="$1" base="$2" declared ids id max live ps seen=0 short=0 t body
declared="$(jget "$pf" contextTokens)"; [ -n "$declared" ] || return 0
ids="$(for t in "${TIERS[@]}"; do jget "$pf" "models.$t"; done | sort -u | grep . || true)"
[ -n "$ids" ] || return 0
ps="$(curl -fsS --max-time 5 "$base/api/ps" 2>/dev/null)"
while IFS= read -r id; do
[ -n "$id" ] || continue
body="$("$PY" -c 'import json,sys; print(json.dumps({"model": sys.argv[1]}))' "$id")"
max="$(curl -fsS --max-time 8 -X POST -H 'content-type: application/json' -d "$body" \
"$base/api/show" 2>/dev/null | "$PY" "$JSON" ollama-ctx show 2>/dev/null)"
if [ -n "$max" ] && [ "$max" -lt "$declared" ] 2>/dev/null; then
warn "$id supports at most $max tokens, below the declared $declared - lower contextTokens"
fi
live="$(printf '%s' "$ps" | "$PY" "$JSON" ollama-ctx ps "$id" 2>/dev/null)"
[ -n "$live" ] || continue
seen=1
if [ "$live" -lt "$declared" ] 2>/dev/null; then
short=1
warn "$id is loaded with a $live-token context, below the declared $declared - requests past it are cut off"
else
ok "$id is loaded with a $live-token context"
fi
done <<EOF_IDS
$ids
EOF_IDS
if [ "$seen" -eq 0 ]; then
warn "none of these models is loaded, so the server's context window cannot be checked"
say "Ollama defaults to 4096 tokens; run it with OLLAMA_CONTEXT_LENGTH=$declared or requests past that are cut off silently"
elif [ "$short" -eq 1 ]; then
say "restart it with OLLAMA_CONTEXT_LENGTH=$declared (or lower contextTokens to match)"
fi
}
cmd_doctor() {
local mode preset pf
mode="$(state_mode)"; preset="$(state_preset)"; pf="$(preset_path "$preset")"
@@ -1456,7 +1642,7 @@ cmd_doctor() {
else err 'apiKeyHelper produced no output'; fi
fi
if [ -n "$key" ] && [ "$mode" = "openrouter" ]; then
if [ -n "$key" ] && doctor_has "$mode" openrouter-key; then
local kinfo
kinfo="$(curl -fsS --max-time 20 -H "Authorization: Bearer $key" https://openrouter.ai/api/v1/key 2>/dev/null)"
if [ -n "$kinfo" ]; then
@@ -1474,60 +1660,24 @@ else: print(' ok spend %.2f of %.2f limit (%s), %.2f remaining' % (use, lim,
show_guardrail_status "$mode" "$key"
fi
if [ -n "$key" ] && [ "$mode" = "zai" ]; then
# No key-info endpoint: the cheapest real check is a 1-token
# message against the Anthropic-compatible surface itself.
if [ -n "$key" ] && doctor_has "$mode" message-check; then
if curl -fsS --max-time 45 -X POST "$base/v1/messages" \
-H 'content-type: application/json' -H "x-api-key: $key" \
-H "authorization: Bearer $key" -H 'anthropic-version: 2023-06-01' \
-d "{\"model\":\"$(jget "$pf" models.haiku)\",\"max_tokens\":1,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}" >/dev/null 2>&1; then
ok "Z.AI endpoint accepted the key ($base/v1/messages)"
ok "$(prov_field "$mode" 3) endpoint accepted the key ($base/v1/messages)"
else
err 'Z.AI request failed'
err "$(prov_field "$mode" 3) request failed"
fi
fi
else
ok "inline token '$(jget "$pf" auth.token)' (no secret in settings.json)"
fi
if [ "$mode" = "lmstudio" ]; then
local cat; cat="$(lms_catalogue "$base" "$(cm_preset_token "$pf")")"
if [ -n "$cat" ]; then
ok "LM Studio reachable at $base ($(printf '%s\n' "$cat" | wc -l | tr -d ' ') models installed)"
local t id row st ctx
for t in "${TIERS[@]}"; do
id="$(jget "$pf" "models.$t")"; [ -n "$id" ] || continue
row="$(printf '%s\n' "$cat" | tsv_find "$id")" || row=''
if [ -z "$row" ]; then err "$t model NOT installed in LM Studio: $id"; continue; fi
st="$(tsv_field "$row" 2)"; ctx="$(tsv_field "$row" 3)"
ok "$t $id [$st, ctx $ctx]"
done
else
err "LM Studio not reachable at $base - start the server (Developer > Start Server)"
fi
elif [ "$mode" = "openrouter" ]; then
local cat; cat="$(or_catalogue)"
if [ -n "$cat" ]; then
local t id row ctx declared
declared="$(jget "$pf" contextTokens)"
for t in "${TIERS[@]}"; do
id="$(jget "$pf" "models.$t")"; [ -n "$id" ] || continue
row="$(printf '%s\n' "$cat" | tsv_find "$id")" || row=''
if [ -z "$row" ]; then err "$t model NOT available from OpenRouter: $id"; continue; fi
ctx="$(tsv_field "$row" 2)"
ok "$(printf '%-6s %s [ctx %s]' "$t" "$id" "$ctx")"
if [ -n "$declared" ] && [ -n "$ctx" ] && [ "$ctx" -lt "$declared" ] 2>/dev/null; then
if [ "$t" = "haiku" ]; then
warn "$t model has $ctx ctx, below the declared $declared - harmless, haiku runs short background tasks"
else
warn "$t model has $ctx ctx, below the declared $declared - this tier can overflow"
fi
fi
done
[ -n "$declared" ] && ok "declared context window: $declared tokens" \
|| warn 'preset has no contextTokens - Claude Code will guess a small window and auto-compact early'
else
warn 'could not fetch the OpenRouter catalogue'
fi
fi
doctor_has "$mode" catalogue-models && doctor_catalogue "$mode" "$pf" "$base"
doctor_has "$mode" ollama-context && doctor_ollama_context "$pf" "$base"
fi
printf '\n'
@@ -1569,17 +1719,18 @@ cmd_preset() {
a="$1"; shift
case "$a" in
--provider)
[ $# -gt 0 ] || { err '--provider needs openrouter, zai or lmstudio'; return 1; }
[ $# -gt 0 ] || { err "--provider needs one of: $(provider_ids | tr '\n' ' ')"; return 1; }
provider="$1"; shift ;;
--blank) blank=1 ;;
-*) err "unknown option '$a'"; return 1 ;;
*) from="$a" ;;
esac
done
case "$provider" in
''|openrouter|zai|lmstudio) ;;
*) err "unknown provider '$provider' - openrouter, zai or lmstudio"; return 1 ;;
esac
if [ -n "$provider" ]; then
local want="$provider"
provider="$(provider_resolve "$want")" || {
err "unknown provider '$want' - one of: $(provider_ids | tr '\n' ' ')"; return 1; }
fi
if [ "$blank" -eq 1 ]; then
[ -n "$provider" ] || { err '--blank needs --provider to know which template'; return 1; }
@@ -1652,7 +1803,7 @@ cmd_preset() {
local prov="$name" target="${3:-}" p cur got
if [ -z "$prov" ]; then
head_ 'default preset per provider'
for p in openrouter zai lmstudio; do
for p in $(provider_ids); do
cur="$(jget "$CM_DEFAULTS" "$p")"
if [ -n "$cur" ] && [ -f "$(preset_path "$cur")" ]; then
printf ' %-11s %s (chosen)\n' "$p" "$cur"
@@ -1671,10 +1822,8 @@ cmd_preset() {
printf '\n %sclaude-mode preset default <provider> <name> (or --clear)%s\n' "$C_DIM" "$C_RESET"
return 0
fi
case "$prov" in
openrouter|zai|lmstudio) ;;
*) err "unknown provider '$prov' - openrouter, zai or lmstudio"; return 1 ;;
esac
prov="$(provider_resolve "$name")" || {
err "unknown provider '$name' - one of: $(provider_ids | tr '\n' ' ')"; return 1; }
if [ -z "$target" ]; then
resolve_preset "$prov" && printf '\n'
return
@@ -1722,9 +1871,12 @@ cmd_preset() {
reapply_if_active "$name"
;;
auth)
local amode="${3:-}" ref="${4:-lmstudio}"
local amode="${3:-}" ref="${4:-}"
[ -n "$name" ] && [ -n "$amode" ] || { err 'usage: claude-mode preset auth <name> none|key [keyRef]'; return 1; }
[ -f "$(preset_path "$name")" ] || { err "preset '$name' not found"; return 1; }
# The provider's own slot name by default: lmstudio, ollama, custom.
[ -n "$ref" ] || ref="$(prov_field "$(jget "$(preset_path "$name")" provider)" 17)"
[ -n "$ref" ] || ref=lmstudio
"$PY" "$JSON" set-auth "$(preset_path "$name")" "$amode" "$ref" >/dev/null || return 1
if [ "$amode" = "key" ]; then
ok "$name : auth -> vault key '$ref'"
@@ -2012,27 +2164,18 @@ ui_pick_model() {
ui_reset_items
ui_add_item '<type an id manually>' 'enter any model id by hand'
local ids=() id ctx a b st
case "$provider" in
openrouter)
while IFS=$'\t' read -r id ctx a b; do
[ -n "$id" ] || continue
ids+=("$id"); ui_add_item "$id" "context $ctx \$$a in / \$$b out per 1M"
done < <(or_catalogue)
;;
lmstudio)
while IFS=$'\t' read -r id st ctx; do
[ -n "$id" ] || continue
ids+=("$id"); ui_add_item "$id" "state: $st max context: $ctx"
done < <(lms_catalogue "$base" "$(cm_preset_token "$pf")")
;;
zai)
while IFS=$'\t' read -r id a; do
[ -n "$id" ] || continue
ids+=("$id"); ui_add_item "$id" "$a"
done < <(zai_catalogue)
;;
esac
local ids=() id ctx a b st kind
kind="$(prov_field "$provider" 10)"
while IFS=$'\t' read -r id a b st; do
[ -n "$id" ] || continue
ids+=("$id")
case "$kind" in
openrouter) ui_add_item "$id" "context $a \$$b in / \$$st out per 1M" ;;
lmstudio) ui_add_item "$id" "state: $a max context: $b" ;;
ollama) ui_add_item "$id" "$a $b" ;;
*) ui_add_item "$id" "$a" ;;
esac
done < <(provider_catalogue "$provider" "$base" "$(cm_preset_token "$pf")")
if [ "${#ids[@]}" -gt 0 ]; then
ui_filter_select "model for '$tier'" "current: $current" || return 1
@@ -2083,7 +2226,7 @@ ui_edit_preset() {
ui_new_preset() {
ui_reset_items
local provs=(openrouter zai lmstudio) p
local provs=($(provider_ids)) p
for p in "${provs[@]}"; do ui_add_item "$p" "$(mode_label "$p")"; done
ui_select 'new preset - which provider' '' || return
local provider="${provs[$UI_SEL]}"
@@ -2254,7 +2397,7 @@ setup_models() {
return 0
fi
if [ "$provider" = "lmstudio" ]; then
if [ "$(prov_field "$provider" 13)" = one-for-all ]; then
# One model for every tier is the normal shape for a local server: it
# has one loaded at a time, and mapping tiers to different models just
# means paying the load cost on every tier change.
@@ -2263,7 +2406,7 @@ setup_models() {
while IFS=$'\t' read -r id st ctx; do
[ -n "$id" ] || continue
ids+=("$id")
done < <(lms_catalogue "$base" "$(cm_preset_token "$pf")")
done < <(provider_catalogue "$provider" "$base" "$(cm_preset_token "$pf")")
if [ "${#ids[@]}" -eq 0 ]; then
warn 'the server returned no models; type an id by hand instead'
@@ -2292,40 +2435,48 @@ setup_models() {
done
}
setup_lmstudio_server() {
local pf="$1" name="$2" url probe token
url="$(jget "$pf" baseUrl)"; [ -n "$url" ] || url='http://127.0.0.1:1234'
# Where a server provider (LM Studio, Ollama, a custom endpoint) lives, and
# whether it wants a key. None of them has to be on this machine.
setup_server() {
local pf="$1" name="$2" mode="$3" url probe token title hint start
title="$(prov_field "$mode" 3)"; hint="$(prov_field "$mode" 20)"; start="$(prov_field "$mode" 21)"
url="$(jget "$pf" baseUrl)"; [ -n "$url" ] || url="$(prov_field "$mode" 19)"
printf '\n'
say 'LM Studio does not have to be on this machine - a LAN address or'
say 'anything reachable through a tunnel or proxy works just as well.'
[ -n "$hint" ] && say "$hint"
url="$(ask_value 'server base URL' "$url")"
[ -n "$url" ] || { err 'a server address is needed'; return 1; }
"$PY" "$JSON" set-url "$pf" "$url" >/dev/null || return 1
ok "baseUrl -> $url"
printf '\n'
if ask_yes 'does that server require an API key?'; then
local ref; ref="$(ask_value 'key name to store it under' 'lmstudio')"
local ref; ref="$(ask_value 'key name to store it under' "$(prov_field "$mode" 17)")"
"$PY" "$JSON" set-auth "$pf" key "$ref" >/dev/null
ok "auth -> vault key '$ref'"
cm_vault_has "$ref" || cmd_set_key "$ref"
token="$(cm_vault_get "$ref" 2>/dev/null || true)"
else
"$PY" "$JSON" set-auth "$pf" none >/dev/null
ok 'auth -> none (inline placeholder token)'
token='lmstudio'
token="$(prov_field "$mode" 18)"
ok "auth -> none (inline placeholder token '$token')"
fi
printf '\n'
say "checking $url ..."
probe="$(cm_probe_server "$url" "$token")"
probe="$(cm_probe_server "$url" "$token" "$(prov_field "$mode" 9)")"
case "$probe" in
ok) ok 'server answered' ;;
auth) err 'the server refused that credential'; return 1 ;;
notfound) err 'something answered there, but not an LM Studio API' ; return 1 ;;
notfound)
if [ "$(prov_field "$mode" 8)" = lenient ]; then
warn "something answered, but it lists no models - fine for a proxy; model ids are typed by hand"
else
err "something answered there, but not a $title API"; return 1
fi ;;
skip) warn 'curl is missing, so the server was not checked' ;;
*) err 'nothing answered at that address'
say 'start the server and run: claude-mode setup lmstudio'
say "${start:+$start, then }run: claude-mode setup $mode"
return 1 ;;
esac
return 0
@@ -2339,10 +2490,8 @@ cmd_setup() {
head_ 'setup: anthropic'
ok 'nothing to configure - it uses your existing Claude login'
return 0 ;;
openrouter|zai|lmstudio) ;;
z.ai|z-ai) mode=zai ;;
'') err 'usage: claude-mode setup <mode>'; return 1 ;;
*) err "unknown mode '$mode'"; return 1 ;;
*) mode="$(provider_resolve "$mode")" || { err "unknown mode '${1:-}'"; return 1; } ;;
esac
name="$(resolve_preset "$mode" "${2:-}")" || return 1
@@ -2357,23 +2506,19 @@ cmd_setup() {
head_ "setup: $mode / preset '$name'"
say "$(mode_label "$mode")"
case "$mode" in
openrouter)
printf '\n'
setup_key openrouter 'OpenRouter'
setup_models "$pf" openrouter "$name"
;;
zai)
printf '\n'
say 'get a key from https://z.ai/manage-apikey/apikey-list'
setup_key zai 'Z.AI'
setup_models "$pf" zai "$name"
;;
lmstudio)
setup_lmstudio_server "$pf" "$name" || return 1
setup_models "$pf" lmstudio "$name"
;;
esac
# A server provider is asked where it is and whether it wants a key; a
# hosted one always wants a key, and the entry says where to get one.
if [ "$(prov_field "$mode" 7)" = 1 ]; then
setup_server "$pf" "$name" "$mode" || return 1
else
local ref url
ref="$(jget "$pf" auth.keyRef)"; [ -n "$ref" ] || ref="$(prov_field "$mode" 17)"
url="$(prov_field "$mode" 14)"
printf '\n'
[ -n "$url" ] && say "get a key from $url"
setup_key "$ref" "$(prov_field "$mode" 3)"
fi
setup_models "$pf" "$mode" "$name"
mark_configured "$pf"
printf '\n'
@@ -2785,6 +2930,7 @@ else:
command -v "$PY" >/dev/null 2>&1 || { echo "claude-mode: $PY not found (set CLAUDE_MODE_PYTHON)" >&2; exit 1; }
init_root
cm_providers_load
# --force is global and may appear anywhere; strip it before the mode arguments
# are positional-matched below.
@@ -2803,10 +2949,6 @@ case "$cmd" in
''|menu) ui_menu ;;
status) cmd_status ;;
anthropic) set_mode anthropic ;;
openrouter|zai|lmstudio)
p="$(resolve_preset "$cmd" "${1:-}")" || exit 1
set_mode "$cmd" "$p" ;;
z.ai|z-ai) p="$(resolve_preset zai "${1:-}")" || exit 1; set_mode zai "$p" ;;
presets) cmd_presets ;;
preset) cmd_preset "$@" ;;
set-key)
@@ -2878,5 +3020,13 @@ case "$cmd" in
for a in "$@"; do [ "$a" = "--all" ] && scope=all; done
cmd_repair "$scope" ;;
help|--help|-h) usage ;;
*) err "unknown command '$cmd'"; usage; exit 1 ;;
*)
# Any provider in providers.json, by id or alias, is a mode.
# Last, so a provider can never shadow a real command.
if _prov="$(provider_resolve "$cmd")"; then
p="$(resolve_preset "$_prov" "${1:-}")" || exit 1
set_mode "$_prov" "$p"
else
err "unknown command '$cmd'"; usage; exit 1
fi ;;
esac
+252 -37
View File
@@ -20,6 +20,10 @@ Subcommands:
preset-rename <dir> <old> <new> <state> [defaults] rename, repointing state + default
auth-of <preset> "mode<TAB>keyRef"; fails if missing
set-default <defaults> <provider> [name] choose (or clear) a provider default
provider-tsv | provider-resolve <word> | provider-get <id> <path> | provider-static <id>
read providers.json
ollama-models | openai-models catalogue parsers (JSON on stdin)
ollama-ctx show | ps <model> context lengths from Ollama (stdin)
"""
import glob
@@ -63,9 +67,162 @@ BASE_MANAGED = [
TIERS = ["opus", "sonnet", "haiku", "fable"]
# Mirrors builtin_default_preset() in the CLI: what `claude-mode <provider>`
# picks when no preset is named and none has been chosen with `preset default`.
BUILTIN_DEFAULT_PRESET = {"openrouter": "default", "zai": "zai", "lmstudio": "lmstudio"}
# ---------------------------------------------------------------------------
# Providers
#
# Every gateway provider is described in providers.json - endpoint, auth, how
# its model list is fetched, how setup runs, which doctor checks apply, how it
# is drawn - so adding one that reuses an existing behaviour is an entry there
# rather than code in four places. What differs in *kind* (parsing a catalogue
# format, probing a server) stays in code, picked by name from the entry.
#
# anthropic is not in it: it is the native login, not a gateway.
#
# The file sits one level above this script in both layouts - the repository
# (linux/cm-json.py) and an install (~/.claude-mode/bin/cm-json.py) - so no
# path has to be passed around. CM_PROVIDERS overrides it, for tests.
# ---------------------------------------------------------------------------
PROVIDERS_PATH = os.environ.get("CM_PROVIDERS") or os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "providers.json")
_PROVIDERS = None
def providers():
global _PROVIDERS
if _PROVIDERS is None:
if not os.path.exists(PROVIDERS_PATH):
raise SystemExit("providers.json not found at %s - re-run the installer" % PROVIDERS_PATH)
data = load(PROVIDERS_PATH, {})
items = data.get("providers") if isinstance(data, dict) else None
_PROVIDERS = [p for p in (items or []) if isinstance(p, dict) and p.get("id")]
return _PROVIDERS
def provider(pid):
for p in providers():
if p["id"] == pid:
return p
return None
def _dig(node, dotted, default=None):
for part in dotted.split("."):
if isinstance(node, dict) and part in node:
node = node[part]
else:
return default
return node
def catalogue_kind(pid):
return _dig(provider(pid) or {}, "catalogue.kind", "")
def per_server_catalogue(pid):
"""A catalogue that belongs to one server rather than to the provider -
two LM Studio presets can point at two machines with different models."""
return bool(_dig(provider(pid) or {}, "catalogue.perServer", False))
def builtin_default_preset():
"""What `claude-mode <provider>` picks when no preset is named and none has
been chosen. Mirrored by builtin_default_preset() in the CLI."""
return {p["id"]: p.get("defaultPreset") or p["id"] for p in providers()}
# Column order of `provider-tsv`, which the shell reads once per run and looks
# rows up in (bash 3.2 on macOS has no associative arrays). Append only: the
# shell addresses these by number.
PROVIDER_TSV = [
("id", lambda p: p["id"]),
("aliases", lambda p: ",".join(p.get("aliases") or [])),
("title", lambda p: p.get("title") or p["id"]),
("label", lambda p: p.get("label") or p.get("title") or p["id"]),
("color", lambda p: p.get("color") or "gray"),
("defaultPreset", lambda p: p.get("defaultPreset") or p["id"]),
("serverEditable", lambda p: "1" if _dig(p, "server.editable") else "0"),
("probe", lambda p: _dig(p, "server.probe", "none")),
("probePaths", lambda p: ",".join(_dig(p, "server.paths", []) or [])),
("catalogueKind", lambda p: _dig(p, "catalogue.kind", "")),
("perServer", lambda p: "1" if _dig(p, "catalogue.perServer") else "0"),
("setupKey", lambda p: _dig(p, "setup.key", "required")),
("setupModels", lambda p: _dig(p, "setup.models", "per-tier")),
("keyUrl", lambda p: _dig(p, "setup.keyUrl", "")),
("guardrail", lambda p: "1" if p.get("guardrail") else "0"),
("doctor", lambda p: ",".join(p.get("doctor") or [])),
("defaultKeyRef", lambda p: _dig(p, "preset.auth.keyRef") or p["id"]),
("literalToken", lambda p: _dig(p, "preset.auth.token") or p["id"]),
("defaultBaseUrl", lambda p: _dig(p, "preset.baseUrl", "")),
("serverHint", lambda p: _dig(p, "server.hint", "")),
("serverStart", lambda p: _dig(p, "server.start", "")),
]
def cmd_provider_static(argv):
"""provider-static <id> - a fixed catalogue from providers.json as id<TAB>note."""
for m in _dig(provider(argv[0]) or {}, "catalogue.static", []) or []:
if isinstance(m, dict) and m.get("id"):
print("%s\t%s" % (m["id"], m.get("note", "")))
def cmd_ollama_ctx(argv):
"""ollama-ctx show | ps <model> (the matching Ollama response on stdin)
show: the model's own maximum, from /api/show's model_info
"<architecture>.context_length".
ps: the context a loaded model is actually running with, from /api/ps -
which is the number that matters, because Ollama sets it on the
server and cuts anything longer off without saying so.
Prints nothing when the answer is not there to read.
"""
try:
data = json.loads(sys.stdin.read() or "{}")
except ValueError:
return
if argv[0] == "show":
info = data.get("model_info") or {}
for k, v in info.items():
if k.endswith(".context_length") and isinstance(v, int):
print(v)
return
elif argv[0] == "ps" and len(argv) > 1:
# A bare name is `:latest` to Ollama, and /api/ps reports the tag.
want = {argv[1]} | ({argv[1] + ":latest"} if ":" not in argv[1] else set())
for m in data.get("models") or []:
if want & {m.get("name"), m.get("model")} and isinstance(m.get("context_length"), int):
print(m["context_length"])
return
def cmd_provider_tsv(argv):
"""provider-tsv - one tab-separated row per provider, columns as PROVIDER_TSV."""
for p in providers():
print("\t".join(str(fn(p)).replace("\t", " ") for _, fn in PROVIDER_TSV))
def cmd_provider_resolve(argv):
"""provider-resolve <word> - the provider id for an id or alias, else exit 1."""
word = (argv[0] if argv else "").strip().lower()
for p in providers():
if word == p["id"] or word in [a.lower() for a in (p.get("aliases") or [])]:
print(p["id"])
return
sys.exit(1)
def cmd_provider_get(argv):
"""provider-get <id> <dotted.path> - one value from a provider entry."""
p = provider(argv[0])
if p is None:
sys.exit(1)
val = _dig(p, argv[1])
if isinstance(val, (dict, list)):
print(json.dumps(val))
elif isinstance(val, bool):
print("true" if val else "false")
elif val is not None:
print(val)
def load(path, default=None):
@@ -229,31 +386,20 @@ def cmd_set_tier(argv):
def cmd_scaffold(argv):
provider = argv[0]
base = {"provider": provider, "description": "new preset"}
if provider == "openrouter":
base["baseUrl"] = "https://openrouter.ai/api"
base["auth"] = {"mode": "vault", "keyRef": "openrouter"}
elif provider == "zai":
base["baseUrl"] = "https://api.z.ai/api/anthropic"
base["auth"] = {"mode": "vault", "keyRef": "zai"}
elif provider == "lmstudio":
base["baseUrl"] = "http://127.0.0.1:1234"
base["auth"] = {"mode": "literal", "token": "lmstudio"}
else:
raise SystemExit("unknown provider '%s'" % provider)
"""scaffold <provider> - a blank preset built from the provider's template."""
p = provider(argv[0])
if p is None:
raise SystemExit("unknown provider '%s'" % argv[0])
tpl = p.get("preset") or {}
base = {"provider": p["id"], "description": "new preset"}
base["baseUrl"] = tpl.get("baseUrl", "")
base["auth"] = dict(tpl.get("auth") or {"mode": "vault", "keyRef": p["id"]})
base["models"] = {t: "" for t in TIERS}
base["subagentModel"] = "inherit"
base["gatewayModelDiscovery"] = provider == "openrouter"
base["contextTokens"] = 262144 if provider == "lmstudio" else 1000000
if provider == "lmstudio":
base["extraEnv"] = {"CLAUDE_CODE_ATTRIBUTION_HEADER": "0"}
if provider == "zai":
base["extraEnv"] = {
"API_TIMEOUT_MS": "3000000",
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
}
base["gatewayModelDiscovery"] = bool(tpl.get("gatewayModelDiscovery"))
base["contextTokens"] = int(tpl.get("contextTokens") or 200000)
if tpl.get("extraEnv"):
base["extraEnv"] = dict(tpl["extraEnv"])
print(json.dumps(base, indent=2))
@@ -416,6 +562,8 @@ def cmd_cache_models(argv):
"""
path, provider, ok = argv[0], argv[1], argv[2] == "1"
base = argv[3].rstrip("/") if len(argv) > 3 else ""
kind = catalogue_kind(provider)
per_server = per_server_catalogue(provider)
def num(s, cast):
try:
@@ -423,6 +571,8 @@ def cmd_cache_models(argv):
except (TypeError, ValueError):
return None
# The TSV columns are whatever that kind's parser prints (or-models,
# lms-models, ollama-models, openai-models, or a static list's id/note).
rows = []
for line in sys.stdin.read().splitlines():
parts = line.split("\t")
@@ -430,17 +580,20 @@ def cmd_cache_models(argv):
if not mid:
continue
m = {"id": mid}
if provider == "openrouter":
if kind == "openrouter":
ctx, pin, pout = (parts[1:] + ["", "", ""])[:3]
m["contextTokens"] = num(ctx, int)
m["priceIn"] = num(pin, float)
m["priceOut"] = num(pout, float)
elif provider == "lmstudio":
elif kind == "lmstudio":
state, ctx = (parts[1:] + ["", ""])[:2]
m["state"] = state
m["contextTokens"] = num(ctx, int)
else:
m["note"] = parts[1] if len(parts) > 1 else ""
elif kind == "ollama":
params, quant = (parts[1:] + ["", ""])[:2]
m["note"] = " ".join(x for x in (params, quant) if x and x != "-")
elif len(parts) > 1 and parts[1]:
m["note"] = parts[1]
rows.append(m)
try:
@@ -458,11 +611,11 @@ def cmd_cache_models(argv):
__import__("datetime").timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
if ok:
node = {"fetchedAt": now, "ok": True, "models": rows}
if provider == "lmstudio":
if per_server:
node["baseUrl"] = base
else:
# A list from a different LM Studio server is not stale, it is wrong.
if provider == "lmstudio" and node.get("baseUrl", "") != base:
# A list from a different server is not stale, it is wrong.
if per_server and node.get("baseUrl", "") != base:
node = {"baseUrl": base, "models": []}
node["ok"] = False
node["failedAt"] = now
@@ -479,6 +632,35 @@ def cmd_lms_models(argv):
print("%s\t%s\t%s" % (m.get("id", ""), m.get("state", "unknown"), m.get("max_context_length", "")))
def cmd_ollama_models(argv):
"""Ollama /api/tags on stdin -> 'id<TAB>parameters<TAB>quantization<TAB>family'.
The id is `name` (e.g. qwen3-coder:30b), which is what the Anthropic
endpoint accepts as a model. /api/tags carries no context length - that is
a server setting (OLLAMA_CONTEXT_LENGTH), not a property of the model file.
"""
data = json.loads(sys.stdin.read())
for m in sorted(data.get("models") or [], key=lambda x: x.get("name", "")):
d = m.get("details") or {}
# "-" rather than empty: bash's `read` with a tab IFS merges empty
# fields, which would slide the later columns left.
print("%s\t%s\t%s\t%s" % (m.get("name") or m.get("model", ""), d.get("parameter_size") or "-",
d.get("quantization_level") or "-", d.get("family") or "-"))
def cmd_openai_models(argv):
"""A `GET /v1/models` list on stdin -> one id per line.
Covers both the OpenAI shape and Anthropic's own ({"data": [{"id": ...}]}),
which is what a proxy in front of either tends to serve.
"""
data = json.loads(sys.stdin.read())
items = data.get("data") if isinstance(data, dict) else data
for m in sorted((items or []), key=lambda x: (x or {}).get("id", "")):
if isinstance(m, dict) and m.get("id"):
print(m["id"])
# A model id carrying a bracket suffix - e.g. `claude-fable-5[1m]` - is Claude
# Code's extended-context marker. It belongs to Anthropic's 1M models and no
# gateway recognises it. A session that had it can carry the tag onto a new id
@@ -586,7 +768,8 @@ def cmd_health(argv):
"schema": 1,
"tool": "claude-mode",
"version": version or "0.0.0",
"updatedAt": __import__("datetime").datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"),
"updatedAt": __import__("datetime").datetime.now(
__import__("datetime").timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"os": "posix",
"mode": mode,
"preset": "" if mode == "anthropic" else preset,
@@ -662,7 +845,7 @@ def cmd_health(argv):
if not isinstance(chosen_raw, dict):
chosen_raw = {}
effective, chosen = {}, {}
for prov, builtin in BUILTIN_DEFAULT_PRESET.items():
for prov, builtin in builtin_default_preset().items():
pick = chosen_raw.get(prov)
if pick and pick in names:
effective[prov] = chosen[prov] = pick
@@ -674,6 +857,25 @@ def cmd_health(argv):
h["defaultPresetFor"] = effective
h["defaultPresetChosen"] = chosen
# The providers as the widget needs them, in menu order: how to draw each
# one and which panel features apply. With this published, a provider
# added to providers.json shows up in the bar without touching its QML -
# or restarting the shell, which a change to Modes.js would need.
h["providers"] = [{
"id": p["id"],
"title": p.get("title") or p["id"],
"blurb": p.get("blurb", ""),
"logo": p.get("logo", ""),
"logoScale": float(p.get("logoScale") or 1.0),
"glyph": p.get("glyph", ""),
"serverEditable": bool(_dig(p, "server.editable")),
"serverHint": _dig(p, "server.hint", ""),
"defaultBaseUrl": _dig(p, "preset.baseUrl", ""),
"perServerCatalogue": bool(_dig(p, "catalogue.perServer")),
"defaultKeyRef": _dig(p, "preset.auth.keyRef") or p["id"],
"keyOptional": _dig(p, "setup.key", "required") != "required",
} for p in providers()]
save(os.path.join(root, "health.json"), h)
@@ -760,10 +962,16 @@ def cmd_set_auth(argv):
never sees it either way.
"""
path, mode = argv[0], argv[1]
key_ref = argv[2] if len(argv) > 2 and argv[2] else "lmstudio"
p = load(path)
# The provider's own names, not LM Studio's: an Ollama preset switched to
# "none" sends Ollama's placeholder, and one switched to "key" defaults to
# a vault slot named after its provider.
pid = p.get("provider", "")
key_ref = argv[2] if len(argv) > 2 and argv[2] else (
_dig(provider(pid) or {}, "preset.auth.keyRef") or pid or "lmstudio")
if mode == "none":
p["auth"] = {"mode": "literal", "token": "lmstudio"}
token = _dig(provider(pid) or {}, "preset.auth.token") or pid or "lmstudio"
p["auth"] = {"mode": "literal", "token": token}
elif mode == "key":
p["auth"] = {"mode": "vault", "keyRef": key_ref}
else:
@@ -1279,6 +1487,13 @@ COMMANDS = {
"strip-tags": cmd_strip_tags,
"or-models": cmd_or_models,
"lms-models": cmd_lms_models,
"ollama-models": cmd_ollama_models,
"openai-models": cmd_openai_models,
"provider-tsv": cmd_provider_tsv,
"provider-resolve": cmd_provider_resolve,
"provider-get": cmd_provider_get,
"provider-static": cmd_provider_static,
"ollama-ctx": cmd_ollama_ctx,
"cache-models": cmd_cache_models,
"apply": cmd_apply,
"summary": cmd_summary,
+14
View File
@@ -82,6 +82,20 @@ if [ -f "$VERSION_SRC" ]; then
green "version $(tr -d '[:space:]' < "$ROOT/VERSION")"
fi
# --- providers (shared with the Windows build) -----------------------------
# Always replaced, unlike presets: it is the tool's own table of what each
# provider is, not something a user edits. cm-json.py looks for it one level
# above bin/.
PROVIDERS_SRC="$SRC/../providers.json"
[ -f "$PROVIDERS_SRC" ] || PROVIDERS_SRC="$SRC/providers.json"
if [ -f "$PROVIDERS_SRC" ]; then
install -m 0644 "$PROVIDERS_SRC" "$ROOT/providers.json"
green "providers: $("$PY" -c 'import json,sys; print(", ".join(p["id"] for p in json.load(open(sys.argv[1]))["providers"]))' "$ROOT/providers.json")"
else
fail 'providers.json missing from the payload - claude-mode cannot run without it'
exit 1
fi
# --- presets (shared with the Windows build) -------------------------------
PRESET_SRC="$SRC/../presets"
[ -d "$PRESET_SRC" ] || PRESET_SRC="$SRC/presets"