Files
claude-mode/lib/guards.ps1
T
smoidoandClaude Opus 5 4091746d4e Split the Windows script into modules; drop the Arkylx Index pieces
claude-mode.ps1 was 2,407 lines. It is now 153: the help block, parameters,
paths, the managed-key list, a loader, and the dispatch. The rest moved,
verbatim, into eleven files under lib/ - providers, output, files, core,
vault, switch, guards, health, catalogue, commands, menu - dot-sourced into
the script's scope in their original order, with the same check as the bash
split that every original line landed in exactly one file. Inside a module
$PSScriptRoot is lib\, so the one path beside the main script
(providers.json) now goes through $script:Here.

install.ps1 ships lib\, clearing old modules first. The Windows suite
parses every module and install.ps1 (36 checks, all green on Windows
PowerShell 5.1); install.ps1 is parsed but never run, since it edits the
real profile and User PATH.

linux/bootstrap.sh and scripts/build-package.ps1 existed only to build and
serve packages for the Arkylx Index. Both installers fetch the repository's
own archive, so a push to master is the release; the two scripts, the dist/
ignore and their mentions in the docs are gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 02:06:45 +03:00

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
}