Files
claude-mode/lib/catalogue.ps1
T
smoidoandClaude Opus 5 4091746d4e Split the Windows script into modules; drop the Arkylx Index pieces
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>
2026-09-15 02:06:45 +03:00

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.'
}
}
}
}