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.
- llama_deploy/cli/auth/client.py +362 -0
- llama_deploy/cli/client.py +14 -5
- llama_deploy/cli/commands/auth.py +279 -22
- llama_deploy/cli/commands/deployment.py +19 -26
- llama_deploy/cli/commands/serve.py +27 -12
- llama_deploy/cli/config/_config.py +178 -202
- llama_deploy/cli/config/_migrations.py +65 -0
- llama_deploy/cli/config/auth_service.py +64 -2
- llama_deploy/cli/config/env_service.py +15 -14
- llama_deploy/cli/config/migrations/0001_init.sql +35 -0
- llama_deploy/cli/config/migrations/0002_add_auth_fields.sql +24 -0
- llama_deploy/cli/config/migrations/__init__.py +7 -0
- llama_deploy/cli/config/schema.py +30 -0
- llama_deploy/cli/textual/deployment_form.py +63 -7
- llama_deploy/cli/textual/deployment_help.py +1 -1
- llama_deploy/cli/textual/deployment_monitor.py +71 -108
- llama_deploy/cli/textual/github_callback_server.py +4 -4
- llama_deploy/cli/textual/secrets_form.py +4 -0
- llama_deploy/cli/textual/styles.tcss +7 -5
- {llamactl-0.3.0a18.dist-info → llamactl-0.3.0a20.dist-info}/METADATA +4 -3
- llamactl-0.3.0a20.dist-info/RECORD +36 -0
- llama_deploy/cli/platform_client.py +0 -52
- llamactl-0.3.0a18.dist-info/RECORD +0 -32
- {llamactl-0.3.0a18.dist-info → llamactl-0.3.0a20.dist-info}/WHEEL +0 -0
- {llamactl-0.3.0a18.dist-info → llamactl-0.3.0a20.dist-info}/entry_points.txt +0 -0
|
@@ -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
|
|
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
|
|
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 =
|
|
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
|
|
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"
|
|
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
|
-
|
|
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"
|
|
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 =
|
|
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
|
|
286
|
-
auth_svc: AuthService,
|
|
526
|
+
def _list_projects(
|
|
527
|
+
auth_svc: AuthService,
|
|
528
|
+
api_key: str | None = None,
|
|
287
529
|
) -> list[ProjectSummary]:
|
|
288
530
|
async def _run():
|
|
289
|
-
|
|
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
|
|
294
|
-
except
|
|
295
|
-
if
|
|
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
|
|
298
|
-
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
deployments = asyncio.run(client.list_deployments())
|
|
281
|
+
client = get_project_client()
|
|
282
|
+
deployments = asyncio.run(client.list_deployments())
|
|
284
283
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
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
|
)
|