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
+36 -4
View File
@@ -42,6 +42,38 @@ BarWidget {
}
readonly property bool known: mode !== ""
// ---- Providers
//
// claude-mode publishes its providers in health.json - how to draw each one
// and which panel features it has - so one added to providers.json appears
// here without this file or Modes.js changing (and Modes.js cannot change
// without a shell restart). Modes.js stays the fallback: for anthropic,
// which is the native login and never in the list, and for a health.json
// written before the list existed. The panel asks through these, too.
readonly property var providerList: health && health.providers ? health.providers : []
readonly property var modeOrder: {
if (providerList.length === 0) return Modes.ORDER
var out = ["anthropic"]
for (var i = 0; i < providerList.length; i++) out.push(String(providerList[i].id))
return out
}
function providerInfo(m) {
for (var i = 0; i < providerList.length; i++) {
if (String(providerList[i].id) === m) return providerList[i]
}
return null
}
function modeTitle(m) { var p = providerInfo(m); return p && p.title ? String(p.title) : Modes.title(m) }
function modeBlurb(m) { var p = providerInfo(m); return p ? String(p.blurb || "") : Modes.blurb(m) }
function modeLogo(m) { var p = providerInfo(m); return p ? String(p.logo || "") : Modes.logo(m) }
function modeLogoScale(m) { var p = providerInfo(m); return p && p.logoScale ? Number(p.logoScale) : Modes.logoScale(m) }
function modeGlyph(m) {
var p = providerInfo(m)
if (p && p.glyph) return String.fromCodePoint(parseInt(String(p.glyph), 16))
return Modes.glyph(m)
}
// Measured against the neighbours: every stock bar glyph in this shell paints
// 11px of ink from a 13px font. These marks fill their box rather than
// carrying a font's internal padding, so the box itself has to be the smaller
@@ -54,8 +86,8 @@ BarWidget {
var n = Math.round(Number(root.setting("iconSize", Style.bar.iconFont)))
return n % 2 === 0 ? n + 1 : n
}
readonly property string logo: Modes.logo(mode)
readonly property string glyph: Modes.glyph(mode)
readonly property string logo: modeLogo(mode)
readonly property string glyph: modeGlyph(mode)
readonly property string label: Modes.shortLabel(mode, preset)
// A gateway mode is spending money or leaning on a local server; native
@@ -226,7 +258,7 @@ BarWidget {
id: icon
anchors.fill: parent
pathData: root.logo
opticalScale: Modes.logoScale(root.mode)
opticalScale: root.modeLogoScale(root.mode)
color: root.activeColor
iconSize: root.iconPx
}
@@ -282,7 +314,7 @@ BarWidget {
function tooltipLabel() {
if (!root.known) return "claude-mode: not installed"
var lines = [Modes.title(root.mode)]
var lines = [root.modeTitle(root.mode)]
if (root.preset !== "") lines.push("preset " + root.preset)
var models = root.health && root.health.models ? root.health.models : null
if (models && models.opus) lines.push("opus " + models.opus)
+7 -4
View File
@@ -1,9 +1,12 @@
.pragma library
// The four modes claude-mode understands, in the order the CLI menu lists
// them. Kept here rather than in either QML file because the bar widget needs
// the glyph and the panel needs the prose, and a second copy of this table is
// exactly the sort of thing that drifts.
// Fallback presentation only. claude-mode publishes its providers - titles,
// blurbs, logos, scales, order - in health.json from providers.json, and the
// widget reads them from there (BarWidget.providerInfo). What remains here is
// anthropic, which is the native login and never in that list, and the
// original three gateways for a health.json written before the list existed.
// Nothing new belongs in this file: it is a .pragma library, cached until the
// shell restarts.
// Material Design icons from the Nerd Font patch set, written as codepoints
// rather than literals so they survive any editor or transport that is not
+63 -37
View File
@@ -72,18 +72,37 @@ Panel {
function cli(args) { return [root.cmRoot + "/bin/claude-mode"].concat(args) }
function parseJson(t) { try { return JSON.parse(String(t || "")) } catch (e) { return null } }
// ---- Server settings (LM Studio)
// ---- Providers, as the widget publishes them
//
// LM Studio ships listening on loopback, but it does not have to be there: it
// can be another machine on the LAN, or something reached through a tunnel or
// a reverse proxy, and it can have authentication switched on. All three are
// The widget owns the lookup, and the fallback to Modes.js for anthropic and
// for an older health.json. This side only asks, so nothing here needs to
// know which providers exist.
readonly property var modeOrder: widget ? widget.modeOrder : Modes.ORDER
function pInfo(m) { return widget ? widget.providerInfo(m) : null }
function pTitle(m) { return widget ? widget.modeTitle(m) : "" }
function pBlurb(m) { return widget ? widget.modeBlurb(m) : "" }
function pLogo(m) { return widget ? widget.modeLogo(m) : "" }
function pLogoScale(m) { return widget ? widget.modeLogoScale(m) : 1.0 }
function pGlyph(m) { return widget ? widget.modeGlyph(m) : "" }
// Before providers were published only LM Studio had either.
function serverEditable(m) { var p = root.pInfo(m); return p ? p.serverEditable === true : m === "lmstudio" }
function perServerCatalogue(m) { var p = root.pInfo(m); return p ? p.perServerCatalogue === true : m === "lmstudio" }
// ---- Server settings
//
// A server provider (LM Studio, Ollama, a custom endpoint) ships pointed at
// its usual local address, but it does not have to be there: it can be
// another machine on the LAN, or something reached through a tunnel or a
// reverse proxy, and it can have authentication switched on. All of that is
// just a baseUrl and an auth block in the preset, so this edits those two
// rather than pretending the local default is the only shape.
property string serverPreset: ""
property string serverProvider: ""
property string serverUrl: ""
property bool serverNeedsKey: false
property string serverKeyRef: "lmstudio"
readonly property string serverLocalDefault: "http://127.0.0.1:1234"
property string serverKeyRef: ""
property string serverDefault: "" // the provider's usual address; "" for custom
property string serverHint: ""
function presetEntry(name) {
var all = (health && health.presets) ? health.presets : []
@@ -99,9 +118,15 @@ Panel {
var e = root.presetEntry(presetName)
root.serverReturn = from || ""
root.serverPreset = presetName
root.serverUrl = e && e.baseUrl ? String(e.baseUrl) : root.serverLocalDefault
root.serverProvider = e ? String(e.provider) : ""
var p = root.pInfo(root.serverProvider)
root.serverDefault = p ? String(p.defaultBaseUrl || "") : "http://127.0.0.1:1234"
root.serverHint = p && p.serverHint ? String(p.serverHint)
: "The server does not have to be on this machine. Point this at a LAN address, or anything reachable through a tunnel or proxy."
root.serverUrl = e && e.baseUrl ? String(e.baseUrl) : root.serverDefault
root.serverNeedsKey = !!(e && String(e.authMode) === "vault")
root.serverKeyRef = e && e.keyRef ? String(e.keyRef) : "lmstudio"
root.serverKeyRef = e && e.keyRef ? String(e.keyRef)
: (p && p.defaultKeyRef ? String(p.defaultKeyRef) : (root.serverProvider || "lmstudio"))
root.blocker = null
root.stage = "server"
}
@@ -408,7 +433,7 @@ Panel {
var all = widget && widget.modelsCache && widget.modelsCache.providers ? widget.modelsCache.providers : null
var n = all && root.editProvider !== "" ? all[root.editProvider] : null
if (!n) return null
if (root.editProvider === "lmstudio") {
if (root.perServerCatalogue(root.editProvider)) {
var want = root.editEntry && root.editEntry.baseUrl ? String(root.editEntry.baseUrl).replace(/\/+$/, "") : ""
if (String(n.baseUrl || "") !== want) return null
}
@@ -797,17 +822,17 @@ Panel {
BrandIcon {
anchors.centerIn: parent
visible: Modes.logo(root.mode) !== ""
pathData: Modes.logo(root.mode)
opticalScale: Modes.logoScale(root.mode)
visible: root.pLogo(root.mode) !== ""
pathData: root.pLogo(root.mode)
opticalScale: root.pLogoScale(root.mode)
color: heroGlyph.tint
iconSize: Style.font.display
}
Text {
anchors.centerIn: parent
visible: Modes.logo(root.mode) === ""
text: Modes.glyph(root.mode)
visible: root.pLogo(root.mode) === ""
text: root.pGlyph(root.mode)
color: heroGlyph.tint
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.font.display
@@ -822,7 +847,7 @@ Panel {
Text {
width: parent.width
elide: Text.ElideRight
text: Modes.title(root.mode)
text: root.pTitle(root.mode)
color: Color.popups.text
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(17)
@@ -831,7 +856,7 @@ Panel {
Text {
width: parent.width
elide: Text.ElideRight
text: root.preset !== "" ? "preset · " + root.preset : Modes.blurb(root.mode)
text: root.preset !== "" ? "preset · " + root.preset : root.pBlurb(root.mode)
color: Color.muted
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(11)
@@ -857,7 +882,7 @@ Panel {
spacing: Style.space(1)
Repeater {
model: root.stage === "list" ? Modes.ORDER : []
model: root.stage === "list" ? root.modeOrder : []
Column {
id: modeEntry
@@ -890,8 +915,8 @@ Panel {
anchors.left: parent.left
anchors.leftMargin: Style.space(3)
anchors.verticalCenter: parent.verticalCenter
pathData: Modes.logo(modeEntry.thisMode)
opticalScale: Modes.logoScale(modeEntry.thisMode)
pathData: root.pLogo(modeEntry.thisMode)
opticalScale: root.pLogoScale(modeEntry.thisMode)
color: modeEntry.isCurrent ? Color.accent : Color.popups.text
iconSize: Style.space(16)
}
@@ -905,7 +930,7 @@ Panel {
spacing: 0
Text {
text: Modes.title(modeEntry.thisMode)
text: root.pTitle(modeEntry.thisMode)
color: Color.popups.text
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.font.body
@@ -914,7 +939,7 @@ Panel {
Text {
width: parent.width
text: Modes.blurb(modeEntry.thisMode)
text: root.pBlurb(modeEntry.thisMode)
color: Color.muted
elide: Text.ElideRight
font.family: root.bar ? root.bar.fontFamily : Style.font.family
@@ -1080,7 +1105,7 @@ Panel {
anchors.left: parent.left
anchors.leftMargin: Style.space(30)
anchors.verticalCenter: parent.verticalCenter
text: "New " + Modes.title(modeEntry.thisMode) + " preset…"
text: "New " + root.pTitle(modeEntry.thisMode) + " preset…"
color: newPresetArea.containsMouse ? Color.accent : Color.muted
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(11)
@@ -1146,7 +1171,7 @@ Panel {
spacing: Style.space(7)
PillButton {
label: "Set up " + Modes.title(root.pendingMode) + "…"
label: "Set up " + root.pTitle(root.pendingMode) + "…"
primary: true
visible: root.blocker && String(root.blocker.remedyKind) === "setup"
onTriggered: root.runSetup()
@@ -1172,7 +1197,7 @@ Panel {
PillButton {
label: "Server settings…"
primary: root.blocker && ["start-server", "set-url", "needs-key"].indexOf(String(root.blocker.remedyKind)) >= 0
visible: root.pendingMode === "lmstudio"
visible: root.serverEditable(root.pendingMode)
&& !(root.blocker && String(root.blocker.remedyKind) === "setup")
onTriggered: root.openServerSettings(root.pendingPreset)
}
@@ -1187,7 +1212,7 @@ Panel {
}
// ---- Where the LM Studio server is, and whether it needs a key.
// ---- Where a server provider is, and whether it needs a key.
Column {
width: parent.width
visible: root.stage === "server"
@@ -1205,8 +1230,7 @@ Panel {
Text {
width: parent.width
text: "LM Studio does not have to be on this machine. Point this at a "
+ "LAN address, or anything reachable through a tunnel or proxy."
text: root.serverHint
color: Color.muted
wrapMode: Text.WordWrap
lineHeight: 1.2
@@ -1218,7 +1242,7 @@ Panel {
id: urlField
width: parent.width
text: root.serverUrl
placeholderText: root.serverLocalDefault
placeholderText: root.serverDefault !== "" ? root.serverDefault : "https://…"
foreground: Color.popups.text
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(11)
@@ -1232,9 +1256,11 @@ Panel {
width: parent.width
spacing: Style.space(7)
// A custom endpoint has no usual address to go back to.
PillButton {
label: "Use local default"
onTriggered: { root.serverUrl = root.serverLocalDefault; urlField.text = root.serverLocalDefault }
visible: root.serverDefault !== ""
onTriggered: { root.serverUrl = root.serverDefault; urlField.text = root.serverDefault }
}
}
@@ -1271,7 +1297,7 @@ Panel {
width: parent.width
text: root.serverNeedsKey
? "Kept in the vault as '" + root.serverKeyRef + "', never in settings.json."
: "Sends LM Studio's placeholder token, which is not a secret."
: "Sends " + root.pTitle(root.serverProvider) + "'s placeholder token, which is not a secret."
color: Color.muted
wrapMode: Text.WordWrap
font.family: root.bar ? root.bar.fontFamily : Style.font.family
@@ -1317,7 +1343,7 @@ Panel {
Text {
width: parent.width
text: "'" + root.editPreset + "' · " + Modes.title(root.editProvider)
text: "'" + root.editPreset + "' · " + root.pTitle(root.editProvider)
color: Color.popups.text
elide: Text.ElideRight
font.family: root.bar ? root.bar.fontFamily : Style.font.family
@@ -1434,7 +1460,7 @@ Panel {
PillButton {
label: "Server settings…"
visible: root.editProvider === "lmstudio"
visible: root.serverEditable(root.editProvider)
onTriggered: root.openServerSettings(root.editPreset, "preset")
}
PillButton { label: "Make default"; visible: !root.editIsDefault; onTriggered: root.setDefault(true) }
@@ -1470,7 +1496,7 @@ Panel {
Text {
width: parent.width
text: "New " + Modes.title(root.newProvider) + " preset"
text: "New " + root.pTitle(root.newProvider) + " preset"
color: Color.popups.text
elide: Text.ElideRight
font.family: root.bar ? root.bar.fontFamily : Style.font.family
@@ -1649,8 +1675,8 @@ Panel {
Text {
width: parent.width
visible: parent.lastOne
text: "It is the only " + Modes.title(root.editProvider) + " preset, so "
+ Modes.title(root.editProvider) + " will have nothing to switch to until you create another."
text: "It is the only " + root.pTitle(root.editProvider) + " preset, so "
+ root.pTitle(root.editProvider) + " will have nothing to switch to until you create another."
color: root.urgentColor
wrapMode: Text.WordWrap
font.family: root.bar ? root.bar.fontFamily : Style.font.family
@@ -1826,7 +1852,7 @@ Panel {
visible: root.modelOptions.length === 0
text: root.catalogueNode && root.catalogueNode.ok === false
? "The last fetch failed and no list is cached. Type an id, or try fetching again."
: "No model list cached for " + Modes.title(root.editProvider) + " yet. Type an id, or fetch the list."
: "No model list cached for " + root.pTitle(root.editProvider) + " yet. Type an id, or fetch the list."
color: Color.muted
wrapMode: Text.WordWrap
font.family: root.bar ? root.bar.fontFamily : Style.font.family
@@ -1892,7 +1918,7 @@ Panel {
// they fail rather than carry on.
Text {
width: parent.width
text: "They keep pointing at " + Modes.title(root.mode) + ", but their key is "
text: "They keep pointing at " + root.pTitle(root.mode) + ", but their key is "
+ "re-fetched on a timer and will switch under them. Worse, if one takes a "
+ "reply from the new provider first, that provider's message-id format "
+ "goes into its transcript and Anthropic will refuse to resume it at all."
+1 -1
View File
@@ -2,7 +2,7 @@
"schemaVersion": 1,
"id": "smoido.claude-mode",
"name": "Claude Mode",
"version": "1.11.0",
"version": "1.12.0",
"author": "smoido",
"description": "Which provider Claude Code is pointed at, and a one-click switch between them",
"kinds": ["bar-widget"],