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:
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user