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>
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
# 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.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user