llamactl 0.3.0a18__py3-none-any.whl → 0.3.0a20__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,18 +1,36 @@
1
1
  import asyncio
2
+ import json
3
+ import os
4
+ import platform
5
+ import subprocess
6
+ import sys
7
+ import webbrowser
2
8
 
3
9
  import click
10
+ import httpx
4
11
  import questionary
5
- from llama_deploy.cli.client import get_control_plane_client
12
+ from llama_deploy.cli.auth.client import (
13
+ DeviceAuthorizationRequest,
14
+ OIDCClient,
15
+ PlatformAuthClient,
16
+ PlatformAuthDiscoveryClient,
17
+ TokenRequestDeviceCode,
18
+ decode_jwt_claims,
19
+ decode_jwt_claims_from_device_oidc,
20
+ )
21
+ from llama_deploy.cli.config._config import ConfigManager
6
22
  from llama_deploy.cli.config.auth_service import AuthService
7
23
  from llama_deploy.cli.config.env_service import service
8
- from llama_deploy.core.client.manage_client import ClientError, ControlPlaneClient
24
+ from llama_deploy.core.client.manage_client import (
25
+ ControlPlaneClient,
26
+ )
9
27
  from llama_deploy.core.schema.projects import ProjectSummary
10
28
  from rich import print as rprint
11
29
  from rich.table import Table
12
30
  from rich.text import Text
13
31
 
14
32
  from ..app import app, console
15
- from ..config.schema import Auth
33
+ from ..config.schema import Auth, DeviceOIDC
16
34
  from ..options import global_options, interactive_option
17
35
 
18
36
 
@@ -61,7 +79,7 @@ def create_api_key_profile(
61
79
 
62
80
  # Interactive mode: prompt for token (masked) and validate
63
81
  token_value = api_key or _prompt_for_api_key()
64
- projects = _validate_token_and_list_projects(auth_svc, token_value)
82
+ projects = _prompt_validate_api_key_and_list_projects(auth_svc, token_value)
65
83
 
66
84
  # Select or enter project ID
67
85
  selected_project_id = project_id or _select_or_enter_project(
@@ -81,10 +99,26 @@ def create_api_key_profile(
81
99
  raise click.Abort()
82
100
 
83
101
 
102
+ @auth.command("login")
103
+ @global_options
104
+ def device_login() -> None:
105
+ """Login via web browser"""
106
+
107
+ try:
108
+ created = _create_device_profile()
109
+ rprint(
110
+ f"[green]Created login profile '{created.name}' and set as current[/green]"
111
+ )
112
+
113
+ except Exception as e:
114
+ rprint(f"[red]Error: {e}[/red]")
115
+ raise click.Abort()
116
+
117
+
84
118
  @auth.command("list")
85
119
  @global_options
86
120
  def list_profiles() -> None:
87
- """List all logged in profiles"""
121
+ """List all logged in users/tokens"""
88
122
  try:
89
123
  auth_svc = service.current_auth_service()
90
124
  profiles = auth_svc.list_profiles()
@@ -119,6 +153,26 @@ def list_profiles() -> None:
119
153
  raise click.Abort()
120
154
 
121
155
 
156
+ @auth.command("destroy", hidden=True)
157
+ @global_options
158
+ def destroy_database() -> None:
159
+ """Destroy the database"""
160
+ if not questionary.confirm(
161
+ "Are you sure you want to destroy all of your local logins? This action cannot be undone."
162
+ ).ask():
163
+ return
164
+ ConfigManager(init_database=False).destroy_database()
165
+ rprint("[green]Database destroyed[/green]")
166
+
167
+
168
+ @auth.command("show-db", hidden=True)
169
+ @global_options
170
+ def config_database() -> None:
171
+ """Config the database"""
172
+ path = service.config_manager().db_path
173
+ rprint(f"[bold]{path}[/bold]")
174
+
175
+
122
176
  @auth.command("switch")
123
177
  @global_options
124
178
  @click.argument("name", required=False)
@@ -153,7 +207,7 @@ def delete_profile(name: str | None, interactive: bool) -> None:
153
207
  rprint("[yellow]No profile selected[/yellow]")
154
208
  return
155
209
 
156
- if auth_svc.delete_profile(auth.name):
210
+ if asyncio.run(auth_svc.delete_profile(auth.name)):
157
211
  rprint(f"[green]Logged out from '{auth.name}'[/green]")
158
212
  else:
159
213
  rprint(f"[red]Profile '{auth.name}' not found[/red]")
@@ -163,6 +217,29 @@ def delete_profile(name: str | None, interactive: bool) -> None:
163
217
  raise click.Abort()
164
218
 
165
219
 
220
+ # Very simple introspection: decode current token via provider JWKS
221
+ @auth.command("me", hidden=True)
222
+ @global_options
223
+ def me() -> None:
224
+ """Print JWT claims for the current profile's token using provider JWKS.
225
+
226
+ Assumes the stored API key is a JWT (e.g., OIDC id_token).
227
+ """
228
+ try:
229
+ auth_svc = service.current_auth_service()
230
+ profile = auth_svc.get_current_profile()
231
+ if not profile or not profile.device_oidc:
232
+ raise click.ClickException(
233
+ "No OIDC profile selected. Run `llamactl auth login` or switch to an existing OIDC profile."
234
+ )
235
+
236
+ claims = asyncio.run(decode_jwt_claims_from_device_oidc(profile.device_oidc))
237
+ click.echo(json.dumps(claims, indent=2, sort_keys=True))
238
+ except Exception as e:
239
+ rprint(f"[red]Error: {e}[/red]")
240
+ raise click.Abort()
241
+
242
+
166
243
  # Projects commands
167
244
  @auth.command("project")
168
245
  @click.argument("project_id", required=False)
@@ -175,16 +252,22 @@ def change_project(project_id: str | None, interactive: bool) -> None:
175
252
  return
176
253
  auth_svc = service.current_auth_service()
177
254
  if project_id:
255
+ if auth_svc.env.requires_auth:
256
+ projects = _list_projects(auth_svc)
257
+ if not next(
258
+ (project for project in projects if project.project_id == project_id),
259
+ None,
260
+ ):
261
+ raise click.ClickException(f"Project {project_id} not found")
178
262
  auth_svc.set_project(profile.name, project_id)
179
- rprint(f"[green]Set active project to '{project_id}'[/green]")
263
+ rprint(f"Set active project to [bold green]{project_id}[/]")
180
264
  return
181
265
  if not interactive:
182
266
  raise click.ClickException(
183
267
  "No --project-id provided. Run `llamactl auth project --help` for more information."
184
268
  )
185
269
  try:
186
- client = get_control_plane_client()
187
- projects = asyncio.run(client.list_projects())
270
+ projects = _list_projects(auth_svc)
188
271
 
189
272
  if not projects:
190
273
  rprint("[yellow]No projects found[/yellow]")
@@ -208,8 +291,12 @@ def change_project(project_id: str | None, interactive: bool) -> None:
208
291
  project_id = questionary.text("Enter project ID").ask()
209
292
  result = project_id
210
293
  if result:
294
+ selected_project = next(
295
+ (project for project in projects if project.project_id == result), None
296
+ )
297
+ name = selected_project.project_name if selected_project else result
211
298
  auth_svc.set_project(profile.name, result)
212
- rprint(f"[green]Set active project to '{result}'[/green]")
299
+ rprint(f"Set active project to [bold cornflower_blue]{name}[/]")
213
300
  else:
214
301
  rprint("[yellow]No project selected[/yellow]")
215
302
  except Exception as e:
@@ -217,6 +304,160 @@ def change_project(project_id: str | None, interactive: bool) -> None:
217
304
  raise click.Abort()
218
305
 
219
306
 
307
+ def _auto_device_name() -> str:
308
+ try:
309
+ if sys.platform == "darwin": # macOS
310
+ return (
311
+ subprocess.check_output(["scutil", "--get", "ComputerName"])
312
+ .decode()
313
+ .strip()
314
+ )
315
+ elif sys.platform.startswith("win"):
316
+ return os.environ["COMPUTERNAME"]
317
+ else: # Linux / Unix
318
+ return platform.node()
319
+ except Exception:
320
+ return platform.node()
321
+
322
+
323
+ async def _create_or_update_agent_api_key(auth_svc: AuthService, profile: Auth) -> None:
324
+ """
325
+ Mutates and updates the profile with an agent API key if it does not exist or is invalid.
326
+ """
327
+ if profile.api_key is not None:
328
+ async with PlatformAuthClient(profile.api_url, profile.api_key) as client:
329
+ try:
330
+ await client.me()
331
+ except httpx.HTTPStatusError as e:
332
+ if e.response.status_code == 401:
333
+ # must have been deleted
334
+ profile.api_key = None
335
+ profile.api_key_id = None
336
+ else:
337
+ raise
338
+ if profile.api_key is None:
339
+ async with auth_svc.profile_client(profile) as client:
340
+ name = f"{profile.name} llamactl on {profile.device_oidc.device_name if profile.device_oidc else 'unknown'}"
341
+ api_key = await client.create_agent_api_key(name)
342
+ profile.api_key = api_key.token
343
+ profile.api_key_id = api_key.id
344
+ auth_svc.update_profile(profile)
345
+
346
+
347
+ def _create_device_profile() -> Auth:
348
+ auth_svc = service.current_auth_service()
349
+ if not auth_svc.env.requires_auth:
350
+ raise click.ClickException("This environment does not support authentication")
351
+
352
+ base_url = auth_svc.env.api_url.rstrip("/")
353
+
354
+ oidc_device = asyncio.run(_run_device_authentication(base_url))
355
+
356
+ # Obtain or prompt for project ID and create profile
357
+ projects = _list_projects(auth_svc, oidc_device.device_access_token)
358
+ selected_project_id = _select_or_enter_project(projects, True)
359
+ if not selected_project_id:
360
+ raise click.ClickException(
361
+ "Project is required. Does this user have access to any projects?"
362
+ )
363
+ created = auth_svc.create_or_update_profile_from_oidc(
364
+ selected_project_id, oidc_device
365
+ )
366
+ asyncio.run(_create_or_update_agent_api_key(auth_svc, created))
367
+ return created
368
+
369
+
370
+ async def _run_device_authentication(base_url: str) -> DeviceOIDC:
371
+ device_name = _auto_device_name()
372
+ # 1) Discover upstream and CLI client_id via client
373
+ async with PlatformAuthDiscoveryClient(base_url) as discovery:
374
+ disc = await discovery.oidc_discovery()
375
+ upstream = disc.discovery_url
376
+ client_ids = disc.client_ids or {}
377
+ client_ids_list = list(client_ids.values())
378
+ client_id = client_ids.get("cli") or (
379
+ client_ids_list[0] if len(client_ids_list) == 1 else None
380
+ )
381
+ if not client_id:
382
+ raise click.ClickException(
383
+ "Expected 'cli' Client ID not found from auth discovery"
384
+ )
385
+
386
+ # 2) Device flow via typed OIDC client
387
+ async with OIDCClient() as oidc:
388
+ provider = await oidc.fetch_provider_configuration(upstream)
389
+ device_endpoint = provider.device_authorization_endpoint
390
+ token_endpoint = provider.token_endpoint
391
+ if not device_endpoint or not token_endpoint:
392
+ raise click.ClickException("Device Authorization not supported by provider")
393
+
394
+ scope_value = " ".join(sorted({"openid", "profile", "email", "offline_access"}))
395
+
396
+ # 3) Start device authorization
397
+ da = await oidc.device_authorization(
398
+ device_endpoint,
399
+ DeviceAuthorizationRequest(client_id=client_id, scope=scope_value),
400
+ )
401
+
402
+ rprint(
403
+ "[bold]complete authentication by visiting the verification URI and confirming the device:[/bold]"
404
+ )
405
+ if da.verification_uri:
406
+ rprint(
407
+ f"Verification URI: {da.verification_uri} (will open in your browser if supported)"
408
+ )
409
+ if da.user_code:
410
+ rprint(f"User Code: {da.user_code} to confirm the device")
411
+ if da.verification_uri_complete:
412
+ try:
413
+ webbrowser.open(da.verification_uri_complete)
414
+ except Exception:
415
+ pass
416
+
417
+ # 4) Poll token endpoint
418
+ interval = int(da.interval or 5)
419
+ while True:
420
+ await asyncio.sleep(interval)
421
+ token = await oidc.token_with_device_code(
422
+ token_endpoint,
423
+ TokenRequestDeviceCode(
424
+ device_code=da.device_code,
425
+ client_id=client_id,
426
+ ),
427
+ )
428
+ if token.error in {"authorization_pending", "slow_down"}:
429
+ if token.error == "slow_down":
430
+ interval += 5
431
+ continue
432
+ if token.error:
433
+ raise click.ClickException(
434
+ f"Token polling failed: {token.error} {token.error_description or ''}"
435
+ )
436
+ if token.id_token:
437
+ if not token.access_token:
438
+ raise click.ClickException(
439
+ "Device flow failed: token response missing access_token"
440
+ )
441
+ claims = await decode_jwt_claims(token.id_token, provider.jwks_uri)
442
+ email = claims.get("email")
443
+ if not email:
444
+ raise click.ClickException(
445
+ "Device flow failed: email not found in token"
446
+ )
447
+ user_id = claims.get("sub") or email
448
+ return DeviceOIDC(
449
+ device_name=device_name,
450
+ email=email,
451
+ user_id=user_id,
452
+ client_id=client_id,
453
+ discovery_url=upstream,
454
+ device_access_token=token.access_token,
455
+ device_refresh_token=token.refresh_token,
456
+ device_id_token=token.id_token,
457
+ )
458
+ raise click.ClickException("Device flow failed: unexpected token response")
459
+
460
+
220
461
  def validate_authenticated_profile(interactive: bool) -> Auth:
221
462
  """Validate that the user is authenticated within the current environment.
222
463
 
@@ -259,7 +500,7 @@ def validate_authenticated_profile(interactive: bool) -> Auth:
259
500
  # No profiles exist for this env
260
501
  if current_env.requires_auth:
261
502
  # Inline token flow
262
- created = _token_flow_for_env(auth_svc)
503
+ created = _create_device_profile()
263
504
  return created
264
505
  else:
265
506
  # No auth required: select project and create a default profile without token
@@ -276,28 +517,44 @@ def validate_authenticated_profile(interactive: bool) -> Auth:
276
517
 
277
518
 
278
519
  def _prompt_for_api_key() -> str:
279
- entered = questionary.password("Enter API key token").ask()
520
+ entered = questionary.password("Enter API key token to login").ask()
280
521
  if entered:
281
522
  return entered.strip()
282
523
  raise click.ClickException("No API key entered")
283
524
 
284
525
 
285
- def _validate_token_and_list_projects(
286
- auth_svc: AuthService, api_key: str
526
+ def _list_projects(
527
+ auth_svc: AuthService,
528
+ api_key: str | None = None,
287
529
  ) -> list[ProjectSummary]:
288
530
  async def _run():
289
- async with ControlPlaneClient.ctx(auth_svc.env.api_url, api_key) as client:
531
+ profile = auth_svc.get_current_profile()
532
+ async with ControlPlaneClient.ctx(
533
+ auth_svc.env.api_url,
534
+ api_key or (profile.api_key if profile else None),
535
+ None if api_key is not None else auth_svc.auth_middleware(profile),
536
+ ) as client:
290
537
  return await client.list_projects()
291
538
 
539
+ return asyncio.run(_run())
540
+
541
+
542
+ def _prompt_validate_api_key_and_list_projects(
543
+ auth_svc: AuthService, api_key: str
544
+ ) -> list[ProjectSummary]:
292
545
  try:
293
- return asyncio.run(_run())
294
- except ClientError as e:
295
- if getattr(e, "status_code", None) == 401:
546
+ return _list_projects(auth_svc, api_key)
547
+ except httpx.HTTPStatusError as e:
548
+ if e.response.status_code == 401:
296
549
  rprint("[red]Invalid API key. Please try again.[/red]")
297
- return _validate_token_and_list_projects(auth_svc, _prompt_for_api_key())
298
- if getattr(e, "status_code", None) == 403:
550
+ return _prompt_validate_api_key_and_list_projects(
551
+ auth_svc, _prompt_for_api_key()
552
+ )
553
+ if e.response.status_code == 403:
299
554
  rprint("[red]This environment requires a valid API key.[/red]")
300
- return _validate_token_and_list_projects(auth_svc, _prompt_for_api_key())
555
+ return _prompt_validate_api_key_and_list_projects(
556
+ auth_svc, _prompt_for_api_key()
557
+ )
301
558
  raise
302
559
  except Exception as e:
303
560
  raise click.ClickException(f"Failed to validate API key: {e}")
@@ -327,7 +584,7 @@ def _select_or_enter_project(
327
584
 
328
585
  def _token_flow_for_env(auth_service: AuthService) -> Auth:
329
586
  token_value = _prompt_for_api_key()
330
- projects = _validate_token_and_list_projects(auth_service, token_value)
587
+ projects = _prompt_validate_api_key_and_list_projects(auth_service, token_value)
331
588
  project_id = _select_or_enter_project(projects, auth_service.env.requires_auth)
332
589
  if not project_id:
333
590
  raise click.ClickException("No project selected")
@@ -225,15 +225,15 @@ def refresh_deployment(deployment_id: str | None, interactive: bool) -> None:
225
225
  """Update the deployment, pulling the latest code from it's branch"""
226
226
  validate_authenticated_profile(interactive)
227
227
  try:
228
- client = get_project_client()
229
-
230
228
  deployment_id = select_deployment(deployment_id)
231
229
  if not deployment_id:
232
230
  rprint("[yellow]No deployment selected[/yellow]")
233
231
  return
234
232
 
235
233
  # Get current deployment details to show what we're refreshing
236
- current_deployment = asyncio.run(client.get_deployment(deployment_id))
234
+ current_deployment = asyncio.run(
235
+ get_project_client().get_deployment(deployment_id)
236
+ )
237
237
  deployment_name = current_deployment.name
238
238
  old_git_sha = current_deployment.git_sha or ""
239
239
 
@@ -241,7 +241,7 @@ def refresh_deployment(deployment_id: str | None, interactive: bool) -> None:
241
241
  with console.status(f"Refreshing {deployment_name}..."):
242
242
  deployment_update = DeploymentUpdate()
243
243
  updated_deployment = asyncio.run(
244
- client.update_deployment(
244
+ get_project_client().update_deployment(
245
245
  deployment_id,
246
246
  deployment_update,
247
247
  )
@@ -278,29 +278,22 @@ def select_deployment(
278
278
  if not interactive:
279
279
  return None
280
280
 
281
- try:
282
- client = get_project_client()
283
- deployments = asyncio.run(client.list_deployments())
281
+ client = get_project_client()
282
+ deployments = asyncio.run(client.list_deployments())
284
283
 
285
- if not deployments:
286
- rprint(
287
- f"[yellow]No deployments found for project {client.project_id}[/yellow]"
288
- )
289
- return None
284
+ if not deployments:
285
+ rprint(f"[yellow]No deployments found for project {client.project_id}[/yellow]")
286
+ return None
290
287
 
291
- choices = []
292
- for deployment in deployments:
293
- name = deployment.name
294
- deployment_id = deployment.id
295
- status = deployment.status
296
- choices.append(
297
- questionary.Choice(
298
- title=f"{name} ({deployment_id}) - {status}", value=deployment_id
299
- )
288
+ choices = []
289
+ for deployment in deployments:
290
+ name = deployment.name
291
+ deployment_id = deployment.id
292
+ status = deployment.status
293
+ choices.append(
294
+ questionary.Choice(
295
+ title=f"{name} ({deployment_id}) - {status}", value=deployment_id
300
296
  )
297
+ )
301
298
 
302
- return questionary.select("Select deployment:", choices=choices).ask()
303
-
304
- except Exception as e:
305
- rprint(f"[red]Error loading deployments: {e}[/red]")
306
- return None
299
+ return questionary.select("Select deployment:", choices=choices).ask()
@@ -1,3 +1,4 @@
1
+ import logging
1
2
  import os
2
3
  from pathlib import Path
3
4
 
@@ -20,6 +21,8 @@ from rich import print as rprint
20
21
 
21
22
  from ..app import app
22
23
 
24
+ logger = logging.getLogger(__name__)
25
+
23
26
 
24
27
  @app.command(
25
28
  "serve",
@@ -97,7 +100,7 @@ def serve(
97
100
  )
98
101
 
99
102
  except KeyboardInterrupt:
100
- print("Shutting down...")
103
+ logger.debug("Shutting down...")
101
104
 
102
105
  except Exception as e:
103
106
  rprint(f"[red]Error: {e}[/red]")
@@ -107,17 +110,19 @@ def serve(
107
110
  def _set_env_vars_from_profile(profile: Auth):
108
111
  if profile.api_key:
109
112
  _set_env_vars(profile.api_key, profile.api_url)
113
+ _set_project_id_from_profile(profile)
110
114
 
111
115
 
112
116
  def _set_env_vars_from_env(env_vars: dict[str, str]):
113
117
  key = env_vars.get("LLAMA_CLOUD_API_KEY")
114
118
  url = env_vars.get("LLAMA_CLOUD_BASE_URL", "https://api.cloud.llamaindex.ai")
119
+ # Also propagate project id if present in the environment
120
+ _set_project_id_from_env(env_vars)
115
121
  if key:
116
122
  _set_env_vars(key, url)
117
123
 
118
124
 
119
125
  def _set_env_vars(key: str, url: str):
120
- print(f"Setting env vars: {key}, {url}")
121
126
  os.environ["LLAMA_CLOUD_API_KEY"] = key
122
127
  os.environ["LLAMA_CLOUD_BASE_URL"] = url
123
128
  # kludge for common web servers to inject local auth key
@@ -126,6 +131,17 @@ def _set_env_vars(key: str, url: str):
126
131
  os.environ[f"{prefix}LLAMA_CLOUD_BASE_URL"] = url
127
132
 
128
133
 
134
+ def _set_project_id_from_env(env_vars: dict[str, str]):
135
+ project_id = env_vars.get("LLAMA_DEPLOY_PROJECT_ID")
136
+ if project_id:
137
+ os.environ["LLAMA_DEPLOY_PROJECT_ID"] = project_id
138
+
139
+
140
+ def _set_project_id_from_profile(profile: Auth):
141
+ if profile.project_id:
142
+ os.environ["LLAMA_DEPLOY_PROJECT_ID"] = profile.project_id
143
+
144
+
129
145
  def _maybe_inject_llama_cloud_credentials(
130
146
  deployment_file: Path, interactive: bool
131
147
  ) -> None:
@@ -155,6 +171,9 @@ def _maybe_inject_llama_cloud_credentials(
155
171
  config, deployment_file.parent if deployment_file.is_file() else deployment_file
156
172
  )
157
173
 
174
+ # Ensure project id is available to the app and UI processes
175
+ _set_project_id_from_env({**os.environ, **vars})
176
+
158
177
  existing = os.environ.get("LLAMA_CLOUD_API_KEY") or vars.get("LLAMA_CLOUD_API_KEY")
159
178
  if existing:
160
179
  _set_env_vars_from_env({**os.environ, **vars})
@@ -176,18 +195,14 @@ def _maybe_inject_llama_cloud_credentials(
176
195
  # No key available; consider prompting if interactive
177
196
  if interactive:
178
197
  should_login = questionary.confirm(
179
- "This deployment requires Llama Cloud. Login now to inject credentials?",
198
+ "This deployment requires Llama Cloud. Login now to inject credentials? Otherwise the app may not work.",
180
199
  default=True,
181
200
  ).ask()
182
201
  if should_login:
183
- try:
184
- authed = validate_authenticated_profile(True)
185
- if authed.api_key:
186
- _set_env_vars_from_profile(authed)
187
- return
188
- except Exception:
189
- # fall through to warning
190
- pass
202
+ authed = validate_authenticated_profile(True)
203
+ if authed.api_key:
204
+ _set_env_vars_from_profile(authed)
205
+ return
191
206
  rprint(
192
207
  "[yellow]Warning: No Llama Cloud credentials configured. The app may not work.[/yellow]"
193
208
  )
@@ -195,5 +210,5 @@ def _maybe_inject_llama_cloud_credentials(
195
210
 
196
211
  # Non-interactive session
197
212
  rprint(
198
- "[yellow]Warning: LLAMA_CLOUD_API_KEY is not set and no logged-in profile was found. The deployment may not work.[/yellow]"
213
+ "[yellow]Warning: LLAMA_CLOUD_API_KEY is not set and no logged-in profile was found. The app may not work.[/yellow]"
199
214
  )