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