From b5824c6611172f4d098f465d944eb67065b22890 Mon Sep 17 00:00:00 2001 From: smoido Date: Tue, 15 Sep 2026 01:20:33 +0300 Subject: [PATCH] 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) --- README.md | 108 ++++- VERSION | 2 +- claude-mode.ps1 | 549 ++++++++++++++-------- install.ps1 | 8 +- linux/claude-mode | 554 ++++++++++++++--------- linux/cm-json.py | 289 ++++++++++-- linux/install.sh | 14 + omarchy/smoido.claude-mode/BarWidget.qml | 40 +- omarchy/smoido.claude-mode/Modes.js | 11 +- omarchy/smoido.claude-mode/Panel.qml | 100 ++-- omarchy/smoido.claude-mode/manifest.json | 2 +- presets/custom.json | 19 + presets/ollama.json | 22 + providers.json | 240 ++++++++++ scripts/build-package.ps1 | 4 +- 15 files changed, 1482 insertions(+), 480 deletions(-) create mode 100644 presets/custom.json create mode 100644 presets/ollama.json create mode 100644 providers.json diff --git a/README.md b/README.md index 499e63d..bd19f7c 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # claude-mode -Switch Claude Code system-wide between **Anthropic**, **OpenRouter**, **Z.AI**, and a -local **LM Studio** server — with named per-tier model presets. +Switch Claude Code system-wide between **Anthropic**, **OpenRouter**, **Z.AI**, a +local **LM Studio** or **Ollama** server, and any **custom** Anthropic-compatible +endpoint — with named per-tier model presets. One write to `~/.claude/settings.json` covers the CLI, the VS Code extension, and the desktop app. Restart Claude Code afterwards — nothing else. Windows @@ -14,12 +15,16 @@ claude-mode anthropic # subscription login claude-mode openrouter # remote gateway (preset: default) claude-mode zai # Z.AI GLM coding plan (preset: zai) claude-mode lmstudio # local server (preset: lmstudio) +claude-mode ollama # local server (preset: ollama) +claude-mode custom # your own endpoint (preset: custom) ``` **Contents:** [Install](#install) · [First run](#first-run) · [The interactive menu](#the-interactive-menu) · [Commands](#commands) · [Presets](#presets-shipped) · [Context windows](#context-windows-and-early-auto-compaction) · [Z.AI](#zai-mode) · -[LM Studio](#lm-studio-mode) · [Live sessions](#live-sessions) · +[LM Studio](#lm-studio-mode) · [Ollama](#ollama-mode) · [Custom +endpoints](#custom-endpoints) · [Adding a provider](#adding-a-provider) · +[Live sessions](#live-sessions) · [Troubleshooting](#troubleshooting) · [Design decisions](#design-decisions) · [Layout](#layout) · [Linux / Omarchy](#linux--omarchy) · [The bar widget](#the-omarchy-bar-widget) · [Uninstall](#uninstall) @@ -177,7 +182,9 @@ claude-mode status active mode, preset, model map claude-mode anthropic native login (deletes all managed keys) claude-mode openrouter [preset] default preset: default claude-mode zai [preset] default preset: zai (alias: z.ai, z-ai) -claude-mode lmstudio [preset] default preset: lmstudio +claude-mode lmstudio [preset] default preset: lmstudio (alias: lm-studio) +claude-mode ollama [preset] default preset: ollama +claude-mode custom [preset] default preset: custom claude-mode presets list presets (* = active) claude-mode preset show @@ -401,6 +408,98 @@ like every other credential — a real key on a public address is a real key. Nothing here changes the shipped presets unless you ask it to; the local default stays exactly as it was. +## Ollama mode + +Ollama serves Anthropic's Messages API itself, at `/v1/messages`, so nothing sits +in between. `claude-mode setup ollama` asks where the server is (it ships on +`http://127.0.0.1:11434`), whether it needs a key, and picks one model for every +tier from what the server has pulled. One model for all four is the right shape +for a local server: it holds one in memory at a time, and mapping tiers to +different models just means paying the load cost on every tier change. The token +is a placeholder, `ollama`, which Ollama requires but does not check. + +### The context window is set on the server + +This is the part that bites. Ollama sizes the context on the server, not per +request: **4096 tokens** unless `ollama serve` runs with `OLLAMA_CONTEXT_LENGTH`, +and anything past it is cut off without an error. Claude Code's system prompt +alone is most of that. Ollama recommends 64k or more for Claude Code, and the +shipped preset declares 65,536 — but `contextTokens` only tells Claude Code what +to expect. It cannot change what the server does. + +```bash +OLLAMA_CONTEXT_LENGTH=65536 ollama serve +# or, for the systemd service: systemctl edit ollama +# [Service] +# Environment=OLLAMA_CONTEXT_LENGTH=65536 +``` + +`claude-mode doctor` reads what each loaded model is actually running with +(`/api/ps`) and the model's own maximum (`/api/show`), and says so when either is +below the preset. When nothing is loaded it cannot see the server's setting, and +says that instead of guessing. + +Also worth knowing: + +- A bare name like `qwen3-coder` is `qwen3-coder:latest` to Ollama; both work. +- Ollama does not implement `count_tokens` or prompt caching, so long sessions + redo more work than they would on a hosted provider. Nothing breaks. +- To reach it from another machine, serve it with `OLLAMA_HOST=0.0.0.0` and + point the preset there: `claude-mode preset url ollama http://192.168.1.40:11434`. + +## Custom endpoints + +For anything else that speaks Anthropic's Messages API: a LiteLLM or Vercel +gateway, vLLM, llama.cpp's server, a company proxy. The `custom` preset ships +with no address — there is no sensible one to guess — and a switch to it is +refused until it has one. + +```bash +claude-mode setup custom # asks for all of the below +# or by hand: +claude-mode preset url custom https://llm.example.com +claude-mode preset auth custom key custom # the key lives in the vault as 'custom' +claude-mode set-key custom +claude-mode preset set custom opus # ...and each other tier +``` + +Several endpoints are several presets, each with its own address and key name: +`claude-mode preset new work --provider custom --blank`, then +`claude-mode preset auth work key work`. + +The model list comes from `/v1/models` when the endpoint has one (OpenAI's shape +or Anthropic's). Plenty of proxies serve Messages and nothing else. That is fine: +the check before a switch only wants something to answer, and model ids can be +typed by hand. The cost guard still applies. A custom endpoint in front of +Anthropic's own models needs `"allowAnthropicModels": true` in the preset, which +keeps it deliberate. + +## Adding a provider + +Every gateway provider is an entry in [`providers.json`](providers.json), installed +next to the presets and read by the CLI, the bar widget (through `health.json`) +and the Windows build. Anthropic is not in it: it is the native login, not a +gateway. An entry holds: + +| field | what it says | +|---|---| +| `id`, `aliases`, `title`, `label`, `blurb`, `color` | names and how the CLI, menu and panel show it | +| `defaultPreset`, `preset` | the preset `claude-mode ` picks, and the template for a blank one (URL, auth, context, extra env) | +| `server` | whether the address is editable, how it is probed before a switch (`always`, `lenient` for proxies, `local` only when it is on this machine), setup hint | +| `catalogue.kind` | how its model list is read: `openrouter`, `lmstudio`, `ollama`, `openai` (`/v1/models`) or `static` (a list kept in the entry) | +| `setup` | whether a key is required or optional, where to get one, and one model for every tier or one per tier | +| `doctor` | which checks apply: `catalogue-models`, `openrouter-key`, `guardrail`, `message-check`, `ollama-context`, `lmstudio-templates` (Windows) | +| `logo`, `logoScale` | a single-path 24×24 SVG mark, and its optical size correction | + +A provider that reuses those kinds is an entry plus a default preset in +`presets/`, and no code. A hosted Anthropic-compatible coding plan — Kimi, +MiniMax, DeepSeek — is an entry shaped like `zai`, with its own URL and a +`static` or `openai` catalogue. Only a genuinely new kind of behaviour, such as +a catalogue format none of the parsers read, needs code: a parser in +`cm-json.py`, and a branch in `provider_catalogue` (bash) and +`Get-ProviderCatalogue` (Windows). The bar widget picks new providers up from +`health.json` — no QML change, and no shell restart. + ## Live sessions ### Why a switch breaks a running session @@ -719,6 +818,7 @@ Windows: ``` ~/.claude-mode/ claude-mode.ps1 main script + providers.json what each gateway provider is (replaced on every install) state.json mode, active preset, and the exact env keys last written presets/*.json provider + model maps vault/*.cred DPAPI-encrypted keys (openrouter, zai, ...) diff --git a/VERSION b/VERSION index 1cac385..0eed1a2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.11.0 +1.12.0 diff --git a/claude-mode.ps1 b/claude-mode.ps1 index 9a24636..261c158 100644 --- a/claude-mode.ps1 +++ b/claude-mode.ps1 @@ -92,22 +92,60 @@ $script:BaseManagedEnvKeys = @( $script:Tiers = @('opus', 'sonnet', 'haiku', 'fable') $script:Version = '0.0.0' try { $__v = Join-Path $PSScriptRoot 'VERSION'; if (Test-Path $__v) { $script:Version = (Get-Content $__v -Raw).Trim() } } catch { } -$script:Modes = @('anthropic', 'openrouter', 'zai', 'lmstudio') $script:NodeExe = $null # resolved lazily by Format-JsonPretty -$script:ModeLabel = @{ - 'anthropic' = 'Anthropic - your subscription login, no gateway' - 'openrouter' = 'OpenRouter - remote, pay-per-token, any vendor' - 'zai' = 'Z.AI - GLM coding plan' - 'lmstudio' = 'LM Studio - local server, offline, free' +# --------------------------------------------------------------------------- +# Providers +# +# Every gateway provider is an entry in providers.json, next to this script and +# shared with the POSIX build: endpoint, auth, how its model list is fetched, +# which doctor checks apply. This script only knows the *kinds* of behaviour +# (Get-ProviderCatalogue, Test-PresetCatalogue) and picks one by name from the +# entry, so a provider that reuses them needs no change here. anthropic is +# built in: it is the native login, not a gateway. +# --------------------------------------------------------------------------- + +$script:ProvidersPath = Join-Path $PSScriptRoot 'providers.json' +$script:Providers = @() +try { + $__pj = Get-Content -LiteralPath $script:ProvidersPath -Raw -Encoding UTF8 | ConvertFrom-Json + $script:Providers = @($__pj.providers | Where-Object { $_.id }) +} catch { } +if ($script:Providers.Count -eq 0) { + Write-Host " FAIL providers.json missing or unreadable at $($script:ProvidersPath) - re-run install.ps1" -ForegroundColor Red + exit 1 } +$script:Modes = @('anthropic') + @($script:Providers | ForEach-Object { [string]$_.id }) +$script:ModeLabel = @{ 'anthropic' = 'Anthropic - your subscription login, no gateway' } + # `claude-mode ` with no preset named uses this one. Deliberately a # fixed choice rather than "most recently used", so the command is predictable. -$script:ProviderDefaultPreset = @{ - 'openrouter' = 'default' - 'zai' = 'zai' - 'lmstudio' = 'lmstudio' +$script:ProviderDefaultPreset = @{} +foreach ($__p in $script:Providers) { + $script:ModeLabel[[string]$__p.id] = [string]$__p.label + $script:ProviderDefaultPreset[[string]$__p.id] = $(if ($__p.defaultPreset) { [string]$__p.defaultPreset } else { [string]$__p.id }) +} + +function Get-Provider { + param([string] $Id) + return ($script:Providers | Where-Object { $_.id -eq $Id } | Select-Object -First 1) +} + +# An id or an alias (z.ai, z-ai) to the provider id; $null if neither. +function Resolve-ProviderId { + param([string] $Word) + $w = ([string]$Word).ToLower() + foreach ($p in $script:Providers) { + if ([string]$p.id -eq $w -or (@($p.aliases) -contains $w)) { return [string]$p.id } + } + return $null +} + +function Test-ProviderDoctor { + param([string] $Id, [string] $Check) + $p = Get-Provider $Id + return [bool]($p -and (@($p.doctor) -contains $Check)) } # --------------------------------------------------------------------------- @@ -121,11 +159,12 @@ function Write-Head ($m) { Write-Host ""; Write-Host $m -ForegroundColor Cyan } # Each mode gets an identity colour, reused for its menu row, its banner tagline # and its status line - so "which mode am I in" is answerable at a glance. -$script:ModeColor = @{ - 'anthropic' = 'Magenta' - 'openrouter' = 'Cyan' - 'zai' = 'Green' - 'lmstudio' = 'Yellow' +$script:ModeColor = @{ 'anthropic' = 'Magenta' } +$__colors = @{ cyan = 'Cyan'; green = 'Green'; yellow = 'Yellow'; magenta = 'Magenta' + white = 'White'; gray = 'Gray'; dkcyan = 'DarkCyan'; red = 'Red' } +foreach ($__p in $script:Providers) { + $__c = $__colors[[string]$__p.color] + $script:ModeColor[[string]$__p.id] = $(if ($__c) { $__c } else { 'Gray' }) } # Deliberately ASCII-only. This file is read by Windows PowerShell 5.1, which @@ -162,15 +201,19 @@ function Show-Banner { function Show-Usage { @' -claude-mode - switch Claude Code between Anthropic, OpenRouter, Z.AI, LM Studio +claude-mode - switch Claude Code between Anthropic and gateway providers claude-mode interactive menu claude-mode status active mode, preset, model map claude-mode anthropic native login/subscription (clears all gateway config) - claude-mode openrouter [preset] remote gateway (default preset: default) - claude-mode zai [preset] Z.AI GLM coding plan (default preset: zai) - claude-mode lmstudio [preset] local LM Studio (default preset: lmstudio) +'@ | Write-Host + # One line per provider in providers.json, so a new one documents itself. + foreach ($p in $script:Providers) { + $desc = ([string]$p.label) -replace '^[^-]*-\s*', '' + Write-Host (" claude-mode {0,-26} {1} (default preset: {2})" -f "$($p.id) [preset]", $desc, $script:ProviderDefaultPreset[[string]$p.id]) + } + @' claude-mode presets list presets claude-mode preset show @@ -474,7 +517,7 @@ function Clear-ManagedSettings { function Set-ClaudeMode { param( - [ValidateSet('anthropic', 'openrouter', 'zai', 'lmstudio')] [string] $Mode, + [string] $Mode, [string] $PresetName ) @@ -483,6 +526,9 @@ function Set-ClaudeMode { New-Item -ItemType Directory -Path $script:SettingsDir -Force | Out-Null } + # Any provider in providers.json, rather than a fixed ValidateSet. + if ($Mode -ne 'anthropic' -and -not (Get-Provider $Mode)) { throw "unknown mode '$Mode'" } + $settings = Read-JsonFile $script:Settings if ($null -eq $settings) { $settings = [ordered]@{} } @@ -500,6 +546,21 @@ function Set-ClaudeMode { $models = $preset['models'] if ($null -eq $models) { throw "preset '$PresetName' has no 'models' block" } + # A custom endpoint ships with no address, since there is no sensible + # one to guess. Switching to it would point every session at nothing. + if ([string]::IsNullOrWhiteSpace([string]$preset['baseUrl'])) { + throw "preset '$PresetName' has no server address - set baseUrl in $(Get-PresetPath $PresetName)" + } + + # Every tier empty - a fresh blank preset - would switch cleanly and + # leave Claude Code asking for its own default Anthropic models: billed + # at full price through OpenRouter, refused by the other providers. + $anyTier = $false + foreach ($tier in $script:Tiers) { if (-not [string]::IsNullOrWhiteSpace([string]$models[$tier])) { $anyTier = $true } } + if (-not $anyTier) { + throw "preset '$PresetName' has no models set - set a tier first: claude-mode preset set $PresetName " + } + # Cost guard. Gateways resell Anthropic models at full list price, with no # subscription discount - routing a tier there is almost never intended # and is expensive enough to be worth blocking outright. Opt in per @@ -566,7 +627,9 @@ function Set-ClaudeMode { } $settings['apiKeyHelper'] = Get-HelperCommandLine } else { - $tok = 'lmstudio' + # The provider's own placeholder (lmstudio, ollama), not LM Studio's. + $pv = Get-Provider ([string]$preset['provider']) + $tok = $(if ($pv -and $pv.preset.auth.token) { [string]$pv.preset.auth.token } else { 'lmstudio' }) if ($auth.Contains('token') -and $auth['token']) { $tok = [string]$auth['token'] } $envBlock['ANTHROPIC_AUTH_TOKEN'] = $tok } @@ -666,7 +729,9 @@ function Test-OpenRouterGuardrail { # nothing actionable to print for them. function Show-GuardrailStatus { param([string] $Mode, [string] $Key) - if ($Mode -ne 'openrouter' -or -not $Key) { return } + # An OpenRouter feature, flagged per provider rather than by name. + $pv = Get-Provider $Mode + if (-not ($pv -and $pv.guardrail) -or -not $Key) { return } $label = ' guardrail ' $state = Test-OpenRouterGuardrail -Key $Key @@ -1054,6 +1119,202 @@ function Test-LmStudioTemplate { return $rep } +# The credential a preset would send: its vault key, or its inline token. +function Get-PresetToken { + param($Preset) + $auth = Get-PresetAuth $Preset + if ([string]$auth['mode'] -eq 'vault') { + $ref = $(if ($auth.Contains('keyRef') -and $auth['keyRef']) { [string]$auth['keyRef'] } else { 'openrouter' }) + return (Get-VaultKey $ref) + } + return [string]$auth['token'] +} + +# One catalogue fetch for any provider, by its catalogue.kind in providers.json. +# Every row comes back in one shape - Id, Ctx, State, InM, OutM, Note - so the +# callers (models, the picker, doctor) never branch on the provider itself. +function Get-ProviderCatalogue { + param($Preset) + $prov = Get-Provider ([string]$Preset['provider']) + if (-not $prov) { return @() } + $base = ([string]$Preset['baseUrl']).TrimEnd('/') + switch ([string]$prov.catalogue.kind) { + 'openrouter' { + return @((Invoke-RestMethod -Uri 'https://openrouter.ai/api/v1/models' -TimeoutSec 30).data | ForEach-Object { + [pscustomobject]@{ + Id = $_.id; Ctx = $_.context_length; State = $null; Note = '' + InM = $(if ($_.pricing -and $_.pricing.prompt) { [math]::Round([double]$_.pricing.prompt * 1e6, 3) } else { $null }) + OutM = $(if ($_.pricing -and $_.pricing.completion) { [math]::Round([double]$_.pricing.completion * 1e6, 3) } else { $null }) + } + }) + } + 'lmstudio' { + return @(Get-LmStudioModels $base | ForEach-Object { + [pscustomobject]@{ Id = $_.Id; Ctx = $_.Ctx; State = $_.State; InM = $null; OutM = $null; Note = '' } + }) + } + 'ollama' { + $h = @{} + $tok = Get-PresetToken $Preset + if ($tok) { $h['Authorization'] = "Bearer $tok" } + return @((Invoke-RestMethod -Uri "$base/api/tags" -Headers $h -TimeoutSec 10).models | ForEach-Object { + $d = $_.details + $note = $(if ($d) { (@($d.parameter_size, $d.quantization_level) | Where-Object { $_ }) -join ' ' } else { '' }) + [pscustomobject]@{ Id = $_.name; Ctx = $null; State = $null; InM = $null; OutM = $null; Note = $note } + }) + } + 'openai' { + # Both header styles: a proxy in front of Anthropic wants x-api-key, + # one in front of anything else wants Bearer. + $h = @{ 'anthropic-version' = '2023-06-01' } + $tok = Get-PresetToken $Preset + if ($tok) { $h['Authorization'] = "Bearer $tok"; $h['x-api-key'] = $tok } + return @((Invoke-RestMethod -Uri "$base/v1/models" -Headers $h -TimeoutSec 15).data | ForEach-Object { + [pscustomobject]@{ Id = $_.id; Ctx = $null; State = $null; InM = $null; OutM = $null; Note = '' } + }) + } + 'static' { + return @($prov.catalogue.static | ForEach-Object { + [pscustomobject]@{ Id = $_.id; Ctx = $null; State = $null; InM = $null; OutM = $null; Note = [string]$_.note } + }) + } + } + return @() +} + +# The preset's model ids against what the provider actually offers. A server +# provider that does not answer is a failure; a hosted catalogue that cannot be +# fetched is only a warning, since the endpoint may be fine regardless. +function Test-PresetCatalogue { + param([string] $Mode, $Preset) + $prov = Get-Provider $Mode + $kind = [string]$prov.catalogue.kind + $title = [string]$prov.title + $base = ([string]$Preset['baseUrl']).TrimEnd('/') + $server = [bool]$prov.server.editable + + $cat = @() + try { $cat = @(Get-ProviderCatalogue $Preset) } catch { $cat = @() } + if ($cat.Count -eq 0) { + if ($server -and [string]$prov.server.probe -eq 'lenient') { + Write-Warn2 "$title at $base lists no models - fine for a proxy, but the ids below cannot be checked" + } elseif ($server) { + $start = [string]$prov.server.start + Write-Err2 ("$title not reachable at $base" + $(if ($start) { " - $start" } else { '' })) + } else { + Write-Warn2 "could not fetch the $title model list" + } + return + } + if ($server) { Write-Ok "$title reachable at $base ($($cat.Count) models)" } + + $declared = $(if ($Preset.Contains('contextTokens') -and $Preset['contextTokens']) { [int]$Preset['contextTokens'] } else { 0 }) + $tpl = $(if (Test-ProviderDoctor $Mode 'lmstudio-templates') { Get-LmStudioTemplateReport } else { @() }) + $warned = @{} # a one-for-all preset names one model four times; say things about it once + + foreach ($t in $script:Tiers) { + if (-not ($Preset['models'].Contains($t) -and $Preset['models'][$t])) { continue } + $id = [string]$Preset['models'][$t] + $m = $cat | Where-Object { $_.Id -eq $id } | Select-Object -First 1 + # Ollama lists every model with its tag; a bare name means :latest. + if (-not $m -and $kind -eq 'ollama' -and $id -notlike '*:*') { + $m = $cat | Where-Object { $_.Id -eq "${id}:latest" } | Select-Object -First 1 + } + if (-not $m) { + # A fixed list is documentation, not the provider's word. + if ($kind -eq 'static') { Write-Ok ("{0,-6} {1} (not in the documented list)" -f $t, $id) } + else { Write-Err2 "$t model NOT available from ${title}: $id" } + continue + } + switch ($kind) { + 'lmstudio' { + if ($m.State -eq 'loaded') { Write-Ok ("{0,-6} {1} [loaded, ctx {2}]" -f $t, $id, $m.Ctx) } + else { Write-Ok ("{0,-6} {1} [{2} - LM Studio will JIT-load it on first request, ctx {3}]" -f $t, $id, $m.State, $m.Ctx) } + if (-not $warned.ContainsKey($id)) { + $warned[$id] = $true + if ($m.Ctx -and [int]$m.Ctx -lt 25000) { Write-Warn2 "$id context is $($m.Ctx); LM Studio recommends >25k for Claude Code." } + if ($declared -and $m.Ctx -and [int]$m.Ctx -lt $declared) { + Write-Warn2 ("declared contextTokens {0:N0} exceeds {1}'s {2:N0} - lower it." -f $declared, $id, [int]$m.Ctx) + } + $short = ($id -split '/')[-1].ToLower() + $risk = $tpl | Where-Object { $_.Key -eq $short } | Select-Object -First 1 + if ($risk -and $risk.Assertions.Count -gt 0) { + Write-Warn2 "$id chat template hard-asserts message order ($($risk.Assertions -join '; '))." + Write-Warn2 " This can surface as: [Server Error] 'Unable to generate parser for this template'." + Write-Warn2 " If you hit that, switch to a model without this flag - see 'claude-mode models'." + } + } + } + 'openrouter' { Write-Ok ("{0,-6} {1} [ctx {2:N0}]" -f $t, $id, [int]$m.Ctx) } + 'ollama' { Write-Ok ("{0,-6} {1} [{2}]" -f $t, $id, $m.Note) } + default { Write-Ok ("{0,-6} {1}" -f $t, $id) } + } + } + + if ($kind -eq 'openrouter') { Test-ContextWindow -Preset $Preset -Catalogue $cat } + elseif ($declared) { Write-Ok ("declared context window: {0:N0} tokens" -f $declared) } + else { Write-Warn2 'preset has no contextTokens - Claude Code will guess a small window and auto-compact early.' } +} + +# Ollama sets the context window on the server, not per request from Claude +# Code: 4096 tokens unless `ollama serve` runs with OLLAMA_CONTEXT_LENGTH, and +# anything past it is cut off without an error. contextTokens only tells Claude +# Code what to expect, so say what the server is actually running where that +# can be seen, and what to set where it cannot. +function Test-OllamaContext { + param($Preset) + if (-not ($Preset.Contains('contextTokens') -and $Preset['contextTokens'])) { return } + $declared = [int]$Preset['contextTokens'] + $base = ([string]$Preset['baseUrl']).TrimEnd('/') + $ids = @() + foreach ($t in $script:Tiers) { + $v = [string]$Preset['models'][$t] + if ($v -and $ids -notcontains $v) { $ids += $v } + } + $ps = $null + try { $ps = Invoke-RestMethod -Uri "$base/api/ps" -TimeoutSec 5 } catch { } + + $seen = $false; $short = $false + foreach ($id in $ids) { + try { + $show = Invoke-RestMethod -Uri "$base/api/show" -Method Post -ContentType 'application/json' ` + -Body (@{ model = $id } | ConvertTo-Json) -TimeoutSec 8 + $max = $null + if ($show.model_info) { + foreach ($prop in $show.model_info.PSObject.Properties) { + if ($prop.Name -like '*.context_length') { $max = [int]$prop.Value; break } + } + } + if ($max -and $max -lt $declared) { Write-Warn2 "$id supports at most $max tokens, below the declared $declared - lower contextTokens" } + } catch { } + + $want = @($id) + if ($id -notlike '*:*') { $want += "${id}:latest" } + $live = $null + if ($ps -and $ps.models) { + foreach ($m in $ps.models) { + if ((($want -contains [string]$m.name) -or ($want -contains [string]$m.model)) -and $m.context_length) { + $live = [int]$m.context_length; break + } + } + } + if ($null -eq $live) { continue } + $seen = $true + if ($live -lt $declared) { + $short = $true + Write-Warn2 "$id is loaded with a $live-token context, below the declared $declared - requests past it are cut off" + } else { + Write-Ok "$id is loaded with a $live-token context" + } + } + if (-not $seen) { + Write-Warn2 "none of these models is loaded, so the server's context window cannot be checked" + Write-Host " Ollama defaults to 4096 tokens; run it with OLLAMA_CONTEXT_LENGTH=$declared or requests past that are cut off silently" + } elseif ($short) { + Write-Host " restart it with OLLAMA_CONTEXT_LENGTH=$declared (or lower contextTokens to match)" + } +} + # CLAUDE_CODE_MAX_CONTEXT_TOKENS is a single global value, but each tier can # point at a model with a different window. Declaring more context than a tier's # model actually has means requests on that tier can overflow, so name the @@ -1073,10 +1334,10 @@ function Test-ContextWindow { foreach ($t in $script:Tiers) { if (-not $Preset['models'].Contains($t)) { continue } $id = [string]$Preset['models'][$t] - $m = $Catalogue | Where-Object { $_.id -eq $id } | Select-Object -First 1 - if (-not $m) { continue } - if ([int]$m.context_length -lt $declared) { - Write-Warn2 ("{0} model {1} only has {2:N0} ctx, below the declared {3:N0}." -f $t, $id, $m.context_length, $declared) + $m = $Catalogue | Where-Object { $_.Id -eq $id } | Select-Object -First 1 + if (-not $m -or -not $m.Ctx) { continue } + if ([int]$m.Ctx -lt $declared) { + Write-Warn2 ("{0} model {1} only has {2:N0} ctx, below the declared {3:N0}." -f $t, $id, [int]$m.Ctx, $declared) if ($t -eq 'haiku') { Write-Warn2 ' haiku only runs short background tasks, so this is usually harmless.' } else { @@ -1251,45 +1512,37 @@ function Invoke-Models { $state = Get-State $mode = [string]$state['mode'] - if ($mode -eq 'lmstudio') { - $preset = Get-Preset ([string]$state['preset']) - Write-Head "models installed in LM Studio at $($preset['baseUrl'])" - $tpl = Get-LmStudioTemplateReport - Get-LmStudioModels ([string]$preset['baseUrl']) | - Where-Object { -not $Filter -or $_.Id -like "*$Filter*" } | - Sort-Object Id | - ForEach-Object { - $short = ($_.Id -split '/')[-1].ToLower() - $t = $tpl | Where-Object { $_.Key -eq $short } | Select-Object -First 1 - $flag = '' - if ($t -and $t.Assertions.Count -gt 0) { $flag = 'TEMPLATE RISK' } - [pscustomobject]@{ Id = $_.Id; State = $_.State; Ctx = $_.Ctx; Note = $flag } - } | Format-Table -AutoSize - Write-Host " 'TEMPLATE RISK' = the model's chat template hard-asserts message order," - Write-Host " which can break tool-call parser generation. Prefer an unflagged model." - return + # On anthropic, OpenRouter's catalogue is the one worth browsing, as before. + $preset = $(if ($mode -ne 'anthropic') { Get-Preset ([string]$state['preset']) } else { [ordered]@{ provider = 'openrouter'; baseUrl = '' } }) + $prov = Get-Provider ([string]$preset['provider']) + $kind = [string]$prov.catalogue.kind + switch ($kind) { + 'static' { Write-Head "$($prov.title) models (from its docs - no public catalogue endpoint)" } + 'openrouter' { Write-Head 'fetching https://openrouter.ai/api/v1/models ...' } + default { Write-Head "models on the $($prov.title) server at $($preset['baseUrl'])" } } - if ($mode -eq 'zai') { - Write-Head 'Z.AI GLM models (from Z.AI docs - no public catalogue endpoint)' - @('glm-5.3 - flagship coding model (opus/sonnet tier)', - 'glm-4.7 - fast/cheap tier (haiku tier)') | ForEach-Object { Write-Host " $_" } - Write-Host ' Full list: https://docs.z.ai/devpack/tool/claude' - return - } - - Write-Head 'fetching https://openrouter.ai/api/v1/models ...' - $resp = Invoke-RestMethod -Uri 'https://openrouter.ai/api/v1/models' -Method Get -TimeoutSec 30 - $rows = foreach ($m in $resp.data) { - if ($Filter -and $m.id -notlike "*$Filter*") { continue } - [pscustomobject]@{ - Id = $m.id - Ctx = $m.context_length - 'In/M$' = if ($m.pricing -and $m.pricing.prompt) { [math]::Round([double]$m.pricing.prompt * 1e6, 3) } else { $null } - 'Out/M$' = if ($m.pricing -and $m.pricing.completion) { [math]::Round([double]$m.pricing.completion * 1e6, 3) } else { $null } + $tpl = $(if ($kind -eq 'lmstudio') { Get-LmStudioTemplateReport } else { @() }) + $rows = foreach ($m in (Get-ProviderCatalogue $preset | Sort-Object Id)) { + if ($Filter -and $m.Id -notlike "*$Filter*") { continue } + if ($kind -eq 'openrouter') { + [pscustomobject]@{ Id = $m.Id; Ctx = $m.Ctx; 'In/M$' = $m.InM; 'Out/M$' = $m.OutM } + } elseif ($kind -eq 'lmstudio') { + $short = ($m.Id -split '/')[-1].ToLower() + $t = $tpl | Where-Object { $_.Key -eq $short } | Select-Object -First 1 + $flag = $(if ($t -and $t.Assertions.Count -gt 0) { 'TEMPLATE RISK' } else { '' }) + [pscustomobject]@{ Id = $m.Id; State = $m.State; Ctx = $m.Ctx; Note = $flag } + } else { + [pscustomobject]@{ Id = $m.Id; Note = $m.Note } } } - $rows | Sort-Object Id | Format-Table -AutoSize + $rows | Format-Table -AutoSize + + if ($kind -eq 'lmstudio') { + Write-Host " 'TEMPLATE RISK' = the model's chat template hard-asserts message order," + Write-Host " which can break tool-call parser generation. Prefer an unflagged model." + } + if ($kind -eq 'static' -and $prov.catalogue.docs) { Write-Host " Full list: $($prov.catalogue.docs)" } } function Invoke-Doctor { @@ -1363,7 +1616,7 @@ function Invoke-Doctor { } } - if ($key -and $mode -eq 'openrouter') { + if ($key -and (Test-ProviderDoctor $mode 'openrouter-key')) { try { $r = Invoke-RestMethod -Uri 'https://openrouter.ai/api/v1/key' -Headers @{ Authorization = "Bearer $key" } -TimeoutSec 20 Write-Ok "OpenRouter key valid (label: $($r.data.label))" @@ -1382,7 +1635,7 @@ function Invoke-Doctor { Show-GuardrailStatus -Mode $mode -Key $key } - if ($key -and $mode -eq 'zai') { + if ($key -and (Test-ProviderDoctor $mode 'message-check')) { # No key-info endpoint; the cheapest real check is a 1-token # message against the Anthropic-compatible surface. try { @@ -1394,80 +1647,20 @@ function Invoke-Doctor { [void](Invoke-RestMethod -Uri "$base/v1/messages" -Method Post -Body $body ` -ContentType 'application/json' -TimeoutSec 45 ` -Headers @{ 'x-api-key' = $key; 'Authorization' = "Bearer $key"; 'anthropic-version' = '2023-06-01' }) - Write-Ok "Z.AI endpoint accepted the key ($base/v1/messages)" + Write-Ok "$((Get-Provider $mode).title) endpoint accepted the key ($base/v1/messages)" } catch { $detail = '' if ($_.ErrorDetails) { $detail = ($_.ErrorDetails.Message -replace '\s+', ' ') } - Write-Err2 "Z.AI request failed: $($_.Exception.Message) $detail" + Write-Err2 "$((Get-Provider $mode).title) request failed: $($_.Exception.Message) $detail" } } } else { Write-Ok "inline token '$($auth['token'])' (no secret in settings.json)" } - if ($mode -eq 'lmstudio') { - $catalogue = $null - try { - $catalogue = Get-LmStudioModels $base - Write-Ok "LM Studio server reachable at $base ($($catalogue.Count) models installed)" - } catch { - Write-Err2 "LM Studio not reachable at $base - start the server (LM Studio > Developer > Start Server)" - } - - if ($catalogue) { - $wanted = @() - foreach ($t in $script:Tiers) { - if ($preset['models'].Contains($t)) { $wanted += [string]$preset['models'][$t] } - } - if ($preset.Contains('subagentModel')) { $wanted += [string]$preset['subagentModel'] } - - foreach ($id in ($wanted | Sort-Object -Unique)) { - $m = $catalogue | Where-Object { $_.Id -eq $id } | Select-Object -First 1 - if (-not $m) { - Write-Err2 "model NOT installed in LM Studio: $id (run 'claude-mode models' for valid ids)" - continue - } - if ($m.State -eq 'loaded') { Write-Ok "$id [loaded, ctx $($m.Ctx)]" } - else { Write-Ok "$id [$($m.State) - LM Studio will JIT-load it on first request, ctx $($m.Ctx)]" } - - if ($m.Ctx -and [int]$m.Ctx -lt 25000) { - Write-Warn2 "$id context is $($m.Ctx); LM Studio recommends >25k for Claude Code." - } - - if ($preset.Contains('contextTokens') -and $preset['contextTokens'] -and $m.Ctx) { - $declared = [int]$preset['contextTokens'] - if ([int]$m.Ctx -lt $declared) { - Write-Warn2 ("declared contextTokens {0:N0} exceeds {1}'s {2:N0} - lower it." -f $declared, $id, $m.Ctx) - } else { - Write-Ok ("declared context window: {0:N0} tokens" -f $declared) - } - } elseif (-not $preset.Contains('contextTokens')) { - Write-Warn2 'preset has no contextTokens - Claude Code will guess a small window and auto-compact early.' - } - - $tpl = Test-LmStudioTemplate $id - if ($tpl -and $tpl.Assertions.Count -gt 0) { - Write-Warn2 "$id chat template hard-asserts message order ($($tpl.Assertions -join '; '))." - Write-Warn2 " This can surface as: [Server Error] 'Unable to generate parser for this template'." - Write-Warn2 " If you hit that, switch to a model without this flag - see 'claude-mode models'." - } - } - } - } elseif ($mode -eq 'openrouter') { - try { - $cat = (Invoke-RestMethod -Uri 'https://openrouter.ai/api/v1/models' -TimeoutSec 30).data - foreach ($t in $script:Tiers) { - if (-not $preset['models'].Contains($t)) { continue } - $id = [string]$preset['models'][$t] - $m = $cat | Where-Object { $_.id -eq $id } | Select-Object -First 1 - if ($m) { Write-Ok ("{0,-6} {1} [ctx {2:N0}]" -f $t, $id, $m.context_length) } - else { Write-Err2 "$t model NOT available from this provider: $id" } - } - Test-ContextWindow -Preset $preset -Catalogue $cat - } catch { - Write-Warn2 "could not verify model ids: $($_.Exception.Message)" - } - } + # Which checks run is the provider's call, by name, in providers.json. + if (Test-ProviderDoctor $mode 'catalogue-models') { Test-PresetCatalogue -Mode $mode -Preset $preset } + if (Test-ProviderDoctor $mode 'ollama-context') { Test-OllamaContext -Preset $preset } } Write-Host '' @@ -1878,37 +2071,26 @@ function Get-ModelChoices { $cacheKey = $provider + '|' + [string]$Preset['baseUrl'] if ($script:ModelCatalogueCache.ContainsKey($cacheKey)) { return $script:ModelCatalogueCache[$cacheKey] } - $out = @() + $out = @() + $kind = [string](Get-Provider $provider).catalogue.kind + $tpl = $(if ($kind -eq 'lmstudio') { Get-LmStudioTemplateReport } else { @() }) - if ($provider -eq 'lmstudio') { - $models = @(Get-LmStudioModels ([string]$Preset['baseUrl'])) - $tpl = Get-LmStudioTemplateReport - foreach ($m in ($models | Sort-Object Id)) { + foreach ($m in (Get-ProviderCatalogue $Preset | Sort-Object Id)) { + $flag = '' + if ($kind -eq 'openrouter') { + $det = @(("context {0:N0} `$$($m.InM) in / `$$($m.OutM) out per 1M tokens" -f [int]$m.Ctx)) + } elseif ($kind -eq 'lmstudio') { + $det = @("state: $($m.State) max context: $($m.Ctx)") $short = ($m.Id -split '/')[-1].ToLower() $risk = $tpl | Where-Object { $_.Key -eq $short } | Select-Object -First 1 - $flag = '' - $det = @("state: $($m.State) max context: $($m.Ctx)") if ($risk -and $risk.Assertions.Count -gt 0) { $flag = ' [TEMPLATE RISK]' $det += 'chat template asserts message order - can break tool calls' } - $out += [pscustomobject]@{ Id = $m.Id; Key = $m.Id; Label = ($m.Id + $flag); Detail = $det } + } else { + $det = @([string]$m.Note) } - } - elseif ($provider -eq 'openrouter') { - $cat = (Invoke-RestMethod -Uri 'https://openrouter.ai/api/v1/models' -TimeoutSec 30).data - foreach ($m in ($cat | Sort-Object id)) { - $inp = if ($m.pricing -and $m.pricing.prompt) { [math]::Round([double]$m.pricing.prompt * 1e6, 3) } else { $null } - $outp= if ($m.pricing -and $m.pricing.completion) { [math]::Round([double]$m.pricing.completion * 1e6, 3) } else { $null } - $det = @(("context {0:N0} `$$inp in / `$$outp out per 1M tokens" -f [int]$m.context_length)) - $out += [pscustomobject]@{ Id = $m.id; Key = $m.id; Label = $m.id; Detail = $det } - } - } - elseif ($provider -eq 'zai') { - # Z.AI publishes no public catalogue endpoint; these are the documented - # coding-plan models. Anything else can still be typed manually. - $out += [pscustomobject]@{ Id = 'glm-5.3'; Key = 'glm-5.3'; Label = 'glm-5.3'; Detail = @('flagship coding model - opus/sonnet tier') } - $out += [pscustomobject]@{ Id = 'glm-4.7'; Key = 'glm-4.7'; Label = 'glm-4.7'; Detail = @('fast/cheap tier - haiku') } + $out += [pscustomobject]@{ Id = $m.Id; Key = $m.Id; Label = ($m.Id + $flag); Detail = $det } } $script:ModelCatalogueCache[$cacheKey] = $out @@ -1951,36 +2133,30 @@ function Read-ModelId { function New-PresetScaffold { param([string] $Provider) + # Built from the provider's own template in providers.json; the POSIX + # build's `cm-json.py scaffold` produces the same thing from the same file. + $p = Get-Provider $Provider + if (-not $p) { throw "unknown provider '$Provider'" } + $tpl = $p.preset + $base = [ordered]@{ provider = $Provider description = 'new preset' } - - switch ($Provider) { - 'openrouter' { - $base['baseUrl'] = 'https://openrouter.ai/api' - $base['auth'] = [ordered]@{ mode = 'vault'; keyRef = 'openrouter' } - } - 'zai' { - $base['baseUrl'] = 'https://api.z.ai/api/anthropic' - $base['auth'] = [ordered]@{ mode = 'vault'; keyRef = 'zai' } - } - 'lmstudio' { - $base['baseUrl'] = 'http://127.0.0.1:1234' - $base['auth'] = [ordered]@{ mode = 'literal'; token = 'lmstudio' } - } - } + $base['baseUrl'] = [string]$tpl.baseUrl + $auth = [ordered]@{} + if ($tpl.auth) { foreach ($prop in $tpl.auth.PSObject.Properties) { $auth[$prop.Name] = $prop.Value } } + else { $auth['mode'] = 'vault'; $auth['keyRef'] = $Provider } + $base['auth'] = $auth $base['models'] = [ordered]@{ opus = ''; sonnet = ''; haiku = ''; fable = '' } $base['subagentModel'] = 'inherit' - $base['gatewayModelDiscovery'] = ($Provider -eq 'openrouter') - $base['contextTokens'] = if ($Provider -eq 'lmstudio') { 262144 } else { 1000000 } - if ($Provider -eq 'lmstudio') { $base['extraEnv'] = [ordered]@{ CLAUDE_CODE_ATTRIBUTION_HEADER = '0' } } - if ($Provider -eq 'zai') { - $base['extraEnv'] = [ordered]@{ - API_TIMEOUT_MS = '3000000' - CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1' - } + $base['gatewayModelDiscovery'] = [bool]$tpl.gatewayModelDiscovery + $base['contextTokens'] = $(if ($tpl.contextTokens) { [int]$tpl.contextTokens } else { 200000 }) + if ($tpl.extraEnv) { + $extra = [ordered]@{} + foreach ($prop in $tpl.extraEnv.PSObject.Properties) { $extra[$prop.Name] = [string]$prop.Value } + $base['extraEnv'] = $extra } return $base } @@ -2186,25 +2362,12 @@ Initialize-Root try { $cmd = $Command.ToLower() - if ($cmd -eq 'z.ai' -or $cmd -eq 'z-ai') { $cmd = 'zai' } switch ($cmd) { '' { Invoke-Menu } 'menu' { Invoke-Menu } 'status' { Invoke-Status } 'anthropic' { Set-ClaudeMode -Mode 'anthropic' -PresetName '' } - 'openrouter' { - $req = if ($Rest -and $Rest.Count -ge 1) { $Rest[0] } else { $null } - Set-ClaudeMode -Mode 'openrouter' -PresetName (Resolve-PresetForProvider 'openrouter' $req) - } - 'zai' { - $req = if ($Rest -and $Rest.Count -ge 1) { $Rest[0] } else { $null } - Set-ClaudeMode -Mode 'zai' -PresetName (Resolve-PresetForProvider 'zai' $req) - } - 'lmstudio' { - $req = if ($Rest -and $Rest.Count -ge 1) { $Rest[0] } else { $null } - Set-ClaudeMode -Mode 'lmstudio' -PresetName (Resolve-PresetForProvider 'lmstudio' $req) - } 'presets' { Invoke-Presets } 'preset' { Invoke-PresetCmd -Argv $Rest } 'set-key' { @@ -2226,7 +2389,17 @@ try { 'help' { Show-Usage } '--help' { Show-Usage } '-h' { Show-Usage } - default { Write-Err2 "unknown command '$Command'"; Show-Usage; exit 1 } + default { + # Any provider in providers.json, by id or alias, is a mode. Last, + # so a provider can never shadow a real command. + $prov = Resolve-ProviderId $cmd + if ($prov) { + $req = if ($Rest -and $Rest.Count -ge 1) { $Rest[0] } else { $null } + Set-ClaudeMode -Mode $prov -PresetName (Resolve-PresetForProvider $prov $req) + } else { + Write-Err2 "unknown command '$Command'"; Show-Usage; exit 1 + } + } } } catch { Write-Err2 $_.Exception.Message diff --git a/install.ps1 b/install.ps1 index 67d2ca7..d2ce112 100644 --- a/install.ps1 +++ b/install.ps1 @@ -91,7 +91,13 @@ if (Test-Path -LiteralPath (Join-Path $src 'VERSION')) { } Copy-Item -LiteralPath (Join-Path $src 'bin\claude-key-helper.ps1') -Destination "$root\bin" -Force Copy-Item -LiteralPath (Join-Path $src 'bin\claude-key-helper.cmd') -Destination "$root\bin" -Force -Write-Host ' ok copied claude-mode.ps1 + key helper' -ForegroundColor Green +# The table of providers claude-mode can switch to. Always replaced, unlike +# presets: it is the tool's own definition of each provider, not user data. +if (-not (Test-Path -LiteralPath (Join-Path $src 'providers.json'))) { + throw 'providers.json missing from the payload - claude-mode cannot run without it' +} +Copy-Item -LiteralPath (Join-Path $src 'providers.json') -Destination $root -Force +Write-Host ' ok copied claude-mode.ps1 + key helper + providers.json' -ForegroundColor Green # --- 3. presets ------------------------------------------------------------- foreach ($p in Get-ChildItem -LiteralPath (Join-Path $src 'presets') -Filter '*.json') { diff --git a/linux/claude-mode b/linux/claude-mode index 440a739..dfe46ac 100755 --- a/linux/claude-mode +++ b/linux/claude-mode @@ -32,7 +32,9 @@ JSON="$CM_BIN/cm-json.py" CM_FORCE=0 CM_SAME_ENDPOINT=0 # set by reapply_if_active for a tier-only edit -MODES=(anthropic openrouter zai lmstudio) +# anthropic is built in; every gateway provider is appended from providers.json +# at startup (see cm_providers_load). +MODES=(anthropic) TIERS=(opus sonnet haiku fable) # --------------------------------------------------------------------------- @@ -219,22 +221,21 @@ err() { printf ' %sFAIL%s %s\n' "$C_BOLD$C_RED" "$C_RESET" "$*" >&2; } head_() { printf '\n%s%s%s\n' "$C_BOLD$C_CYAN" "$*" "$C_RESET"; } mode_color() { - case "$1" in - anthropic) printf '%s' "$C_MAGENTA" ;; - openrouter) printf '%s' "$C_CYAN" ;; - zai) printf '%s' "$C_GREEN" ;; - lmstudio) printf '%s' "$C_YELLOW" ;; - *) printf '%s' "$C_GRAY" ;; + [ "$1" = anthropic ] && { printf '%s' "$C_MAGENTA"; return; } + case "$(prov_field "$1" 5)" in + cyan) printf '%s' "$C_CYAN" ;; + green) printf '%s' "$C_GREEN" ;; + yellow) printf '%s' "$C_YELLOW" ;; + magenta) printf '%s' "$C_MAGENTA" ;; + white) printf '%s' "$C_WHITE" ;; + dkcyan) printf '%s' "$C_DKCYAN" ;; + *) printf '%s' "$C_GRAY" ;; esac } mode_label() { - case "$1" in - anthropic) printf 'Anthropic - your subscription login, no gateway' ;; - openrouter) printf 'OpenRouter - remote, pay-per-token, any vendor' ;; - zai) printf 'Z.AI - GLM coding plan' ;; - lmstudio) printf 'LM Studio - local server, offline, free' ;; - esac + [ "$1" = anthropic ] && { printf 'Anthropic - your subscription login, no gateway'; return; } + prov_field "$1" 4 } # Pure ASCII on purpose - renders identically in every terminal and locale. @@ -254,15 +255,20 @@ show_banner() { usage() { cat <<'EOF' -claude-mode - switch Claude Code between Anthropic, OpenRouter, Z.AI, LM Studio +claude-mode - switch Claude Code between Anthropic and gateway providers claude-mode interactive menu claude-mode status active mode, preset, model map claude-mode anthropic native login (clears all gateway config) - claude-mode openrouter [preset] remote gateway (default: default) - claude-mode zai [preset] Z.AI GLM coding plan (default: zai) - claude-mode lmstudio [preset] local LM Studio (default: lmstudio) +EOF + # One line per provider in providers.json, so a new one documents itself. + local id + for id in $(provider_ids); do + printf ' claude-mode %-26s %s (default: %s)\n' "$id [preset]" \ + "$(prov_field "$id" 4 | sed 's/^[^-]*- //')" "$(default_preset_for "$id")" + done +cat <<'EOF' claude-mode presets list presets claude-mode preset show @@ -350,6 +356,66 @@ tsv_find() { tsv_field() { printf '%s' "$1" | cut -f"$2"; } +# --------------------------------------------------------------------------- +# Providers +# +# Every gateway provider is an entry in providers.json; this script only knows +# the *kinds* of behaviour (how a catalogue is fetched, how a server is probed) +# and picks one by name from the entry. The table is read once, in the main +# shell before dispatch - read inside a $( ) it would be re-read on every +# lookup. Columns are cm-json.py's PROVIDER_TSV: +# +# 1 id 2 aliases 3 title 4 label 5 color 6 defaultPreset +# 7 serverEditable 8 probe 9 probePaths 10 catalogueKind 11 perServer +# 12 setupKey 13 setupModels 14 keyUrl 15 guardrail 16 doctor +# 17 defaultKeyRef 18 literalToken 19 defaultBaseUrl 20 serverHint +# 21 serverStart +# +# Always read with cut, never `IFS=$'\t' read`: tab counts as whitespace to +# read, so an empty column (most providers have no aliases) would vanish and +# shift every column after it. +# --------------------------------------------------------------------------- + +CM_PROVIDERS_TSV='' + +cm_providers_load() { + CM_PROVIDERS_TSV="$("$PY" "$JSON" provider-tsv)" || { + echo "claude-mode: could not read providers.json (next to $CM_BIN)" >&2; exit 1; } + local id + for id in $(provider_ids); do MODES+=("$id"); done +} + +provider_ids() { printf '%s\n' "$CM_PROVIDERS_TSV" | cut -f1; } + +prov_field() { + local row + row="$(printf '%s\n' "$CM_PROVIDERS_TSV" | tsv_find "$1")" || return 1 + tsv_field "$row" "$2" +} + +# An id or an alias (z.ai, z-ai) to the provider id; non-zero if neither. +provider_resolve() { + local w line + w="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" + [ -n "$w" ] || return 1 + while IFS= read -r line; do + [ -n "$line" ] || continue + if [ "${line%%$'\t'*}" = "$w" ]; then printf '%s\n' "$w"; return 0; fi + case ",$(tsv_field "$line" 2)," in + *",$w,"*) printf '%s\n' "${line%%$'\t'*}"; return 0 ;; + esac + done </dev/null 2>&1; then c="$(tput cols 2>/dev/null)"; fi @@ -367,13 +433,7 @@ term_cols() { # The existence test is what lets a stale choice degrade instead of break. # cm-json.py's BUILTIN_DEFAULT_PRESET and the widget's Modes.DEFAULT_PRESET # mirror the built-in names. -builtin_default_preset() { - case "$1" in - openrouter) printf 'default' ;; - zai) printf 'zai' ;; - lmstudio) printf 'lmstudio' ;; - esac -} +builtin_default_preset() { prov_field "$1" 6; } default_preset_for() { local chosen @@ -452,11 +512,11 @@ cm_probe_timeout() { cm_url_is_local "$1" && printf '4' || printf '10'; } # The distinction matters because the remedies are opposites: `refused` means go # and start the server, `auth` means the server is fine and the key is not. cm_probe_server() { - local base="${1%/}" token="${2:-}" t code ep + local base="${1%/}" token="${2:-}" paths="${3:-/api/v0/models,/v1/models}" t code ep command -v curl >/dev/null 2>&1 || { printf 'skip'; return 0; } t="$(cm_probe_timeout "$base")" - for ep in /api/v0/models /v1/models; do + for ep in $(printf '%s' "$paths" | tr ',' ' '); do code="$(curl -s -o /dev/null -w '%{http_code}' --max-time "$t" \ ${token:+-H "Authorization: Bearer $token"} \ "$base$ep" 2>/dev/null)" @@ -521,6 +581,15 @@ cm_preflight() { base="$(jget "$pf" baseUrl)"; CM_PF_BASEURL="$base" auth_mode="$(jget "$pf" auth.mode)"; [ -z "$auth_mode" ] && auth_mode=vault + # A custom endpoint ships with no address, since there is no sensible one + # to guess; switching to it would point every session at nothing. + if [ -z "$base" ]; then + cm_pf_set 'no-url' "Preset '$preset' has no server address" \ + "$(prov_field "$mode" 3) needs to know where the server is before anything can be sent to it." \ + "claude-mode preset url $preset " 'set-url' + return 1 + fi + if [ "$auth_mode" = "vault" ]; then key_ref="$(jget "$pf" auth.keyRef)"; [ -z "$key_ref" ] && key_ref=openrouter CM_PF_KEYREF="$key_ref" @@ -538,17 +607,21 @@ cm_preflight() { fi fi - # LM Studio is checked wherever it is; other providers only when they are - # pointed at this machine. A public gateway that is briefly unreachable is - # the network's problem and not worth blocking a config change over. - if [ -n "$base" ] && { [ "$mode" = "lmstudio" ] || cm_url_is_local "$base"; }; then + # A server provider is checked wherever it is (probe "always", or + # "lenient" for a proxy that may list no models); a public gateway only + # when it has been pointed at this machine (probe "local"). A public + # gateway that is briefly unreachable is the network's problem and not + # worth blocking a config change over. + local probe_rule title + probe_rule="$(prov_field "$mode" 8)"; title="$(prov_field "$mode" 3)" + if [ "$probe_rule" = always ] || [ "$probe_rule" = lenient ] || cm_url_is_local "$base"; then local token='' probe where if [ "$auth_mode" = "vault" ]; then token="$(cm_vault_get "$key_ref" 2>/dev/null || true)" else token="$(jget "$pf" auth.token)" fi - probe="$(cm_probe_server "$base" "$token")" + probe="$(cm_probe_server "$base" "$token" "$(prov_field "$mode" 9)")" cm_url_is_local "$base" && where='on this machine' || where='at that address' case "$probe" in @@ -560,13 +633,17 @@ cm_preflight() { "claude-mode set-key $key_ref" 'set-key' else cm_pf_set 'server-auth' 'The server wants an API key' \ - "$base is running but is refusing an unauthenticated request. This preset is set to send LM Studio's placeholder token, which only works on a server with authentication switched off." \ + "$base is running but is refusing an unauthenticated request. This preset is set to send $title's placeholder token, which only works on a server with authentication switched off." \ "claude-mode preset auth $preset key" 'needs-key' fi return 1 ;; notfound) - cm_pf_set 'server-wrong' 'That address answered, but not as LM Studio' \ - "Something is listening at $base, but neither /api/v0/models nor /v1/models is there. Check the port, or whether a proxy in front of it is rewriting the path." \ + # A proxy in front of a Messages API often serves no model + # list at all; for one of those, something answering is + # as much as can be checked. + [ "$probe_rule" = lenient ] && return 0 + cm_pf_set 'server-wrong' "That address answered, but not as $title" \ + "Something is listening at $base, but $(prov_field "$mode" 9 | sed 's/,/ and /g') is not there. Check the port, or whether a proxy in front of it is rewriting the path." \ "claude-mode preset url $preset " 'set-url' return 1 ;; *) @@ -582,14 +659,11 @@ cm_preflight() { cmd_preflight() { local mode="${1:-}" preset="${2:-}" p - case "$mode" in - anthropic) ;; - openrouter|zai|lmstudio) - p="$(resolve_preset "$mode" "$preset" 2>/dev/null)" || p="$preset" - preset="$p" ;; - z.ai|z-ai) mode=zai; p="$(resolve_preset zai "$preset" 2>/dev/null)" || p="$preset"; preset="$p" ;; - *) err "unknown mode '$mode'"; return 1 ;; - esac + if [ "$mode" != anthropic ]; then + mode="$(provider_resolve "$mode")" || { err "unknown mode '${1:-}'"; return 1; } + p="$(resolve_preset "$mode" "$preset" 2>/dev/null)" || p="$preset" + preset="$p" + fi if cm_preflight "$mode" "$preset"; then "$PY" "$JSON" preflight-json ok "$mode" "$preset" '' '' '' '' '' '' '' @@ -1046,7 +1120,8 @@ write_health() { # nothing actionable to print for them. show_guardrail_status() { local mode="$1" key="$2" code - [ "$mode" = "openrouter" ] || return 0 + # An OpenRouter feature, flagged per provider rather than by name. + [ "$(prov_field "$mode" 15)" = 1 ] || return 0 [ -n "$key" ] || return 0 code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 25 \ @@ -1187,26 +1262,41 @@ cm_cache_catalogue() { "$PY" "$JSON" cache-models "$CM_MODELS_CACHE" "$1" "$2" "${3:-}" >/dev/null 2>&1 || true } -or_catalogue() { - local out rc - out="$(curl -fsS --max-time 30 https://openrouter.ai/api/v1/models 2>/dev/null | "$PY" "$JSON" or-models 2>/dev/null)" +# One catalogue fetch, for any provider: fetched according to its +# catalogue.kind, parsed to TSV, and left in the cache for the panel. Prints +# the TSV; the status says whether the fetch itself worked. TSV columns by kind: +# +# openrouter id ctx $in $out lmstudio id state ctx +# ollama id params quant family openai id +# static id note (a list kept in providers.json) +provider_catalogue() { + local pid="$1" base="${2%/}" token="${3:-}" out rc + case "$(prov_field "$pid" 10)" in + openrouter) + out="$(curl -fsS --max-time 30 https://openrouter.ai/api/v1/models 2>/dev/null \ + | "$PY" "$JSON" or-models 2>/dev/null)" ;; + lmstudio) + out="$(curl -fsS --max-time 10 ${token:+-H "Authorization: Bearer $token"} \ + "$base/api/v0/models" 2>/dev/null | "$PY" "$JSON" lms-models 2>/dev/null)" ;; + ollama) + out="$(curl -fsS --max-time 10 ${token:+-H "Authorization: Bearer $token"} \ + "$base/api/tags" 2>/dev/null | "$PY" "$JSON" ollama-models 2>/dev/null)" ;; + openai) + # Both header styles: a proxy in front of Anthropic wants + # x-api-key, one in front of anything else wants Bearer. + out="$(curl -fsS --max-time 15 ${token:+-H "Authorization: Bearer $token"} \ + ${token:+-H "x-api-key: $token"} -H 'anthropic-version: 2023-06-01' \ + "$base/v1/models" 2>/dev/null | "$PY" "$JSON" openai-models 2>/dev/null)" ;; + static) + out="$("$PY" "$JSON" provider-static "$pid" 2>/dev/null)" ;; + *) return 1 ;; + esac rc=$? - cm_cache_catalogue openrouter "$([ "$rc" -eq 0 ] && echo 1 || echo 0)" <<<"$out" + cm_cache_catalogue "$pid" "$([ "$rc" -eq 0 ] && echo 1 || echo 0)" "$base" <<<"$out" [ -n "$out" ] && printf '%s\n' "$out" return "$rc" } -# Z.AI publishes no catalogue endpoint, so this list comes from its docs - and -# this is the one place it is kept. -zai_catalogue() { - local out - out="$(printf '%s\t%s\n' \ - glm-5.3 'flagship coding model - opus/sonnet tier' \ - glm-4.7 'fast/cheap tier - haiku')" - cm_cache_catalogue zai 1 <<<"$out" - printf '%s\n' "$out" -} - # The credential a preset would send. Empty when there is nothing to send. cm_preset_token() { local pf="$1" am ref @@ -1219,19 +1309,11 @@ cm_preset_token() { fi } -# The token is not optional decoration. An LM Studio server with authentication -# switched on answers /api/v0/models with 401 like anything else, so without it -# the catalogue comes back empty and every caller silently believes the server -# has no models installed - on exactly the setups that need the list most. -lms_catalogue() { - local base="${1%/}" token="${2:-}" out rc - out="$(curl -fsS --max-time 10 ${token:+-H "Authorization: Bearer $token"} \ - "$base/api/v0/models" 2>/dev/null | "$PY" "$JSON" lms-models 2>/dev/null)" - rc=$? - cm_cache_catalogue lmstudio "$([ "$rc" -eq 0 ] && echo 1 || echo 0)" "$base" <<<"$out" - [ -n "$out" ] && printf '%s\n' "$out" - return "$rc" -} +# The token passed to provider_catalogue is not optional decoration. A local +# server with authentication switched on answers its model list with 401 like +# anything else, so without it the catalogue comes back empty and every caller +# silently believes the server has no models - on exactly the setups that need +# the list most. # --------------------------------------------------------------------------- # Commands @@ -1325,18 +1407,16 @@ cmd_models() { provider=openrouter # on anthropic, the catalogue worth browsing fi - local quiet=$(( refresh || as_json )) - case "$provider" in - lmstudio) - [ "$quiet" -eq 1 ] || head_ "models installed in LM Studio at $base" - tsv="$(lms_catalogue "$base" "$(cm_preset_token "$pf")")"; rc=$? ;; - zai) - [ "$quiet" -eq 1 ] || head_ 'Z.AI GLM models (from Z.AI docs - no public catalogue endpoint)' - tsv="$(zai_catalogue)"; rc=$? ;; - *) - [ "$quiet" -eq 1 ] || head_ 'fetching https://openrouter.ai/api/v1/models ...' - tsv="$(or_catalogue)"; rc=$? ;; - esac + local quiet=$(( refresh || as_json )) kind title + kind="$(prov_field "$provider" 10)"; title="$(prov_field "$provider" 3)" + if [ "$quiet" -eq 0 ]; then + case "$kind" in + static) head_ "$title models (from its docs - no public catalogue endpoint)" ;; + openrouter) head_ 'fetching https://openrouter.ai/api/v1/models ...' ;; + *) head_ "models on the $title server at $base" ;; + esac + fi + tsv="$(provider_catalogue "$provider" "$base" "$(cm_preset_token "$pf")")"; rc=$? if [ "$as_json" -eq 1 ]; then jget "$CM_MODELS_CACHE" "providers.$provider" @@ -1346,6 +1426,10 @@ cmd_models() { if [ "$refresh" -eq 1 ]; then if [ "$rc" -ne 0 ]; then err "could not fetch the $provider catalogue${base:+ from $base}; the cached list, if any, is kept" + # Not necessarily a fault: plenty of proxies serve Messages and + # nothing else. + [ "$(prov_field "$provider" 8)" = lenient ] && \ + say 'some endpoints serve no model list at all - model ids can still be typed by hand' return 1 fi ok "cached $(printf '%s\n' "$tsv" | grep -c .) $provider model(s)" @@ -1357,10 +1441,12 @@ cmd_models() { while IFS=$'\t' read -r id c2 c3 c4; do [ -n "$id" ] || continue [ -z "$filter" ] || case "$id" in *"$filter"*) ;; *) continue ;; esac - case "$provider" in - lmstudio) printf ' %-58s %-11s %s\n' "$id" "$c2" "$c3" ;; - zai) say "$(printf '%-8s - %s' "$id" "$c2")" ;; - *) printf ' %-52s %10s $%-8s $%s\n' "$id" "$c2" "$c3" "$c4" ;; + case "$kind" in + lmstudio) printf ' %-58s %-11s %s\n' "$id" "$c2" "$c3" ;; + static) say "$(printf '%-8s - %s' "$id" "$c2")" ;; + ollama) printf ' %-44s %-8s %s\n' "$id" "$c2" "$c3" ;; + openrouter) printf ' %-52s %10s $%-8s $%s\n' "$id" "$c2" "$c3" "$c4" ;; + *) printf ' %s\n' "$id" ;; esac done <<<"$tsv" } @@ -1399,6 +1485,106 @@ cmd_set_key() { printf '%s' "$secret" | cm_vault_set "$ref" && ok "stored key '$ref' via $(cm_vault_backend_label)" } +# The preset's model ids against what the provider actually offers. A server +# provider that does not answer is a failure; a hosted catalogue that cannot be +# fetched is only a warning, since the endpoint may be fine regardless. +doctor_catalogue() { + local mode="$1" pf="$2" base="$3" kind title cat t id row declared c2 c3 + kind="$(prov_field "$mode" 10)"; title="$(prov_field "$mode" 3)" + declared="$(jget "$pf" contextTokens)" + cat="$(provider_catalogue "$mode" "$base" "$(cm_preset_token "$pf")")" + if [ -z "$cat" ]; then + if [ "$(prov_field "$mode" 7)" = 1 ]; then + local start; start="$(prov_field "$mode" 21)" + if [ "$(prov_field "$mode" 8)" = lenient ]; then + warn "$title at $base lists no models - fine for a proxy, but the ids below cannot be checked" + else + err "$title not reachable at $base${start:+ - $start}" + fi + else + warn "could not fetch the $title model list" + fi + return 0 + fi + [ "$(prov_field "$mode" 7)" = 1 ] && ok "$title reachable at $base ($(printf '%s\n' "$cat" | grep -c .) models)" + + for t in "${TIERS[@]}"; do + id="$(jget "$pf" "models.$t")"; [ -n "$id" ] || continue + row="$(printf '%s\n' "$cat" | tsv_find "$id")" || row='' + # Ollama lists every model with its tag, and a bare name means + # `:latest` - `qwen3-coder` is served as `qwen3-coder:latest`. + if [ -z "$row" ] && [ "$kind" = ollama ]; then + case "$id" in *:*) ;; *) row="$(printf '%s\n' "$cat" | tsv_find "$id:latest")" || row='' ;; esac + fi + if [ -z "$row" ]; then + # A fixed list is documentation, not the provider's word: an id + # missing from it may still be served. + if [ "$kind" = static ]; then ok "$(printf '%-6s %s (not in the documented list)' "$t" "$id")" + else err "$t model NOT available from $title: $id"; fi + continue + fi + c2="$(tsv_field "$row" 2)"; c3="$(tsv_field "$row" 3)" + case "$kind" in + openrouter) ok "$(printf '%-6s %s [ctx %s]' "$t" "$id" "$c2")"; doctor_ctx_vs "$t" "$c2" "$declared" ;; + lmstudio) ok "$(printf '%-6s %s [%s, ctx %s]' "$t" "$id" "$c2" "$c3")"; doctor_ctx_vs "$t" "$c3" "$declared" ;; + ollama) ok "$(printf '%-6s %s [%s %s]' "$t" "$id" "$c2" "$c3")" ;; + *) ok "$(printf '%-6s %s' "$t" "$id")" ;; + esac + done + [ -n "$declared" ] && ok "declared context window: $declared tokens" \ + || warn 'preset has no contextTokens - Claude Code will guess a small window and auto-compact early' +} + +doctor_ctx_vs() { + local t="$1" ctx="$2" declared="$3" + { [ -n "$declared" ] && [ -n "$ctx" ] && [ "$ctx" -lt "$declared" ]; } 2>/dev/null || return 0 + if [ "$t" = haiku ]; then + warn "$t model has $ctx ctx, below the declared $declared - harmless, haiku runs short background tasks" + else + warn "$t model has $ctx ctx, below the declared $declared - this tier can overflow" + fi +} + +# Ollama sets the context window on the server, not per request from Claude +# Code: 4096 tokens unless `ollama serve` runs with OLLAMA_CONTEXT_LENGTH, and +# anything past it is cut off without an error. contextTokens in a preset only +# tells Claude Code what to expect - it cannot change what the server does - so +# say what the server is actually running where that can be seen, and what to +# set where it cannot. +doctor_ollama_context() { + local pf="$1" base="$2" declared ids id max live ps seen=0 short=0 t body + declared="$(jget "$pf" contextTokens)"; [ -n "$declared" ] || return 0 + ids="$(for t in "${TIERS[@]}"; do jget "$pf" "models.$t"; done | sort -u | grep . || true)" + [ -n "$ids" ] || return 0 + ps="$(curl -fsS --max-time 5 "$base/api/ps" 2>/dev/null)" + while IFS= read -r id; do + [ -n "$id" ] || continue + body="$("$PY" -c 'import json,sys; print(json.dumps({"model": sys.argv[1]}))' "$id")" + max="$(curl -fsS --max-time 8 -X POST -H 'content-type: application/json' -d "$body" \ + "$base/api/show" 2>/dev/null | "$PY" "$JSON" ollama-ctx show 2>/dev/null)" + if [ -n "$max" ] && [ "$max" -lt "$declared" ] 2>/dev/null; then + warn "$id supports at most $max tokens, below the declared $declared - lower contextTokens" + fi + live="$(printf '%s' "$ps" | "$PY" "$JSON" ollama-ctx ps "$id" 2>/dev/null)" + [ -n "$live" ] || continue + seen=1 + if [ "$live" -lt "$declared" ] 2>/dev/null; then + short=1 + warn "$id is loaded with a $live-token context, below the declared $declared - requests past it are cut off" + else + ok "$id is loaded with a $live-token context" + fi + done </dev/null)" if [ -n "$kinfo" ]; then @@ -1474,60 +1660,24 @@ else: print(' ok spend %.2f of %.2f limit (%s), %.2f remaining' % (use, lim, show_guardrail_status "$mode" "$key" fi - if [ -n "$key" ] && [ "$mode" = "zai" ]; then + # No key-info endpoint: the cheapest real check is a 1-token + # message against the Anthropic-compatible surface itself. + if [ -n "$key" ] && doctor_has "$mode" message-check; then if curl -fsS --max-time 45 -X POST "$base/v1/messages" \ -H 'content-type: application/json' -H "x-api-key: $key" \ -H "authorization: Bearer $key" -H 'anthropic-version: 2023-06-01' \ -d "{\"model\":\"$(jget "$pf" models.haiku)\",\"max_tokens\":1,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}" >/dev/null 2>&1; then - ok "Z.AI endpoint accepted the key ($base/v1/messages)" + ok "$(prov_field "$mode" 3) endpoint accepted the key ($base/v1/messages)" else - err 'Z.AI request failed' + err "$(prov_field "$mode" 3) request failed" fi fi else ok "inline token '$(jget "$pf" auth.token)' (no secret in settings.json)" fi - if [ "$mode" = "lmstudio" ]; then - local cat; cat="$(lms_catalogue "$base" "$(cm_preset_token "$pf")")" - if [ -n "$cat" ]; then - ok "LM Studio reachable at $base ($(printf '%s\n' "$cat" | wc -l | tr -d ' ') models installed)" - local t id row st ctx - for t in "${TIERS[@]}"; do - id="$(jget "$pf" "models.$t")"; [ -n "$id" ] || continue - row="$(printf '%s\n' "$cat" | tsv_find "$id")" || row='' - if [ -z "$row" ]; then err "$t model NOT installed in LM Studio: $id"; continue; fi - st="$(tsv_field "$row" 2)"; ctx="$(tsv_field "$row" 3)" - ok "$t $id [$st, ctx $ctx]" - done - else - err "LM Studio not reachable at $base - start the server (Developer > Start Server)" - fi - elif [ "$mode" = "openrouter" ]; then - local cat; cat="$(or_catalogue)" - if [ -n "$cat" ]; then - local t id row ctx declared - declared="$(jget "$pf" contextTokens)" - for t in "${TIERS[@]}"; do - id="$(jget "$pf" "models.$t")"; [ -n "$id" ] || continue - row="$(printf '%s\n' "$cat" | tsv_find "$id")" || row='' - if [ -z "$row" ]; then err "$t model NOT available from OpenRouter: $id"; continue; fi - ctx="$(tsv_field "$row" 2)" - ok "$(printf '%-6s %s [ctx %s]' "$t" "$id" "$ctx")" - if [ -n "$declared" ] && [ -n "$ctx" ] && [ "$ctx" -lt "$declared" ] 2>/dev/null; then - if [ "$t" = "haiku" ]; then - warn "$t model has $ctx ctx, below the declared $declared - harmless, haiku runs short background tasks" - else - warn "$t model has $ctx ctx, below the declared $declared - this tier can overflow" - fi - fi - done - [ -n "$declared" ] && ok "declared context window: $declared tokens" \ - || warn 'preset has no contextTokens - Claude Code will guess a small window and auto-compact early' - else - warn 'could not fetch the OpenRouter catalogue' - fi - fi + doctor_has "$mode" catalogue-models && doctor_catalogue "$mode" "$pf" "$base" + doctor_has "$mode" ollama-context && doctor_ollama_context "$pf" "$base" fi printf '\n' @@ -1569,17 +1719,18 @@ cmd_preset() { a="$1"; shift case "$a" in --provider) - [ $# -gt 0 ] || { err '--provider needs openrouter, zai or lmstudio'; return 1; } + [ $# -gt 0 ] || { err "--provider needs one of: $(provider_ids | tr '\n' ' ')"; return 1; } provider="$1"; shift ;; --blank) blank=1 ;; -*) err "unknown option '$a'"; return 1 ;; *) from="$a" ;; esac done - case "$provider" in - ''|openrouter|zai|lmstudio) ;; - *) err "unknown provider '$provider' - openrouter, zai or lmstudio"; return 1 ;; - esac + if [ -n "$provider" ]; then + local want="$provider" + provider="$(provider_resolve "$want")" || { + err "unknown provider '$want' - one of: $(provider_ids | tr '\n' ' ')"; return 1; } + fi if [ "$blank" -eq 1 ]; then [ -n "$provider" ] || { err '--blank needs --provider to know which template'; return 1; } @@ -1652,7 +1803,7 @@ cmd_preset() { local prov="$name" target="${3:-}" p cur got if [ -z "$prov" ]; then head_ 'default preset per provider' - for p in openrouter zai lmstudio; do + for p in $(provider_ids); do cur="$(jget "$CM_DEFAULTS" "$p")" if [ -n "$cur" ] && [ -f "$(preset_path "$cur")" ]; then printf ' %-11s %s (chosen)\n' "$p" "$cur" @@ -1671,10 +1822,8 @@ cmd_preset() { printf '\n %sclaude-mode preset default (or --clear)%s\n' "$C_DIM" "$C_RESET" return 0 fi - case "$prov" in - openrouter|zai|lmstudio) ;; - *) err "unknown provider '$prov' - openrouter, zai or lmstudio"; return 1 ;; - esac + prov="$(provider_resolve "$name")" || { + err "unknown provider '$name' - one of: $(provider_ids | tr '\n' ' ')"; return 1; } if [ -z "$target" ]; then resolve_preset "$prov" && printf '\n' return @@ -1722,9 +1871,12 @@ cmd_preset() { reapply_if_active "$name" ;; auth) - local amode="${3:-}" ref="${4:-lmstudio}" + local amode="${3:-}" ref="${4:-}" [ -n "$name" ] && [ -n "$amode" ] || { err 'usage: claude-mode preset auth none|key [keyRef]'; return 1; } [ -f "$(preset_path "$name")" ] || { err "preset '$name' not found"; return 1; } + # The provider's own slot name by default: lmstudio, ollama, custom. + [ -n "$ref" ] || ref="$(prov_field "$(jget "$(preset_path "$name")" provider)" 17)" + [ -n "$ref" ] || ref=lmstudio "$PY" "$JSON" set-auth "$(preset_path "$name")" "$amode" "$ref" >/dev/null || return 1 if [ "$amode" = "key" ]; then ok "$name : auth -> vault key '$ref'" @@ -2012,27 +2164,18 @@ ui_pick_model() { ui_reset_items ui_add_item '' 'enter any model id by hand' - local ids=() id ctx a b st - case "$provider" in - openrouter) - while IFS=$'\t' read -r id ctx a b; do - [ -n "$id" ] || continue - ids+=("$id"); ui_add_item "$id" "context $ctx \$$a in / \$$b out per 1M" - done < <(or_catalogue) - ;; - lmstudio) - while IFS=$'\t' read -r id st ctx; do - [ -n "$id" ] || continue - ids+=("$id"); ui_add_item "$id" "state: $st max context: $ctx" - done < <(lms_catalogue "$base" "$(cm_preset_token "$pf")") - ;; - zai) - while IFS=$'\t' read -r id a; do - [ -n "$id" ] || continue - ids+=("$id"); ui_add_item "$id" "$a" - done < <(zai_catalogue) - ;; - esac + local ids=() id ctx a b st kind + kind="$(prov_field "$provider" 10)" + while IFS=$'\t' read -r id a b st; do + [ -n "$id" ] || continue + ids+=("$id") + case "$kind" in + openrouter) ui_add_item "$id" "context $a \$$b in / \$$st out per 1M" ;; + lmstudio) ui_add_item "$id" "state: $a max context: $b" ;; + ollama) ui_add_item "$id" "$a $b" ;; + *) ui_add_item "$id" "$a" ;; + esac + done < <(provider_catalogue "$provider" "$base" "$(cm_preset_token "$pf")") if [ "${#ids[@]}" -gt 0 ]; then ui_filter_select "model for '$tier'" "current: $current" || return 1 @@ -2083,7 +2226,7 @@ ui_edit_preset() { ui_new_preset() { ui_reset_items - local provs=(openrouter zai lmstudio) p + local provs=($(provider_ids)) p for p in "${provs[@]}"; do ui_add_item "$p" "$(mode_label "$p")"; done ui_select 'new preset - which provider' '' || return local provider="${provs[$UI_SEL]}" @@ -2254,7 +2397,7 @@ setup_models() { return 0 fi - if [ "$provider" = "lmstudio" ]; then + if [ "$(prov_field "$provider" 13)" = one-for-all ]; then # One model for every tier is the normal shape for a local server: it # has one loaded at a time, and mapping tiers to different models just # means paying the load cost on every tier change. @@ -2263,7 +2406,7 @@ setup_models() { while IFS=$'\t' read -r id st ctx; do [ -n "$id" ] || continue ids+=("$id") - done < <(lms_catalogue "$base" "$(cm_preset_token "$pf")") + done < <(provider_catalogue "$provider" "$base" "$(cm_preset_token "$pf")") if [ "${#ids[@]}" -eq 0 ]; then warn 'the server returned no models; type an id by hand instead' @@ -2292,40 +2435,48 @@ setup_models() { done } -setup_lmstudio_server() { - local pf="$1" name="$2" url probe token - url="$(jget "$pf" baseUrl)"; [ -n "$url" ] || url='http://127.0.0.1:1234' +# Where a server provider (LM Studio, Ollama, a custom endpoint) lives, and +# whether it wants a key. None of them has to be on this machine. +setup_server() { + local pf="$1" name="$2" mode="$3" url probe token title hint start + title="$(prov_field "$mode" 3)"; hint="$(prov_field "$mode" 20)"; start="$(prov_field "$mode" 21)" + url="$(jget "$pf" baseUrl)"; [ -n "$url" ] || url="$(prov_field "$mode" 19)" printf '\n' - say 'LM Studio does not have to be on this machine - a LAN address or' - say 'anything reachable through a tunnel or proxy works just as well.' + [ -n "$hint" ] && say "$hint" url="$(ask_value 'server base URL' "$url")" + [ -n "$url" ] || { err 'a server address is needed'; return 1; } "$PY" "$JSON" set-url "$pf" "$url" >/dev/null || return 1 ok "baseUrl -> $url" printf '\n' if ask_yes 'does that server require an API key?'; then - local ref; ref="$(ask_value 'key name to store it under' 'lmstudio')" + local ref; ref="$(ask_value 'key name to store it under' "$(prov_field "$mode" 17)")" "$PY" "$JSON" set-auth "$pf" key "$ref" >/dev/null ok "auth -> vault key '$ref'" cm_vault_has "$ref" || cmd_set_key "$ref" token="$(cm_vault_get "$ref" 2>/dev/null || true)" else "$PY" "$JSON" set-auth "$pf" none >/dev/null - ok 'auth -> none (inline placeholder token)' - token='lmstudio' + token="$(prov_field "$mode" 18)" + ok "auth -> none (inline placeholder token '$token')" fi printf '\n' say "checking $url ..." - probe="$(cm_probe_server "$url" "$token")" + probe="$(cm_probe_server "$url" "$token" "$(prov_field "$mode" 9)")" case "$probe" in ok) ok 'server answered' ;; auth) err 'the server refused that credential'; return 1 ;; - notfound) err 'something answered there, but not an LM Studio API' ; return 1 ;; + notfound) + if [ "$(prov_field "$mode" 8)" = lenient ]; then + warn "something answered, but it lists no models - fine for a proxy; model ids are typed by hand" + else + err "something answered there, but not a $title API"; return 1 + fi ;; skip) warn 'curl is missing, so the server was not checked' ;; *) err 'nothing answered at that address' - say 'start the server and run: claude-mode setup lmstudio' + say "${start:+$start, then }run: claude-mode setup $mode" return 1 ;; esac return 0 @@ -2339,10 +2490,8 @@ cmd_setup() { head_ 'setup: anthropic' ok 'nothing to configure - it uses your existing Claude login' return 0 ;; - openrouter|zai|lmstudio) ;; - z.ai|z-ai) mode=zai ;; '') err 'usage: claude-mode setup '; return 1 ;; - *) err "unknown mode '$mode'"; return 1 ;; + *) mode="$(provider_resolve "$mode")" || { err "unknown mode '${1:-}'"; return 1; } ;; esac name="$(resolve_preset "$mode" "${2:-}")" || return 1 @@ -2357,23 +2506,19 @@ cmd_setup() { head_ "setup: $mode / preset '$name'" say "$(mode_label "$mode")" - case "$mode" in - openrouter) - printf '\n' - setup_key openrouter 'OpenRouter' - setup_models "$pf" openrouter "$name" - ;; - zai) - printf '\n' - say 'get a key from https://z.ai/manage-apikey/apikey-list' - setup_key zai 'Z.AI' - setup_models "$pf" zai "$name" - ;; - lmstudio) - setup_lmstudio_server "$pf" "$name" || return 1 - setup_models "$pf" lmstudio "$name" - ;; - esac + # A server provider is asked where it is and whether it wants a key; a + # hosted one always wants a key, and the entry says where to get one. + if [ "$(prov_field "$mode" 7)" = 1 ]; then + setup_server "$pf" "$name" "$mode" || return 1 + else + local ref url + ref="$(jget "$pf" auth.keyRef)"; [ -n "$ref" ] || ref="$(prov_field "$mode" 17)" + url="$(prov_field "$mode" 14)" + printf '\n' + [ -n "$url" ] && say "get a key from $url" + setup_key "$ref" "$(prov_field "$mode" 3)" + fi + setup_models "$pf" "$mode" "$name" mark_configured "$pf" printf '\n' @@ -2785,6 +2930,7 @@ else: command -v "$PY" >/dev/null 2>&1 || { echo "claude-mode: $PY not found (set CLAUDE_MODE_PYTHON)" >&2; exit 1; } init_root +cm_providers_load # --force is global and may appear anywhere; strip it before the mode arguments # are positional-matched below. @@ -2803,10 +2949,6 @@ case "$cmd" in ''|menu) ui_menu ;; status) cmd_status ;; anthropic) set_mode anthropic ;; - openrouter|zai|lmstudio) - p="$(resolve_preset "$cmd" "${1:-}")" || exit 1 - set_mode "$cmd" "$p" ;; - z.ai|z-ai) p="$(resolve_preset zai "${1:-}")" || exit 1; set_mode zai "$p" ;; presets) cmd_presets ;; preset) cmd_preset "$@" ;; set-key) @@ -2878,5 +3020,13 @@ case "$cmd" in for a in "$@"; do [ "$a" = "--all" ] && scope=all; done cmd_repair "$scope" ;; help|--help|-h) usage ;; - *) err "unknown command '$cmd'"; usage; exit 1 ;; + *) + # Any provider in providers.json, by id or alias, is a mode. + # Last, so a provider can never shadow a real command. + if _prov="$(provider_resolve "$cmd")"; then + p="$(resolve_preset "$_prov" "${1:-}")" || exit 1 + set_mode "$_prov" "$p" + else + err "unknown command '$cmd'"; usage; exit 1 + fi ;; esac diff --git a/linux/cm-json.py b/linux/cm-json.py index b5405e1..ea2a573 100644 --- a/linux/cm-json.py +++ b/linux/cm-json.py @@ -20,6 +20,10 @@ Subcommands: preset-rename [defaults] rename, repointing state + default auth-of "modekeyRef"; fails if missing set-default [name] choose (or clear) a provider default + provider-tsv | provider-resolve | provider-get | provider-static + read providers.json + ollama-models | openai-models catalogue parsers (JSON on stdin) + ollama-ctx show | ps 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 ` -# 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 ` 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 - a fixed catalogue from providers.json as idnote.""" + 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 (the matching Ollama response on stdin) + + show: the model's own maximum, from /api/show's model_info + ".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 - 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 - 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 - 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 -> 'idparametersquantizationfamily'. + + 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, diff --git a/linux/install.sh b/linux/install.sh index 6ba044c..c881a3c 100755 --- a/linux/install.sh +++ b/linux/install.sh @@ -82,6 +82,20 @@ if [ -f "$VERSION_SRC" ]; then green "version $(tr -d '[:space:]' < "$ROOT/VERSION")" fi +# --- providers (shared with the Windows build) ----------------------------- +# Always replaced, unlike presets: it is the tool's own table of what each +# provider is, not something a user edits. cm-json.py looks for it one level +# above bin/. +PROVIDERS_SRC="$SRC/../providers.json" +[ -f "$PROVIDERS_SRC" ] || PROVIDERS_SRC="$SRC/providers.json" +if [ -f "$PROVIDERS_SRC" ]; then + install -m 0644 "$PROVIDERS_SRC" "$ROOT/providers.json" + green "providers: $("$PY" -c 'import json,sys; print(", ".join(p["id"] for p in json.load(open(sys.argv[1]))["providers"]))' "$ROOT/providers.json")" +else + fail 'providers.json missing from the payload - claude-mode cannot run without it' + exit 1 +fi + # --- presets (shared with the Windows build) ------------------------------- PRESET_SRC="$SRC/../presets" [ -d "$PRESET_SRC" ] || PRESET_SRC="$SRC/presets" diff --git a/omarchy/smoido.claude-mode/BarWidget.qml b/omarchy/smoido.claude-mode/BarWidget.qml index c785bb2..825c54a 100644 --- a/omarchy/smoido.claude-mode/BarWidget.qml +++ b/omarchy/smoido.claude-mode/BarWidget.qml @@ -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) diff --git a/omarchy/smoido.claude-mode/Modes.js b/omarchy/smoido.claude-mode/Modes.js index 281fe06..4a742b9 100644 --- a/omarchy/smoido.claude-mode/Modes.js +++ b/omarchy/smoido.claude-mode/Modes.js @@ -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 diff --git a/omarchy/smoido.claude-mode/Panel.qml b/omarchy/smoido.claude-mode/Panel.qml index 372988d..0a532be 100644 --- a/omarchy/smoido.claude-mode/Panel.qml +++ b/omarchy/smoido.claude-mode/Panel.qml @@ -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." diff --git a/omarchy/smoido.claude-mode/manifest.json b/omarchy/smoido.claude-mode/manifest.json index cbb08a4..6baa2a5 100644 --- a/omarchy/smoido.claude-mode/manifest.json +++ b/omarchy/smoido.claude-mode/manifest.json @@ -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"], diff --git a/presets/custom.json b/presets/custom.json new file mode 100644 index 0000000..0be8985 --- /dev/null +++ b/presets/custom.json @@ -0,0 +1,19 @@ +{ + "provider": "custom", + "description": "Any Anthropic-compatible endpoint. Setup asks where it is and whether it needs a key.", + "baseUrl": "", + "auth": { + "mode": "vault", + "keyRef": "custom" + }, + "models": { + "opus": "", + "sonnet": "", + "haiku": "", + "fable": "" + }, + "subagentModel": "inherit", + "gatewayModelDiscovery": false, + "contextTokens": 200000, + "configured": false +} diff --git a/presets/ollama.json b/presets/ollama.json new file mode 100644 index 0000000..d71323c --- /dev/null +++ b/presets/ollama.json @@ -0,0 +1,22 @@ +{ + "provider": "ollama", + "description": "Local Ollama server. Setup picks the model from whatever the server has pulled.", + "baseUrl": "http://127.0.0.1:11434", + "auth": { + "mode": "literal", + "token": "ollama" + }, + "models": { + "opus": "qwen3-coder", + "sonnet": "qwen3-coder", + "haiku": "qwen3-coder", + "fable": "qwen3-coder" + }, + "subagentModel": "inherit", + "gatewayModelDiscovery": false, + "extraEnv": { + "CLAUDE_CODE_ATTRIBUTION_HEADER": "0" + }, + "contextTokens": 65536, + "configured": false +} diff --git a/providers.json b/providers.json new file mode 100644 index 0000000..8c8f07a --- /dev/null +++ b/providers.json @@ -0,0 +1,240 @@ +{ + "_comment": [ + "Every gateway provider claude-mode can switch to. anthropic is not here: it is the native login.", + "Read by linux/cm-json.py (CLI + bar widget, via health.json) and claude-mode.ps1 (Windows).", + "Behaviour that differs in kind is chosen by name: catalogue.kind (openrouter | lmstudio | ollama | openai | static),", + "server.probe (always | lenient | local), setup.key (required | optional), setup.models (per-tier | one-for-all),", + "and the doctor checks. A new provider that reuses those is an entry here and a preset in presets/.", + "Logos are single-path 24x24 SVG marks; logoScale corrects apparent size (see Modes.js)." + ], + "providers": [ + { + "id": "openrouter", + "aliases": [], + "title": "OpenRouter", + "label": "OpenRouter - remote, pay-per-token, any vendor", + "blurb": "Remote gateway, pay per token, any vendor.", + "color": "cyan", + "defaultPreset": "default", + "preset": { + "baseUrl": "https://openrouter.ai/api", + "auth": { + "mode": "vault", + "keyRef": "openrouter" + }, + "contextTokens": 1000000, + "gatewayModelDiscovery": true + }, + "server": { + "editable": false, + "probe": "local" + }, + "catalogue": { + "kind": "openrouter", + "url": "https://openrouter.ai/api/v1/models" + }, + "setup": { + "key": "required", + "models": "per-tier", + "keyUrl": "https://openrouter.ai/settings/keys" + }, + "guardrail": true, + "doctor": [ + "openrouter-key", + "guardrail", + "catalogue-models" + ], + "logo": "M16.778 1.844v1.919q-.569-.026-1.138-.032-.708-.008-1.415.037c-1.93.126-4.023.728-6.149 2.237-2.911 2.066-2.731 1.95-4.14 2.75-.396.223-1.342.574-2.185.798-.841.225-1.753.333-1.751.333v4.229s.768.108 1.61.333c.842.224 1.789.575 2.185.799 1.41.798 1.228.683 4.14 2.75 2.126 1.509 4.22 2.11 6.148 2.236.88.058 1.716.041 2.555.005v1.918l7.222-4.168-7.222-4.17v2.176c-.86.038-1.611.065-2.278.021-1.364-.09-2.417-.357-3.979-1.465-2.244-1.593-2.866-2.027-3.68-2.508.889-.518 1.449-.906 3.822-2.59 1.56-1.109 2.614-1.377 3.978-1.466.667-.044 1.418-.017 2.278.02v2.176L24 6.014Z", + "logoScale": 1.12, + "glyph": "F0469" + }, + { + "id": "zai", + "aliases": [ + "z.ai", + "z-ai" + ], + "title": "Z.AI", + "label": "Z.AI - GLM coding plan", + "blurb": "GLM coding plan on Z.AI's Anthropic endpoint.", + "color": "green", + "defaultPreset": "zai", + "preset": { + "baseUrl": "https://api.z.ai/api/anthropic", + "auth": { + "mode": "vault", + "keyRef": "zai" + }, + "contextTokens": 1000000, + "gatewayModelDiscovery": false, + "extraEnv": { + "API_TIMEOUT_MS": "3000000", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1" + } + }, + "server": { + "editable": false, + "probe": "local" + }, + "catalogue": { + "kind": "static", + "docs": "https://docs.z.ai/devpack/tool/claude", + "static": [ + { + "id": "glm-5.3", + "note": "flagship coding model - opus/sonnet tier" + }, + { + "id": "glm-4.7", + "note": "fast/cheap tier - haiku" + } + ] + }, + "setup": { + "key": "required", + "models": "per-tier", + "keyUrl": "https://z.ai/manage-apikey/apikey-list" + }, + "doctor": [ + "message-check" + ], + "logo": "M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z", + "logoScale": 1.07, + "glyph": "F015F" + }, + { + "id": "lmstudio", + "aliases": [ + "lm-studio" + ], + "title": "LM Studio", + "label": "LM Studio - local server, offline, free", + "blurb": "Local LM Studio server. Offline and free.", + "color": "yellow", + "defaultPreset": "lmstudio", + "preset": { + "baseUrl": "http://127.0.0.1:1234", + "auth": { + "mode": "literal", + "token": "lmstudio" + }, + "contextTokens": 262144, + "gatewayModelDiscovery": false, + "extraEnv": { + "CLAUDE_CODE_ATTRIBUTION_HEADER": "0" + } + }, + "server": { + "editable": true, + "probe": "always", + "paths": [ + "/api/v0/models", + "/v1/models" + ], + "hint": "LM Studio does not have to be on this machine. Point this at a LAN address, or anything reachable through a tunnel or proxy.", + "start": "start the server (Developer > Start Server)" + }, + "catalogue": { + "kind": "lmstudio", + "perServer": true + }, + "setup": { + "key": "optional", + "models": "one-for-all" + }, + "doctor": [ + "catalogue-models", + "lmstudio-templates" + ], + "logo": "M14.025 0c3.492 0 5.237 0 6.571.68a6.24 6.24 0 0 1 2.725 2.724C24 4.738 24 6.484 24 9.975v4.05c0 3.492 0 5.237-.68 6.571a6.24 6.24 0 0 1-2.724 2.725c-1.334.679-3.08.679-6.571.679h-4.05c-3.492 0-5.237 0-6.571-.68A6.24 6.24 0 0 1 .68 20.597C0 19.262 0 17.516 0 14.025v-4.05c0-3.492 0-5.237.68-6.571A6.23 6.23 0 0 1 3.404.68C4.738 0 6.484 0 9.975 0zM7.688 16.313a1.313 1.313 0 0 0 0 2.625h11.625a1.313 1.313 0 0 0 0-2.625zm-3-3.75a1.313 1.313 0 0 0 0 2.624h11.625a1.313 1.313 0 0 0 0-2.624zm3-3.75a1.313 1.313 0 0 0 0 2.624h11.625a1.313 1.313 0 0 0 0-2.624zm-3-3.75a1.313 1.313 0 0 0 0 2.625h11.625a1.313 1.313 0 0 0 0-2.625z", + "logoScale": 0.86, + "glyph": "F048B" + }, + { + "id": "ollama", + "aliases": [], + "title": "Ollama", + "label": "Ollama - local server, offline, free", + "blurb": "Local Ollama server. Offline and free.", + "color": "white", + "defaultPreset": "ollama", + "preset": { + "baseUrl": "http://127.0.0.1:11434", + "auth": { + "mode": "literal", + "token": "ollama" + }, + "contextTokens": 65536, + "gatewayModelDiscovery": false, + "extraEnv": { + "CLAUDE_CODE_ATTRIBUTION_HEADER": "0" + } + }, + "server": { + "editable": true, + "probe": "always", + "paths": [ + "/api/tags" + ], + "hint": "Ollama does not have to be on this machine. Serve it with OLLAMA_HOST=0.0.0.0 to reach it over the LAN.", + "start": "start it with: ollama serve" + }, + "catalogue": { + "kind": "ollama", + "perServer": true + }, + "setup": { + "key": "optional", + "models": "one-for-all" + }, + "doctor": [ + "catalogue-models", + "ollama-context" + ], + "logo": "M16.361 10.26a.894.894 0 0 0-.558.47l-.072.148.001.207c0 .193.004.217.059.353.076.193.152.312.291.448.24.238.51.3.872.205a.86.86 0 0 0 .517-.436.752.752 0 0 0 .08-.498c-.064-.453-.33-.782-.724-.897a1.06 1.06 0 0 0-.466 0zm-9.203.005c-.305.096-.533.32-.65.639a1.187 1.187 0 0 0-.06.52c.057.309.31.59.598.667.362.095.632.033.872-.205.14-.136.215-.255.291-.448.055-.136.059-.16.059-.353l.001-.207-.072-.148a.894.894 0 0 0-.565-.472 1.02 1.02 0 0 0-.474.007Zm4.184 2c-.131.071-.223.25-.195.383.031.143.157.288.353.407.105.063.112.072.117.136.004.038-.01.146-.029.243-.02.094-.036.194-.036.222.002.074.07.195.143.253.064.052.076.054.255.059.164.005.198.001.264-.03.169-.082.212-.234.15-.525-.052-.243-.042-.28.087-.355.137-.08.281-.219.324-.314a.365.365 0 0 0-.175-.48.394.394 0 0 0-.181-.033c-.126 0-.207.03-.355.124l-.085.053-.053-.032c-.219-.13-.259-.145-.391-.143a.396.396 0 0 0-.193.032zm.39-2.195c-.373.036-.475.05-.654.086-.291.06-.68.195-.951.328-.94.46-1.589 1.226-1.787 2.114-.04.176-.045.234-.045.53 0 .294.005.357.043.524.264 1.16 1.332 2.017 2.714 2.173.3.033 1.596.033 1.896 0 1.11-.125 2.064-.727 2.493-1.571.114-.226.169-.372.22-.602.039-.167.044-.23.044-.523 0-.297-.005-.355-.045-.531-.288-1.29-1.539-2.304-3.072-2.497a6.873 6.873 0 0 0-.855-.031zm.645.937a3.283 3.283 0 0 1 1.44.514c.223.148.537.458.671.662.166.251.26.508.303.82.02.143.01.251-.043.482-.08.345-.332.705-.672.957a3.115 3.115 0 0 1-.689.348c-.382.122-.632.144-1.525.138-.582-.006-.686-.01-.853-.042-.57-.107-1.022-.334-1.35-.68-.264-.28-.385-.535-.45-.946-.03-.192.025-.509.137-.776.136-.326.488-.73.836-.963.403-.269.934-.46 1.422-.512.187-.02.586-.02.773-.002zm-5.503-11a1.653 1.653 0 0 0-.683.298C5.617.74 5.173 1.666 4.985 2.819c-.07.436-.119 1.04-.119 1.503 0 .544.064 1.24.155 1.721.02.107.031.202.023.208a8.12 8.12 0 0 1-.187.152 5.324 5.324 0 0 0-.949 1.02 5.49 5.49 0 0 0-.94 2.339 6.625 6.625 0 0 0-.023 1.357c.091.78.325 1.438.727 2.04l.13.195-.037.064c-.269.452-.498 1.105-.605 1.732-.084.496-.095.629-.095 1.294 0 .67.009.803.088 1.266.095.555.288 1.143.503 1.534.071.128.243.393.264.407.007.003-.014.067-.046.141a7.405 7.405 0 0 0-.548 1.873c-.062.417-.071.552-.071.991 0 .56.031.832.148 1.279L3.42 24h1.478l-.05-.091c-.297-.552-.325-1.575-.068-2.597.117-.472.25-.819.498-1.296l.148-.29v-.177c0-.165-.003-.184-.057-.293a.915.915 0 0 0-.194-.25 1.74 1.74 0 0 1-.385-.543c-.424-.92-.506-2.286-.208-3.451.124-.486.329-.918.544-1.154a.787.787 0 0 0 .223-.531c0-.195-.07-.355-.224-.522a3.136 3.136 0 0 1-.817-1.729c-.14-.96.114-2.005.69-2.834.563-.814 1.353-1.336 2.237-1.475.199-.033.57-.028.776.01.226.04.367.028.512-.041.179-.085.268-.19.374-.431.093-.215.165-.333.36-.576.234-.29.46-.489.822-.729.413-.27.884-.467 1.352-.561.17-.035.25-.04.569-.04.319 0 .398.005.569.04a4.07 4.07 0 0 1 1.914.997c.117.109.398.457.488.602.034.057.095.177.132.267.105.241.195.346.374.43.14.068.286.082.503.045.343-.058.607-.053.943.016 1.144.23 2.14 1.173 2.581 2.437.385 1.108.276 2.267-.296 3.153-.097.15-.193.27-.333.419-.301.322-.301.722-.001 1.053.493.539.801 1.866.708 3.036-.062.772-.26 1.463-.533 1.854a2.096 2.096 0 0 1-.224.258.916.916 0 0 0-.194.25c-.054.109-.057.128-.057.293v.178l.148.29c.248.476.38.823.498 1.295.253 1.008.231 2.01-.059 2.581a.845.845 0 0 0-.044.098c0 .006.329.009.732.009h.73l.02-.074.036-.134c.019-.076.057-.3.088-.516.029-.217.029-1.016 0-1.258-.11-.875-.295-1.57-.597-2.226-.032-.074-.053-.138-.046-.141.008-.005.057-.074.108-.152.376-.569.607-1.284.724-2.228.031-.26.031-1.378 0-1.628-.083-.645-.182-1.082-.348-1.525a6.083 6.083 0 0 0-.329-.7l-.038-.064.131-.194c.402-.604.636-1.262.727-2.04a6.625 6.625 0 0 0-.024-1.358 5.512 5.512 0 0 0-.939-2.339 5.325 5.325 0 0 0-.95-1.02 8.097 8.097 0 0 1-.186-.152.692.692 0 0 1 .023-.208c.208-1.087.201-2.443-.017-3.503-.19-.924-.535-1.658-.98-2.082-.354-.338-.716-.482-1.15-.455-.996.059-1.8 1.205-2.116 3.01a6.805 6.805 0 0 0-.097.726c0 .036-.007.066-.015.066a.96.96 0 0 1-.149-.078A4.857 4.857 0 0 0 12 3.03c-.832 0-1.687.243-2.456.698a.958.958 0 0 1-.148.078c-.008 0-.015-.03-.015-.066a6.71 6.71 0 0 0-.097-.725C8.997 1.392 8.337.319 7.46.048a2.096 2.096 0 0 0-.585-.041Zm.293 1.402c.248.197.523.759.682 1.388.03.113.06.244.069.292.007.047.026.152.041.233.067.365.098.76.102 1.24l.002.475-.12.175-.118.178h-.278c-.324 0-.646.041-.954.124l-.238.06c-.033.007-.038-.003-.057-.144a8.438 8.438 0 0 1 .016-2.323c.124-.788.413-1.501.696-1.711.067-.05.079-.049.157.013zm9.825-.012c.17.126.358.46.498.888.28.854.36 2.028.212 3.145-.019.14-.024.151-.057.144l-.238-.06a3.693 3.693 0 0 0-.954-.124h-.278l-.119-.178-.119-.175.002-.474c.004-.669.066-1.19.214-1.772.157-.623.434-1.185.68-1.382.078-.062.09-.063.159-.012z", + "logoScale": 1.0, + "glyph": "" + }, + { + "id": "custom", + "aliases": [], + "title": "Custom", + "label": "Custom - any Anthropic-compatible endpoint", + "blurb": "Your own endpoint: a proxy, gateway or self-hosted server.", + "color": "gray", + "defaultPreset": "custom", + "preset": { + "baseUrl": "", + "auth": { + "mode": "vault", + "keyRef": "custom" + }, + "contextTokens": 200000, + "gatewayModelDiscovery": false + }, + "server": { + "editable": true, + "probe": "lenient", + "paths": [ + "/v1/models" + ], + "hint": "Any server that speaks Anthropic's Messages API: a LiteLLM or Vercel gateway, vLLM, llama.cpp, a company proxy.", + "start": "" + }, + "catalogue": { + "kind": "openai", + "perServer": true + }, + "setup": { + "key": "optional", + "models": "per-tier" + }, + "doctor": [ + "catalogue-models" + ], + "logo": "M9 3.5 1 12l8 8.5 2-2L4.8 12 11 5.5zm6 0-2 2 6.2 6.5-6.2 6.5 2 2 8-8.5z", + "logoScale": 1.09, + "glyph": "" + } + ] +} diff --git a/scripts/build-package.ps1 b/scripts/build-package.ps1 index 12b9f78..2590045 100644 --- a/scripts/build-package.ps1 +++ b/scripts/build-package.ps1 @@ -33,6 +33,7 @@ Copy-Item (Join-Path $root 'claude-mode.ps1') $stage -Force Copy-Item (Join-Path $root 'install.ps1') $stage -Force Copy-Item (Join-Path $root 'profile-snippet.ps1') $stage -Force Copy-Item (Join-Path $root 'VERSION') $stage -Force +Copy-Item (Join-Path $root 'providers.json') $stage -Force Get-ChildItem (Join-Path $root 'bin') -File | ForEach-Object { Copy-Item $_.FullName (Join-Path $stage 'bin') -Force } Get-ChildItem (Join-Path $root 'presets') -File | ForEach-Object { Copy-Item $_.FullName (Join-Path $stage 'presets') -Force } @@ -81,6 +82,7 @@ New-Item -ItemType Directory -Path (Join-Path $posixStage 'presets') -Force | Ou Get-ChildItem (Join-Path $root 'linux') -File | ForEach-Object { Copy-Item $_.FullName (Join-Path $posixStage 'linux') -Force } Get-ChildItem (Join-Path $root 'presets') -File | ForEach-Object { Copy-Item $_.FullName (Join-Path $posixStage 'presets') -Force } Copy-Item (Join-Path $root 'VERSION') $posixStage -Force +Copy-Item (Join-Path $root 'providers.json') $posixStage -Force # Shell scripts authored on Windows carry CRLF, which makes the kernel reject # the "#!/usr/bin/env bash" line. Normalise before packaging. @@ -92,7 +94,7 @@ Get-ChildItem (Join-Path $posixStage 'linux') -File | ForEach-Object { $tarName = "claude-mode-$version-posix.tar.gz" $tarPath = Join-Path $outDir $tarName if (Test-Path -LiteralPath $tarPath) { Remove-Item -LiteralPath $tarPath -Force } -& tar.exe -czf $tarPath -C $posixStage 'linux' 'presets' 'VERSION' +& tar.exe -czf $tarPath -C $posixStage 'linux' 'presets' 'VERSION' 'providers.json' if ($LASTEXITCODE -ne 0) { throw 'tar failed - cannot build the POSIX package' } $shaPosix = (Get-FileHash -LiteralPath $tarPath -Algorithm SHA256).Hash.ToLower()