`claude-mode set-key openrouter sk-or-...` did the wrong thing on the POSIX port: the dispatcher loop kept the *last* positional, so the key became the ref name and the prompt asked for "the key for ref 'sk-or-...'". Storing to that ref succeeds, so nothing looks wrong until the switch later reports no key for 'openrouter' - which is exactly what happened on macOS. Both ports now take `set-key [ref] [key]`: the first positional is the ref unless it is key-shaped (sk-* or longer than a ref name would plausibly be), in which case it is the key for the default ref, and a second positional is always the key. An inline key warns that it is now in the shell history, since the hidden prompt leaves no trace. Extra arguments are refused instead of ignored. The Windows dispatch already read the first positional correctly, so it only gains the inline form. The shipped default preset moves to the models asked for: DeepSeek 4.1 Flash on opus, GLM Flash on sonnet. Existing installs keep their own presets - the installer never overwrites one without --force - so an upgrade needs `claude-mode preset set default opus ...` and the same for sonnet, or a --force install. Verified in the bash 3.2 container: ref+key stores under the ref, a bare key stores under openrouter, an empty prompt aborts, a custom ref still works, the stored value is byte-identical, and four positionals are refused. Co-Authored-By: Claude Code <noreply@anthropic.com>
2232 lines
95 KiB
PowerShell
2232 lines
95 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] [key] 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, [string] $Key = '')
|
|
Initialize-Root
|
|
if ($Key) {
|
|
Write-Warn2 'the key was given on the command line, so it is in this shell history - the hidden prompt leaves no trace'
|
|
$plain = $Key
|
|
} else {
|
|
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) {
|
|
$unit = if ($plain.Length -eq 1) { 'character' } else { 'characters' }
|
|
Write-Warn2 "that key is only $($plain.Length) $unit - 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-key [ref] [key]`. The key is normally typed at the hidden
|
|
# prompt; anything key-shaped in the ref position is taken as the
|
|
# key for the default ref, which is the easy mistake to make.
|
|
$kRef = ''; $kKey = ''
|
|
foreach ($a in $Rest) {
|
|
if (-not $kRef -and -not ($a -like 'sk-*' -or $a.Length -gt 24)) { $kRef = $a; continue }
|
|
if (-not $kKey) { $kKey = $a; continue }
|
|
throw "unexpected extra argument '$a'"
|
|
}
|
|
if (-not $kRef) { $kRef = 'openrouter' }
|
|
Set-VaultKey -Ref $kRef -Key $kKey
|
|
}
|
|
'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
|
|
}
|