claude-mode.ps1 was 2,407 lines. It is now 153: the help block, parameters, paths, the managed-key list, a loader, and the dispatch. The rest moved, verbatim, into eleven files under lib/ - providers, output, files, core, vault, switch, guards, health, catalogue, commands, menu - dot-sourced into the script's scope in their original order, with the same check as the bash split that every original line landed in exactly one file. Inside a module $PSScriptRoot is lib\, so the one path beside the main script (providers.json) now goes through $script:Here. install.ps1 ships lib\, clearing old modules first. The Windows suite parses every module and install.ps1 (36 checks, all green on Windows PowerShell 5.1); install.ps1 is parsed but never run, since it edits the real profile and User PATH. linux/bootstrap.sh and scripts/build-package.ps1 existed only to build and serve packages for the Arkylx Index. Both installers fetch the repository's own archive, so a push to master is the release; the two scripts, the dist/ ignore and their mentions in the docs are gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
220 lines
10 KiB
PowerShell
220 lines
10 KiB
PowerShell
# lib/guards.ps1 - the Anthropic-model cost guard, the OpenRouter guardrail, stale cached model ids.
|
|
#
|
|
# Part of claude-mode.ps1, which dot-sources it into its own script scope after
|
|
# the settings at its top. Not meant to run on its own. ASCII only: Windows
|
|
# PowerShell 5.1 reads a .ps1 without a BOM as ANSI. $PSScriptRoot here would be
|
|
# lib\, so paths beside the main script go through $script:Here.
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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)
|
|
# An OpenRouter feature, flagged per provider rather than by name.
|
|
$pv = Get-Provider $Mode
|
|
if (-not ($pv -and $pv.guardrail) -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
|
|
}
|