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