apiKeyHelper is a shell command line, not a path, so the raw value written into settings.json was split at the first space. A Windows profile named "Mohammed Ahmed" produced an attempt to run C:\Users\Mohammed, surfacing as "your apiKeyHelper script is failing" with nothing to go on. Quote the value when it contains anything a shell cares about, and leave it bare otherwise so no existing settings.json churns on the next switch. The POSIX port had the same bug against a /Users/First Last home; shlex.quote has exactly the wanted "leave ordinary paths alone" behaviour. doctor could not see any of this. It quoted the path itself before running it, so it exercised a command line Claude Code never uses and passed while the real one failed. It now reads the string out of settings.json, reports it when it is not what a switch would write, and runs that string through a shell. The helper itself exited 1 in silence on four distinct faults - no state, no preset, no key, undecryptable key - collapsing them into one indistinguishable message. Each now names itself on stderr, which is what /status displays. The DPAPI case says what it actually means: a key stored by a different Windows account than the one Claude Code runs as. Success paths stay silent, so stdout still carries the key and nothing else. Also make install.ps1 survive a Restricted execution policy: piped through iex it is not subject to the policy, but invoking the installed script for the key prompt is, which is where a fresh install died. Set Process scope for the install, offer to set CurrentUser to RemoteSigned, and clear the mark-of-the-web that Expand-Archive can leave on the extracted scripts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2214 lines
96 KiB
PowerShell
2214 lines
96 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
claude-mode - switch Claude Code system-wide between native Anthropic auth,
|
|
OpenRouter, Z.AI, and a local LM Studio server, with named model presets.
|
|
|
|
.DESCRIPTION
|
|
State lives in %USERPROFILE%\.claude-mode. Switching rewrites the managed keys
|
|
inside %USERPROFILE%\.claude\settings.json, which Claude Code re-reads at every
|
|
startup - so a switch applies to every new `claude` invocation from any shell,
|
|
the VS Code extension, and the desktop app, with nothing to re-source.
|
|
|
|
Secrets are never written to settings.json. A remote provider's API key is
|
|
stored DPAPI-encrypted (bound to this Windows user + machine) and handed to
|
|
Claude Code at runtime via the `apiKeyHelper` hook. LM Studio's placeholder
|
|
token is not a secret and is written inline.
|
|
|
|
Run with no arguments for an interactive menu.
|
|
|
|
.NOTES
|
|
Windows PowerShell 5.1 compatible. No external dependencies.
|
|
#>
|
|
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Position = 0)]
|
|
[string] $Command = '',
|
|
|
|
[Parameter(Position = 1, ValueFromRemainingArguments = $true)]
|
|
[string[]] $Rest
|
|
)
|
|
|
|
# v1 only (uninitialised variables). v2 would throw on absent JSON properties,
|
|
# which is normal here - presets and settings.json are both partially shaped.
|
|
Set-StrictMode -Version 1.0
|
|
$ErrorActionPreference = 'Stop'
|
|
try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch { }
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Paths
|
|
# ---------------------------------------------------------------------------
|
|
|
|
$script:Root = Join-Path $env:USERPROFILE '.claude-mode'
|
|
$script:PresetDir = Join-Path $script:Root 'presets'
|
|
$script:VaultDir = Join-Path $script:Root 'vault'
|
|
$script:BackupDir = Join-Path $script:Root 'backups'
|
|
$script:BinDir = Join-Path $script:Root 'bin'
|
|
$script:StatePath = Join-Path $script:Root 'state.json'
|
|
$script:HelperCmd = Join-Path $script:BinDir 'claude-key-helper.cmd'
|
|
$script:SettingsDir = Join-Path $env:USERPROFILE '.claude'
|
|
$script:Settings = Join-Path $script:SettingsDir 'settings.json'
|
|
$script:LmStudioDir = Join-Path $env:USERPROFILE '.lmstudio'
|
|
|
|
# Claude Code runs apiKeyHelper as a shell command line, not as a bare argv[0],
|
|
# so the value in settings.json is parsed by cmd before anything is executed. A
|
|
# profile path containing a space therefore has to arrive already quoted:
|
|
# C:\Users\Firstname Lastname\... otherwise splits and cmd tries to run
|
|
# C:\Users\Firstname. Paths with nothing cmd cares about are written bare,
|
|
# exactly as before, so no existing settings.json churns on the next switch.
|
|
function Get-HelperCommandLine {
|
|
param([string] $Path = $script:HelperCmd)
|
|
if ($Path -match '[\s&()^;,]') { return '"' + $Path + '"' }
|
|
return $Path
|
|
}
|
|
|
|
# Baseline set of settings.json env keys this tool owns. On every switch these
|
|
# are deleted first, together with whatever a previous switch actually wrote
|
|
# (tracked in state.json), so no value can survive a mode change.
|
|
$script:BaseManagedEnvKeys = @(
|
|
'ANTHROPIC_BASE_URL',
|
|
'ANTHROPIC_AUTH_TOKEN',
|
|
'ANTHROPIC_API_KEY',
|
|
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
|
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
|
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
|
'ANTHROPIC_DEFAULT_FABLE_MODEL',
|
|
# Both are read by the CLI (21 and 37 references in 2.1.221) and both pin a
|
|
# concrete model outside the tier mapping. A stale value in either survives a
|
|
# switch and is a known cause of "works normally, dies on compaction",
|
|
# because background summarisation uses the small/fast slot.
|
|
'ANTHROPIC_MODEL',
|
|
'ANTHROPIC_SMALL_FAST_MODEL',
|
|
'CLAUDE_CODE_SUBAGENT_MODEL',
|
|
'CLAUDE_CODE_DISABLE_1M_CONTEXT',
|
|
'CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY',
|
|
'CLAUDE_CODE_ATTRIBUTION_HEADER',
|
|
'CLAUDE_CODE_AUTO_COMPACT_WINDOW',
|
|
'CLAUDE_CODE_MAX_CONTEXT_TOKENS',
|
|
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
|
|
'API_TIMEOUT_MS'
|
|
)
|
|
|
|
$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'
|
|
}
|
|
|
|
# `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'
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Output helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
function Write-Ok ($m) { Write-Host " ok $m" -ForegroundColor Green }
|
|
function Write-Warn2 ($m) { Write-Host " warn $m" -ForegroundColor Yellow }
|
|
function Write-Err2 ($m) { Write-Host " FAIL $m" -ForegroundColor Red }
|
|
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'
|
|
}
|
|
|
|
# Deliberately ASCII-only. This file is read by Windows PowerShell 5.1, which
|
|
# assumes the ANSI codepage for a .ps1 without a BOM - box-drawing characters
|
|
# would arrive mangled on some machines. Plain ASCII always renders.
|
|
$script:Banner = @(
|
|
' ____ _ _ __ __ _ ',
|
|
' / ___| | __ _ _ _ __| | ___ | \/ | ___ __| | ___ ',
|
|
' | | | |/ _` | | | |/ _` |/ _ \ | |\/| |/ _ \ / _` |/ _ \',
|
|
' | |___| | (_| | |_| | (_| | __/ | | | | (_) | (_| | __/',
|
|
' \____|_|\__,_|\__,_|\__,_|\___| |_| |_|\___/ \__,_|\___|'
|
|
)
|
|
|
|
function Show-Banner {
|
|
param([string] $Mode, [string] $Preset)
|
|
|
|
$shades = @('DarkCyan', 'Cyan', 'Cyan', 'Cyan', 'DarkCyan')
|
|
Write-Host ''
|
|
for ($i = 0; $i -lt $script:Banner.Count; $i++) {
|
|
Write-Host $script:Banner[$i] -ForegroundColor $shades[$i]
|
|
}
|
|
|
|
$accent = $script:ModeColor[$Mode]
|
|
if (-not $accent) { $accent = 'Gray' }
|
|
$tag = if ($Preset) { "$Mode / $Preset" } else { $Mode }
|
|
|
|
Write-Host ' ' -NoNewline
|
|
Write-Host ('-' * 59) -ForegroundColor DarkGray
|
|
Write-Host ' now ' -NoNewline -ForegroundColor DarkGray
|
|
Write-Host $tag -NoNewline -ForegroundColor $accent
|
|
Write-Host ' ' -NoNewline
|
|
Write-Host 'switch Claude Code between providers' -ForegroundColor DarkGray
|
|
}
|
|
|
|
function Show-Usage {
|
|
@'
|
|
claude-mode - switch Claude Code between Anthropic, OpenRouter, Z.AI, LM Studio
|
|
|
|
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)
|
|
|
|
claude-mode presets list presets
|
|
claude-mode preset show <name>
|
|
claude-mode preset new <name> [from] create a preset (copies 'from')
|
|
claude-mode preset set <name> <tier> <model-id>
|
|
tier = opus | sonnet | haiku | fable | subagent
|
|
claude-mode preset all <name> <model-id>
|
|
point every tier at one model
|
|
claude-mode preset rm <name>
|
|
|
|
claude-mode set-key [ref] store an API key (hidden prompt, DPAPI-encrypted)
|
|
claude-mode models [filter] models available from the active provider
|
|
claude-mode doctor verify auth, endpoint, model ids, stray env vars
|
|
claude-mode repair [--all] strip [1m] tags from cached model ids
|
|
(default: gateway ids only; --all includes Anthropic)
|
|
'@ | Write-Host
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# JSON helpers (PS 5.1 has no ConvertFrom-Json -AsHashtable)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
function ConvertTo-DeepHashtable {
|
|
param($InputObject)
|
|
|
|
if ($null -eq $InputObject) { return $null }
|
|
|
|
if ($InputObject -is [System.Collections.IDictionary]) {
|
|
$h = [ordered]@{}
|
|
foreach ($k in $InputObject.Keys) { $h[[string]$k] = ConvertTo-DeepHashtable $InputObject[$k] }
|
|
return $h
|
|
}
|
|
|
|
if ($InputObject -is [System.Management.Automation.PSCustomObject]) {
|
|
$h = [ordered]@{}
|
|
foreach ($p in $InputObject.PSObject.Properties) { $h[$p.Name] = ConvertTo-DeepHashtable $p.Value }
|
|
return $h
|
|
}
|
|
|
|
if ($InputObject -is [string]) { return $InputObject }
|
|
|
|
if ($InputObject -is [System.Collections.IEnumerable]) {
|
|
$list = New-Object System.Collections.ArrayList
|
|
foreach ($item in $InputObject) { [void]$list.Add((ConvertTo-DeepHashtable $item)) }
|
|
return , $list.ToArray()
|
|
}
|
|
|
|
return $InputObject
|
|
}
|
|
|
|
function Read-JsonFile {
|
|
param([string] $Path)
|
|
if (-not (Test-Path -LiteralPath $Path)) { return $null }
|
|
$raw = Get-Content -LiteralPath $Path -Raw -Encoding UTF8
|
|
if ([string]::IsNullOrWhiteSpace($raw)) { return $null }
|
|
return ConvertTo-DeepHashtable (ConvertFrom-Json $raw)
|
|
}
|
|
|
|
function Write-JsonFile {
|
|
param([string] $Path, $Data)
|
|
$dir = Split-Path -Parent $Path
|
|
if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
|
|
$json = Format-JsonPretty ($Data | ConvertTo-Json -Depth 100)
|
|
# UTF-8 without BOM; some JSON readers choke on a BOM.
|
|
[System.IO.File]::WriteAllText($Path, $json, (New-Object System.Text.UTF8Encoding($false)))
|
|
}
|
|
|
|
# PowerShell 5.1's ConvertTo-Json indents by aligning values into a column,
|
|
# which is valid but painful to hand-edit. Re-indent with node when available.
|
|
function Format-JsonPretty {
|
|
param([string] $Json)
|
|
if ($null -eq $script:NodeExe) {
|
|
$c = Get-Command node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
|
|
if ($c) { $script:NodeExe = $c.Source } else { $script:NodeExe = '' }
|
|
}
|
|
if (-not $script:NodeExe) { return $Json }
|
|
|
|
$tmp = [System.IO.Path]::GetTempFileName()
|
|
try {
|
|
[System.IO.File]::WriteAllText($tmp, $Json, (New-Object System.Text.UTF8Encoding($false)))
|
|
$out = & $script:NodeExe -e "const fs=require('fs');process.stdout.write(JSON.stringify(JSON.parse(fs.readFileSync(process.argv[1],'utf8')),null,2)+'\n')" $tmp
|
|
if ($out) { return (($out -join "`n") + "`n") }
|
|
return $Json
|
|
} catch {
|
|
return $Json
|
|
} finally {
|
|
Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ACL hardening - restrict a file to the current user only
|
|
# ---------------------------------------------------------------------------
|
|
|
|
function Protect-FileAcl {
|
|
param([string] $Path)
|
|
try {
|
|
$acl = Get-Acl -LiteralPath $Path
|
|
if ($acl.AreAccessRulesProtected) { return }
|
|
$acl.SetAccessRuleProtection($true, $false)
|
|
foreach ($rule in @($acl.Access)) { [void]$acl.RemoveAccessRule($rule) }
|
|
$me = New-Object System.Security.Principal.NTAccount($env:USERDOMAIN, $env:USERNAME)
|
|
$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule(
|
|
$me, 'FullControl', 'None', 'None', 'Allow')))
|
|
Set-Acl -LiteralPath $Path -AclObject $acl
|
|
} catch {
|
|
Write-Warn2 "could not harden ACL on $Path : $($_.Exception.Message)"
|
|
}
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# State / presets
|
|
# ---------------------------------------------------------------------------
|
|
|
|
function Initialize-Root {
|
|
foreach ($d in @($script:Root, $script:PresetDir, $script:VaultDir, $script:BackupDir, $script:BinDir)) {
|
|
if (-not (Test-Path -LiteralPath $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null }
|
|
}
|
|
}
|
|
|
|
function Get-State {
|
|
$s = Read-JsonFile $script:StatePath
|
|
if ($null -eq $s) { $s = [ordered]@{} }
|
|
if (-not $s.Contains('mode')) { $s['mode'] = 'anthropic' }
|
|
if (-not $s.Contains('preset')) { $s['preset'] = '' }
|
|
if (-not $s.Contains('writtenEnvKeys')) { $s['writtenEnvKeys'] = @() }
|
|
return $s
|
|
}
|
|
|
|
function Set-State {
|
|
param([string] $Mode, [string] $PresetName, [string[]] $WrittenKeys)
|
|
$s = Get-State
|
|
$s['mode'] = $Mode
|
|
$s['preset'] = $PresetName
|
|
$s['writtenEnvKeys'] = @($WrittenKeys)
|
|
$s['updated'] = (Get-Date).ToString('o')
|
|
# Written by older builds that picked "most recently used" presets; the
|
|
# per-provider default is fixed now, so nothing maintains this.
|
|
if ($s.Contains('lastByProvider')) { $s.Remove('lastByProvider') }
|
|
Write-JsonFile $script:StatePath $s
|
|
}
|
|
|
|
function Get-PresetPath { param([string] $Name) return (Join-Path $script:PresetDir "$Name.json") }
|
|
|
|
function Get-Preset {
|
|
param([string] $Name)
|
|
$p = Read-JsonFile (Get-PresetPath $Name)
|
|
if ($null -eq $p) { throw "preset '$Name' not found. Run: claude-mode presets" }
|
|
if (-not $p.Contains('provider')) { $p['provider'] = 'openrouter' }
|
|
return $p
|
|
}
|
|
|
|
function Get-PresetNames {
|
|
if (-not (Test-Path -LiteralPath $script:PresetDir)) { return @() }
|
|
return @(Get-ChildItem -LiteralPath $script:PresetDir -Filter '*.json' |
|
|
ForEach-Object { $_.BaseName } | Sort-Object)
|
|
}
|
|
|
|
function Get-PresetNamesForProvider {
|
|
param([string] $Provider)
|
|
$out = @()
|
|
foreach ($n in Get-PresetNames) {
|
|
try { if ([string](Get-Preset $n)['provider'] -eq $Provider) { $out += $n } } catch { }
|
|
}
|
|
return $out
|
|
}
|
|
|
|
function Resolve-PresetForProvider {
|
|
param([string] $Provider, [string] $Requested)
|
|
|
|
if ($Requested) {
|
|
$p = Get-Preset $Requested
|
|
if ([string]$p['provider'] -ne $Provider) {
|
|
throw "preset '$Requested' is a '$($p['provider'])' preset, not '$Provider'"
|
|
}
|
|
return $Requested
|
|
}
|
|
|
|
# Fixed per-provider default, so `claude-mode openrouter` is predictable
|
|
# rather than depending on what you last used or on alphabetical order.
|
|
$fallback = $script:ProviderDefaultPreset[$Provider]
|
|
if ($fallback -and (Test-Path -LiteralPath (Get-PresetPath $fallback))) {
|
|
if ([string](Get-Preset $fallback)['provider'] -eq $Provider) { return $fallback }
|
|
}
|
|
|
|
$names = Get-PresetNamesForProvider $Provider
|
|
if ($names.Count -gt 0) { return $names[0] }
|
|
throw "no preset found for provider '$Provider'"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Vault (DPAPI: CurrentUser scope)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
function Get-VaultPath { param([string] $Ref) return (Join-Path $script:VaultDir "$Ref.cred") }
|
|
|
|
function ConvertFrom-SecureStringPlain {
|
|
param([System.Security.SecureString] $Secure)
|
|
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Secure)
|
|
try { return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) }
|
|
finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }
|
|
}
|
|
|
|
function Set-VaultKey {
|
|
param([string] $Ref)
|
|
Initialize-Root
|
|
Write-Host "Paste the API key for ref '$Ref' (input hidden):"
|
|
$secure = Read-Host -AsSecureString
|
|
$plain = ConvertFrom-SecureStringPlain $secure
|
|
if ([string]::IsNullOrWhiteSpace($plain)) { throw 'empty key, aborted' }
|
|
|
|
# A hidden prompt will happily swallow a mis-paste. Guard the two shapes that
|
|
# are never a real key, because the failure is otherwise invisible until the
|
|
# provider answers 401 and the UI just spins.
|
|
if ($plain -match '\s') {
|
|
throw "that value contains whitespace, so it is not an API key (a pasted command line?). Nothing was stored."
|
|
}
|
|
if ($plain -like 'claude-mode*') {
|
|
throw "that value is a claude-mode command, not an API key. Nothing was stored."
|
|
}
|
|
if ($plain.Length -lt 16) {
|
|
Write-Warn2 "that key is only $($plain.Length) characters - unusually short. Storing anyway."
|
|
}
|
|
if ($Ref -eq 'openrouter' -and $plain -notlike 'sk-or-*') {
|
|
Write-Warn2 "key does not start with 'sk-or-' - storing anyway"
|
|
}
|
|
|
|
$path = Get-VaultPath $Ref
|
|
# ConvertFrom-SecureString with no -Key uses DPAPI, CurrentUser scope.
|
|
ConvertFrom-SecureString -SecureString $secure |
|
|
Set-Content -LiteralPath $path -Encoding ASCII -NoNewline
|
|
Protect-FileAcl $path
|
|
Write-Ok "stored DPAPI-encrypted key at $path"
|
|
}
|
|
|
|
function Get-VaultKey {
|
|
param([string] $Ref)
|
|
$path = Get-VaultPath $Ref
|
|
if (-not (Test-Path -LiteralPath $path)) { return $null }
|
|
$blob = (Get-Content -LiteralPath $path -Raw).Trim()
|
|
if ([string]::IsNullOrWhiteSpace($blob)) { return $null }
|
|
try { return ConvertFrom-SecureStringPlain (ConvertTo-SecureString $blob) }
|
|
catch { return $null }
|
|
}
|
|
|
|
function Format-KeyMask {
|
|
param([string] $Key)
|
|
if ([string]::IsNullOrEmpty($Key)) { return '(none)' }
|
|
if ($Key.Length -le 12) { return '****' }
|
|
return ($Key.Substring(0, 8) + '...' + $Key.Substring($Key.Length - 4))
|
|
}
|
|
|
|
function Get-PresetAuth {
|
|
param($Preset)
|
|
$auth = $Preset['auth']
|
|
if ($null -eq $auth) { $auth = [ordered]@{ mode = 'vault'; keyRef = 'openrouter' } }
|
|
if (-not $auth.Contains('mode')) { $auth['mode'] = 'vault' }
|
|
return $auth
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# settings.json rewriting
|
|
# ---------------------------------------------------------------------------
|
|
|
|
function Backup-Settings {
|
|
if (-not (Test-Path -LiteralPath $script:Settings)) { return $null }
|
|
Initialize-Root
|
|
$stamp = (Get-Date).ToString('yyyyMMdd-HHmmss-fff')
|
|
$dest = Join-Path $script:BackupDir "settings.$stamp.json"
|
|
Copy-Item -LiteralPath $script:Settings -Destination $dest -Force
|
|
$old = @(Get-ChildItem -LiteralPath $script:BackupDir -Filter 'settings.*.json' |
|
|
Sort-Object Name -Descending | Select-Object -Skip 20)
|
|
foreach ($f in $old) { Remove-Item -LiteralPath $f.FullName -Force }
|
|
return $dest
|
|
}
|
|
|
|
function Clear-ManagedSettings {
|
|
param($Settings)
|
|
# Baseline keys plus whatever the previous switch actually wrote, so a
|
|
# preset's custom extraEnv key cannot outlive the preset that added it.
|
|
$keys = @($script:BaseManagedEnvKeys) + @((Get-State)['writtenEnvKeys']) | Sort-Object -Unique
|
|
|
|
if ($Settings.Contains('env') -and $Settings['env'] -is [System.Collections.IDictionary]) {
|
|
foreach ($k in $keys) {
|
|
if ($k -and $Settings['env'].Contains($k)) { $Settings['env'].Remove($k) }
|
|
}
|
|
if ($Settings['env'].Count -eq 0) { $Settings.Remove('env') }
|
|
}
|
|
if ($Settings.Contains('apiKeyHelper')) { $Settings.Remove('apiKeyHelper') }
|
|
return $Settings
|
|
}
|
|
|
|
function Set-ClaudeMode {
|
|
param(
|
|
[ValidateSet('anthropic', 'openrouter', 'zai', 'lmstudio')] [string] $Mode,
|
|
[string] $PresetName
|
|
)
|
|
|
|
Initialize-Root
|
|
if (-not (Test-Path -LiteralPath $script:SettingsDir)) {
|
|
New-Item -ItemType Directory -Path $script:SettingsDir -Force | Out-Null
|
|
}
|
|
|
|
$settings = Read-JsonFile $script:Settings
|
|
if ($null -eq $settings) { $settings = [ordered]@{} }
|
|
|
|
$backup = Backup-Settings
|
|
$settings = Clear-ManagedSettings $settings
|
|
$written = @()
|
|
$preset = $null
|
|
|
|
if ($Mode -ne 'anthropic') {
|
|
$preset = Get-Preset $PresetName
|
|
if ([string]$preset['provider'] -ne $Mode) {
|
|
throw "preset '$PresetName' declares provider '$($preset['provider'])', not '$Mode'"
|
|
}
|
|
|
|
$models = $preset['models']
|
|
if ($null -eq $models) { throw "preset '$PresetName' has no 'models' block" }
|
|
|
|
# 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
|
|
# preset with "allowAnthropicModels": true.
|
|
if (-not ($preset.Contains('allowAnthropicModels') -and $preset['allowAnthropicModels'])) {
|
|
$offenders = @()
|
|
foreach ($tier in $script:Tiers) {
|
|
$id = [string]$models[$tier]
|
|
if ($id -and (Test-AnthropicModelId $id)) { $offenders += "$tier -> $id" }
|
|
}
|
|
$sub = [string]$preset['subagentModel']
|
|
if ($sub -and (Test-AnthropicModelId $sub)) { $offenders += "subagent -> $sub" }
|
|
if ($offenders.Count -gt 0) {
|
|
Write-Err2 "preset '$PresetName' routes a tier at an Anthropic model through '$Mode':"
|
|
foreach ($o in $offenders) { Write-Err2 " $o" }
|
|
throw "refusing to switch - gateways bill Anthropic models at full price. Add `"allowAnthropicModels`": true to the preset if this is deliberate."
|
|
}
|
|
}
|
|
|
|
$envBlock = [ordered]@{}
|
|
$envBlock['ANTHROPIC_BASE_URL'] = [string]$preset['baseUrl']
|
|
# Explicitly empty, not absent: a cached Anthropic login can otherwise
|
|
# override the gateway config and surface as a model-not-found error.
|
|
# Removed entirely when switching back to anthropic.
|
|
$envBlock['ANTHROPIC_API_KEY'] = ''
|
|
|
|
foreach ($tier in $script:Tiers) {
|
|
if ($models.Contains($tier) -and -not [string]::IsNullOrWhiteSpace([string]$models[$tier])) {
|
|
$envBlock["ANTHROPIC_DEFAULT_$($tier.ToUpper())_MODEL"] = [string]$models[$tier]
|
|
}
|
|
}
|
|
if ($preset.Contains('subagentModel') -and -not [string]::IsNullOrWhiteSpace([string]$preset['subagentModel'])) {
|
|
$envBlock['CLAUDE_CODE_SUBAGENT_MODEL'] = [string]$preset['subagentModel']
|
|
}
|
|
if ($preset.Contains('gatewayModelDiscovery') -and $preset['gatewayModelDiscovery']) {
|
|
$envBlock['CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY'] = '1'
|
|
}
|
|
|
|
# Behind a custom base URL, Claude Code cannot resolve a third-party
|
|
# model id to a context length, so it falls back to a conservative
|
|
# default and starts auto-compacting long before the model is actually
|
|
# full. State the real window explicitly.
|
|
if ($preset.Contains('contextTokens') -and $preset['contextTokens']) {
|
|
$ctx = [string][int]$preset['contextTokens']
|
|
$envBlock['CLAUDE_CODE_MAX_CONTEXT_TOKENS'] = $ctx
|
|
$envBlock['CLAUDE_CODE_AUTO_COMPACT_WINDOW'] = $ctx
|
|
}
|
|
if ($preset.Contains('extraEnv') -and $preset['extraEnv'] -is [System.Collections.IDictionary]) {
|
|
foreach ($k in $preset['extraEnv'].Keys) { $envBlock[$k] = [string]$preset['extraEnv'][$k] }
|
|
}
|
|
|
|
# Auth. 'vault' keeps the secret out of settings.json entirely and hands
|
|
# it over at runtime; 'literal' is for non-secrets like LM Studio's
|
|
# placeholder token.
|
|
$auth = Get-PresetAuth $preset
|
|
if ([string]$auth['mode'] -eq 'vault') {
|
|
$keyRef = 'openrouter'
|
|
if ($auth.Contains('keyRef') -and $auth['keyRef']) { $keyRef = [string]$auth['keyRef'] }
|
|
if (-not (Get-VaultKey $keyRef)) {
|
|
throw "no key stored for ref '$keyRef'. Run: claude-mode set-key $keyRef"
|
|
}
|
|
if (-not (Test-Path -LiteralPath $script:HelperCmd)) {
|
|
throw "key helper missing at $($script:HelperCmd). Re-run install.ps1"
|
|
}
|
|
$settings['apiKeyHelper'] = Get-HelperCommandLine
|
|
} else {
|
|
$tok = 'lmstudio'
|
|
if ($auth.Contains('token') -and $auth['token']) { $tok = [string]$auth['token'] }
|
|
$envBlock['ANTHROPIC_AUTH_TOKEN'] = $tok
|
|
}
|
|
|
|
if ($settings.Contains('env') -and $settings['env'] -is [System.Collections.IDictionary]) {
|
|
foreach ($k in $envBlock.Keys) { $settings['env'][$k] = $envBlock[$k] }
|
|
} else {
|
|
$settings['env'] = $envBlock
|
|
}
|
|
$written = @($envBlock.Keys)
|
|
}
|
|
|
|
# settings.json deliberately keeps its default ACL: it never holds a real
|
|
# secret (apiKeyHelper supplies those), and other tools read it.
|
|
Write-JsonFile $script:Settings $settings
|
|
Set-State -Mode $Mode -PresetName $PresetName -WrittenKeys $written
|
|
|
|
if ($Mode -eq 'anthropic') { Write-Head 'switched to: anthropic' }
|
|
else { Write-Head "switched to: $Mode / preset '$PresetName'" }
|
|
if ($backup) { Write-Ok "settings.json backed up to $backup" }
|
|
|
|
if ($Mode -eq 'anthropic') {
|
|
Write-Ok 'all gateway env + apiKeyHelper removed; native Anthropic login is authoritative'
|
|
} else {
|
|
Write-Ok "base url $($preset['baseUrl'])"
|
|
foreach ($tier in $script:Tiers) {
|
|
if ($preset['models'].Contains($tier)) {
|
|
Write-Ok ("{0,-7} -> {1}" -f $tier, $preset['models'][$tier])
|
|
}
|
|
}
|
|
if ($preset.Contains('contextTokens') -and $preset['contextTokens']) {
|
|
Write-Ok ("context -> {0:N0} tokens (max + auto-compact window)" -f [int]$preset['contextTokens'])
|
|
} else {
|
|
Write-Warn2 'no contextTokens in this preset - Claude Code will guess a small window and compact early'
|
|
}
|
|
$auth = Get-PresetAuth $preset
|
|
if ([string]$auth['mode'] -eq 'vault') {
|
|
Write-Ok 'auth via apiKeyHelper (key stays DPAPI-encrypted on disk)'
|
|
} else {
|
|
Write-Ok "auth inline placeholder token '$($auth['token'])' (not a secret)"
|
|
}
|
|
}
|
|
|
|
[void](Test-StrayEnvVars -Mode $Mode)
|
|
|
|
if ($Mode -ne 'anthropic') {
|
|
$auth = Get-PresetAuth $preset
|
|
if ([string]$auth['mode'] -eq 'vault') {
|
|
Show-GuardrailStatus -Mode $Mode -Key (Get-VaultKey ([string]$auth['keyRef']))
|
|
}
|
|
}
|
|
[void](Test-StaleModelSelections -Mode $Mode)
|
|
|
|
Write-HealthFile -Mode $Mode -PresetName $PresetName
|
|
|
|
Write-Host ''
|
|
Write-Host ' restart claude (and reload the VS Code window) to pick this up' -ForegroundColor DarkGray
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stray environment variable detection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Anthropic-model cost guard
|
|
#
|
|
# Matches both the qualified gateway id (anthropic/claude-opus-5) and the bare
|
|
# internal id Claude Code persists (claude-opus-5, claude-opus-4-8,
|
|
# claude-haiku-4-5-20251001). Both have been observed in the wild.
|
|
# ---------------------------------------------------------------------------
|
|
# Ask OpenRouter whether this key can still reach Anthropic models, by trying the
|
|
# cheapest possible request against one. Free when the guardrail blocks it; a
|
|
# fraction of a cent when it does not, which is exactly the case worth knowing.
|
|
# Returns 'active' | 'open' | 'unknown'.
|
|
function Test-OpenRouterGuardrail {
|
|
param([string] $Key)
|
|
$body = '{"model":"claude-opus-5","max_tokens":1,"messages":[{"role":"user","content":"hi"}]}'
|
|
try {
|
|
[void](Invoke-RestMethod -Uri 'https://openrouter.ai/api/v1/messages' -Method Post -Body $body `
|
|
-ContentType 'application/json' -TimeoutSec 25 `
|
|
-Headers @{ 'x-api-key' = $Key; 'Authorization' = "Bearer $Key"; 'anthropic-version' = '2023-06-01' })
|
|
return 'open'
|
|
} catch {
|
|
$c = 0
|
|
if ($_.Exception.Response) { $c = [int]$_.Exception.Response.StatusCode }
|
|
# 403/404 is OpenRouter refusing the model, which is what a guardrail
|
|
# looks like. 401 is the KEY being rejected - that says nothing about the
|
|
# guardrail and must not read as an all-clear. Anything else (timeout,
|
|
# 5xx) is equally uninformative.
|
|
if ($c -eq 403 -or $c -eq 404) { return 'active' }
|
|
if ($c -eq 401) { return 'badkey' }
|
|
return 'unknown'
|
|
}
|
|
}
|
|
|
|
# OpenRouter only. Z.AI and LM Studio have no equivalent control, so there is
|
|
# nothing actionable to print for them.
|
|
function Show-GuardrailStatus {
|
|
param([string] $Mode, [string] $Key)
|
|
if ($Mode -ne 'openrouter' -or -not $Key) { return }
|
|
|
|
$label = ' guardrail '
|
|
$state = Test-OpenRouterGuardrail -Key $Key
|
|
$script:HealthGuardrail = switch ($state) { 'active' { 'active' } 'open' { 'not_set' } default { 'unknown' } }
|
|
switch ($state) {
|
|
'active' {
|
|
Write-Host $label -NoNewline -ForegroundColor DarkGray
|
|
Write-Host 'active' -NoNewline -ForegroundColor Green
|
|
Write-Host ' - Anthropic models blocked for this key' -ForegroundColor DarkGray
|
|
}
|
|
'open' {
|
|
Write-Host $label -NoNewline -ForegroundColor DarkGray
|
|
Write-Host 'NOT SET' -NoNewline -ForegroundColor Red
|
|
Write-Host ' - Anthropic models reachable, billed at list price' -ForegroundColor DarkGray
|
|
Write-Host ' openrouter.ai -> Guardrails -> new, select this key,' -ForegroundColor DarkGray
|
|
Write-Host ' then exclude anthropic models (or allow only the ones you use)' -ForegroundColor DarkGray
|
|
}
|
|
'badkey' {
|
|
Write-Host $label -NoNewline -ForegroundColor DarkGray
|
|
Write-Host 'unknown' -NoNewline -ForegroundColor Yellow
|
|
Write-Host ' - OpenRouter rejected the key, so it could not be checked' -ForegroundColor DarkGray
|
|
}
|
|
default {
|
|
Write-Host $label -NoNewline -ForegroundColor DarkGray
|
|
Write-Host 'unknown' -NoNewline -ForegroundColor Yellow
|
|
Write-Host ' - could not reach OpenRouter to check' -ForegroundColor DarkGray
|
|
}
|
|
}
|
|
}
|
|
|
|
function Test-AnthropicModelId {
|
|
param([string] $Id)
|
|
if (-not $Id) { return $false }
|
|
return ($Id -match '(^|/)claude[-.]' -or $Id -like 'anthropic/*')
|
|
}
|
|
|
|
# Claude Code caches a resolved model per (entrypoint, model, org) in
|
|
# ~/.claude.json. A session that was running - or a picker selection made -
|
|
# before the switch keeps its old model id, and that id is then sent to whatever
|
|
# endpoint is now configured. That is how an Anthropic model ends up billed
|
|
# through a gateway. Detect it; we cannot prevent it from here.
|
|
# Walk every string in ~/.claude.json and collect model-ish values. Claude Code
|
|
# caches resolved models under more than one key - clientDataCacheSlots and
|
|
# additionalModelOptionsCache have both been observed - so scan rather than
|
|
# reach for a fixed path.
|
|
function Get-CachedModelIds {
|
|
$cfg = Join-Path $env:USERPROFILE '.claude.json'
|
|
if (-not (Test-Path -LiteralPath $cfg)) { return @() }
|
|
try { $j = Get-Content -LiteralPath $cfg -Raw -Encoding UTF8 | ConvertFrom-Json } catch { return @() }
|
|
|
|
$out = New-Object System.Collections.ArrayList
|
|
$walk = {
|
|
param($o)
|
|
if ($null -eq $o) { return }
|
|
if ($o -is [string]) { return }
|
|
foreach ($p in $o.PSObject.Properties) {
|
|
$v = $p.Value
|
|
if ($v -is [string]) {
|
|
if ($p.Name -match 'model|value' -and $v -match '^[~a-zA-Z0-9]') { [void]$out.Add($v) }
|
|
} elseif ($v -is [System.Collections.IEnumerable]) {
|
|
foreach ($i in $v) { if ($i -isnot [string]) { & $walk $i } }
|
|
} elseif ($null -ne $v) { & $walk $v }
|
|
}
|
|
}
|
|
& $walk $j
|
|
|
|
# The structural walk only reaches keys named model/value. Tagged ids have
|
|
# been found under other keys too, and `repair` matches them by raw text, so
|
|
# union that in - otherwise health.json under-reports what repair would act
|
|
# on, which is worse than either alone.
|
|
try {
|
|
$raw = Get-Content -LiteralPath $cfg -Raw -Encoding UTF8
|
|
foreach ($m in [regex]::Matches($raw, '"([^"]*\[[0-9]+[a-zA-Z]\])"')) {
|
|
[void]$out.Add($m.Groups[1].Value)
|
|
}
|
|
} catch { }
|
|
|
|
return @($out | Sort-Object -Unique)
|
|
}
|
|
|
|
# A model id carrying a bracket suffix - claude-mode has seen `claude-fable-5[1m]`
|
|
# - is Claude Code's extended-context marker. It belongs to Anthropic's 1M models
|
|
# and no gateway recognises it. When a session that had it switches to a gateway,
|
|
# the tag can survive onto the new id, producing something like
|
|
# `~deepseek/deepseek-v4-flash-latest[1m]` that only fails at compaction time,
|
|
# because compaction re-resolves the model from session state.
|
|
function Test-TaggedModelId {
|
|
param([string] $Id)
|
|
return ($Id -match '\[[0-9]+[a-zA-Z]\]$')
|
|
}
|
|
|
|
function Test-StaleModelSelections {
|
|
param([string] $Mode)
|
|
if ($Mode -eq 'anthropic') { return 0 }
|
|
|
|
$ids = Get-CachedModelIds
|
|
$anth = @($ids | Where-Object { Test-AnthropicModelId $_ })
|
|
$tagged = @($ids | Where-Object { Test-TaggedModelId $_ })
|
|
$n = $anth.Count + $tagged.Count
|
|
if ($n -eq 0) { return 0 }
|
|
|
|
if ($anth.Count -gt 0) {
|
|
Write-Host ' sessions ' -NoNewline -ForegroundColor DarkGray
|
|
Write-Host "$($anth.Count) cached Anthropic model ids" -NoNewline -ForegroundColor Yellow
|
|
Write-Host ' - restart running claude sessions' -ForegroundColor DarkGray
|
|
}
|
|
if ($tagged.Count -gt 0) {
|
|
Write-Host ' tagged ' -NoNewline -ForegroundColor DarkGray
|
|
Write-Host "$($tagged.Count) model id(s) carry a [1m] tag" -NoNewline -ForegroundColor Yellow
|
|
Write-Host ' - breaks compaction on gateways; claude-mode repair' -ForegroundColor DarkGray
|
|
}
|
|
return $n
|
|
}
|
|
|
|
# Strip extended-context tags from cached model ids. Backed up first; the file is
|
|
# the user's own Claude Code config, not ours.
|
|
# Strip extended-context tags from cached model ids.
|
|
#
|
|
# The tag is NOT junk everywhere. On an Anthropic id it is how Claude Code
|
|
# selects the 1M variant (`qS()` in the CLI literally tests the id string for
|
|
# `[1m]`), so stripping `claude-fable-5[1m]` silently downgrades that choice to
|
|
# the 200k variant and the user has to re-pick it in /model. On a gateway id the
|
|
# same tag is meaningless and breaks compaction.
|
|
#
|
|
# So the default - and the only thing done automatically - is to strip tags from
|
|
# NON-Anthropic ids only. -All includes Anthropic ids and is a deliberate,
|
|
# manual choice.
|
|
function Invoke-Repair {
|
|
param([switch] $All, [switch] $Quiet)
|
|
|
|
$cfg = Join-Path $env:USERPROFILE '.claude.json'
|
|
if (-not (Test-Path -LiteralPath $cfg)) { if (-not $Quiet) { Write-Err2 'no ~/.claude.json' }; return 0 }
|
|
|
|
$raw = Get-Content -LiteralPath $cfg -Raw -Encoding UTF8
|
|
$tagged = @([regex]::Matches($raw, '"([^"]*\[[0-9]+[a-zA-Z]\])"') |
|
|
ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique)
|
|
if ($tagged.Count -eq 0) { if (-not $Quiet) { Write-Ok 'no tagged model ids in ~/.claude.json' }; return 0 }
|
|
|
|
$target = @($tagged | Where-Object { $All -or -not (Test-AnthropicModelId $_) })
|
|
$kept = @($tagged | Where-Object { $_ -notin $target })
|
|
|
|
if ($target.Count -eq 0) {
|
|
if (-not $Quiet) {
|
|
Write-Ok "nothing to strip - $($kept.Count) tagged id(s) are Anthropic models, where the tag is meaningful"
|
|
foreach ($k in $kept) { Write-Host " keeping $k" -ForegroundColor DarkGray }
|
|
Write-Host ' use --all to strip those too (downgrades them to the 200k variant)' -ForegroundColor DarkGray
|
|
}
|
|
return 0
|
|
}
|
|
|
|
Initialize-Root
|
|
$bak = Join-Path $script:BackupDir ('claude.json.' + (Get-Date).ToString('yyyyMMdd-HHmmss') + '.bak')
|
|
Copy-Item -LiteralPath $cfg -Destination $bak -Force
|
|
|
|
$fixed = $raw
|
|
foreach ($t in $target) {
|
|
$clean = $t -replace '\[[0-9]+[a-zA-Z]\]$', ''
|
|
$fixed = $fixed.Replace('"' + $t + '"', '"' + $clean + '"')
|
|
}
|
|
try { [void](ConvertFrom-Json $fixed) } catch { Write-Err2 'repair would produce invalid JSON - aborted'; return 0 }
|
|
[System.IO.File]::WriteAllText($cfg, $fixed, (New-Object System.Text.UTF8Encoding($false)))
|
|
|
|
if ($Quiet) {
|
|
Write-Host ' repaired ' -NoNewline -ForegroundColor DarkGray
|
|
Write-Host "$($target.Count) gateway model id(s) had a [1m] tag stripped" -ForegroundColor Green
|
|
} else {
|
|
foreach ($t in $target) { Write-Host " $t" -ForegroundColor DarkGray }
|
|
foreach ($k in $kept) { Write-Host " keeping $k (Anthropic - tag is meaningful)" -ForegroundColor DarkGray }
|
|
Write-Ok "stripped $($target.Count) tag(s); backup at $bak"
|
|
Write-Host ' restart claude for this to take effect' -ForegroundColor DarkGray
|
|
}
|
|
return $target.Count
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# health.json - the machine-readable state the Arkylx Index (or anything else)
|
|
# reads. Written on every switch and on every `doctor`.
|
|
#
|
|
# Contract, deliberately narrow:
|
|
# * NO key material, ever. `keysConfigured` is names only; `keyBackend` says
|
|
# how they are stored, never what they are.
|
|
# * Model-id lists are [{id, anthropic}] rather than bare strings, so a reader
|
|
# never has to re-derive the Anthropic matcher. A tagged *Anthropic* id is
|
|
# normal (that is how the 1M variant is selected) and must not render as a
|
|
# fault; a tagged gateway id is the breakage.
|
|
# * `guardrailStatus` is tri-state (active|not_set|unknown) or null when not
|
|
# probed. Never collapse to a boolean - "unknown" must not read as safe.
|
|
# ---------------------------------------------------------------------------
|
|
$script:HealthPath = Join-Path $script:Root 'health.json'
|
|
$script:HealthGuardrail = $null # set by Show-GuardrailStatus when it probes
|
|
|
|
# Must serialise as a JSON array for 0, 1 and many entries. PS 5.1 unrolls a
|
|
# single-element array on return (rendering it as a bare object) and renders a
|
|
# comma-wrapped empty array as [[]]. A generic List survives both: `,` stops the
|
|
# unroll, and ConvertTo-Json always emits a List as an array.
|
|
function ConvertTo-ModelEntry {
|
|
param([string[]] $Ids)
|
|
$out = New-Object 'System.Collections.Generic.List[object]'
|
|
foreach ($i in @($Ids)) {
|
|
$out.Add([ordered]@{ id = $i; anthropic = [bool](Test-AnthropicModelId $i) })
|
|
}
|
|
return , $out
|
|
}
|
|
|
|
function Write-HealthFile {
|
|
param([string] $Mode, [string] $PresetName)
|
|
|
|
$h = [ordered]@{
|
|
schema = 1
|
|
tool = 'claude-mode'
|
|
version = $script:Version
|
|
updatedAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
|
|
os = 'windows'
|
|
mode = $Mode
|
|
preset = ''
|
|
}
|
|
|
|
$ids = Get-CachedModelIds
|
|
$stale = @($ids | Where-Object { Test-AnthropicModelId $_ })
|
|
$tagged = @($ids | Where-Object { Test-TaggedModelId $_ })
|
|
|
|
if ($Mode -eq 'anthropic') {
|
|
# Nothing gateway-shaped is meaningful here, and a cached Anthropic id is
|
|
# simply the model in use - not a finding.
|
|
$h['staleModelIds'] = (ConvertTo-ModelEntry @())
|
|
$h['taggedModelIds'] = ConvertTo-ModelEntry $tagged
|
|
} else {
|
|
$h['preset'] = $PresetName
|
|
try {
|
|
$p = Get-Preset $PresetName
|
|
$h['provider'] = [string]$p['provider']
|
|
$h['baseUrl'] = [string]$p['baseUrl']
|
|
$models = [ordered]@{}
|
|
foreach ($t in $script:Tiers) { if ($p['models'].Contains($t)) { $models[$t] = [string]$p['models'][$t] } }
|
|
$h['models'] = $models
|
|
$h['subagentModel'] = [string]$p['subagentModel']
|
|
$h['contextTokens'] = if ($p['contextTokens']) { [int]$p['contextTokens'] } else { $null }
|
|
$h['gatewayDiscovery'] = [bool]$p['gatewayModelDiscovery']
|
|
|
|
$auth = Get-PresetAuth $p
|
|
if ([string]$auth['mode'] -eq 'vault') {
|
|
$ref = [string]$auth['keyRef']
|
|
$h['keyBackend'] = 'dpapi'
|
|
$kc = New-Object 'System.Collections.Generic.List[object]'
|
|
if (Get-VaultKey $ref) { $kc.Add($ref) }
|
|
$h['keysConfigured'] = $kc
|
|
} else {
|
|
$h['keyBackend'] = 'inline'
|
|
$h['keysConfigured'] = (New-Object 'System.Collections.Generic.List[object]')
|
|
}
|
|
# It passed the guard, or Set-ClaudeMode would have thrown.
|
|
$h['costGuardPassed'] = $true
|
|
} catch {
|
|
$h['costGuardPassed'] = $null
|
|
}
|
|
$h['guardrailStatus'] = $script:HealthGuardrail
|
|
$h['staleModelIds'] = ConvertTo-ModelEntry $stale
|
|
$h['taggedModelIds'] = ConvertTo-ModelEntry $tagged
|
|
}
|
|
|
|
try { Write-JsonFile $script:HealthPath $h } catch { }
|
|
}
|
|
|
|
function Get-PersistentEnv {
|
|
param([string] $Name)
|
|
return [pscustomobject]@{
|
|
Name = $Name
|
|
User = [Environment]::GetEnvironmentVariable($Name, 'User')
|
|
Machine = [Environment]::GetEnvironmentVariable($Name, 'Machine')
|
|
}
|
|
}
|
|
|
|
function Test-StrayEnvVars {
|
|
param([string] $Mode)
|
|
|
|
$problems = 0
|
|
|
|
$ak = Get-PersistentEnv 'ANTHROPIC_API_KEY'
|
|
if ($Mode -ne 'anthropic') {
|
|
if ($ak.User) { Write-Err2 'ANTHROPIC_API_KEY is set at User scope - it will bypass the gateway. Fix: claude-mode doctor'; $problems++ }
|
|
if ($ak.Machine) { Write-Err2 'ANTHROPIC_API_KEY is set at Machine scope - it will bypass the gateway. Remove it (admin required).'; $problems++ }
|
|
if ($env:ANTHROPIC_API_KEY -and -not $ak.User -and -not $ak.Machine) {
|
|
Write-Warn2 'ANTHROPIC_API_KEY is set in THIS shell only. The `claude` wrapper strips it; other shells are unaffected.'
|
|
}
|
|
}
|
|
|
|
foreach ($name in $script:BaseManagedEnvKeys) {
|
|
if ($name -eq 'ANTHROPIC_API_KEY') { continue }
|
|
$v = Get-PersistentEnv $name
|
|
if ($v.User) { Write-Err2 "$name is set at User scope - it overrides claude-mode. Fix: claude-mode doctor"; $problems++ }
|
|
if ($v.Machine) { Write-Err2 "$name is set at Machine scope - remove it (admin required)."; $problems++ }
|
|
}
|
|
|
|
return $problems
|
|
}
|
|
|
|
function Repair-StrayEnvVars {
|
|
$fixed = 0
|
|
foreach ($name in $script:BaseManagedEnvKeys) {
|
|
$v = [Environment]::GetEnvironmentVariable($name, 'User')
|
|
if ($v) {
|
|
$bak = Join-Path $script:BackupDir ("userenv-$name-" + (Get-Date).ToString('yyyyMMdd-HHmmss') + '.txt')
|
|
Set-Content -LiteralPath $bak -Value $v -Encoding UTF8
|
|
Protect-FileAcl $bak
|
|
[Environment]::SetEnvironmentVariable($name, $null, 'User')
|
|
Write-Ok "removed User-scope $name (old value saved to $bak)"
|
|
$fixed++
|
|
}
|
|
}
|
|
if ($fixed -eq 0) { Write-Ok 'no User-scope overrides to remove' }
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|
|
}
|
|
|
|
# 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) { 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)
|
|
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.'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Commands
|
|
# ---------------------------------------------------------------------------
|
|
|
|
function Invoke-Status {
|
|
$state = Get-State
|
|
$mode = [string]$state['mode']
|
|
|
|
Write-Head "claude-mode: $mode"
|
|
|
|
if ($mode -eq 'anthropic') {
|
|
Write-Host ' native Anthropic login/subscription; no gateway env, no apiKeyHelper'
|
|
} else {
|
|
$name = [string]$state['preset']
|
|
Write-Host " preset: $name"
|
|
try {
|
|
$preset = Get-Preset $name
|
|
Write-Host " baseUrl: $($preset['baseUrl'])"
|
|
foreach ($tier in $script:Tiers) {
|
|
if ($preset['models'].Contains($tier)) {
|
|
Write-Host (" {0,-9} {1}" -f ($tier + ':'), $preset['models'][$tier])
|
|
}
|
|
}
|
|
if ($preset.Contains('subagentModel')) { Write-Host " subagent: $($preset['subagentModel'])" }
|
|
if ($preset.Contains('contextTokens') -and $preset['contextTokens']) {
|
|
Write-Host (" context: {0:N0} tokens" -f [int]$preset['contextTokens'])
|
|
}
|
|
$auth = Get-PresetAuth $preset
|
|
if ([string]$auth['mode'] -eq 'vault') {
|
|
$ref = [string]$auth['keyRef']
|
|
Write-Host " key: $ref -> $(Format-KeyMask (Get-VaultKey $ref))"
|
|
} else {
|
|
Write-Host " token: $($auth['token']) (inline, not a secret)"
|
|
}
|
|
} catch {
|
|
Write-Err2 $_.Exception.Message
|
|
}
|
|
}
|
|
|
|
Write-Host ''
|
|
Write-Host ' settings.json managed keys:'
|
|
$settings = Read-JsonFile $script:Settings
|
|
$found = 0
|
|
if ($settings) {
|
|
if ($settings.Contains('apiKeyHelper')) { Write-Host " apiKeyHelper = $($settings['apiKeyHelper'])"; $found++ }
|
|
if ($settings.Contains('env') -and $settings['env'] -is [System.Collections.IDictionary]) {
|
|
$keys = @($script:BaseManagedEnvKeys) + @($state['writtenEnvKeys']) | Sort-Object -Unique
|
|
foreach ($k in $keys) {
|
|
if ($k -and $settings['env'].Contains($k)) {
|
|
Write-Host " $k = $($settings['env'][$k])"
|
|
$found++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if ($found -eq 0) { Write-Host ' (none - clean)' }
|
|
|
|
Write-Host ''
|
|
[void](Test-StrayEnvVars -Mode $mode)
|
|
}
|
|
|
|
function Invoke-Presets {
|
|
Write-Head 'presets'
|
|
$state = Get-State
|
|
foreach ($n in Get-PresetNames) {
|
|
$mark = ' '
|
|
if ($n -eq [string]$state['preset'] -and [string]$state['mode'] -ne 'anthropic') { $mark = '*' }
|
|
try {
|
|
$p = Get-Preset $n
|
|
$bits = @()
|
|
foreach ($t in $script:Tiers) {
|
|
if ($p['models'].Contains($t)) { $bits += "$t=$($p['models'][$t])" }
|
|
}
|
|
Write-Host (" {0} {1,-18} [{2,-10}] {3}" -f $mark, $n, $p['provider'], ($bits -join ' '))
|
|
} catch {
|
|
Write-Host (" {0} {1,-18} <unreadable>" -f $mark, $n)
|
|
}
|
|
}
|
|
}
|
|
|
|
function Invoke-PresetCmd {
|
|
# NOT named $Args - that collides with PowerShell's automatic variable.
|
|
param([string[]] $Argv)
|
|
if (-not $Argv -or $Argv.Count -lt 1) { Show-Usage; return }
|
|
$sub = $Argv[0]
|
|
$name = if ($Argv.Count -ge 2) { $Argv[1] } else { $null }
|
|
|
|
switch ($sub) {
|
|
'show' {
|
|
if (-not $name) { throw 'usage: claude-mode preset show <name>' }
|
|
(Get-Preset $name) | ConvertTo-Json -Depth 20 | Write-Host
|
|
}
|
|
'new' {
|
|
if (-not $name) { throw 'usage: claude-mode preset new <name> [copy-from]' }
|
|
$path = Get-PresetPath $name
|
|
if (Test-Path -LiteralPath $path) { throw "preset '$name' already exists" }
|
|
$from = if ($Argv.Count -ge 3) { $Argv[2] } else { 'default' }
|
|
$base = Get-Preset $from
|
|
$base['description'] = "copy of '$from'"
|
|
Write-JsonFile $path $base
|
|
Write-Ok "created $path from '$from' - edit with: claude-mode preset set $name <tier> <model-id>"
|
|
}
|
|
'rm' {
|
|
if (-not $name) { throw 'usage: claude-mode preset rm <name>' }
|
|
$path = Get-PresetPath $name
|
|
if (-not (Test-Path -LiteralPath $path)) { throw "preset '$name' not found" }
|
|
$state = Get-State
|
|
if ([string]$state['preset'] -eq $name -and [string]$state['mode'] -ne 'anthropic') {
|
|
throw "preset '$name' is active. Switch away first (claude-mode anthropic)."
|
|
}
|
|
Remove-Item -LiteralPath $path -Force
|
|
Write-Ok "deleted preset '$name'"
|
|
}
|
|
'set' {
|
|
if ($Argv.Count -lt 4) { throw 'usage: claude-mode preset set <name> <tier> <model-id>' }
|
|
Set-PresetTier -Name $name -Tier $Argv[2].ToLower() -Model $Argv[3]
|
|
}
|
|
'all' {
|
|
if ($Argv.Count -lt 3) { throw 'usage: claude-mode preset all <name> <model-id>' }
|
|
$model = $Argv[2]
|
|
$preset = Get-Preset $name
|
|
if (-not $preset.Contains('models')) { $preset['models'] = [ordered]@{} }
|
|
foreach ($t in $script:Tiers) { $preset['models'][$t] = $model }
|
|
$preset['subagentModel'] = $model
|
|
Write-JsonFile (Get-PresetPath $name) $preset
|
|
Write-Ok "$name : all tiers + subagent -> $model"
|
|
Update-ActivePreset $name
|
|
}
|
|
default { Show-Usage }
|
|
}
|
|
}
|
|
|
|
function Set-PresetTier {
|
|
param([string] $Name, [string] $Tier, [string] $Model)
|
|
$preset = Get-Preset $Name
|
|
|
|
if ($Tier -eq 'subagent') {
|
|
$preset['subagentModel'] = $Model
|
|
} elseif ($script:Tiers -contains $Tier) {
|
|
if (-not $preset.Contains('models')) { $preset['models'] = [ordered]@{} }
|
|
$preset['models'][$Tier] = $Model
|
|
} else {
|
|
throw "unknown tier '$Tier' (use: opus | sonnet | haiku | fable | subagent)"
|
|
}
|
|
|
|
Write-JsonFile (Get-PresetPath $Name) $preset
|
|
Write-Ok "$Name : $Tier -> $Model"
|
|
Update-ActivePreset $Name
|
|
}
|
|
|
|
# Editing the live preset re-applies it, so there is no second command to run.
|
|
function Update-ActivePreset {
|
|
param([string] $Name)
|
|
$state = Get-State
|
|
if ([string]$state['mode'] -ne 'anthropic' -and [string]$state['preset'] -eq $Name) {
|
|
Write-Host ' re-applying active preset...'
|
|
Set-ClaudeMode -Mode ([string]$state['mode']) -PresetName $Name
|
|
}
|
|
}
|
|
|
|
function Invoke-Models {
|
|
param([string] $Filter)
|
|
$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
|
|
}
|
|
|
|
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 }
|
|
}
|
|
}
|
|
$rows | Sort-Object Id | Format-Table -AutoSize
|
|
}
|
|
|
|
function Invoke-Doctor {
|
|
$state = Get-State
|
|
$mode = [string]$state['mode']
|
|
Write-Head "doctor - mode '$mode'"
|
|
|
|
try {
|
|
$liveSettings = Read-JsonFile $script:Settings
|
|
Write-Ok 'settings.json parses'
|
|
} catch {
|
|
Write-Err2 "settings.json does not parse: $($_.Exception.Message)"
|
|
return
|
|
}
|
|
|
|
if (Test-Path -LiteralPath $script:HelperCmd) { Write-Ok "key helper present: $($script:HelperCmd)" }
|
|
else { Write-Err2 "key helper missing: $($script:HelperCmd)" }
|
|
|
|
if ($mode -ne 'anthropic') {
|
|
$preset = Get-Preset ([string]$state['preset'])
|
|
$auth = Get-PresetAuth $preset
|
|
$base = ([string]$preset['baseUrl']).TrimEnd('/')
|
|
|
|
if ([string]$auth['mode'] -eq 'vault') {
|
|
$keyRef = [string]$auth['keyRef']
|
|
$key = Get-VaultKey $keyRef
|
|
if ($key) {
|
|
Write-Ok "vault '$keyRef' decrypts -> $(Format-KeyMask $key)"
|
|
if ($key -match '\s' -or $key -like 'claude-mode*') {
|
|
Write-Err2 "the stored '$keyRef' value looks like a pasted command, not a key. Re-run: claude-mode set-key $keyRef"
|
|
}
|
|
}
|
|
else { Write-Err2 "vault '$keyRef' missing or undecryptable. Run: claude-mode set-key $keyRef" }
|
|
|
|
# The stored string is what Claude Code hands to a shell, and a
|
|
# path this process can quote correctly is not evidence that the
|
|
# recorded one parses. Check the value, then run that value.
|
|
$stored = ''
|
|
if ($liveSettings.Contains('apiKeyHelper')) { $stored = [string]$liveSettings['apiKeyHelper'] }
|
|
$expected = Get-HelperCommandLine
|
|
$reSwitch = "claude-mode $mode $([string]$state['preset'])"
|
|
|
|
if (-not $stored) {
|
|
Write-Err2 "settings.json has no apiKeyHelper. Run: $reSwitch"
|
|
} elseif ($stored -ne $expected) {
|
|
Write-Err2 "apiKeyHelper reads $stored"
|
|
Write-Err2 " but should read $expected - run: $reSwitch"
|
|
} else {
|
|
Write-Ok "apiKeyHelper wired as $stored"
|
|
}
|
|
|
|
if ($stored) {
|
|
# Run it through cmd the way a shell would, via a batch file, so
|
|
# PowerShell's own native-argument quoting cannot paper over a
|
|
# value that a real shell would split.
|
|
$probe = Join-Path $env:TEMP ('cm-helper-probe-' + [IO.Path]::GetRandomFileName().Replace('.', '') + '.cmd')
|
|
try {
|
|
Set-Content -LiteralPath $probe -Value ("@echo off`r`n" + $stored) -Encoding ASCII
|
|
$out = (& cmd.exe /c "`"$probe`"" 2>&1 | Out-String).Trim()
|
|
if ($out -and $key -and $out -eq $key) { Write-Ok 'apiKeyHelper emits the correct key' }
|
|
elseif ($out -match '^\S+$') {
|
|
# One unbroken token: a credential, just the wrong one. Never echo it.
|
|
Write-Err2 "apiKeyHelper output does not match vault key (got: $(Format-KeyMask $out))"
|
|
}
|
|
elseif ($out) { Write-Err2 "apiKeyHelper failed: $out" }
|
|
else { Write-Err2 'apiKeyHelper produced no output' }
|
|
} catch {
|
|
Write-Err2 "apiKeyHelper failed to run: $($_.Exception.Message)"
|
|
} finally {
|
|
Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
|
|
if ($key -and $mode -eq 'openrouter') {
|
|
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))"
|
|
if ($null -ne $r.data.limit) {
|
|
Write-Ok ("spend {0:N2} of {1:N2} limit ({2}), {3:N2} remaining" -f `
|
|
[double]$r.data.usage, [double]$r.data.limit, $r.data.limit_reset, [double]$r.data.limit_remaining)
|
|
} else {
|
|
Write-Ok ("spend {0:N2} this month (no key limit set)" -f [double]$r.data.usage)
|
|
}
|
|
} catch {
|
|
Write-Err2 "OpenRouter rejected the key: $($_.Exception.Message)"
|
|
}
|
|
|
|
# The guardrail is not exposed by /api/v1/key, so it can only be
|
|
# established by trying a blocked model.
|
|
Show-GuardrailStatus -Mode $mode -Key $key
|
|
}
|
|
|
|
if ($key -and $mode -eq 'zai') {
|
|
# No key-info endpoint; the cheapest real check is a 1-token
|
|
# message against the Anthropic-compatible surface.
|
|
try {
|
|
$body = @{
|
|
model = [string]$preset['models']['haiku']
|
|
max_tokens = 1
|
|
messages = @(@{ role = 'user'; content = 'hi' })
|
|
} | ConvertTo-Json -Depth 6
|
|
[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)"
|
|
} catch {
|
|
$detail = ''
|
|
if ($_.ErrorDetails) { $detail = ($_.ErrorDetails.Message -replace '\s+', ' ') }
|
|
Write-Err2 "Z.AI 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)"
|
|
}
|
|
}
|
|
}
|
|
|
|
Write-Host ''
|
|
if ((Test-StaleModelSelections -Mode $mode) -eq 0 -and $mode -ne 'anthropic') {
|
|
Write-Ok 'no cached Anthropic model ids'
|
|
}
|
|
|
|
Write-Host ''
|
|
$bad = Test-StrayEnvVars -Mode $mode
|
|
if ($bad -gt 0) {
|
|
Write-Host ''
|
|
$ans = Read-Host 'Remove the User-scope overrides listed above? [y/N]'
|
|
if ($ans -match '^[Yy]') { Repair-StrayEnvVars }
|
|
} else {
|
|
Write-Ok 'no persistent env-var overrides'
|
|
}
|
|
|
|
Write-Host ''
|
|
$exe = Get-Command claude.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
|
|
if ($exe) {
|
|
Write-Host " claude: $((& $exe.Source --version 2>&1 | Out-String).Trim()) [$($exe.Source)]"
|
|
} else {
|
|
Write-Warn2 'claude.exe not found on PATH'
|
|
}
|
|
|
|
# Refresh the machine-readable state so a fleet reader sees doctor's findings
|
|
# (notably guardrailStatus, which only a probe can establish).
|
|
Write-HealthFile -Mode $mode -PresetName ([string]$state['preset'])
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Interactive menu (claude-mode with no arguments)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
function Test-Interactive {
|
|
try {
|
|
if ([Console]::IsInputRedirected) { return $false }
|
|
if ([Console]::IsOutputRedirected) { return $false }
|
|
[void][Console]::WindowWidth
|
|
return $true
|
|
} catch { return $false }
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# UI frame - the screen region the interactive menus own.
|
|
#
|
|
# Successive selectors (mode -> preset, preset -> tier) must REPLACE each other
|
|
# rather than stack, otherwise the earlier list stays on screen looking frozen
|
|
# and interactive. The frame remembers where the current run of menus began;
|
|
# each new selector wipes back to that line and redraws from it.
|
|
#
|
|
# Output that must persist (a switch summary, doctor results) calls Stop-UiFrame
|
|
# first: that erases the menu, then lets the output print in the freed space and
|
|
# stay there, with the next menu opening a fresh frame below it.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
$script:UiActive = $false
|
|
$script:UiTop = 0
|
|
$script:UiQuit = $false # set by the submenu to unwind the whole menu stack
|
|
|
|
function Start-UiFrame {
|
|
if ($script:UiActive) { return }
|
|
try { $script:UiTop = [Console]::CursorTop } catch { $script:UiTop = 0 }
|
|
$script:UiActive = $true
|
|
}
|
|
|
|
function Clear-UiFrame {
|
|
if (-not $script:UiActive) { return }
|
|
try {
|
|
$w = [Math]::Max(1, [Console]::WindowWidth - 1)
|
|
$blank = ' ' * $w
|
|
$bottom = [Console]::CursorTop
|
|
for ($y = $script:UiTop; $y -le $bottom; $y++) {
|
|
[Console]::SetCursorPosition(0, $y)
|
|
[Console]::Write($blank)
|
|
}
|
|
[Console]::SetCursorPosition(0, $script:UiTop)
|
|
} catch { }
|
|
}
|
|
|
|
function Stop-UiFrame {
|
|
Clear-UiFrame
|
|
$script:UiActive = $false
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Show-Select - arrow-key list picker.
|
|
#
|
|
# Draws the list once, then repaints it in place on every keypress by parking
|
|
# the cursor back at the top of the region. Each row is padded to the console
|
|
# width so a repaint erases whatever the previous, longer row left behind.
|
|
# A fixed-height detail pane under the list shows the highlighted row's info,
|
|
# which keeps the geometry constant - variable-height rows would make the
|
|
# in-place repaint arithmetic fragile.
|
|
#
|
|
# Returns the selected index, or -1 if the user cancelled / the console cannot
|
|
# support this (callers fall back to Read-Choice).
|
|
# ---------------------------------------------------------------------------
|
|
function Show-Select {
|
|
param(
|
|
[string] $Title,
|
|
[string] $Status,
|
|
[object[]] $Items, # each: Label (string), Detail (string[])
|
|
[int] $Default = 0,
|
|
[int] $DetailLines = 3
|
|
)
|
|
|
|
if (-not (Test-Interactive)) { return -1 }
|
|
$n = @($Items).Count
|
|
if ($n -eq 0) { return -1 }
|
|
|
|
$idx = [Math]::Max(0, [Math]::Min($Default, $n - 1))
|
|
$w = [Math]::Max(40, [Console]::WindowWidth - 1)
|
|
|
|
# Take over the frame: a menu already on screen is wiped so this one lands
|
|
# in its place instead of below it.
|
|
if ($script:UiActive) { Clear-UiFrame } else { Start-UiFrame }
|
|
|
|
$headerH = 3
|
|
Write-Host ''
|
|
if ($Title) {
|
|
$headerH = 4
|
|
$t = " $Title"
|
|
if ($Status) { $t = $t.PadRight(34) + $Status }
|
|
Write-Host $t -ForegroundColor Cyan
|
|
}
|
|
Write-Host ' up/down move enter select esc cancel' -ForegroundColor DarkGray
|
|
Write-Host ''
|
|
|
|
# Reserve the region first so the cursor never has to scroll mid-repaint,
|
|
# then rewind to its top.
|
|
$regionH = $n + 1 + $DetailLines
|
|
for ($i = 0; $i -lt $regionH; $i++) { Write-Host '' }
|
|
$top = [Console]::CursorTop - $regionH
|
|
|
|
# Reserving may have scrolled the buffer, which moves everything up and
|
|
# invalidates the remembered frame top. Re-anchor it from where the list
|
|
# actually landed.
|
|
$script:UiTop = [Math]::Max(0, $top - $headerH)
|
|
|
|
# Truncate-or-pad a row to exactly the console width, so repainting a short
|
|
# row fully erases a longer one underneath it.
|
|
$pad = {
|
|
param([string] $s)
|
|
if ($s.Length -gt $w) { $s = $s.Substring(0, $w) }
|
|
return $s.PadRight($w)
|
|
}
|
|
|
|
$cursorWasVisible = $true
|
|
try { $cursorWasVisible = [Console]::CursorVisible; [Console]::CursorVisible = $false } catch { }
|
|
|
|
try {
|
|
while ($true) {
|
|
[Console]::SetCursorPosition(0, $top)
|
|
|
|
for ($i = 0; $i -lt $n; $i++) {
|
|
$sel = ($i -eq $idx)
|
|
$mark = if ($sel) { ' > ' } else { ' ' }
|
|
$row = & $pad ($mark + $Items[$i].Label)
|
|
if ($sel) {
|
|
# An item may carry its own accent (mode rows do), so the
|
|
# highlight itself tells you which provider you are on.
|
|
$bg = 'Cyan'
|
|
if ($Items[$i].PSObject.Properties.Name -contains 'Accent' -and $Items[$i].Accent) {
|
|
$bg = [string]$Items[$i].Accent
|
|
}
|
|
Write-Host $row -ForegroundColor Black -BackgroundColor $bg
|
|
}
|
|
else { Write-Host $row -ForegroundColor Gray }
|
|
}
|
|
|
|
Write-Host (& $pad '')
|
|
|
|
$detail = @($Items[$idx].Detail)
|
|
for ($k = 0; $k -lt $DetailLines; $k++) {
|
|
$line = if ($k -lt $detail.Count) { ' ' + $detail[$k] } else { '' }
|
|
Write-Host (& $pad $line) -ForegroundColor DarkGray
|
|
}
|
|
|
|
# if/elseif rather than switch: `break`/`continue` inside a switch
|
|
# nested in a loop is ambiguous in PowerShell, and this is the input
|
|
# loop - it has to be unambiguous.
|
|
$key = [Console]::ReadKey($true)
|
|
$k = [string]$key.Key
|
|
$ch = $key.KeyChar
|
|
|
|
if ($k -eq 'UpArrow' -or $k -eq 'K') { $idx = ($idx - 1 + $n) % $n }
|
|
elseif ($k -eq 'DownArrow' -or $k -eq 'J') { $idx = ($idx + 1) % $n }
|
|
elseif ($k -eq 'Home') { $idx = 0 }
|
|
elseif ($k -eq 'End') { $idx = $n - 1 }
|
|
elseif ($k -eq 'Enter' -or $k -eq 'Spacebar') { return $idx }
|
|
elseif ($k -eq 'Escape') { return -1 }
|
|
elseif ($ch -eq 'q' -or $ch -eq 'Q') { return -1 }
|
|
elseif ($ch -match '^[1-9]$' -and [int][string]$ch -le $n) {
|
|
# Number keys still work as direct shortcuts.
|
|
return ([int][string]$ch - 1)
|
|
}
|
|
}
|
|
} finally {
|
|
try {
|
|
[Console]::SetCursorPosition(0, $top + $regionH)
|
|
[Console]::CursorVisible = $cursorWasVisible
|
|
} catch { }
|
|
}
|
|
}
|
|
|
|
# Numeric fallback, used when the console cannot host Show-Select.
|
|
function Read-Choice {
|
|
param([string] $Prompt, [int] $Max, [string] $Default = '')
|
|
while ($true) {
|
|
$raw = Read-Host $Prompt
|
|
if ([string]::IsNullOrWhiteSpace($raw)) {
|
|
if ($Default) { return $Default }
|
|
continue
|
|
}
|
|
$raw = $raw.Trim()
|
|
if ($raw -eq 'q' -or $raw -eq '0') { return 'q' }
|
|
$n = 0
|
|
if ([int]::TryParse($raw, [ref]$n) -and $n -ge 1 -and $n -le $Max) { return [string]$n }
|
|
Write-Host ' (invalid choice)' -ForegroundColor DarkGray
|
|
}
|
|
}
|
|
|
|
function Get-PresetSummaryLines {
|
|
param($Preset)
|
|
$out = @()
|
|
if ($Preset.Contains('description')) { $out += [string]$Preset['description'] }
|
|
$bits = @()
|
|
foreach ($t in $script:Tiers) {
|
|
if ($Preset['models'].Contains($t)) { $bits += "$t=$($Preset['models'][$t])" }
|
|
}
|
|
if ($bits.Count) { $out += ($bits -join ' ') }
|
|
if ($Preset.Contains('contextTokens') -and $Preset['contextTokens']) {
|
|
$out += ("context {0:N0} tokens base {1}" -f [int]$Preset['contextTokens'], $Preset['baseUrl'])
|
|
} else {
|
|
$out += "base $($Preset['baseUrl'])"
|
|
}
|
|
return $out
|
|
}
|
|
|
|
function Invoke-PresetPicker {
|
|
param([string] $Provider)
|
|
|
|
$names = @(Get-PresetNamesForProvider $Provider)
|
|
if ($names.Count -eq 0) { throw "no presets defined for provider '$Provider'" }
|
|
|
|
$default = $script:ProviderDefaultPreset[$Provider]
|
|
if (-not ($names -contains $default)) { $default = $names[0] }
|
|
$defaultIdx = [Math]::Max(0, [array]::IndexOf($names, $default))
|
|
|
|
$items = @()
|
|
foreach ($n in $names) {
|
|
$p = Get-Preset $n
|
|
$label = $n
|
|
if ($n -eq $default) { $label = "$n (default)" }
|
|
$items += [pscustomobject]@{ Label = $label; Detail = (Get-PresetSummaryLines $p) }
|
|
}
|
|
|
|
$sel = Show-Select -Title "preset for $Provider" -Items $items -Default $defaultIdx
|
|
if ($sel -ge 0) { return $names[$sel] }
|
|
if ($sel -eq -1 -and (Test-Interactive)) { return $null } # cancelled
|
|
|
|
# console too limited for the picker - fall back to numbers
|
|
for ($i = 0; $i -lt $names.Count; $i++) { Write-Host (" {0}) {1}" -f ($i + 1), $names[$i]) }
|
|
$c = Read-Choice -Prompt " preset [Enter = $default, q = cancel]" -Max $names.Count -Default 'D'
|
|
if ($c -eq 'q') { return $null }
|
|
if ($c -eq 'D') { return $default }
|
|
return $names[[int]$c - 1]
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Show-SearchSelect - arrow-key picker with a live type-to-filter box.
|
|
#
|
|
# Show-Select is fine for a handful of rows, but OpenRouter lists 300+ models,
|
|
# which is unusable as a flat list. This keeps the same in-place repaint but adds
|
|
# a filter line and a scrolling window over the matches.
|
|
#
|
|
# Returns the index into $Items, or -1 to cancel.
|
|
# ---------------------------------------------------------------------------
|
|
function Show-SearchSelect {
|
|
param(
|
|
[string] $Title,
|
|
[string] $Status,
|
|
[object[]] $Items, # each: Label (string), Detail (string[]), Key (string, searched)
|
|
[string] $Query = '',
|
|
[int] $MaxRows = 12,
|
|
[int] $DetailLines = 2
|
|
)
|
|
|
|
if (-not (Test-Interactive)) { return -1 }
|
|
$all = @($Items)
|
|
if ($all.Count -eq 0) { return -1 }
|
|
|
|
$w = [Math]::Max(40, [Console]::WindowWidth - 1)
|
|
$MaxRows = [Math]::Max(3, [Math]::Min($MaxRows, [Console]::WindowHeight - 12))
|
|
|
|
if ($script:UiActive) { Clear-UiFrame } else { Start-UiFrame }
|
|
|
|
$headerH = 3
|
|
Write-Host ''
|
|
if ($Title) {
|
|
$headerH = 4
|
|
$t = " $Title"
|
|
if ($Status) { $t = $t.PadRight(34) + $Status }
|
|
Write-Host $t -ForegroundColor Cyan
|
|
}
|
|
Write-Host ' type to filter up/down move enter select esc cancel' -ForegroundColor DarkGray
|
|
Write-Host ''
|
|
|
|
# filter line + blank + rows + count + blank + detail
|
|
$regionH = 1 + 1 + $MaxRows + 1 + 1 + $DetailLines
|
|
for ($i = 0; $i -lt $regionH; $i++) { Write-Host '' }
|
|
$top = [Console]::CursorTop - $regionH
|
|
$script:UiTop = [Math]::Max(0, $top - $headerH)
|
|
|
|
$pad = {
|
|
param([string] $s)
|
|
if ($s.Length -gt $w) { $s = $s.Substring(0, $w) }
|
|
return $s.PadRight($w)
|
|
}
|
|
|
|
$idx = 0
|
|
$off = 0
|
|
|
|
$cursorWasVisible = $true
|
|
try { $cursorWasVisible = [Console]::CursorVisible; [Console]::CursorVisible = $false } catch { }
|
|
|
|
try {
|
|
while ($true) {
|
|
# Filter on every keystroke. Match against Key when present so a row
|
|
# can display extra decoration without breaking search.
|
|
$q = $Query.Trim().ToLower()
|
|
if ($q) {
|
|
$matches = @($all | Where-Object {
|
|
$hay = if ($_.PSObject.Properties.Name -contains 'Key' -and $_.Key) { [string]$_.Key } else { [string]$_.Label }
|
|
$hay.ToLower().Contains($q)
|
|
})
|
|
} else {
|
|
$matches = $all
|
|
}
|
|
|
|
$m = $matches.Count
|
|
if ($idx -ge $m) { $idx = [Math]::Max(0, $m - 1) }
|
|
if ($idx -lt $off) { $off = $idx }
|
|
if ($idx -ge $off + $MaxRows) { $off = $idx - $MaxRows + 1 }
|
|
if ($off -gt [Math]::Max(0, $m - $MaxRows)) { $off = [Math]::Max(0, $m - $MaxRows) }
|
|
|
|
[Console]::SetCursorPosition(0, $top)
|
|
|
|
Write-Host (& $pad (" filter: " + $Query + "_")) -ForegroundColor White
|
|
Write-Host (& $pad '')
|
|
|
|
for ($r = 0; $r -lt $MaxRows; $r++) {
|
|
$i = $off + $r
|
|
if ($i -ge $m) { Write-Host (& $pad ''); continue }
|
|
$sel = ($i -eq $idx)
|
|
$mark = if ($sel) { ' > ' } else { ' ' }
|
|
$row = & $pad ($mark + $matches[$i].Label)
|
|
if ($sel) { Write-Host $row -ForegroundColor Black -BackgroundColor Cyan }
|
|
else { Write-Host $row -ForegroundColor Gray }
|
|
}
|
|
|
|
$count = if ($m -eq 0) { ' (no match)' } else { " $($idx + 1) of $m" + $(if ($all.Count -ne $m) { " (filtered from $($all.Count))" } else { '' }) }
|
|
Write-Host (& $pad $count) -ForegroundColor DarkGray
|
|
Write-Host (& $pad '')
|
|
|
|
$detail = if ($m -gt 0) { @($matches[$idx].Detail) } else { @() }
|
|
for ($k = 0; $k -lt $DetailLines; $k++) {
|
|
$line = if ($k -lt $detail.Count) { ' ' + $detail[$k] } else { '' }
|
|
Write-Host (& $pad $line) -ForegroundColor DarkGray
|
|
}
|
|
|
|
$key = [Console]::ReadKey($true)
|
|
$k = [string]$key.Key
|
|
$ch = $key.KeyChar
|
|
|
|
if ($k -eq 'UpArrow') { if ($m) { $idx = ($idx - 1 + $m) % $m } }
|
|
elseif ($k -eq 'DownArrow') { if ($m) { $idx = ($idx + 1) % $m } }
|
|
elseif ($k -eq 'PageUp') { $idx = [Math]::Max(0, $idx - $MaxRows) }
|
|
elseif ($k -eq 'PageDown') { $idx = [Math]::Min([Math]::Max(0, $m - 1), $idx + $MaxRows) }
|
|
elseif ($k -eq 'Home') { $idx = 0 }
|
|
elseif ($k -eq 'End') { $idx = [Math]::Max(0, $m - 1) }
|
|
elseif ($k -eq 'Enter') { if ($m -gt 0) { return [array]::IndexOf($all, $matches[$idx]) } }
|
|
elseif ($k -eq 'Escape') { return -1 }
|
|
elseif ($k -eq 'Backspace') {
|
|
if ($Query.Length -gt 0) { $Query = $Query.Substring(0, $Query.Length - 1); $idx = 0; $off = 0 }
|
|
}
|
|
elseif ($ch -and [int][char]$ch -ge 32 -and [int][char]$ch -lt 127) {
|
|
$Query += $ch; $idx = 0; $off = 0
|
|
}
|
|
}
|
|
} finally {
|
|
try {
|
|
[Console]::SetCursorPosition(0, $top + $regionH)
|
|
[Console]::CursorVisible = $cursorWasVisible
|
|
} catch { }
|
|
}
|
|
}
|
|
|
|
# Catalogue per provider, shaped for the picker. Cached per process so opening
|
|
# the picker repeatedly in one session does not refetch OpenRouter's 300+ models.
|
|
$script:ModelCatalogueCache = @{}
|
|
|
|
function Get-ModelChoices {
|
|
param($Preset)
|
|
|
|
$provider = [string]$Preset['provider']
|
|
$cacheKey = $provider + '|' + [string]$Preset['baseUrl']
|
|
if ($script:ModelCatalogueCache.ContainsKey($cacheKey)) { return $script:ModelCatalogueCache[$cacheKey] }
|
|
|
|
$out = @()
|
|
|
|
if ($provider -eq 'lmstudio') {
|
|
$models = @(Get-LmStudioModels ([string]$Preset['baseUrl']))
|
|
$tpl = Get-LmStudioTemplateReport
|
|
foreach ($m in ($models | Sort-Object Id)) {
|
|
$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 }
|
|
}
|
|
}
|
|
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') }
|
|
}
|
|
|
|
$script:ModelCatalogueCache[$cacheKey] = $out
|
|
return $out
|
|
}
|
|
|
|
function Read-ModelId {
|
|
param($Preset, [string] $Tier, [string] $Current)
|
|
|
|
$choices = @()
|
|
$err = $null
|
|
try { $choices = @(Get-ModelChoices $Preset) } catch { $err = $_.Exception.Message }
|
|
|
|
if ($choices.Count -gt 0) {
|
|
$items = @()
|
|
# First row is always the manual escape hatch - a catalogue can lag
|
|
# behind what the provider actually accepts.
|
|
$items += [pscustomobject]@{ Id = $null; Key = 'type manually custom'; Label = '<type an id manually>'; Detail = @('enter any model id by hand') }
|
|
$items += $choices
|
|
|
|
$sel = Show-SearchSelect -Title "model for '$Tier'" -Status "current: $Current" -Items $items
|
|
if ($sel -lt 0) { return $null }
|
|
if ($sel -gt 0) { return $items[$sel].Id }
|
|
# fall through to manual entry
|
|
}
|
|
|
|
# A typed prompt cannot live inside the repainting frame - the redraw would
|
|
# erase what is being typed. Close the frame first.
|
|
Stop-UiFrame
|
|
Write-Host ''
|
|
if ($err) { Write-Warn2 "could not load the model list: $err" }
|
|
Write-Host " current $Tier : $Current" -ForegroundColor DarkGray
|
|
$val = Read-Host " new model id for '$Tier' (blank = cancel)"
|
|
if ([string]::IsNullOrWhiteSpace($val)) { return $null }
|
|
return $val.Trim()
|
|
}
|
|
|
|
# A blank preset per provider, with the endpoint/auth/context bits already right
|
|
# so only the model choices are left to make.
|
|
function New-PresetScaffold {
|
|
param([string] $Provider)
|
|
|
|
$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['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'
|
|
}
|
|
}
|
|
return $base
|
|
}
|
|
|
|
function New-PresetInteractive {
|
|
# 1. provider
|
|
$provs = @($script:Modes | Where-Object { $_ -ne 'anthropic' })
|
|
$items = @()
|
|
foreach ($p in $provs) { $items += [pscustomobject]@{ Label = $p; Detail = @($script:ModeLabel[$p]) } }
|
|
$sel = Show-Select -Title 'new preset - which provider' -Items $items
|
|
if ($sel -lt 0) { return }
|
|
$provider = $provs[$sel]
|
|
|
|
# 2. start from an existing preset of that provider, or blank
|
|
$siblings = @(Get-PresetNamesForProvider $provider)
|
|
$items = @([pscustomobject]@{ Label = '<blank>'; Detail = @("empty $provider preset - pick every model yourself") })
|
|
foreach ($n in $siblings) {
|
|
$items += [pscustomobject]@{ Label = "copy of $n"; Detail = (Get-PresetSummaryLines (Get-Preset $n)) }
|
|
}
|
|
$sel = Show-Select -Title 'start from' -Items $items
|
|
if ($sel -lt 0) { return }
|
|
|
|
$preset = if ($sel -eq 0) { New-PresetScaffold $provider } else { Get-Preset $siblings[$sel - 1] }
|
|
|
|
# 3. name it
|
|
Stop-UiFrame
|
|
Write-Host ''
|
|
Write-Host " new $provider preset" -ForegroundColor Cyan
|
|
$name = Read-Host ' name (letters, digits, dash; blank = cancel)'
|
|
if ([string]::IsNullOrWhiteSpace($name)) { return }
|
|
$name = $name.Trim()
|
|
if ($name -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$') {
|
|
Write-Err2 "invalid name '$name' - use letters, digits, dash, dot, underscore"
|
|
return
|
|
}
|
|
if (Test-Path -LiteralPath (Get-PresetPath $name)) {
|
|
Write-Err2 "preset '$name' already exists"
|
|
return
|
|
}
|
|
|
|
$preset['description'] = if ($sel -eq 0) { "custom $provider preset" } else { "copy of $($siblings[$sel - 1])" }
|
|
Write-JsonFile (Get-PresetPath $name) $preset
|
|
Write-Ok "created preset '$name' ($provider)"
|
|
|
|
# 4. straight into editing it
|
|
Invoke-PresetEditor -Name $name
|
|
}
|
|
|
|
function Invoke-PresetEditor {
|
|
param([string] $Name)
|
|
|
|
if (-not $Name) {
|
|
$names = @(Get-PresetNames)
|
|
if ($names.Count -eq 0) { Write-Warn2 'no presets'; return }
|
|
|
|
$items = @()
|
|
foreach ($n in $names) {
|
|
$p = Get-Preset $n
|
|
$items += [pscustomobject]@{
|
|
Label = ("{0,-18} [{1}]" -f $n, $p['provider'])
|
|
Detail = (Get-PresetSummaryLines $p)
|
|
}
|
|
}
|
|
|
|
$sel = Show-Select -Title 'edit which preset' -Items $items
|
|
if ($sel -lt 0) { return }
|
|
$Name = $names[$sel]
|
|
}
|
|
|
|
$name = $Name
|
|
$preset = Get-Preset $name
|
|
|
|
while ($true) {
|
|
$rows = @()
|
|
foreach ($t in $script:Tiers) {
|
|
$cur = ''
|
|
if ($preset['models'].Contains($t)) { $cur = [string]$preset['models'][$t] }
|
|
$rows += [pscustomobject]@{ Tier = $t; Current = $cur }
|
|
}
|
|
$sub = ''
|
|
if ($preset.Contains('subagentModel')) { $sub = [string]$preset['subagentModel'] }
|
|
$rows += [pscustomobject]@{ Tier = 'subagent'; Current = $sub }
|
|
|
|
$items = @()
|
|
foreach ($r in $rows) {
|
|
$items += [pscustomobject]@{
|
|
Label = ("{0,-9} {1}" -f $r.Tier, $r.Current)
|
|
Detail = @("change which model backs the '$($r.Tier)' tier", "current: $($r.Current)")
|
|
}
|
|
}
|
|
|
|
$c = Show-Select -Title "$name [$($preset['provider'])]" -Status 'esc = done' -Items $items
|
|
if ($c -lt 0) { return }
|
|
|
|
$tier = $rows[$c].Tier
|
|
$val = Read-ModelId -Preset $preset -Tier $tier -Current $rows[$c].Current
|
|
if (-not $val) { continue }
|
|
|
|
# Set-PresetTier may re-apply the live preset, which prints a full switch
|
|
# summary. Drop the frame so that output survives instead of being wiped
|
|
# by the next redraw of the tier list.
|
|
Stop-UiFrame
|
|
Set-PresetTier -Name $name -Tier $tier -Model $val
|
|
$preset = Get-Preset $name
|
|
}
|
|
}
|
|
|
|
# The second level: everything that is not switching mode. Loops until the user
|
|
# backs out, so running doctor then editing a preset costs one trip in.
|
|
function Invoke-MoreMenu {
|
|
while ($true) {
|
|
$state = Get-State
|
|
$current = [string]$state['mode']
|
|
$curDesc = $current
|
|
if ($current -ne 'anthropic' -and $state['preset']) { $curDesc = "$current / $($state['preset'])" }
|
|
|
|
$items = @(
|
|
[pscustomobject]@{ Label = 'status'; Detail = @('show the full active configuration') }
|
|
[pscustomobject]@{ Label = 'edit presets'; Detail = @('pick models per tier from the provider catalogue') }
|
|
[pscustomobject]@{ Label = 'new preset'; Detail = @('create a preset - blank or copied from an existing one') }
|
|
[pscustomobject]@{ Label = 'doctor'; Detail = @('verify auth, endpoint, model ids, context window') }
|
|
[pscustomobject]@{ Label = 'back'; Detail = @('return to the mode menu') }
|
|
[pscustomobject]@{ Label = 'quit'; Detail = @() }
|
|
)
|
|
|
|
$sel = Show-Select -Title 'claude-mode - more' -Status "currently: $curDesc" -Items $items
|
|
if ($sel -lt 0) { return } # esc = back
|
|
|
|
switch ($sel) {
|
|
0 { Stop-UiFrame; Invoke-Status } # output worth keeping
|
|
1 { Invoke-PresetEditor } # more menus - keep the frame
|
|
2 { New-PresetInteractive }
|
|
3 { Stop-UiFrame; Invoke-Doctor }
|
|
4 { return }
|
|
5 { Stop-UiFrame; $script:UiQuit = $true; return }
|
|
}
|
|
}
|
|
}
|
|
|
|
function Invoke-Menu {
|
|
if (-not (Test-Interactive)) { Invoke-Status; return }
|
|
$script:UiQuit = $false
|
|
|
|
# Drawn once, above the frame anchor, so the menus repaint underneath it and
|
|
# the banner stays put instead of flickering on every keypress.
|
|
$st0 = Get-State
|
|
Show-Banner -Mode ([string]$st0['mode']) -Preset $(if ([string]$st0['mode'] -ne 'anthropic') { [string]$st0['preset'] } else { '' })
|
|
|
|
while ($true) {
|
|
$state = Get-State
|
|
$current = [string]$state['mode']
|
|
$curDesc = $current
|
|
if ($current -ne 'anthropic' -and $state['preset']) { $curDesc = "$current / $($state['preset'])" }
|
|
|
|
# The mode you are already in is not offered - nothing to switch to.
|
|
$choices = @($script:Modes | Where-Object { $_ -ne $current })
|
|
|
|
$items = @()
|
|
foreach ($m in $choices) {
|
|
$detail = @($script:ModeLabel[$m])
|
|
try {
|
|
$pn = $script:ProviderDefaultPreset[$m]
|
|
if ($pn -and (Test-Path -LiteralPath (Get-PresetPath $pn))) {
|
|
$detail += (Get-PresetSummaryLines (Get-Preset $pn))[1]
|
|
}
|
|
} catch { }
|
|
$items += [pscustomobject]@{
|
|
Label = ("switch to " + $m)
|
|
Detail = $detail
|
|
Accent = $script:ModeColor[$m]
|
|
}
|
|
}
|
|
# Everything that is not "switch mode" lives one level down, so the three
|
|
# things this tool exists to do are the whole first screen.
|
|
$items += [pscustomobject]@{ Label = 'more ...'; Detail = @('status, presets, doctor') }
|
|
|
|
$sel = Show-Select -Title 'claude-mode' -Status "currently: $curDesc" -Items $items
|
|
if ($sel -lt 0) { Stop-UiFrame; return }
|
|
|
|
$nChoices = $choices.Count
|
|
if ($sel -eq $nChoices) {
|
|
Invoke-MoreMenu
|
|
if ($script:UiQuit) { return }
|
|
continue
|
|
}
|
|
|
|
$mode = $choices[$sel]
|
|
if ($mode -eq 'anthropic') { Stop-UiFrame; Set-ClaudeMode -Mode 'anthropic' -PresetName ''; return }
|
|
|
|
$preset = Invoke-PresetPicker -Provider $mode
|
|
if (-not $preset) { continue }
|
|
Stop-UiFrame
|
|
Set-ClaudeMode -Mode $mode -PresetName $preset
|
|
return
|
|
}
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dispatch
|
|
# ---------------------------------------------------------------------------
|
|
|
|
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' { Set-VaultKey -Ref $(if ($Rest -and $Rest.Count -ge 1) { $Rest[0] } else { 'openrouter' }) }
|
|
'models' { Invoke-Models -Filter $(if ($Rest -and $Rest.Count -ge 1) { $Rest[0] } else { $null }) }
|
|
'doctor' { Invoke-Doctor }
|
|
'repair' { [void](Invoke-Repair -All:([bool]($Rest -and ($Rest -contains '--all')))) }
|
|
'help' { Show-Usage }
|
|
'--help' { Show-Usage }
|
|
'-h' { Show-Usage }
|
|
default { Write-Err2 "unknown command '$Command'"; Show-Usage; exit 1 }
|
|
}
|
|
} catch {
|
|
Write-Err2 $_.Exception.Message
|
|
exit 1
|
|
}
|