Providers as data; add Ollama and Custom endpoints

Every gateway provider is now an entry in providers.json - endpoint and auth
template, how its model list is read, how it is probed before a switch, what
setup asks, which doctor checks apply, and its title, colour and logo -
installed next to the presets and read by all three consumers: the bash CLI
(through cm-json.py), the Windows script, and the bar widget (through
health.json). anthropic stays built in; it is the native login, not a gateway.

Behaviour that differs in kind stays in code, chosen by name from the entry:
catalogue parsers (openrouter, lmstudio, ollama, openai, static), probe rules
(always, lenient, local), and named doctor checks. A provider that reuses them
is an entry and a default preset, with no code. The widget draws providers
from health.json, so a new one needs no QML change and no shell restart.

The existing three are unchanged in behaviour: their blank presets come out
byte-identical from the file, and setup, doctor, models and the picker run the
same checks through the generic paths.

Ollama: local server on :11434, placeholder token, one model for every tier,
models from /api/tags. doctor reads the context each loaded model actually runs
with (/api/ps) and its maximum (/api/show), because Ollama defaults to 4096
tokens unless OLLAMA_CONTEXT_LENGTH is set and silently truncates past it. A
bare model name matches its :latest tag.

Custom: any Anthropic-compatible endpoint. Ships with no address and is refused
until it has one; key optional; models from /v1/models when the endpoint has a
list, and a lenient probe so a proxy without one is not blocked.

Also: preflight (and Set-ClaudeMode on Windows) refuses a preset with no
server address; the server form, setup and set-auth use each provider's own
default URL, key name and placeholder token instead of LM Studio's; the
Windows build gains the no-models and no-address guards it never had.
Tested on Linux against fake Ollama/Custom servers, and on Windows 5.1 in a
USERPROFILE sandbox on winbox.

1.12.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
smoido
2026-09-15 01:20:33 +03:00
co-authored by Claude Opus 5
parent a54f7bcb55
commit b5824c6611
15 changed files with 1482 additions and 480 deletions
+361 -188
View File
@@ -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 <provider>` 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 <name>
@@ -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 <tier> <model-id>"
}
# 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