Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 59 additions & 1 deletion bin/ultracode
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ PID_FILE="$BASE_STATE/proxy.pid"
LOG_FILE="$BASE_STATE/proxy.log"
REF_DIR="$BASE_STATE/refs"
OWNER_REF="$REF_DIR/$$"
SAVED_MODEL_FILE="$BASE_STATE/saved_global_model.json"

# Claude Code persists an in-session `/model` pick (Enter in the picker) to the
# user-global settings file as the `model` key (v2.1.153+). Under UltraCode that
# means picking a proxy-only id (e.g. claude-composer) becomes your global
# default and breaks a plain `claude` run outside the proxy. We snapshot that key
# before launch and restore it on exit (ref-count-safe across sessions), so
# /model picks stay session-scoped. Disable with UC_PRESERVE_GLOBAL_MODEL=0.
CFG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
UC_PRESERVE_GLOBAL_MODEL="${UC_PRESERVE_GLOBAL_MODEL:-1}"

PY="$(command -v python3 || command -v python || true)"
[[ -n "$PY" ]] || { echo "Python 3 not found." >&2; exit 1; }
Expand Down Expand Up @@ -118,6 +128,53 @@ refs_active() {
[[ -d "$REF_DIR" ]] && [[ -n "$(ls -A "$REF_DIR" 2>/dev/null)" ]]
}

snapshot_global_model() {
# Record the current user-global `model` so an in-session /model pick can't
# leave a proxy-only id as your default. On a clean start (no other live
# UltraCode session) take a fresh snapshot; if a session is already running,
# keep its snapshot so we don't capture a mid-session pick as the "original".
[[ "$UC_PRESERVE_GLOBAL_MODEL" == "0" ]] && return 0
refs_active && [[ -f "$SAVED_MODEL_FILE" ]] && return 0
"$PY" - "$CFG_DIR/settings.json" "$SAVED_MODEL_FILE" <<'PY' 2>/dev/null || true
import json,sys
src,dst=sys.argv[1],sys.argv[2]
try:
s=json.load(open(src))
except Exception:
s={}
json.dump({"had":"model" in s,"model":s.get("model")},open(dst,"w"))
PY
}

restore_global_model() {
# Last session out restores the saved `model` key (or removes it if there was
# none), undoing any /model pick that Claude Code persisted globally.
[[ "$UC_PRESERVE_GLOBAL_MODEL" == "0" ]] && return 0
[[ -f "$SAVED_MODEL_FILE" ]] || return 0
refs_active && return 0
"$PY" - "$CFG_DIR/settings.json" "$SAVED_MODEL_FILE" <<'PY' 2>/dev/null || true
import json,sys
settings_f,saved_f=sys.argv[1],sys.argv[2]
try:
saved=json.load(open(saved_f))
except Exception:
saved={}
try:
s=json.load(open(settings_f))
except Exception:
s=None
if isinstance(s,dict):
if saved.get("had"):
s["model"]=saved.get("model")
else:
s.pop("model",None)
with open(settings_f,"w") as f:
json.dump(s,f,indent=2)
f.write("\n")
PY
rm -f "$SAVED_MODEL_FILE"
}

start_proxy() {
mkdir -p "$REF_DIR"
: > "$OWNER_REF"
Expand All @@ -137,18 +194,19 @@ start_proxy() {

stop_proxy() {
rm -f "$OWNER_REF"
restore_global_model
if refs_active; then return 0; fi
[[ -f "$PID_FILE" ]] && kill "$(cat "$PID_FILE" 2>/dev/null)" 2>/dev/null || true
rm -f "$PID_FILE"
}
trap stop_proxy EXIT

snapshot_global_model
start_proxy

# Seed Claude Code's gateway-models cache from the live proxy so the stock Claude
# models, your configured models, and the synthesized Worker -> entries all show
# on the very first /model open (before Claude Code re-fetches /v1/models).
CFG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
"$PY" - "$CFG_DIR/cache/gateway-models.json" "$BASE_URL" <<'PY' || true
import json,os,sys,time,urllib.request
cache_f, base = sys.argv[1], sys.argv[2]
Expand Down
24 changes: 24 additions & 0 deletions docs/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,30 @@ caches them, so a new Opus normally appears on its own. If it hasn't:
- **Reset the learned cache.** Delete the file shown at `/healthz` →
`stock_learning.cache` and relaunch.

### A `/model` pick leaked into my global default (plain `claude` now errors on the model)

Claude Code persists an in-session `/model` pick (pressing Enter in the picker) to
your **user-global** settings (`~/.claude/settings.json`, the `model` key) as of
v2.1.153. Under UltraCode that means picking a proxy-only id (e.g.
`claude-composer`, `claude-gpt-5.5-codex`) becomes your global default — and a
plain `claude` run **outside** the proxy then fails, because the real Anthropic
API doesn't know that id.

The launchers guard against this: `bin/ultracode` (and
`windows\Start-UltraCode.ps1`) snapshot the `model` key before launch and restore
it on exit, so `/model` picks stay session-scoped. It's ref-count-safe across
concurrent sessions (the last one out restores) and only touches the `model` key —
the rest of `settings.json` is left intact.

- **Already polluted?** Set `"model"` in `~/.claude/settings.json` back to a real
id (e.g. `"claude-opus-4-8"`), or run `/model` once in a plain `claude` session
and pick a real Claude model.
- **Want a `/model` pick to persist for plain `claude` too?** Disable the guard
with `UC_PRESERVE_GLOBAL_MODEL=0` before launching — in-session picks then save
globally as Claude Code normally does.
- **Keep a pick for this session only without saving (even with the guard off):**
press `s` in the `/model` picker instead of Enter.

### The pre-launch selector doesn't open / says it cannot reach `/uc/select`

- **Proxy not healthy yet or wrong port.** The launcher starts the proxy before
Expand Down
59 changes: 58 additions & 1 deletion windows/Start-UltraCode.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,61 @@ $RefDir = Join-Path $StateDir "refs"
$PidFile = Join-Path $StateDir "proxy.pid"
$OwnerRef = Join-Path $RefDir "$PID"

# Preserve the user-global model across the session. Claude Code persists an
# in-session /model pick (Enter in the picker) to settings.json as the `model`
# key (v2.1.153+), so picking a proxy-only id would become your global default
# and break a plain `claude` run outside the proxy. We snapshot that key before
# launch and restore it on exit (ref-count-safe). The JSON edit is done via
# Python (not ConvertTo-Json) so the rest of settings.json is left byte-for-key
# intact. Disable with UC_PRESERVE_GLOBAL_MODEL=0.
$CfgDir = if ($env:CLAUDE_CONFIG_DIR) { $env:CLAUDE_CONFIG_DIR } else { Join-Path $HOME ".claude" }
$GlobalSettings = Join-Path $CfgDir "settings.json"
$SavedModelFile = Join-Path $StateDir "saved_global_model.json"
$PreserveGlobalModel = ($env:UC_PRESERVE_GLOBAL_MODEL -ne "0")
$SnapPy = @'
import json,sys
src,dst=sys.argv[1],sys.argv[2]
try: s=json.load(open(src))
except Exception: s={}
json.dump({"had":"model" in s,"model":s.get("model")},open(dst,"w"))
'@
$RestorePy = @'
import json,sys
settings_f,saved_f=sys.argv[1],sys.argv[2]
try: saved=json.load(open(saved_f))
except Exception: saved={}
try: s=json.load(open(settings_f))
except Exception: s=None
if isinstance(s,dict):
if saved.get("had"): s["model"]=saved.get("model")
else: s.pop("model",None)
f=open(settings_f,"w"); json.dump(s,f,indent=2); f.write("\n"); f.close()
'@

function Invoke-UcPy {
param([string]$Code, [string[]]$Rest)
$a = @()
if ($PyCmd.Count -gt 1) { $a += $PyCmd[1..($PyCmd.Count-1)] }
$a += @("-c", $Code) + $Rest
& $PyCmd[0] @a 2>$null
}

function Save-GlobalModel {
if (-not $PreserveGlobalModel) { return }
# Clean start: refresh the snapshot. Other session live: keep theirs so we
# never capture a mid-session pick as the "original".
if ((Test-RefsActive) -and (Test-Path $SavedModelFile)) { return }
try { Invoke-UcPy $SnapPy @($GlobalSettings, $SavedModelFile) } catch {}
}

function Restore-GlobalModel {
if (-not $PreserveGlobalModel) { return }
if (-not (Test-Path $SavedModelFile)) { return }
if (Test-RefsActive) { return } # other sessions live; last one out restores
try { Invoke-UcPy $RestorePy @($GlobalSettings, $SavedModelFile) } catch {}
Remove-Item $SavedModelFile -Force -ErrorAction SilentlyContinue
}

function Test-ProxyHealthy {
try { return (Invoke-WebRequest -Uri "$BaseUrl/healthz" -UseBasicParsing -TimeoutSec 2).StatusCode -eq 200 }
catch { return $false }
Expand All @@ -152,6 +207,7 @@ function Test-RefsActive {

function Stop-ProxyIfLast {
Remove-Item $OwnerRef -Force -ErrorAction SilentlyContinue
Restore-GlobalModel
if (Test-RefsActive) { return }
if (Test-Path $PidFile) {
$stopId = Get-Content $PidFile -ErrorAction SilentlyContinue | Select-Object -First 1
Expand All @@ -161,6 +217,7 @@ function Stop-ProxyIfLast {
}

New-Item -ItemType Directory -Force -Path $RefDir | Out-Null
Save-GlobalModel
New-Item -ItemType File -Force -Path $OwnerRef | Out-Null

if (Test-ProxyHealthy) {
Expand Down Expand Up @@ -190,7 +247,6 @@ if (Test-ProxyHealthy) {
# ----- seed Claude Code's gateway-models cache (first-launch visibility) -----
# Seed stock Claude + your configured models so real Claude and your picks all
# show on the very first /model open (before Claude Code re-fetches /v1/models).
$CfgDir = if ($env:CLAUDE_CONFIG_DIR) { $env:CLAUDE_CONFIG_DIR } else { Join-Path $HOME ".claude" }
$GwCache = Join-Path (Join-Path $CfgDir "cache") "gateway-models.json"
try {
New-Item -ItemType Directory -Force -Path (Split-Path $GwCache) | Out-Null
Expand All @@ -217,6 +273,7 @@ try {

if ($ProxyOnly) {
Remove-Item $OwnerRef -Force -ErrorAction SilentlyContinue
Restore-GlobalModel
$shownPid = if (Test-Path $PidFile) { Get-Content $PidFile -ErrorAction SilentlyContinue | Select-Object -First 1 } else { "<pid>" }
Write-Host ""
Write-Host "Proxy running. Connect Claude Code with:"
Expand Down
Loading