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

225 statements  

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

1"""Persistent Claude Code and Codex gateway configuration.""" 

2 

3import os 

4import sys 

5from collections.abc import Callable, Sequence 

6from dataclasses import dataclass 

7from pathlib import Path 

8from types import MappingProxyType 

9from typing import Final 

10 

11import click 

12from InquirerPy import inquirer 

13from InquirerPy.base.control import Choice 

14from pydantic import BaseModel 

15 

16from litellm.proxy.common_utils.model_listing_utils import ( 

17 CLAUDE_CODE_CLIENT, 

18 CLAUDE_CODE_PICKER_PATTERN, 

19 GATEWAY_CLIENT_HEADER, 

20) 

21 

22from .agents import codex_config_path 

23from .auth import CliContextObj 

24from .claude_settings import ( 

25 STARTING_MODEL_ROLE, 

26 ClaudeSettingsError, 

27 ModelChoice, 

28 StartOn, 

29 StaticToken, 

30 UnconfigureOutcome, 

31 UnpinModel, 

32 claude_settings_path, 

33 configure_claude_settings, 

34 configure_state_path, 

35 preflight_claude_settings, 

36 settings_file_owners, 

37 unconfigure_claude_settings, 

38) 

39from .codex_settings import ( 

40 CodexSettingsError, 

41 configure_codex_settings, 

42 preflight_codex_settings, 

43 unconfigure_codex_settings, 

44) 

45from .config import normalize_base_url 

46from .pi import ListedModel, ListingFailure, PiSyncError, fetch_model_listing 

47 

48_LISTED_MODELS_SHOWN: Final = 20 

49_CLAUDE_TARGET: Final = "claude" 

50_CODEX_TARGET: Final = "codex" 

51_TARGETS: Final = ((_CLAUDE_TARGET, "Claude Code (CLI)"), (_CODEX_TARGET, "Codex (CLI)")) 

52_KEEP_DEFAULT_MODEL: Final = "Keep Claude Code's own default" 

53_CLAUDE_CODE_VIEW: Final = MappingProxyType( 

54 {"anthropic-version": "2023-06-01", GATEWAY_CLIENT_HEADER: CLAUDE_CODE_CLIENT} 

55) 

56_MODEL_OPTION_HELP: Final = ( 

57 f"Proxy model to set as {STARTING_MODEL_ROLE}. Must be listed on /v1/models for the key; without it, " 

58 "Claude Code keeps its own default and a pin an earlier configure made is let go of. Nothing pins Claude " 

59 "Code's sub-agent or background tiers; `lite autoroute start` is the mode that does." 

60) 

61 

62 

63def resolve_credential(ctx: click.Context, api_key: str | None) -> StaticToken: 

64 """The long-lived key written into settings.json: --api-key, `lite --api-key` or LITELLM_PROXY_API_KEY. 

65 

66 A `lite login` credential is never written: it expires within a day, and keeping it fresh would mean 

67 Claude Code running `lite` through `apiKeyHelper` on every credential refresh. 

68 """ 

69 ctx_obj: Final[CliContextObj] = ctx.obj 

70 explicit: Final = api_key or (None if ctx_obj.get("api_key_from_token_file") else ctx_obj.get("api_key")) 

71 if not explicit: 

72 raise ClaudeSettingsError( 

73 "`lite configure` needs a long-lived virtual key: pass --api-key, `lite --api-key`, or set " 

74 "LITELLM_PROXY_API_KEY. Your `lite login` credential expires within a day, so it is not written " 

75 "into agent settings." 

76 ) 

77 if not explicit.strip() or any(ord(char) <= 32 or ord(char) == 127 for char in explicit): 

78 raise ClaudeSettingsError("The virtual key must not be blank or contain whitespace or control characters.") 

79 return StaticToken(explicit) 

80 

81 

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

83class _Listing: 

84 models: tuple[ListedModel, ...] 

85 

86 @property 

87 def ids(self) -> tuple[str, ...]: 

88 return tuple(model.id for model in self.models) 

89 

90 

91def _preflight(target: str) -> None: 

92 try: 

93 if target == _CLAUDE_TARGET: 

94 preflight_claude_settings(claude_settings_path(os.environ)) 

95 else: 

96 preflight_codex_settings(codex_config_path(os.environ)) 

97 except (ClaudeSettingsError, CodexSettingsError) as e: 

98 raise click.ClickException(str(e)) from e 

99 

100 

101def _start( 

102 ctx: click.Context, base_url: str, api_key: str | None, target: str = _CLAUDE_TARGET 

103) -> tuple[StaticToken, _Listing]: 

104 _preflight(target) 

105 try: 

106 credential: Final = resolve_credential(ctx, api_key) 

107 except ClaudeSettingsError as e: 

108 raise click.ClickException(str(e)) 

109 return credential, _listed_models(base_url, credential.token, target) 

110 

111 

112def _listing_error(base_url: str, error: PiSyncError, target: str) -> str: 

113 """The hint that fits how the listing failed: only an unreachable proxy gets the "is it running" question.""" 

114 if error.kind is ListingFailure.REJECTED: 

115 return f"LiteLLM rejected your key (HTTP {error.status}). Pass a valid --api-key." 

116 if error.kind is ListingFailure.UNREACHABLE: 

117 return ( 

118 f"Could not connect. Is the proxy at {base_url} running, and is --base-url (or LITELLM_PROXY_URL) correct?" 

119 ) 

120 if error.kind is ListingFailure.EMPTY: 

121 name: Final = "Claude Code" if target == _CLAUDE_TARGET else "Codex" 

122 return f"{error.message} {name} would have nothing to run; give the key access to at least one model." 

123 return f"The proxy at {base_url} answered, so check that it is a LiteLLM proxy and is healthy." 

124 

125 

126def _listed_models(base_url: str, key: str, target: str = _CLAUDE_TARGET) -> _Listing: 

127 listed: Final = fetch_model_listing( 

128 base_url, key, headers=_CLAUDE_CODE_VIEW if target == _CLAUDE_TARGET else MappingProxyType({}) 

129 ) 

130 if isinstance(listed, PiSyncError): 

131 raise click.ClickException(_listing_error(base_url, listed, target)) 

132 return _Listing(listed) 

133 

134 

135def _starting_model(model: str, listing: _Listing) -> str | None: 

136 source: Final = next((listed.id for listed in listing.models if listed.source_model == model), None) 

137 return source or next((listed.id for listed in listing.models if listed.id == model), None) 

138 

139 

140def _model_choice(model: str | None) -> ModelChoice: 

141 return StartOn(model) if model is not None else UnpinModel() 

142 

143 

144def _validated_model(model: str | None, listing: _Listing, base_url: str) -> str | None: 

145 starting: Final = _starting_model(model, listing) if model is not None else None 

146 if model is not None and starting is None: 

147 shown: Final = ", ".join(listing.ids[:_LISTED_MODELS_SHOWN]) 

148 raise click.ClickException(f"{model!r} is not served by {base_url} for this key. /v1/models lists: {shown}.") 

149 return starting 

150 

151 

152def _apply_claude(base_url: str, credential: StaticToken, listing: _Listing, model: str | None) -> None: 

153 listed: Final = listing.ids 

154 starting: Final = _validated_model(model, listing, base_url) 

155 settings_path: Final = claude_settings_path(os.environ) 

156 try: 

157 configure_claude_settings( 

158 base_url, 

159 credential, 

160 _model_choice(starting), 

161 settings_path, 

162 configure_state_path(settings_path), 

163 settings_file_owners(settings_path), 

164 ) 

165 except ClaudeSettingsError as e: 

166 raise click.ClickException(str(e)) 

167 in_picker: Final = sum(1 for listed_model in listed if CLAUDE_CODE_PICKER_PATTERN.search(listed_model)) 

168 click.echo(f"Configured Claude Code: {settings_path} now routes through {base_url}.") 

169 

170 click.echo("Credential: your virtual key, stored in the file as ANTHROPIC_AUTH_TOKEN.") 

171 click.echo( 

172 f"Starting model: {starting} ({STARTING_MODEL_ROLE}); switch any time with /model." 

173 if starting is not None 

174 else "Starting model: not pinned (Claude Code's default, or a model you set yourself); switch with /model, or " 

175 "pass --model to start on a proxy model. Without a pin, a resumed session re-sends the model its transcript " 

176 "recorded, which behind a raw-model auto-router is the tier model." 

177 ) 

178 click.echo( 

179 f"/model will list all {len(listed)} of the proxy's models." 

180 if in_picker == len(listed) 

181 else f"/model will list {in_picker} of the proxy's {len(listed)} models: Claude Code shows only ids containing " 

182 "'claude' or 'anthropic', and this proxy does not list the rest under such names." 

183 ) 

184 click.echo("Start `claude` from any terminal. Undo with `lite unconfigure claude`.") 

185 if settings_path.is_symlink(): 

186 click.echo( 

187 f"Note: {settings_path} is a symlink to {settings_path.resolve()}, so your key now lives in " 

188 "that file; keep it out of version control.", 

189 err=True, 

190 ) 

191 

192 

193def _pick_targets() -> tuple[str, ...]: 

194 picked: Final = inquirer.checkbox( 

195 message="Which agents should route through LiteLLM?", 

196 choices=[Choice(value, name=label, enabled=True) for value, label in _TARGETS], 

197 validate=lambda chosen: len(chosen) > 0, 

198 invalid_message="Pick at least one.", 

199 ).execute() 

200 return tuple(str(value) for value in picked) 

201 

202 

203def _pick_model(listed: Sequence[str]) -> str | None: 

204 picked: Final = inquirer.fuzzy( 

205 message="Model Claude Code starts on (type to filter; /model switches any time):", 

206 choices=[_KEEP_DEFAULT_MODEL, *listed], 

207 default=listed[0] if listed else _KEEP_DEFAULT_MODEL, 

208 ).execute() 

209 return None if picked == _KEEP_DEFAULT_MODEL else str(picked) 

210 

211 

212def _pick_codex_model(listed: Sequence[str]) -> str: 

213 choices: Final = list(listed) # mutable-ok: InquirerPy's choices parameter requires a list 

214 return str(inquirer.fuzzy(message="Model Codex starts on (type to filter):", choices=choices).execute()) 

215 

216 

217def _apply_codex(base_url: str, credential: StaticToken, listing: _Listing, model: str) -> None: 

218 _validated_model(model, listing, base_url) 

219 settings_path: Final = codex_config_path(os.environ) 

220 try: 

221 configure_codex_settings(base_url, credential.token, model, settings_path) 

222 except CodexSettingsError as e: 

223 raise click.ClickException(str(e)) from e 

224 click.echo(f"Configured Codex: {settings_path} now routes through {base_url}.") 

225 click.echo(f"Starting model: {model}. Credential: your virtual key, stored in the private provider settings.") 

226 click.echo("Start `codex` from any terminal. Undo with `lite unconfigure codex`.") 

227 if settings_path.is_symlink(): 

228 click.echo(f"Note: your key now lives in {settings_path.resolve()}; keep it out of version control.", err=True) 

229 

230 

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

232class _Setup: 

233 target: str 

234 listing: _Listing 

235 model: str | None 

236 

237 

238def _choose_setup( 

239 base_url: str, 

240 target: str, 

241 credential: StaticToken, 

242 pick_model: Callable[[Sequence[str]], str | None], 

243 pick_codex_model: Callable[[Sequence[str]], str], 

244) -> _Setup: 

245 listing: Final = _listed_models(base_url, credential.token, target) 

246 model: Final = ( 

247 pick_model(tuple(item.source_model or item.id for item in listing.models)) 

248 if target == _CLAUDE_TARGET 

249 else pick_codex_model(listing.ids) 

250 ) 

251 _validated_model(model, listing, base_url) 

252 return _Setup(target, listing, model) 

253 

254 

255def interactive_configure( 

256 ctx: click.Context, 

257 pick_targets: Callable[[], tuple[str, ...]] = _pick_targets, 

258 pick_model: Callable[[Sequence[str]], str | None] = _pick_model, 

259 pick_codex_model: Callable[[Sequence[str]], str] = _pick_codex_model, 

260) -> None: 

261 """`lite configure` with no agent named: ask which agents to wire and which model to pin.""" 

262 targets: Final = pick_targets() 

263 if not targets: 

264 return 

265 for target in targets: 

266 _preflight(target) 

267 try: 

268 credential: Final = resolve_credential(ctx, None) 

269 except ClaudeSettingsError as e: 

270 raise click.ClickException(str(e)) from e 

271 base_url: Final[str] = ctx.obj["base_url"] 

272 setups: Final = tuple( 

273 _choose_setup(base_url, target, credential, pick_model, pick_codex_model) for target in targets 

274 ) 

275 for setup in setups: 

276 if setup.target == _CLAUDE_TARGET: 

277 _apply_claude(base_url, credential, setup.listing, setup.model) 

278 elif setup.model is not None: 

279 _apply_codex(base_url, credential, setup.listing, setup.model) 

280 

281 

282class _ConnectionOptions(BaseModel): 

283 api_key: str | None = None 

284 gateway_url: str | None = None 

285 

286 

287def _connection_settings(ctx: click.Context, api_key: str | None, gateway_url: str | None) -> CliContextObj: 

288 """The context object a subcommand runs with: its own --api-key / --gateway-url over the group's, over `lite`'s.""" 

289 ctx_obj: Final[CliContextObj] = ctx.obj 

290 group: Final = ( 

291 _ConnectionOptions.model_validate(ctx.parent.params) 

292 if ctx.parent is not None and ctx.parent.command.name == "configure" 

293 else _ConnectionOptions() 

294 ) 

295 key: Final = api_key if api_key is not None else group.api_key 

296 url: Final = gateway_url if gateway_url is not None else group.gateway_url 

297 normalized: Final = normalize_base_url(url if url is not None else ctx_obj["base_url"]) 

298 connection: Final[CliContextObj] = { 

299 **ctx_obj, 

300 "base_url": normalized.removesuffix("/v1"), 

301 "base_url_explicit": url is not None or ctx_obj.get("base_url_explicit", False), 

302 "api_key": key if key is not None else ctx_obj.get("api_key"), 

303 "api_key_from_token_file": False if key is not None else ctx_obj.get("api_key_from_token_file", False), 

304 } 

305 return connection 

306 

307 

308def _connection_context(ctx: click.Context, settings: CliContextObj) -> click.Context: 

309 return click.Context(ctx.command, parent=ctx.parent, obj=settings) 

310 

311 

312@click.group(name="configure", invoke_without_command=True) 

313@click.option("--api-key", default=None, help="Long-lived LiteLLM virtual key to store in the selected agents.") 

314@click.option( 

315 "--gateway-url", "--base-url", default=None, help="Gateway URL; defaults to `lite --base-url` / LITELLM_PROXY_URL." 

316) 

317@click.pass_context 

318def configure_group(ctx: click.Context, api_key: str | None, gateway_url: str | None) -> None: 

319 """Persistently route a coding agent through your LiteLLM proxy. 

320 

321 With no agent named, asks which agents to wire and which proxy model to pin. 

322 """ 

323 if ctx.invoked_subcommand is not None: 

324 return 

325 settings: Final = _connection_settings(ctx, api_key, gateway_url) 

326 connection: Final = _connection_context(ctx, settings) 

327 if not sys.stdin.isatty(): 

328 raise click.ClickException( 

329 "`lite configure` asks questions, so it needs a terminal. Non-interactively, run " 

330 "`lite configure claude --api-key <key> --model <model>` or " 

331 "`lite configure codex --api-key <key> --model <model>`." 

332 ) 

333 if settings.get("base_url_explicit"): 

334 interactive_configure(connection) 

335 return 

336 prompted: Final = _connection_settings(connection, None, click.prompt("Gateway URL", default=settings["base_url"])) 

337 interactive_configure(_connection_context(connection, prompted)) 

338 

339 

340@click.group(name="unconfigure") 

341def unconfigure_group() -> None: 

342 """Undo `lite configure` for a coding agent.""" 

343 

344 

345@configure_group.command(name="claude") 

346@click.option( 

347 "--api-key", 

348 "api_key", 

349 default=None, 

350 help="Long-lived LiteLLM virtual key written into Claude Code's settings. Defaults to the `lite --api-key` / " 

351 "LITELLM_PROXY_API_KEY value; required, since a `lite login` credential expires within a day.", 

352) 

353@click.option("--model", default=None, help=_MODEL_OPTION_HELP) 

354@click.option("--gateway-url", "--base-url", default=None, help="Gateway URL, including any deployment path prefix.") 

355@click.pass_context 

356def configure_claude(ctx: click.Context, api_key: str | None, model: str | None, gateway_url: str | None) -> None: 

357 """Route every Claude Code session through your LiteLLM proxy until `lite unconfigure claude`. 

358 

359 Patches ~/.claude/settings.json in place: the proxy URL, your virtual key as a static token, 

360 and gateway model discovery so /model lists the proxy's models; --model picks the one Claude 

361 Code starts on and resumes with. Every other 

362 setting is kept, and what changed is recorded so `lite unconfigure claude` can put it back. 

363 Assumes the proxy is already running. 

364 """ 

365 settings: Final = _connection_settings(ctx, api_key, gateway_url) 

366 credential, listing = _start(_connection_context(ctx, settings), settings["base_url"], api_key) 

367 _apply_claude(settings["base_url"], credential, listing, model) 

368 

369 

370@configure_group.command(name="codex") 

371@click.option("--api-key", default=None, help="Long-lived LiteLLM virtual key to store in Codex's user config.") 

372@click.option("--gateway-url", "--base-url", default=None, help="Gateway URL, including any deployment path prefix.") 

373@click.option("--model", required=True, help="Gateway model Codex starts on, as listed by /v1/models for your key.") 

374@click.pass_context 

375def configure_codex(ctx: click.Context, api_key: str | None, gateway_url: str | None, model: str) -> None: 

376 """Route plain `codex` through the gateway until `lite unconfigure codex`.""" 

377 settings: Final = _connection_settings(ctx, api_key, gateway_url) 

378 credential, listing = _start(_connection_context(ctx, settings), settings["base_url"], api_key, _CODEX_TARGET) 

379 _apply_codex(settings["base_url"], credential, listing, model) 

380 

381 

382@unconfigure_group.command(name="codex") 

383def unconfigure_codex() -> None: 

384 """Restore only Codex settings still holding what configure wrote.""" 

385 settings_path: Final = codex_config_path(os.environ) 

386 try: 

387 outcome: Final = unconfigure_codex_settings(settings_path) 

388 except CodexSettingsError as e: 

389 raise click.ClickException(str(e)) from e 

390 if outcome.file_removed: 

391 click.echo(f"Removed {settings_path}; it held only settings created by `lite configure codex`.") 

392 elif outcome.restored: 

393 click.echo(f"Restored in {settings_path}: {', '.join(outcome.restored)}.") 

394 else: 

395 click.echo(f"Nothing in {settings_path} was still ours to restore.") 

396 if outcome.kept: 

397 click.echo(f"Left as you changed them since: {', '.join(outcome.kept)}.") 

398 

399 

400@unconfigure_group.command(name="claude") 

401def unconfigure_claude() -> None: 

402 """Return Claude Code's settings to what they were before `lite configure claude`. 

403 

404 Also undoes `lite login --config-claude`. Only keys still holding what configure wrote are 

405 put back; anything you changed since is left as it is and named in the output. 

406 """ 

407 settings_path: Final = claude_settings_path(os.environ) 

408 state_path: Final = configure_state_path(settings_path) 

409 try: 

410 outcome: Final = unconfigure_claude_settings(settings_path, state_path, settings_file_owners(settings_path)) 

411 except ClaudeSettingsError as e: 

412 raise click.ClickException(str(e)) 

413 _report_unconfigure(settings_path, state_path, outcome) 

414 

415 

416def _report_unconfigure(settings_path: Path, state_path: Path, outcome: UnconfigureOutcome) -> None: 

417 """Say what unconfigure did, naming only keys whose value it changed.""" 

418 if outcome.file_removed: 

419 click.echo( 

420 f"No settings file remains at {settings_path}; it held nothing but `lite configure claude`'s own keys." 

421 ) 

422 elif outcome.restored: 

423 click.echo(f"Restored in {settings_path}: {', '.join(outcome.restored)}.") 

424 else: 

425 click.echo(f"Nothing in {settings_path} was still ours to restore.") 

426 if outcome.kept: 

427 click.echo(f"Left as you changed them since: {', '.join(outcome.kept)}.") 

428 if outcome.withheld: 

429 click.echo( 

430 "Left removed, since the file now points at a different server than they were issued for: " 

431 + "; ".join(f"{item.key} (captured with {item.endpoint})" for item in outcome.withheld) 

432 + f". They stay in {state_path}: point env.ANTHROPIC_BASE_URL back and run `lite unconfigure claude` " 

433 "again to put them back, or delete that file to drop them." 

434 ) 

435 

436 

437__all__ = ("configure_group", "interactive_configure", "resolve_credential", "unconfigure_group")