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
+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."