Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/client/cli/commands/claude_settings.py: 0%

307 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 12:01 +0000

1"""Shared handling of Claude Code's ~/.claude/settings.json. 

2 

3`lite up` and `lite autoroute start` patch this file temporarily and restore it on 

4exit; `lite configure claude` patches it persistently and records how to undo it. 

5All of them need the same merge, and `up` already imports from `auth`, so the 

6shared parts live here rather than in any one command module. The credential is 

7always a static token in `env.ANTHROPIC_AUTH_TOKEN`: Claude Code's `apiKeyHelper` 

8would spawn a `lite` process on every credential refresh, and that process touches 

9the keychain, so nothing here writes one; a helper left by an earlier version is 

10owned like any other key and stripped. 

11""" 

12 

13import hashlib 

14import json 

15import shlex 

16import sys 

17from collections.abc import Callable, Mapping, Sequence 

18from dataclasses import dataclass 

19from functools import reduce 

20from itertools import chain 

21from pathlib import Path 

22from types import MappingProxyType 

23from typing import Final, TypeAlias 

24 

25import click 

26from filelock import FileLock 

27from packaging.version import InvalidVersion, Version 

28from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter, ValidationError 

29 

30from litellm._version import version as litellm_version 

31from litellm.litellm_core_utils.private_json import ( 

32 commit_staged_json, 

33 discard_staged_json, 

34 ensure_private_dir, 

35 stage_private_json, 

36 write_private_bytes, 

37) 

38 

39from . import statusline_script 

40from .cmd_quoting import quote_for_cmd 

41 

42ENV_KEY: Final = "env" 

43API_KEY_HELPER_KEY: Final = "apiKeyHelper" 

44MODEL_KEY: Final = "model" 

45STATUS_LINE_KEY: Final = "statusLine" 

46ANTHROPIC_BASE_URL_KEY: Final = "ANTHROPIC_BASE_URL" 

47ANTHROPIC_AUTH_TOKEN_KEY: Final = "ANTHROPIC_AUTH_TOKEN" 

48ANTHROPIC_API_KEY_KEY: Final = "ANTHROPIC_API_KEY" 

49ENABLE_TOOL_SEARCH_KEY: Final = "ENABLE_TOOL_SEARCH" 

50ENABLE_TOOL_SEARCH_VALUE: Final = "true" 

51ENABLE_GATEWAY_MODEL_DISCOVERY_KEY: Final = "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY" 

52ENABLE_GATEWAY_MODEL_DISCOVERY_VALUE: Final = "1" 

53ANTHROPIC_MODEL_KEY: Final = "ANTHROPIC_MODEL" 

54ANTHROPIC_DEFAULT_MODEL_ENV_KEYS: Final = ( 

55 "ANTHROPIC_DEFAULT_SONNET_MODEL", 

56 "ANTHROPIC_DEFAULT_HAIKU_MODEL", 

57 "ANTHROPIC_DEFAULT_OPUS_MODEL", 

58 "ANTHROPIC_DEFAULT_FABLE_MODEL", 

59) 

60OWNED_ENV_KEYS: Final = ( 

61 ENABLE_TOOL_SEARCH_KEY, 

62 ENABLE_GATEWAY_MODEL_DISCOVERY_KEY, 

63 ANTHROPIC_BASE_URL_KEY, 

64 ANTHROPIC_AUTH_TOKEN_KEY, 

65 ANTHROPIC_API_KEY_KEY, 

66 ANTHROPIC_MODEL_KEY, 

67) 

68OWNED_TOP_LEVEL_KEYS: Final = (API_KEY_HELPER_KEY, MODEL_KEY, STATUS_LINE_KEY) 

69OWNED_PATHS: Final = (*(f"{ENV_KEY}.{key}" for key in OWNED_ENV_KEYS), *OWNED_TOP_LEVEL_KEYS) 

70_CREDENTIAL_ENV_KEYS: Final = frozenset((ANTHROPIC_API_KEY_KEY, ANTHROPIC_AUTH_TOKEN_KEY)) 

71_CREDENTIAL_PATHS: Final = (*(f"{ENV_KEY}.{key}" for key in sorted(_CREDENTIAL_ENV_KEYS)), API_KEY_HELPER_KEY) 

72_BASE_URL_PATH: Final = f"{ENV_KEY}.{ANTHROPIC_BASE_URL_KEY}" 

73_MODEL_PATHS: Final = (MODEL_KEY, f"{ENV_KEY}.{ANTHROPIC_MODEL_KEY}") 

74STARTING_MODEL_ROLE: Final = "the /model picker's default row, the model Claude Code starts and resumes on" 

75 

76CLAUDE_SETTINGS_PATH: Final = Path.home() / ".claude" / "settings.json" 

77CLAUDE_CONFIG_DIR_ENV: Final = "CLAUDE_CONFIG_DIR" 

78BACKUP_PATH: Final = Path.home() / ".litellm" / "claude_settings_backup.json" 

79AUTOROUTE_BACKUP_PATH: Final = Path.home() / ".litellm" / "autorouter" / "claude_settings_backup.json" 

80CONFIGURE_STATE_PATH: Final = Path.home() / ".litellm" / "claude_configure_state.json" 

81STATUSLINE_SCRIPT_PATH: Final = Path.home() / ".litellm" / "statusline.py" 

82STATUSLINE_VERSION_PREFIX: Final = b"# litellm-statusline-version: " 

83 

84 

85@dataclass(frozen=True, slots=True) 

86class SettingsFileOwner: 

87 """A command that takes temporary ownership of CLAUDE_SETTINGS_PATH and restores it later.""" 

88 

89 backup_path: Path 

90 start_command: str 

91 stop_command: str 

92 

93 

94SETTINGS_FILE_OWNERS: Final = ( 

95 SettingsFileOwner(BACKUP_PATH, "lite up", "lite down"), 

96 SettingsFileOwner(AUTOROUTE_BACKUP_PATH, "lite autoroute start", "lite autoroute stop"), 

97) 

98 

99_SETTINGS_ADAPTER: Final = TypeAdapter(dict[str, JsonValue]) 

100 

101 

102class ClaudeSettingsError(Exception): 

103 """Raised for any user-actionable failure while reading or writing Claude Code settings.""" 

104 

105 

106def claude_settings_path(environ: Mapping[str, str]) -> Path: 

107 """The settings.json Claude Code reads: under CLAUDE_CONFIG_DIR when set, else ~/.claude/settings.json.""" 

108 config_dir: Final = environ.get(CLAUDE_CONFIG_DIR_ENV, "") 

109 if not config_dir: 

110 return CLAUDE_SETTINGS_PATH 

111 return Path(config_dir).expanduser() / "settings.json" 

112 

113 

114def _is_default_settings_file(settings_path: Path) -> bool: 

115 return settings_path.resolve() == CLAUDE_SETTINGS_PATH.resolve() 

116 

117 

118def settings_file_owners(settings_path: Path) -> tuple[SettingsFileOwner, ...]: 

119 """The commands whose backups guard settings_path: `lite up` and `lite autoroute start` only ever manage the default file.""" 

120 return SETTINGS_FILE_OWNERS if _is_default_settings_file(settings_path) else () 

121 

122 

123def configure_state_path(settings_path: Path) -> Path: 

124 """The receipt describing settings_path: the default file keeps CONFIGURE_STATE_PATH, and any other file 

125 (a CLAUDE_CONFIG_DIR) gets its own beside it, keyed by its resolved path, so two settings files never 

126 share one undo record.""" 

127 if _is_default_settings_file(settings_path): 

128 return CONFIGURE_STATE_PATH 

129 digest: Final = hashlib.sha256(str(settings_path.resolve()).encode()).hexdigest() 

130 return CONFIGURE_STATE_PATH.parent / CONFIGURE_STATE_PATH.stem / f"{digest}.json" 

131 

132 

133@dataclass(frozen=True, slots=True) 

134class StaticToken: 

135 """A long-lived virtual key, written into env.ANTHROPIC_AUTH_TOKEN.""" 

136 

137 token: str 

138 

139 

140@dataclass(frozen=True, slots=True) 

141class KeepModel: 

142 """Leave the top-level `model` as it is, the user's or an earlier configure's (a re-login).""" 

143 

144 

145@dataclass(frozen=True, slots=True) 

146class UnpinModel: 

147 """Let go of a `model` an earlier configure pinned; one the user set themselves stays.""" 

148 

149 

150@dataclass(frozen=True, slots=True) 

151class StartOn: 

152 """Pin `model` and `env.ANTHROPIC_MODEL`: the row Claude Code starts on, and the one a resumed session stays 

153 on, since resume otherwise re-sends the transcript's served model, which a raw-model router made a tier 

154 model the key may not reach.""" 

155 

156 model: str 

157 

158 

159ModelChoice: TypeAlias = KeepModel | UnpinModel | StartOn 

160 

161 

162class OwnedValue(BaseModel): 

163 """What one key held at a moment in time; `present=False` is an absent key, not a null one.""" 

164 

165 model_config = ConfigDict(frozen=True) 

166 

167 present: bool 

168 value: JsonValue = None 

169 

170 

171class ConfigureReceipt(BaseModel): 

172 """What `lite configure claude` found and what it owns, keyed by dotted path (`env.X` or a top-level key). 

173 

174 Ownership moves only by a write: `written` fingerprints the keys some configure changed, at the 

175 value it wrote; a repeat configure refreshes a fingerprint only for a key its merge changed and 

176 carries the earlier one otherwise, so a key the user edited in between stops matching and is left 

177 alone. `previous` is what each key held before configure took it over; a repeat keeps the earlier 

178 snapshot while the key still holds our value and snapshots afresh otherwise, so whatever the 

179 repeat displaces is what comes back. `endpoints` is the ANTHROPIC_BASE_URL each credential slot 

180 was captured beside, so a credential is only ever put back next to the server it was issued for. 

181 No fingerprint is a second copy of a token. 

182 """ 

183 

184 model_config = ConfigDict(frozen=True) 

185 

186 file_existed: bool 

187 env_present: bool 

188 env_was_object: bool 

189 previous: Mapping[str, OwnedValue] 

190 written: Mapping[str, str] 

191 endpoints: Mapping[str, OwnedValue] 

192 

193 

194@dataclass(frozen=True, slots=True) 

195class WithheldCredential: 

196 """A credential left removed: captured beside `endpoint`, while the restored file points elsewhere.""" 

197 

198 key: str 

199 endpoint: str 

200 

201 

202@dataclass(frozen=True, slots=True) 

203class UnconfigureOutcome: 

204 """Keys whose value unconfigure changed back, keys the user changed since and so were left as they 

205 are, credentials withheld (the receipt is kept for them, so a later unconfigure can finish once the 

206 URL points back), and whether no settings file remains.""" 

207 

208 restored: tuple[str, ...] 

209 kept: tuple[str, ...] 

210 withheld: tuple[WithheldCredential, ...] = () 

211 file_removed: bool = False 

212 

213 

214@dataclass(frozen=True, slots=True) 

215class _Claim: 

216 previous: OwnedValue 

217 written: str | None 

218 endpoint: OwnedValue | None 

219 

220 

221def load_json_or_empty(path: Path) -> dict[str, JsonValue]: 

222 try: 

223 content: Final = path.read_bytes() if path.exists() else b"" 

224 except OSError as e: 

225 raise ClaudeSettingsError(f"Could not read {path}: {e}") from e 

226 if not content.strip(): 

227 return {} 

228 try: 

229 return _SETTINGS_ADAPTER.validate_json(content) 

230 except ValidationError: 

231 raise ClaudeSettingsError( 

232 f"{path} contains invalid JSON (or its root is not an object); cannot proceed safely." 

233 ) 

234 

235 

236def _env_object(settings: Mapping[str, JsonValue], path: Path) -> Mapping[str, JsonValue]: 

237 raw_env: Final = settings.get(ENV_KEY) 

238 if raw_env is None: 

239 return MappingProxyType({}) 

240 if not isinstance(raw_env, dict): 

241 raise ClaudeSettingsError( 

242 f'{path} has a non-object "{ENV_KEY}" value, which this would discard. Fix or remove it, then retry.' 

243 ) 

244 return raw_env 

245 

246 

247def refuse_while_owned(settings_path: Path, owners: Sequence[SettingsFileOwner]) -> None: 

248 """Refuse while `lite up` or `lite autoroute start` holds a backup it will restore over any write; a 

249 purely local check, so commands run it before any login prompt or request.""" 

250 for owner in owners: 

251 if owner.backup_path.exists(): 

252 raise ClaudeSettingsError( 

253 f"`{owner.start_command}` is currently managing {settings_path} (backup at " 

254 f"{owner.backup_path}) and will restore it when it stops. " 

255 f"Run `{owner.stop_command}` first, then retry." 

256 ) 

257 

258 

259def _write_target(settings_path: Path) -> Path: 

260 """Write through a symlinked settings.json rather than replacing the link, which would silently 

261 detach a file symlinked into a dotfiles repo.""" 

262 try: 

263 return settings_path.resolve() if settings_path.is_symlink() else settings_path 

264 except OSError as e: 

265 raise ClaudeSettingsError(f"Could not resolve {settings_path}: {e}") from e 

266 

267 

268def write_claude_settings(settings_path: Path, settings: Mapping[str, JsonValue]) -> None: 

269 """The one way a settings document lands on disk: staged owner-only beside the target and renamed into 

270 place, through a symlink rather than over it. Every writer (`configure`, `up`, `autoroute start` and the 

271 restores) may be carrying the credential, so none creates the file under the umask or truncates it.""" 

272 target: Final = _write_target(settings_path) 

273 try: 

274 commit_staged_json(stage_private_json(str(target), settings), str(target)) 

275 except OSError as e: 

276 raise ClaudeSettingsError(f"Could not write {settings_path}: {e}") from e 

277 

278 

279def _stage(path: Path, document: Mapping[str, object]) -> str: 

280 try: 

281 return stage_private_json(str(path), document) 

282 except OSError as e: 

283 raise ClaudeSettingsError(f"Could not write {path}: {e}") from e 

284 

285 

286def _land( 

287 path: Path, 

288 staged: str | None, 

289 also_discard: Sequence[str | None] = (), 

290 commit: Callable[[str, str], None] = commit_staged_json, 

291) -> None: 

292 """Commit a staged file to `path`, or remove `path` when nothing is staged for it. The one place a 

293 filesystem error becomes a ClaudeSettingsError; on failure the operation's other staged files are 

294 discarded, so no temp file holding a token is left behind.""" 

295 try: 

296 if staged is None: 

297 path.unlink(missing_ok=True) 

298 else: 

299 commit(staged, str(path)) 

300 except OSError as e: 

301 for other in also_discard: 

302 if other is not None: 

303 discard_staged_json(other) 

304 raise ClaudeSettingsError(f"Could not {'remove' if staged is None else 'write'} {path}: {e}") from e 

305 

306 

307def statusline_command(script_path: Path, platform: str = sys.platform) -> str: 

308 """This interpreter, not a bare `python3`: it is the one the apiKeyHelper already depends on.""" 

309 quote: Final = quote_for_cmd if platform.startswith("win") else shlex.quote 

310 return " ".join(quote(token) for token in (sys.executable, str(script_path))) 

311 

312 

313def _statusline_version(value: str) -> Version | None: 

314 try: 

315 return Version(value) 

316 except InvalidVersion: 

317 return None 

318 

319 

320def _installed_statusline_version(target: Path) -> Version | None: 

321 try: 

322 with target.open("rb") as script: 

323 header: Final = script.readline(256) 

324 except FileNotFoundError: 

325 return None 

326 if not header.startswith(STATUSLINE_VERSION_PREFIX): 

327 return None 

328 try: 

329 return _statusline_version(header.removeprefix(STATUSLINE_VERSION_PREFIX).decode("ascii").strip()) 

330 except UnicodeDecodeError: 

331 return None 

332 

333 

334def install_statusline_script( 

335 script_path: Path | None = None, 

336 *, 

337 package_version: str = litellm_version, 

338 write: Callable[[str, bytes], None] = write_private_bytes, 

339) -> str: 

340 target: Final = script_path or STATUSLINE_SCRIPT_PATH 

341 try: 

342 ensure_private_dir(target.parent) 

343 bundled_version: Final = _statusline_version(package_version) 

344 with FileLock(str(target) + ".lock", timeout=10, mode=0o600): 

345 installed_version: Final = _installed_statusline_version(target) 

346 if installed_version is not None and (bundled_version is None or installed_version > bundled_version): 

347 cli_version: Final = str(bundled_version) if bundled_version is not None else "unknown" 

348 click.echo( 

349 f"Keeping the status line from LiteLLM {installed_version}; this CLI is {cli_version}. " 

350 "Upgrade the CLI to refresh it.", 

351 err=True, 

352 ) 

353 return statusline_command(target) 

354 source: Final = Path(statusline_script.__file__).read_bytes() 

355 header: Final = ( 

356 STATUSLINE_VERSION_PREFIX + str(bundled_version).encode("ascii") + b"\n" 

357 if bundled_version is not None 

358 else b"" 

359 ) 

360 write(str(target), header + source) 

361 except OSError as e: 

362 raise ClaudeSettingsError(f"Could not install the status line script at {target}: {e}") from e 

363 return statusline_command(target) 

364 

365 

366def with_status_line(settings: Mapping[str, JsonValue], command: str) -> Mapping[str, JsonValue]: 

367 """Ours is recognised by the script it runs, so a re-install under another interpreter is still ours.""" 

368 existing: Final = settings.get(STATUS_LINE_KEY) 

369 existing_command: Final = existing.get("command") if isinstance(existing, dict) else None 

370 ours: Final = existing is None or (isinstance(existing_command, str) and command.split()[-1] in existing_command) 

371 if not ours: 

372 return settings 

373 entry: Final = dict((("type", "command"), ("command", command))) # mutable-ok: JSON document 

374 return dict(chain(settings.items(), ((STATUS_LINE_KEY, entry),))) # mutable-ok: JSON document 

375 

376 

377def merge_claude_settings( 

378 settings: Mapping[str, JsonValue], 

379 base_url: str, 

380 credential: StaticToken, 

381 default_model: str | None = None, 

382 tier_model: str | None = None, 

383 *, 

384 status_line: str | None = None, 

385) -> Mapping[str, JsonValue]: 

386 """Return a new settings mapping wired to route Claude Code through the proxy. 

387 

388 The token lands in env.ANTHROPIC_AUTH_TOKEN; the other credential slots (a stray ANTHROPIC_API_KEY, 

389 an apiKeyHelper) are removed, since Claude Code given two credentials may send the wrong one. 

390 ENABLE_TOOL_SEARCH and CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY get their defaults only when 

391 missing. `default_model` is the top-level `model` and env.ANTHROPIC_MODEL (see StartOn); 

392 `tier_model` is `lite autoroute start`'s knob that points every ANTHROPIC_DEFAULT_*_MODEL at one 

393 group. Apart from those tier keys, exactly OWNED_PATHS are touched. 

394 """ 

395 raw_env: Final = settings.get(ENV_KEY, {}) 

396 current_env: Final = raw_env if isinstance(raw_env, dict) else {} 

397 env: Final = dict( # mutable-ok: JSON document handed to json.dump, which rejects a read-only mapping 

398 chain( 

399 ( 

400 (ENABLE_TOOL_SEARCH_KEY, ENABLE_TOOL_SEARCH_VALUE), 

401 (ENABLE_GATEWAY_MODEL_DISCOVERY_KEY, ENABLE_GATEWAY_MODEL_DISCOVERY_VALUE), 

402 ), 

403 ((key, value) for key, value in current_env.items() if key not in _CREDENTIAL_ENV_KEYS), 

404 ((ANTHROPIC_BASE_URL_KEY, base_url.rstrip("/")), (ANTHROPIC_AUTH_TOKEN_KEY, credential.token)), 

405 ((ANTHROPIC_MODEL_KEY, default_model),) if default_model is not None else (), 

406 ((key, tier_model) for key in ANTHROPIC_DEFAULT_MODEL_ENV_KEYS if tier_model is not None), 

407 ) 

408 ) 

409 return dict( # mutable-ok: JSON document handed to json.dump, which rejects a read-only mapping 

410 chain( 

411 ( 

412 (key, value) 

413 for key, value in (with_status_line(settings, status_line) if status_line else settings).items() 

414 if key not in (API_KEY_HELPER_KEY, ENV_KEY) 

415 ), 

416 ((ENV_KEY, env),), 

417 ((MODEL_KEY, default_model),) if default_model is not None else (), 

418 ) 

419 ) 

420 

421 

422def _owned(container: Mapping[str, JsonValue], key: str) -> OwnedValue: 

423 return OwnedValue(present=key in container, value=container.get(key)) 

424 

425 

426def _fingerprint(owned: OwnedValue) -> str: 

427 return hashlib.sha256(json.dumps(owned.model_dump(mode="json"), sort_keys=True).encode()).hexdigest() 

428 

429 

430def _env(settings: Mapping[str, JsonValue]) -> Mapping[str, JsonValue]: 

431 raw_env: Final = settings.get(ENV_KEY) 

432 return raw_env if isinstance(raw_env, dict) else MappingProxyType({}) 

433 

434 

435def _lookup(settings: Mapping[str, JsonValue], path: str) -> OwnedValue: 

436 section, _, key = path.rpartition(".") 

437 return _owned(_env(settings) if section else settings, key) 

438 

439 

440def _with_key(container: Mapping[str, JsonValue], key: str, owned: OwnedValue) -> Mapping[str, JsonValue]: 

441 return dict( # mutable-ok: JSON document handed to json.dump, which rejects a read-only mapping 

442 chain(((k, v) for k, v in container.items() if k != key), ((key, owned.value),) if owned.present else ()) 

443 ) 

444 

445 

446def _with(settings: Mapping[str, JsonValue], path: str, owned: OwnedValue) -> Mapping[str, JsonValue]: 

447 """`settings` with the key at `path` set (or removed when `owned` is absent); nothing else changes.""" 

448 section, _, key = path.rpartition(".") 

449 if not section: 

450 return _with_key(settings, key, owned) 

451 return _with_key(settings, section, OwnedValue(present=True, value=_with_key(_env(settings), key, owned))) 

452 

453 

454def _with_all(settings: Mapping[str, JsonValue], updates: Mapping[str, OwnedValue]) -> Mapping[str, JsonValue]: 

455 return reduce(lambda acc, item: _with(acc, *item), updates.items(), settings) 

456 

457 

458def _ours(settings: Mapping[str, JsonValue], path: str, receipt: ConfigureReceipt) -> bool: 

459 """Whether the key still holds what a configure wrote (a key no configure ever changed is never ours).""" 

460 return receipt.written.get(path) == _fingerprint(_lookup(settings, path)) 

461 

462 

463def _claim( 

464 path: str, 

465 current: Mapping[str, JsonValue], 

466 merged: Mapping[str, JsonValue], 

467 earlier: ConfigureReceipt | None, 

468 url_now: OwnedValue, 

469) -> _Claim: 

470 """What this configure records for one key; see ConfigureReceipt for the rules.""" 

471 before, after = _lookup(current, path), _lookup(merged, path) 

472 carried: Final = earlier if earlier is not None and _ours(current, path, earlier) else None 

473 return _Claim( 

474 previous=before if carried is None else carried.previous.get(path, before), 

475 written=_fingerprint(after) if before != after else (None if earlier is None else earlier.written.get(path)), 

476 endpoint=None 

477 if path not in _CREDENTIAL_PATHS 

478 else (url_now if carried is None else carried.endpoints.get(path, url_now)), 

479 ) 

480 

481 

482def _receipt( 

483 current: Mapping[str, JsonValue], 

484 merged: Mapping[str, JsonValue], 

485 earlier: ConfigureReceipt | None, 

486 file_exists: bool, 

487) -> ConfigureReceipt: 

488 url_now: Final = _lookup(current, _BASE_URL_PATH) 

489 claims: Final = MappingProxyType({path: _claim(path, current, merged, earlier, url_now) for path in OWNED_PATHS}) 

490 return ConfigureReceipt( 

491 file_existed=file_exists if earlier is None else earlier.file_existed, 

492 env_present=ENV_KEY in current if earlier is None else earlier.env_present, 

493 env_was_object=isinstance(current.get(ENV_KEY), dict) if earlier is None else earlier.env_was_object, 

494 previous=MappingProxyType({path: claim.previous for path, claim in claims.items()}), 

495 written=MappingProxyType({path: claim.written for path, claim in claims.items() if claim.written is not None}), 

496 endpoints=MappingProxyType( 

497 {path: claim.endpoint for path, claim in claims.items() if claim.endpoint is not None} 

498 ), 

499 ) 

500 

501 

502def read_configure_receipt(state_path: Path) -> ConfigureReceipt | None: 

503 if not state_path.exists(): 

504 return None 

505 try: 

506 return ConfigureReceipt.model_validate_json(state_path.read_bytes()) 

507 except (OSError, ValidationError) as e: 

508 raise ClaudeSettingsError( 

509 f"{state_path} is not a readable `lite configure claude` receipt. " 

510 "Remove it and edit Claude Code's settings by hand if they still point at the proxy." 

511 ) from e 

512 

513 

514def preflight_claude_settings(settings_path: Path) -> None: 

515 refuse_while_owned(settings_path, settings_file_owners(settings_path)) 

516 _env_object(load_json_or_empty(settings_path), settings_path) 

517 read_configure_receipt(configure_state_path(settings_path)) 

518 

519 

520def configure_claude_settings( 

521 base_url: str, 

522 credential: StaticToken, 

523 model: ModelChoice, 

524 settings_path: Path, 

525 state_path: Path, 

526 owners: Sequence[SettingsFileOwner], 

527 commit: Callable[[str, str], None] = commit_staged_json, 

528 script_path: Path | None = None, 

529) -> None: 

530 """Persistently route Claude Code through base_url, recording how to undo it. 

531 

532 Both files are staged before either is committed, so a full disk or a read-only directory fails 

533 before anything changes. The two commits are still two renames: a receipt rename that fails 

534 discards the staged settings, and a settings rename that fails after the receipt landed puts the 

535 earlier receipt back (or removes the new one), so the receipt on disk never describes settings 

536 that were not written. `model`: StartOn pins the starting model, UnpinModel lets go of a pin an 

537 earlier configure made (never of the user's own), KeepModel leaves it alone (a re-login). The 

538 status line script is installed and registered under `statusLine` unless the user runs their own; 

539 the receipt owns that key like any other, so unconfigure removes only ours. 

540 """ 

541 refuse_while_owned(settings_path, owners) 

542 current: Final = load_json_or_empty(settings_path) 

543 _env_object(current, settings_path) 

544 earlier: Final = read_configure_receipt(state_path) 

545 unpinned: Final = MappingProxyType( 

546 { 

547 path: earlier.previous[path] 

548 for path in _MODEL_PATHS 

549 if isinstance(model, UnpinModel) and earlier is not None and _ours(current, path, earlier) 

550 } 

551 ) 

552 existing: Final = _with_all(current, unpinned) 

553 merged: Final = merge_claude_settings( 

554 existing, 

555 base_url, 

556 credential, 

557 model.model if isinstance(model, StartOn) else None, 

558 status_line=install_statusline_script(script_path), 

559 ) 

560 receipt: Final = _receipt(current, merged, earlier, settings_path.exists()) 

561 target: Final = _write_target(settings_path) 

562 try: 

563 ensure_private_dir(state_path.parent) 

564 except OSError as e: 

565 raise ClaudeSettingsError(f"Could not write {state_path}: {e}") from e 

566 staged_receipt: Final = _stage(state_path, receipt.model_dump(mode="json")) 

567 try: 

568 staged_settings: Final = _stage(target, merged) 

569 except ClaudeSettingsError: 

570 discard_staged_json(staged_receipt) 

571 raise 

572 _land(state_path, staged_receipt, (staged_settings,), commit) 

573 try: 

574 _land(target, staged_settings, commit=commit) 

575 except ClaudeSettingsError as settings_error: 

576 try: 

577 _land(state_path, None if earlier is None else _stage(state_path, earlier.model_dump(mode="json"))) 

578 except ClaudeSettingsError as receipt_error: 

579 raise ClaudeSettingsError( 

580 f"{settings_error} The receipt at {state_path} now describes settings that were not written and " 

581 f"could not be put back either ({receipt_error}); remove it before retrying." 

582 ) from settings_error 

583 raise 

584 

585 

586def _endpoint_text(endpoint: OwnedValue) -> str: 

587 if not endpoint.present: 

588 return f"no {ANTHROPIC_BASE_URL_KEY} (Anthropic's default endpoint)" 

589 return endpoint.value if isinstance(endpoint.value, str) else json.dumps(endpoint.value) 

590 

591 

592def unconfigure_claude_settings( 

593 settings_path: Path, state_path: Path, owners: Sequence[SettingsFileOwner] 

594) -> UnconfigureOutcome: 

595 """Undo `lite configure claude`: put back every key still holding what configure wrote, leave the 

596 rest alone, and withhold a credential the restored file would send to a different server than it 

597 was issued for (the receipt stays, owning only those slots, so a later unconfigure can finish).""" 

598 refuse_while_owned(settings_path, owners) 

599 receipt: Final = read_configure_receipt(state_path) 

600 if receipt is None: 

601 raise ClaudeSettingsError( 

602 f"Claude Code is not configured by `lite configure claude` (no receipt at {state_path}); nothing to undo." 

603 ) 

604 current: Final = load_json_or_empty(settings_path) 

605 _env_object(current, settings_path) 

606 ours: Final = tuple(path for path in receipt.written if _ours(current, path, receipt)) 

607 kept: Final = tuple(path for path in receipt.written if path not in ours and _lookup(current, path).present) 

608 put_back: Final = _with_all(current, MappingProxyType({path: receipt.previous[path] for path in ours})) 

609 url_after: Final = _lookup(put_back, _BASE_URL_PATH) 

610 withheld: Final = tuple( 

611 WithheldCredential(path, _endpoint_text(receipt.endpoints[path])) 

612 for path in _CREDENTIAL_PATHS 

613 if path in ours and receipt.previous[path].present and receipt.endpoints[path] != url_after 

614 ) 

615 absent: Final = OwnedValue(present=False) 

616 trimmed: Final = _with_all(put_back, MappingProxyType({item.key: absent for item in withheld})) 

617 settings: Final = ( 

618 trimmed 

619 if _env(trimmed) or receipt.env_was_object 

620 else _with_key(trimmed, ENV_KEY, OwnedValue(present=receipt.env_present, value=None)) 

621 ) 

622 target: Final = _write_target(settings_path) 

623 file_removed: Final = not settings and not (receipt.file_existed and target.exists()) 

624 kept_receipt: Final = ( 

625 receipt.model_copy(update={"written": {item.key: _fingerprint(absent) for item in withheld}}) 

626 if withheld 

627 else None 

628 ) 

629 staged_settings: Final = None if file_removed else _stage(target, settings) 

630 try: 

631 staged_receipt: Final = ( 

632 None if kept_receipt is None else _stage(state_path, kept_receipt.model_dump(mode="json")) 

633 ) 

634 except ClaudeSettingsError: 

635 if staged_settings is not None: 

636 discard_staged_json(staged_settings) 

637 raise 

638 _land(target, staged_settings, (staged_receipt,)) 

639 _land(state_path, staged_receipt) 

640 return UnconfigureOutcome( 

641 restored=tuple(path for path in ours if _lookup(current, path) != _lookup(settings, path)), 

642 kept=kept, 

643 withheld=withheld, 

644 file_removed=file_removed, 

645 ) 

646 

647 

648__all__ = ( 

649 "ANTHROPIC_API_KEY_KEY", 

650 "ANTHROPIC_AUTH_TOKEN_KEY", 

651 "ANTHROPIC_BASE_URL_KEY", 

652 "ANTHROPIC_DEFAULT_MODEL_ENV_KEYS", 

653 "ANTHROPIC_MODEL_KEY", 

654 "API_KEY_HELPER_KEY", 

655 "AUTOROUTE_BACKUP_PATH", 

656 "BACKUP_PATH", 

657 "CLAUDE_CONFIG_DIR_ENV", 

658 "CLAUDE_SETTINGS_PATH", 

659 "CONFIGURE_STATE_PATH", 

660 "ENABLE_GATEWAY_MODEL_DISCOVERY_KEY", 

661 "ENABLE_GATEWAY_MODEL_DISCOVERY_VALUE", 

662 "ENABLE_TOOL_SEARCH_KEY", 

663 "ENABLE_TOOL_SEARCH_VALUE", 

664 "ENV_KEY", 

665 "MODEL_KEY", 

666 "OWNED_ENV_KEYS", 

667 "OWNED_PATHS", 

668 "OWNED_TOP_LEVEL_KEYS", 

669 "SETTINGS_FILE_OWNERS", 

670 "STARTING_MODEL_ROLE", 

671 "STATUSLINE_SCRIPT_PATH", 

672 "STATUS_LINE_KEY", 

673 "ClaudeSettingsError", 

674 "ConfigureReceipt", 

675 "KeepModel", 

676 "ModelChoice", 

677 "OwnedValue", 

678 "SettingsFileOwner", 

679 "StartOn", 

680 "StaticToken", 

681 "UnconfigureOutcome", 

682 "UnpinModel", 

683 "WithheldCredential", 

684 "claude_settings_path", 

685 "configure_claude_settings", 

686 "configure_state_path", 

687 "load_json_or_empty", 

688 "merge_claude_settings", 

689 "read_configure_receipt", 

690 "refuse_while_owned", 

691 "settings_file_owners", 

692 "unconfigure_claude_settings", 

693 "write_claude_settings", 

694)