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