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
+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,