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:
+252
-37
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user