claude-mode.ps1 was 2,407 lines. It is now 153: the help block, parameters, paths, the managed-key list, a loader, and the dispatch. The rest moved, verbatim, into eleven files under lib/ - providers, output, files, core, vault, switch, guards, health, catalogue, commands, menu - dot-sourced into the script's scope in their original order, with the same check as the bash split that every original line landed in exactly one file. Inside a module $PSScriptRoot is lib\, so the one path beside the main script (providers.json) now goes through $script:Here. install.ps1 ships lib\, clearing old modules first. The Windows suite parses every module and install.ps1 (36 checks, all green on Windows PowerShell 5.1); install.ps1 is parsed but never run, since it edits the real profile and User PATH. linux/bootstrap.sh and scripts/build-package.ps1 existed only to build and serve packages for the Arkylx Index. Both installers fetch the repository's own archive, so a push to master is the release; the two scripts, the dist/ ignore and their mentions in the docs are gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
310 lines
15 KiB
PowerShell
310 lines
15 KiB
PowerShell
# lib/catalogue.ps1 - provider model lists, and the checks doctor runs against them.
|
|
#
|
|
# Part of claude-mode.ps1, which dot-sources it into its own script scope after
|
|
# the settings at its top. Not meant to run on its own. ASCII only: Windows
|
|
# PowerShell 5.1 reads a .ps1 without a BOM as ANSI. $PSScriptRoot here would be
|
|
# lib\, so paths beside the main script go through $script:Here.
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Provider probes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# LM Studio's /v1/models lists only *loaded* instances, so an installed model
|
|
# that has idle-unloaded disappears from it. /api/v0/models lists everything
|
|
# with a load state, which is what we want: LM Studio JIT-loads on first
|
|
# request, so "installed but not loaded" is fine - "not installed" is not.
|
|
function Get-LmStudioModels {
|
|
param([string] $BaseUrl)
|
|
$base = $BaseUrl.TrimEnd('/')
|
|
try {
|
|
return @((Invoke-RestMethod -Uri "$base/api/v0/models" -TimeoutSec 10).data |
|
|
ForEach-Object {
|
|
[pscustomobject]@{
|
|
Id = $_.id
|
|
State = $_.state
|
|
Ctx = $(if ($_.PSObject.Properties.Name -contains 'max_context_length') { $_.max_context_length } else { $null })
|
|
}
|
|
})
|
|
} catch {
|
|
return @((Invoke-RestMethod -Uri "$base/v1/models" -TimeoutSec 10).data |
|
|
ForEach-Object { [pscustomobject]@{ Id = $_.id; State = 'unknown'; Ctx = $null } })
|
|
}
|
|
}
|
|
|
|
# Some GGUF chat templates hard-assert message ordering, e.g.
|
|
# {%- if message.role == "system" %}{%- if not loop.first %}
|
|
# {{- raise_exception('System message must be at the beginning.') }}
|
|
# Runtimes that auto-generate a tool-call parser probe the template with
|
|
# synthetic message sequences; those probes trip the assertion and the request
|
|
# dies with "Unable to generate parser for this template". Detect it up front
|
|
# instead of letting the user hit a wall of [Server Error] spam.
|
|
$script:TemplateAssertions = @(
|
|
'System message must be at the beginning',
|
|
'No user query found in messages'
|
|
)
|
|
|
|
function Get-LmStudioTemplateReport {
|
|
$cache = Join-Path $script:LmStudioDir '.internal\gguf-metadata-cache.json'
|
|
if (-not (Test-Path -LiteralPath $cache)) { return @() }
|
|
|
|
$out = @()
|
|
try {
|
|
$j = Get-Content -LiteralPath $cache -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
foreach ($entry in $j.json.map) {
|
|
$path = [string]$entry[0]
|
|
$meta = $entry[1]
|
|
if (-not $meta -or -not $meta.metadata) { continue }
|
|
if ($meta.metadata.PSObject.Properties.Name -notcontains 'chatTemplate') { continue }
|
|
$tpl = [string]$meta.metadata.chatTemplate
|
|
if (-not $tpl) { continue }
|
|
|
|
$hits = @()
|
|
foreach ($a in $script:TemplateAssertions) { if ($tpl.Contains($a)) { $hits += $a } }
|
|
|
|
# Derive the id LM Studio serves this file under: the repo folder
|
|
# name, lowercased, minus the -GGUF suffix.
|
|
$folder = Split-Path -Leaf (Split-Path -Parent ($path -replace '/', '\'))
|
|
$key = ($folder -replace '(?i)-GGUF$', '').ToLower()
|
|
|
|
$out += [pscustomobject]@{ Key = $key; Path = $path; Assertions = $hits }
|
|
}
|
|
} catch { return @() }
|
|
return $out
|
|
}
|
|
|
|
function Test-LmStudioTemplate {
|
|
param([string] $ModelId)
|
|
$short = ($ModelId -split '/')[-1].ToLower()
|
|
$rep = Get-LmStudioTemplateReport | Where-Object { $_.Key -eq $short } | Select-Object -First 1
|
|
if (-not $rep) { return $null }
|
|
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
|
|
# offenders rather than silently trusting the preset.
|
|
function Test-ContextWindow {
|
|
param($Preset, $Catalogue)
|
|
|
|
if (-not ($Preset.Contains('contextTokens') -and $Preset['contextTokens'])) {
|
|
Write-Warn2 'preset has no contextTokens - Claude Code will guess a small window and auto-compact early.'
|
|
Write-Warn2 " Fix: add \"contextTokens\": 1000000 to $((Get-PresetPath ([string](Get-State)['preset']))) "
|
|
return
|
|
}
|
|
|
|
$declared = [int]$Preset['contextTokens']
|
|
Write-Ok ("declared context window: {0:N0} tokens" -f $declared)
|
|
|
|
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 -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 {
|
|
Write-Warn2 ' This tier can overflow. Lower contextTokens or pick a bigger model.'
|
|
}
|
|
}
|
|
}
|
|
}
|