Skip to content

Commit e8fc8a0

Browse files
lifeodysseyclaude
andauthored
feat(proxy): cc-switch reconciler — keep Headroom in the request path alongside cc-switch (headroomlabs-ai#1030)
## Description [cc-switch](https://github.com/farion1231/cc-switch) is a desktop provider manager for Claude Code and other coding agents; when a Claude Code provider is selected, it writes that provider's endpoint and token into `~/.claude/settings.json`. This PR adds an opt-in reconciler so Headroom can stay in Claude Code's request path when cc-switch rewrites that file during provider switches. The reconciler captures third-party Anthropic-compatible upstream URLs, points Claude back at the local Headroom proxy, and leaves official/empty OAuth settings direct unless explicitly opted in. This update also hardens the watcher so rapid settings rewrites that share the same float-second mtime are still detected. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Added an opt-in `HEADROOM_CC_SWITCH_RECONCILE=1` watcher for cc-switch direct-injection mode. - Added loopback-only `GET/PUT /admin/upstream` runtime upstream inspection and override endpoints. - Preserved token/model settings while rewriting only `env.ANTHROPIC_BASE_URL` back to the local Headroom proxy. - Switched reconciler change detection from float-second `st_mtime` to nanosecond `st_mtime_ns` so rapid provider switches are not missed. - Added pytest coverage for capture/rewrite behavior, official-provider defaults, route-official opt-in, loop safety, enabled flags, and the same-float-mtime provider-switch case. ## Testing - [ ] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text python -m pytest tests/test_proxy/test_cc_switch_reconciler.py 12 passed in 0.17s python -m ruff check . All checks passed! python -m mypy headroom Success: no issues found in 359 source files python -m pytest 7 failed, 5996 passed, 492 skipped, 5814 warnings in 396.41s ``` ## Real Behavior Proof - Environment: macOS, branch `feat/cc-switch-reconciler`, Python 3.13.3. - Exact command / steps: Ran the focused reconciler pytest file, full repository ruff check, full `mypy headroom`, and full pytest from the local PR branch. - Observed result: All 12 reconciler tests passed, including the rapid provider-switch case where two writes share the same float mtime but differ by nanoseconds. Full `ruff check .` and `mypy headroom` passed. Full pytest completed with 7 failures outside the cc-switch reconciler test file. - Not tested: Live cc-switch plus Claude Code end-to-end switch. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A. ## Additional Notes Documentation and CHANGELOG updates are not included in this PR. The reconciler remains opt-in and off by default. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 75f81cd commit e8fc8a0

3 files changed

Lines changed: 408 additions & 0 deletions

File tree

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
"""cc-switch reconciler: keep Headroom in the request path without fighting cc-switch.
2+
3+
Background
4+
----------
5+
cc-switch (https://github.com/farion1231/cc-switch) in its default *direct
6+
injection* mode rewrites the **entire** ``~/.claude/settings.json`` every time
7+
the user switches provider (atomic overwrite -- see
8+
``src-tauri/src/services/provider/live.rs``). It writes the selected provider's
9+
real endpoint + token, e.g.::
10+
11+
{"env": {"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic",
12+
"ANTHROPIC_AUTH_TOKEN": "sk-..."}}
13+
14+
Claude Code re-reads that file, so a running session immediately follows the
15+
switch. The problem: that overwrite blows away any ``ANTHROPIC_BASE_URL`` that
16+
points at Headroom.
17+
18+
What this does
19+
--------------
20+
A lightweight in-process watcher (poll-based, robust against atomic renames):
21+
22+
1. Detects cc-switch's overwrite.
23+
2. **Captures** the real provider endpoint and sets it as Headroom's upstream
24+
(runtime, no restart -- ``HeadroomProxy.ANTHROPIC_API_URL`` is a class attr
25+
read per request).
26+
3. **Rewrites only** ``env.ANTHROPIC_BASE_URL`` back to Headroom's local URL,
27+
leaving the token / model / everything else untouched.
28+
29+
Result: ``Claude -> Headroom (compress) -> selected provider``. cc-switch never
30+
knows; it is the *trigger*, not a coordination partner. The token rides in the
31+
request (Claude -> Headroom -> upstream, passed through verbatim); Headroom
32+
never reads or stores it.
33+
34+
Safety
35+
------
36+
- Loop-safe: once ``base_url`` already equals Headroom's URL, it is left alone.
37+
- Official / empty env (``{"env": {}}``, what cc-switch writes for "Claude
38+
Official") is **left direct** by default -- subscription OAuth through a custom
39+
base URL is fragile, so v1 does not route it through Headroom. Set
40+
``HEADROOM_CC_SWITCH_ROUTE_OFFICIAL=1`` to route official through Headroom too.
41+
- Gated entirely behind ``HEADROOM_CC_SWITCH_RECONCILE=1`` -- off by default, so
42+
it never affects users who do not opt in.
43+
"""
44+
45+
from __future__ import annotations
46+
47+
import asyncio
48+
import json
49+
import logging
50+
import os
51+
from collections.abc import Callable
52+
from pathlib import Path
53+
54+
logger = logging.getLogger(__name__)
55+
56+
_POLL_INTERVAL_S = 0.3
57+
58+
59+
def _settings_path() -> Path:
60+
base = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.join(os.path.expanduser("~"), ".claude")
61+
return Path(base).expanduser() / "settings.json"
62+
63+
64+
def reconciler_enabled() -> bool:
65+
return os.environ.get("HEADROOM_CC_SWITCH_RECONCILE", "").strip().lower() in (
66+
"1",
67+
"true",
68+
"yes",
69+
"on",
70+
)
71+
72+
73+
def _route_official() -> bool:
74+
return os.environ.get("HEADROOM_CC_SWITCH_ROUTE_OFFICIAL", "").strip().lower() in (
75+
"1",
76+
"true",
77+
"yes",
78+
"on",
79+
)
80+
81+
82+
class CCSwitchReconciler:
83+
"""Polls Claude settings.json and keeps Headroom in the path (see module docstring)."""
84+
85+
def __init__(
86+
self,
87+
*,
88+
proxy_url: str,
89+
default_upstream: str,
90+
set_upstream: Callable[[str], None],
91+
path: Path | None = None,
92+
) -> None:
93+
self.proxy_url = proxy_url.rstrip("/")
94+
self.default_upstream = default_upstream
95+
self._set_upstream = set_upstream
96+
self.path = path or _settings_path()
97+
self.current_upstream: str | None = None
98+
self._task: asyncio.Task | None = None
99+
self._last_mtime_ns: int | None = None
100+
101+
async def start(self) -> None:
102+
if self._task is not None:
103+
return
104+
logger.info("cc-switch reconciler: watching %s -> proxy %s", self.path, self.proxy_url)
105+
self._task = asyncio.create_task(self._loop())
106+
107+
async def stop(self) -> None:
108+
if self._task is None:
109+
return
110+
self._task.cancel()
111+
try:
112+
await self._task
113+
except (asyncio.CancelledError, Exception): # noqa: BLE001
114+
pass
115+
self._task = None
116+
117+
async def _loop(self) -> None:
118+
while True:
119+
try:
120+
self.tick()
121+
except Exception as exc: # noqa: BLE001 - watcher must never die
122+
logger.debug("cc-switch reconciler tick error: %s", exc)
123+
await asyncio.sleep(_POLL_INTERVAL_S)
124+
125+
# Synchronous core (also directly unit-testable).
126+
def tick(self) -> bool:
127+
"""One reconcile pass. Returns True if it rewrote settings.json."""
128+
try:
129+
mtime_ns = self.path.stat().st_mtime_ns
130+
except FileNotFoundError:
131+
return False
132+
if mtime_ns == self._last_mtime_ns:
133+
return False
134+
135+
try:
136+
data = json.loads(self.path.read_text(encoding="utf-8"))
137+
except (json.JSONDecodeError, OSError):
138+
# Transient read/parse failure (e.g. caught mid atomic-replace, or
139+
# cc-switch wrote partial JSON). Do NOT consume this mtime — leave
140+
# _last_mtime_ns untouched so the next tick retries instead of
141+
# treating the broken state as already-processed.
142+
return False
143+
# Read succeeded: now it is safe to mark this mtime processed.
144+
self._last_mtime_ns = mtime_ns
145+
if not isinstance(data, dict):
146+
return False
147+
env = data.get("env")
148+
env = dict(env) if isinstance(env, dict) else {}
149+
url = env.get("ANTHROPIC_BASE_URL")
150+
# A non-string base_url (number/list from a hand-edited file) would
151+
# raise on .rstrip() below and spam the watcher loop; treat as empty.
152+
if not isinstance(url, str):
153+
url = ""
154+
155+
# Empty / official: cc-switch wrote {"env": {}} (Claude Official, OAuth).
156+
if not url:
157+
if _route_official():
158+
self.current_upstream = self.default_upstream
159+
self._set_upstream(self.default_upstream)
160+
env["ANTHROPIC_BASE_URL"] = self.proxy_url
161+
data["env"] = env
162+
self._atomic_write(data)
163+
logger.info("cc-switch reconciler: official -> route via Headroom")
164+
return True
165+
return False # leave official direct (default, safe for OAuth)
166+
167+
# Already pointing at us: nothing to do (loop guard).
168+
if url.rstrip("/") == self.proxy_url:
169+
return False
170+
171+
# Third-party / custom endpoint: capture it as upstream, point Claude at us.
172+
self.current_upstream = url
173+
self._set_upstream(url)
174+
env["ANTHROPIC_BASE_URL"] = self.proxy_url
175+
data["env"] = env
176+
self._atomic_write(data)
177+
logger.info(
178+
"cc-switch reconciler: captured upstream=%s, base_url -> %s", url, self.proxy_url
179+
)
180+
return True
181+
182+
def _atomic_write(self, data: dict) -> None:
183+
# Per-process temp name: multiple Headroom processes reconciling the
184+
# same settings.json must not clobber each other's temp file.
185+
tmp = self.path.with_name(f"{self.path.name}.{os.getpid()}.hrtmp")
186+
tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
187+
os.replace(tmp, self.path)
188+
# Skip the mtime bump caused by our own write so we don't re-process it.
189+
try:
190+
self._last_mtime_ns = self.path.stat().st_mtime_ns
191+
except OSError:
192+
pass

headroom/proxy/server.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1672,6 +1672,32 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
16721672
config = config or ProxyConfig()
16731673
proxy = HeadroomProxy(config)
16741674

1675+
# cc-switch reconciler (opt-in: HEADROOM_CC_SWITCH_RECONCILE=1).
1676+
# Keeps Headroom in the request path while cc-switch overwrites
1677+
# ~/.claude/settings.json on every provider switch. See
1678+
# headroom/proxy/cc_switch_reconciler.py for the full rationale.
1679+
from headroom.proxy.cc_switch_reconciler import (
1680+
CCSwitchReconciler,
1681+
reconciler_enabled,
1682+
)
1683+
1684+
_cc_reconciler: CCSwitchReconciler | None = None
1685+
if reconciler_enabled():
1686+
_cc_proxy_port = config.port if hasattr(config, "port") else 8787
1687+
1688+
def _set_anthropic_upstream(url: str) -> None:
1689+
from headroom.providers.registry import _normalize_api_url
1690+
1691+
HeadroomProxy.ANTHROPIC_API_URL = _normalize_api_url(
1692+
url, default=DEFAULT_ANTHROPIC_API_URL
1693+
)
1694+
1695+
_cc_reconciler = CCSwitchReconciler(
1696+
proxy_url=f"http://127.0.0.1:{_cc_proxy_port}",
1697+
default_upstream=DEFAULT_ANTHROPIC_API_URL,
1698+
set_upstream=_set_anthropic_upstream,
1699+
)
1700+
16751701
# Telemetry beacon (anonymous aggregate stats).
16761702
# With uvicorn workers > 1, each worker runs the lifespan independently.
16771703
# We must ensure only ONE beacon runs across all workers — otherwise each
@@ -1771,6 +1797,15 @@ async def lifespan(app: FastAPI): # type: ignore[no-untyped-def]
17711797
else:
17721798
logger.debug("Beacon: skipping (another worker owns the lock)")
17731799

1800+
# Only the beacon-lock owner runs the reconciler. With
1801+
# uvicorn workers > 1 each worker runs this lifespan; without
1802+
# this guard every worker would watch + rewrite settings.json
1803+
# concurrently and each process would hold its own
1804+
# HeadroomProxy.ANTHROPIC_API_URL, so workers could disagree on
1805+
# the upstream. Single-owner mirrors the beacon's reasoning.
1806+
if _cc_reconciler is not None and _beacon_is_owner[0]:
1807+
await _cc_reconciler.start()
1808+
17741809
app.state.ready = True
17751810
yield
17761811
except Exception as exc:
@@ -1779,6 +1814,8 @@ async def lifespan(app: FastAPI): # type: ignore[no-untyped-def]
17791814
finally:
17801815
app.state.ready = False
17811816
# Shutdown
1817+
if _cc_reconciler is not None:
1818+
await _cc_reconciler.stop()
17821819
if _beacon_is_owner[0]:
17831820
await _beacon.stop()
17841821
_release_beacon_lock()
@@ -2239,6 +2276,21 @@ async def health():
22392276
)
22402277
from headroom.proxy.loopback_guard import require_loopback as _require_loopback
22412278

2279+
@app.get("/admin/upstream", dependencies=[Depends(_require_loopback)])
2280+
async def get_upstream():
2281+
"""Current Anthropic upstream + cc-switch reconciler state (loopback-only).
2282+
2283+
Read-only. The upstream is mutated only via the in-process cc-switch
2284+
reconciler (driven by ~/.claude/settings.json) — there is deliberately
2285+
no HTTP write route, so a local process cannot redirect credential-
2286+
bearing traffic to an arbitrary URL through this surface.
2287+
"""
2288+
return {
2289+
"anthropic": HeadroomProxy.ANTHROPIC_API_URL,
2290+
"cc_switch_reconcile": _cc_reconciler is not None,
2291+
"captured_upstream": getattr(_cc_reconciler, "current_upstream", None),
2292+
}
2293+
22422294
@app.get("/debug/tasks", dependencies=[Depends(_require_loopback)])
22432295
async def debug_tasks(stack: bool = False):
22442296
"""Enumerate running asyncio tasks.

0 commit comments

Comments
 (0)