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:
+361
-188
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user