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

223 statements  

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

1import builtins 

2import json 

3from collections.abc import Mapping, Sequence 

4from datetime import datetime 

5from typing import Any, Final, Literal 

6 

7import click 

8import requests 

9import rich 

10from rich.table import Table 

11from typing_extensions import ReadOnly, TypedDict 

12 

13from ...keys import KeysManagementClient 

14 

15 

16class _CliContext(TypedDict): 

17 """Values the top-level CLI group stores on the click context.""" 

18 

19 base_url: ReadOnly[str] 

20 api_key: ReadOnly[str | None] 

21 

22 

23class _CliContextView(TypedDict): 

24 obj: ReadOnly[_CliContext] 

25 

26 

27class _KeyRowsView(TypedDict): 

28 rows: ReadOnly[Sequence[Mapping[str, object]]] 

29 

30 

31class _JsonBodyView(TypedDict): 

32 body: ReadOnly[object] 

33 

34 

35@click.group() 

36def keys(): 

37 """Manage API keys for the LiteLLM proxy server""" 

38 

39 

40@keys.command() 

41@click.option("--page", type=int, help="Page number for pagination") 

42@click.option("--size", type=int, help="Number of items per page") 

43@click.option("--user-id", type=str, help="Filter keys by user ID") 

44@click.option("--team-id", type=str, help="Filter keys by team ID") 

45@click.option("--organization-id", type=str, help="Filter keys by organization ID") 

46@click.option("--key-hash", type=str, help="Filter by specific key hash") 

47@click.option("--key-alias", type=str, help="Filter by key alias") 

48@click.option( 

49 "--return-full-object", 

50 is_flag=True, 

51 default=True, 

52 help="Return the full key object", 

53) 

54@click.option("--include-team-keys", is_flag=True, help="Include team keys in the response") 

55@click.option( 

56 "--format", 

57 "output_format", 

58 type=click.Choice(["table", "json"]), 

59 default="table", 

60 help="Output format (table or json)", 

61) 

62@click.pass_context 

63def list( 

64 ctx: click.Context, 

65 page: int | None, 

66 size: int | None, 

67 user_id: str | None, 

68 team_id: str | None, 

69 organization_id: str | None, 

70 key_hash: str | None, 

71 key_alias: str | None, 

72 include_team_keys: bool, 

73 output_format: Literal["table", "json"], 

74 return_full_object: bool, 

75): 

76 """List all API keys""" 

77 context: Final[_CliContextView] = {"obj": ctx.obj} 

78 client: Final = KeysManagementClient(context["obj"]["base_url"], context["obj"]["api_key"]) 

79 response: Final = client.list( 

80 page=page, 

81 size=size, 

82 user_id=user_id, 

83 team_id=team_id, 

84 organization_id=organization_id, 

85 key_hash=key_hash, 

86 key_alias=key_alias, 

87 return_full_object=return_full_object, 

88 include_team_keys=include_team_keys, 

89 ) 

90 assert isinstance(response, dict) 

91 

92 if output_format == "json": 

93 rich.print_json(data=response) 

94 else: 

95 listed: Final[_KeyRowsView] = {"rows": response.get("keys", [])} 

96 rich.print(f"Showing {len(listed['rows'])} keys out of {response.get('total_count', 0)}") 

97 table: Final = Table(title="API Keys") 

98 table.add_column("Key Hash", style="cyan") 

99 table.add_column("Alias", style="green") 

100 table.add_column("User ID", style="magenta") 

101 table.add_column("Team ID", style="yellow") 

102 table.add_column("Spend", style="red") 

103 key_rows: Final[_KeyRowsView] = {"rows": response.get("keys", [])} 

104 for key in key_rows["rows"]: 

105 table.add_row( 

106 str(key.get("token", "")), 

107 str(key.get("key_alias", "")), 

108 str(key.get("user_id", "")), 

109 str(key.get("team_id", "")), 

110 str(key.get("spend", "")), 

111 ) 

112 rich.print(table) 

113 

114 

115@keys.command() 

116@click.option("--models", type=str, help="Comma-separated list of allowed models") 

117@click.option("--aliases", type=str, help="JSON string of model alias mappings") 

118@click.option("--spend", type=float, help="Maximum spend limit for this key") 

119@click.option( 

120 "--duration", 

121 type=str, 

122 help="Duration for which the key is valid (e.g. '24h', '7d')", 

123) 

124@click.option("--key-alias", type=str, help="Alias/name for the key") 

125@click.option("--team-id", type=str, help="Team ID to associate the key with") 

126@click.option("--user-id", type=str, help="User ID to associate the key with") 

127@click.option("--budget-id", type=str, help="Budget ID to associate the key with") 

128@click.option("--config", type=str, help="JSON string of additional configuration parameters") 

129@click.pass_context 

130def generate( 

131 ctx: click.Context, 

132 models: str | None, 

133 aliases: str | None, 

134 spend: float | None, 

135 duration: str | None, 

136 key_alias: str | None, 

137 team_id: str | None, 

138 user_id: str | None, 

139 budget_id: str | None, 

140 config: str | None, 

141): 

142 """Generate a new API key""" 

143 context: Final[_CliContextView] = {"obj": ctx.obj} 

144 client: Final = KeysManagementClient(context["obj"]["base_url"], context["obj"]["api_key"]) 

145 try: 

146 models_list: Final = [m.strip() for m in models.split(",")] if models else None 

147 aliases_dict: Final = json.loads(aliases) if aliases else None 

148 config_dict: Final = json.loads(config) if config else None 

149 except json.JSONDecodeError as e: 

150 raise click.BadParameter(f"Invalid JSON: {e}") 

151 try: 

152 response: Final = client.generate( 

153 models=models_list, 

154 aliases=aliases_dict, 

155 spend=spend, 

156 duration=duration, 

157 key_alias=key_alias, 

158 team_id=team_id, 

159 user_id=user_id, 

160 budget_id=budget_id, 

161 config=config_dict, 

162 ) 

163 rich.print_json(data=response) 

164 except requests.exceptions.HTTPError as e: 

165 click.echo(f"Error: HTTP {e.response.status_code}", err=True) 

166 try: 

167 error_body: Final[_JsonBodyView] = {"body": e.response.json()} 

168 rich.print_json(data=error_body["body"]) 

169 except json.JSONDecodeError: 

170 click.echo(e.response.text, err=True) 

171 raise click.Abort() 

172 

173 

174@keys.command() 

175@click.option("--keys", type=str, help="Comma-separated list of API keys to delete") 

176@click.option("--key-aliases", type=str, help="Comma-separated list of key aliases to delete") 

177@click.pass_context 

178def delete(ctx: click.Context, keys: str | None, key_aliases: str | None): 

179 """Delete API keys by key or alias""" 

180 context: Final[_CliContextView] = {"obj": ctx.obj} 

181 client: Final = KeysManagementClient(context["obj"]["base_url"], context["obj"]["api_key"]) 

182 keys_list: Final = [k.strip() for k in keys.split(",")] if keys else None 

183 aliases_list: Final = [a.strip() for a in key_aliases.split(",")] if key_aliases else None 

184 try: 

185 response: Final = client.delete(keys=keys_list, key_aliases=aliases_list) 

186 rich.print_json(data=response) 

187 except requests.exceptions.HTTPError as e: 

188 click.echo(f"Error: HTTP {e.response.status_code}", err=True) 

189 try: 

190 error_body: Final[_JsonBodyView] = {"body": e.response.json()} 

191 rich.print_json(data=error_body["body"]) 

192 except json.JSONDecodeError: 

193 click.echo(e.response.text, err=True) 

194 raise click.Abort() 

195 

196 

197def _parse_created_since_filter(created_since: str | None) -> datetime | None: 

198 """Parse and validate the created_since date filter.""" 

199 if not created_since: 

200 return None 

201 

202 try: 

203 # Support formats: YYYY-MM-DD_HH:MM or YYYY-MM-DD 

204 if "_" in created_since: 

205 return datetime.strptime(created_since, "%Y-%m-%d_%H:%M") 

206 else: 

207 return datetime.strptime(created_since, "%Y-%m-%d") 

208 except ValueError: 

209 click.echo( 

210 f"Error: Invalid date format '{created_since}'. Use YYYY-MM-DD_HH:MM or YYYY-MM-DD", 

211 err=True, 

212 ) 

213 raise click.Abort() 

214 

215 

216def _fetch_all_keys_with_pagination( 

217 source_client: KeysManagementClient, source_base_url: str 

218) -> Sequence[Mapping[str, object]]: 

219 """Fetch all keys from source instance using pagination.""" 

220 click.echo(f"Fetching keys from source server: {source_base_url}") 

221 source_keys: Final[builtins.list[Mapping[str, object]]] = [] 

222 page = 1 

223 page_size: Final = 100 # Use a larger page size to minimize API calls 

224 

225 while True: 

226 source_response = source_client.list(return_full_object=True, page=page, size=page_size) 

227 # source_client.list() returns Dict[str, Any] when return_request is False (default) 

228 assert isinstance(source_response, dict), "Expected dict response from list API" 

229 page_keys: Sequence[Mapping[str, object]] = source_response.get("keys", []) 

230 

231 if not page_keys: 

232 break 

233 

234 source_keys.extend(page_keys) 

235 click.echo(f"Fetched page {page}: {len(page_keys)} keys") 

236 

237 # Check if we got fewer keys than the page size, indicating last page 

238 if len(page_keys) < page_size: 

239 break 

240 

241 page += 1 

242 

243 return source_keys 

244 

245 

246def _filter_keys_by_created_since( 

247 source_keys: Sequence[Mapping[str, object]], 

248 created_since_dt: datetime | None, 

249 created_since: str, 

250) -> Sequence[Mapping[str, object]]: 

251 """Filter keys by created_since date if specified.""" 

252 if not created_since_dt: 

253 return source_keys 

254 

255 filtered_keys: Final[builtins.list[Mapping[str, object]]] = [] 

256 for key in source_keys: 

257 key_created_at = key.get("created_at") 

258 if key_created_at: 

259 # Parse the key's created_at timestamp 

260 if isinstance(key_created_at, str): 

261 if "T" in key_created_at: 

262 key_dt = datetime.fromisoformat(key_created_at.replace("Z", "+00:00")) 

263 else: 

264 key_dt = datetime.fromisoformat(key_created_at) 

265 

266 # Convert to naive datetime for comparison (assuming UTC) 

267 if key_dt.tzinfo: 

268 key_dt = key_dt.replace(tzinfo=None) 

269 

270 if key_dt >= created_since_dt: 

271 filtered_keys.append(key) 

272 

273 click.echo(f"Filtered {len(source_keys)} keys to {len(filtered_keys)} keys created since {created_since}") 

274 return filtered_keys 

275 

276 

277def _display_dry_run_table(source_keys: Sequence[Mapping[str, object]]) -> None: 

278 """Display a table of keys that would be imported in dry-run mode.""" 

279 click.echo("\n--- DRY RUN MODE ---") 

280 table: Final = Table(title="Keys that would be imported") 

281 table.add_column("Key Alias", style="green") 

282 table.add_column("User ID", style="magenta") 

283 table.add_column("Created", style="cyan") 

284 

285 for key in source_keys: 

286 created_at = key.get("created_at", "") 

287 # Format the timestamp if it exists 

288 if created_at: 

289 # Try to parse and format the timestamp for better readability 

290 if isinstance(created_at, str): 

291 # Handle common timestamp formats 

292 if "T" in created_at: 

293 dt = datetime.fromisoformat(created_at.replace("Z", "+00:00")) 

294 created_at = dt.strftime("%Y-%m-%d %H:%M") 

295 

296 table.add_row(str(key.get("key_alias", "")), str(key.get("user_id", "")), str(created_at)) 

297 rich.print(table) 

298 

299 

300def _prepare_key_import_data(key: Mapping[str, object]) -> dict[str, Any]: 

301 """Prepare key data for import by extracting relevant fields.""" 

302 import_data: Final = {} 

303 

304 # Copy relevant fields if they exist 

305 for field in [ 

306 "models", 

307 "aliases", 

308 "spend", 

309 "key_alias", 

310 "team_id", 

311 "user_id", 

312 "budget_id", 

313 "config", 

314 ]: 

315 if key.get(field): 

316 import_data[field] = key[field] 

317 

318 return import_data 

319 

320 

321def _import_keys_to_destination( 

322 source_keys: Sequence[Mapping[str, object]], dest_client: KeysManagementClient 

323) -> tuple[int, int]: 

324 """Import each key to the destination instance and return counts.""" 

325 imported_count = 0 

326 failed_count = 0 

327 

328 for key in source_keys: 

329 try: 

330 # Prepare key data for import 

331 import_data = _prepare_key_import_data(key) 

332 

333 # Generate the key in destination instance 

334 response = dest_client.generate(**import_data) 

335 click.echo(f"Generated key: {response}") 

336 # The generate method returns JSON data directly, not a Response object 

337 imported_count += 1 

338 

339 key_alias = key.get("key_alias", "N/A") 

340 click.echo(f"Imported key: {key_alias}") 

341 

342 except Exception as e: 

343 failed_count += 1 

344 key_alias = key.get("key_alias", "N/A") 

345 click.echo(f"Failed to import key {key_alias}: {e}", err=True) 

346 

347 return imported_count, failed_count 

348 

349 

350@keys.command(name="import") 

351@click.option( 

352 "--source-base-url", 

353 required=True, 

354 help="Base URL of the source LiteLLM proxy server to import keys from", 

355) 

356@click.option("--source-api-key", help="API key for authentication to the source server") 

357@click.option( 

358 "--dry-run", 

359 is_flag=True, 

360 help="Show what would be imported without actually importing", 

361) 

362@click.option( 

363 "--created-since", 

364 help="Only import keys created after this date/time (format: YYYY-MM-DD_HH:MM or YYYY-MM-DD)", 

365) 

366@click.pass_context 

367def import_keys( 

368 ctx: click.Context, 

369 source_base_url: str, 

370 source_api_key: str | None, 

371 dry_run: bool, 

372 created_since: str | None, 

373): 

374 """Import API keys from another LiteLLM instance""" 

375 # Parse created_since filter if provided 

376 created_since_dt: Final = _parse_created_since_filter(created_since) 

377 

378 # Create clients for both source and destination 

379 source_client: Final = KeysManagementClient(source_base_url, source_api_key) 

380 context: Final[_CliContextView] = {"obj": ctx.obj} 

381 dest_client: Final = KeysManagementClient(context["obj"]["base_url"], context["obj"]["api_key"]) 

382 

383 try: 

384 # Get all keys from source instance with pagination 

385 source_keys = _fetch_all_keys_with_pagination(source_client, source_base_url) 

386 

387 # Filter keys by created_since if specified 

388 if created_since: 

389 source_keys = _filter_keys_by_created_since(source_keys, created_since_dt, created_since) 

390 

391 if not source_keys: 

392 click.echo("No keys found in source instance.") 

393 return 

394 

395 click.echo(f"Found {len(source_keys)} keys in source instance.") 

396 

397 if dry_run: 

398 _display_dry_run_table(source_keys) 

399 return 

400 

401 # Import each key 

402 imported_count, failed_count = _import_keys_to_destination(source_keys, dest_client) 

403 

404 # Summary 

405 click.echo("\nImport completed:") 

406 click.echo(f" Successfully imported: {imported_count}") 

407 click.echo(f" Failed to import: {failed_count}") 

408 click.echo(f" Total keys processed: {len(source_keys)}") 

409 

410 except requests.exceptions.HTTPError as e: 

411 click.echo(f"Error: HTTP {e.response.status_code}", err=True) 

412 try: 

413 error_body: Final[_JsonBodyView] = {"body": e.response.json()} 

414 rich.print_json(data=error_body["body"]) 

415 except json.JSONDecodeError: 

416 click.echo(e.response.text, err=True) 

417 raise click.Abort() 

418 except Exception as e: 

419 click.echo(f"Error: {e}", err=True) 

420 raise click.Abort()