llamactl 0.2.7a1__tar.gz → 0.3.0__tar.gz
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.
- {llamactl-0.2.7a1 → llamactl-0.3.0}/PKG-INFO +9 -6
- {llamactl-0.2.7a1 → llamactl-0.3.0}/pyproject.toml +13 -6
- llamactl-0.3.0/src/llama_deploy/cli/__init__.py +19 -0
- llamactl-0.3.0/src/llama_deploy/cli/app.py +69 -0
- llamactl-0.3.0/src/llama_deploy/cli/auth/client.py +362 -0
- llamactl-0.3.0/src/llama_deploy/cli/client.py +50 -0
- llamactl-0.3.0/src/llama_deploy/cli/commands/aliased_group.py +33 -0
- llamactl-0.3.0/src/llama_deploy/cli/commands/auth.py +696 -0
- llamactl-0.3.0/src/llama_deploy/cli/commands/deployment.py +300 -0
- llamactl-0.3.0/src/llama_deploy/cli/commands/env.py +211 -0
- llamactl-0.3.0/src/llama_deploy/cli/commands/init.py +313 -0
- llamactl-0.3.0/src/llama_deploy/cli/commands/serve.py +239 -0
- llamactl-0.3.0/src/llama_deploy/cli/config/_config.py +390 -0
- llamactl-0.3.0/src/llama_deploy/cli/config/_migrations.py +65 -0
- llamactl-0.3.0/src/llama_deploy/cli/config/auth_service.py +130 -0
- llamactl-0.3.0/src/llama_deploy/cli/config/env_service.py +67 -0
- llamactl-0.3.0/src/llama_deploy/cli/config/migrations/0001_init.sql +35 -0
- llamactl-0.3.0/src/llama_deploy/cli/config/migrations/0002_add_auth_fields.sql +24 -0
- llamactl-0.3.0/src/llama_deploy/cli/config/migrations/__init__.py +7 -0
- llamactl-0.3.0/src/llama_deploy/cli/config/schema.py +61 -0
- {llamactl-0.2.7a1 → llamactl-0.3.0}/src/llama_deploy/cli/env.py +5 -3
- llamactl-0.3.0/src/llama_deploy/cli/interactive_prompts/session_utils.py +37 -0
- llamactl-0.3.0/src/llama_deploy/cli/interactive_prompts/utils.py +20 -0
- llamactl-0.3.0/src/llama_deploy/cli/options.py +43 -0
- llamactl-0.3.0/src/llama_deploy/cli/py.typed +0 -0
- llamactl-0.3.0/src/llama_deploy/cli/styles.py +10 -0
- {llamactl-0.2.7a1 → llamactl-0.3.0}/src/llama_deploy/cli/textual/deployment_form.py +263 -36
- llamactl-0.3.0/src/llama_deploy/cli/textual/deployment_help.py +53 -0
- llamactl-0.3.0/src/llama_deploy/cli/textual/deployment_monitor.py +466 -0
- {llamactl-0.2.7a1 → llamactl-0.3.0}/src/llama_deploy/cli/textual/git_validation.py +20 -21
- {llamactl-0.2.7a1 → llamactl-0.3.0}/src/llama_deploy/cli/textual/github_callback_server.py +17 -14
- {llamactl-0.2.7a1 → llamactl-0.3.0}/src/llama_deploy/cli/textual/llama_loader.py +13 -1
- {llamactl-0.2.7a1 → llamactl-0.3.0}/src/llama_deploy/cli/textual/secrets_form.py +28 -8
- {llamactl-0.2.7a1 → llamactl-0.3.0}/src/llama_deploy/cli/textual/styles.tcss +49 -8
- llamactl-0.3.0/src/llama_deploy/cli/utils/env_inject.py +23 -0
- llamactl-0.2.7a1/src/llama_deploy/cli/__init__.py +0 -32
- llamactl-0.2.7a1/src/llama_deploy/cli/client.py +0 -173
- llamactl-0.2.7a1/src/llama_deploy/cli/commands.py +0 -549
- llamactl-0.2.7a1/src/llama_deploy/cli/config.py +0 -173
- llamactl-0.2.7a1/src/llama_deploy/cli/interactive_prompts/utils.py +0 -86
- llamactl-0.2.7a1/src/llama_deploy/cli/options.py +0 -21
- llamactl-0.2.7a1/src/llama_deploy/cli/textual/profile_form.py +0 -171
- {llamactl-0.2.7a1 → llamactl-0.3.0}/README.md +0 -0
- {llamactl-0.2.7a1 → llamactl-0.3.0}/src/llama_deploy/cli/debug.py +0 -0
|
@@ -1,21 +1,24 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: llamactl
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A command-line interface for managing LlamaDeploy projects and deployments
|
|
5
5
|
Author: Adrian Lyjak
|
|
6
6
|
Author-email: Adrian Lyjak <adrianlyjak@gmail.com>
|
|
7
7
|
License: MIT
|
|
8
|
-
Requires-Dist: llama-deploy-core>=0.
|
|
9
|
-
Requires-Dist: llama-deploy-appserver>=0.
|
|
10
|
-
Requires-Dist: httpx>=0.24.0
|
|
8
|
+
Requires-Dist: llama-deploy-core[client]>=0.3.0,<0.4.0
|
|
9
|
+
Requires-Dist: llama-deploy-appserver>=0.3.0,<0.4.0
|
|
10
|
+
Requires-Dist: httpx>=0.24.0,<1.0.0
|
|
11
11
|
Requires-Dist: rich>=13.0.0
|
|
12
12
|
Requires-Dist: questionary>=2.0.0
|
|
13
13
|
Requires-Dist: click>=8.2.1
|
|
14
14
|
Requires-Dist: python-dotenv>=1.0.0
|
|
15
15
|
Requires-Dist: tenacity>=9.1.2
|
|
16
|
-
Requires-Dist: textual>=
|
|
16
|
+
Requires-Dist: textual>=6.0.0
|
|
17
17
|
Requires-Dist: aiohttp>=3.12.14
|
|
18
|
-
Requires-
|
|
18
|
+
Requires-Dist: copier>=9.9.0
|
|
19
|
+
Requires-Dist: pyjwt[crypto]>=2.10.1
|
|
20
|
+
Requires-Dist: vibe-llama>=0.4.2,<0.5.0
|
|
21
|
+
Requires-Python: >=3.11, <4
|
|
19
22
|
Description-Content-Type: text/markdown
|
|
20
23
|
|
|
21
24
|
# llamactl
|
|
@@ -1,24 +1,27 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "llamactl"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "A command-line interface for managing LlamaDeploy projects and deployments"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "MIT" }
|
|
7
7
|
authors = [
|
|
8
8
|
{ name = "Adrian Lyjak", email = "adrianlyjak@gmail.com" }
|
|
9
9
|
]
|
|
10
|
-
requires-python = ">=3.
|
|
10
|
+
requires-python = ">=3.11, <4"
|
|
11
11
|
dependencies = [
|
|
12
|
-
"llama-deploy-core>=0.
|
|
13
|
-
"llama-deploy-appserver>=0.
|
|
14
|
-
"httpx>=0.24.0",
|
|
12
|
+
"llama-deploy-core[client]>=0.3.0,<0.4.0",
|
|
13
|
+
"llama-deploy-appserver>=0.3.0,<0.4.0",
|
|
14
|
+
"httpx>=0.24.0,<1.0.0",
|
|
15
15
|
"rich>=13.0.0",
|
|
16
16
|
"questionary>=2.0.0",
|
|
17
17
|
"click>=8.2.1",
|
|
18
18
|
"python-dotenv>=1.0.0",
|
|
19
19
|
"tenacity>=9.1.2",
|
|
20
|
-
"textual>=
|
|
20
|
+
"textual>=6.0.0",
|
|
21
21
|
"aiohttp>=3.12.14",
|
|
22
|
+
"copier>=9.9.0",
|
|
23
|
+
"pyjwt[crypto]>=2.10.1",
|
|
24
|
+
"vibe-llama>=0.4.2,<0.5.0",
|
|
22
25
|
]
|
|
23
26
|
|
|
24
27
|
[project.scripts]
|
|
@@ -33,6 +36,9 @@ dev = [
|
|
|
33
36
|
"pytest>=8.3.4",
|
|
34
37
|
"pytest-asyncio>=0.25.3",
|
|
35
38
|
"respx>=0.22.0",
|
|
39
|
+
"pytest-xdist>=3.8.0",
|
|
40
|
+
"ty>=0.0.1a19",
|
|
41
|
+
"ruff>=0.12.9",
|
|
36
42
|
]
|
|
37
43
|
|
|
38
44
|
[tool.uv.build-backend]
|
|
@@ -40,3 +46,4 @@ module-name = "llama_deploy.cli"
|
|
|
40
46
|
|
|
41
47
|
[tool.uv.sources]
|
|
42
48
|
llama-deploy-appserver = { workspace = true }
|
|
49
|
+
llama-deploy-core = { workspace = true }
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from llama_deploy.cli.commands.auth import auth
|
|
2
|
+
from llama_deploy.cli.commands.deployment import deployments
|
|
3
|
+
from llama_deploy.cli.commands.env import env_group
|
|
4
|
+
from llama_deploy.cli.commands.init import init
|
|
5
|
+
from llama_deploy.cli.commands.serve import serve
|
|
6
|
+
|
|
7
|
+
from .app import app
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Main entry point function (called by the script)
|
|
11
|
+
def main() -> None:
|
|
12
|
+
app()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
__all__ = ["app", "deployments", "auth", "serve", "init", "env_group"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
if __name__ == "__main__":
|
|
19
|
+
app()
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError
|
|
2
|
+
from importlib.metadata import version as pkg_version
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
from llama_deploy.cli.commands.aliased_group import AliasedGroup
|
|
6
|
+
from llama_deploy.cli.config.env_service import service
|
|
7
|
+
from llama_deploy.cli.options import global_options
|
|
8
|
+
from rich import print as rprint
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
console = Console(highlight=False)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def print_version(ctx: click.Context, param: click.Option, value: bool) -> None:
|
|
16
|
+
"""Print the version of llama_deploy"""
|
|
17
|
+
if not value or ctx.resilient_parsing:
|
|
18
|
+
return
|
|
19
|
+
try:
|
|
20
|
+
ver = pkg_version("llamactl")
|
|
21
|
+
console.print(Text.assemble("client version: ", (ver, "green")))
|
|
22
|
+
|
|
23
|
+
# If there is an active profile, attempt to query server version
|
|
24
|
+
auth_service = service.current_auth_service()
|
|
25
|
+
if auth_service:
|
|
26
|
+
try:
|
|
27
|
+
data = auth_service.fetch_server_version()
|
|
28
|
+
server_ver = data.version
|
|
29
|
+
console.print(
|
|
30
|
+
Text.assemble(
|
|
31
|
+
"server version: ",
|
|
32
|
+
(
|
|
33
|
+
server_ver or "unknown",
|
|
34
|
+
"bright_yellow" if server_ver is None else "green",
|
|
35
|
+
),
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
except Exception as e:
|
|
39
|
+
console.print(
|
|
40
|
+
Text.assemble(
|
|
41
|
+
"server version: ",
|
|
42
|
+
("unavailable", "bright_yellow"),
|
|
43
|
+
(f" - {e}", "dim"),
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
except PackageNotFoundError:
|
|
47
|
+
rprint("[red]Package 'llamactl' not found[/red]")
|
|
48
|
+
raise click.Abort()
|
|
49
|
+
except Exception as e:
|
|
50
|
+
rprint(f"[red]Error: {e}[/red]")
|
|
51
|
+
raise click.Abort()
|
|
52
|
+
ctx.exit()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Main CLI application
|
|
56
|
+
@click.group(
|
|
57
|
+
help="Create, develop, and deploy LlamaIndex workflow based apps", cls=AliasedGroup
|
|
58
|
+
)
|
|
59
|
+
@click.option(
|
|
60
|
+
"--version",
|
|
61
|
+
is_flag=True,
|
|
62
|
+
callback=print_version,
|
|
63
|
+
expose_value=False,
|
|
64
|
+
is_eager=True,
|
|
65
|
+
help="Print client and server versions of LlamaDeploy",
|
|
66
|
+
)
|
|
67
|
+
@global_options
|
|
68
|
+
def app():
|
|
69
|
+
pass
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from types import TracebackType
|
|
6
|
+
from typing import Any, AsyncContextManager, AsyncGenerator, Awaitable, Callable, Self
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import jwt
|
|
10
|
+
from jwt.algorithms import RSAAlgorithm # type: ignore[possibly-unbound-import]
|
|
11
|
+
from llama_deploy.cli.config.schema import DeviceOIDC
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OidcDiscoveryResponse(BaseModel):
|
|
18
|
+
discovery_url: str
|
|
19
|
+
client_ids: dict[str, str] | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OidcProviderConfiguration(BaseModel):
|
|
23
|
+
device_authorization_endpoint: str | None = None
|
|
24
|
+
token_endpoint: str | None = None
|
|
25
|
+
scopes_supported: list[str] | None = None
|
|
26
|
+
jwks_uri: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class JsonWebKey(BaseModel):
|
|
30
|
+
kty: str
|
|
31
|
+
kid: str | None = None
|
|
32
|
+
use: str | None = None
|
|
33
|
+
alg: str | None = None
|
|
34
|
+
n: str | None = None
|
|
35
|
+
e: str | None = None
|
|
36
|
+
x5c: list[str] | None = None
|
|
37
|
+
x5t: str | None = None
|
|
38
|
+
x5t_s256: str | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class JsonWebKeySet(BaseModel):
|
|
42
|
+
keys: list[JsonWebKey]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AuthMeResponse(BaseModel):
|
|
46
|
+
id: str
|
|
47
|
+
email: str | None = None
|
|
48
|
+
last_login_provider: str | None = None
|
|
49
|
+
name: str | None = None
|
|
50
|
+
first_name: str | None = None
|
|
51
|
+
last_name: str | None = None
|
|
52
|
+
claims: dict[str, Any] | None = None
|
|
53
|
+
restrict: Any | None = None
|
|
54
|
+
created_at: str | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ClientContextManager(AsyncContextManager):
|
|
58
|
+
def __init__(self, base_url: str | None, auth: httpx.Auth | None = None) -> None:
|
|
59
|
+
self.base_url = base_url.rstrip("/") if base_url else None
|
|
60
|
+
if self.base_url:
|
|
61
|
+
self.client = httpx.AsyncClient(base_url=self.base_url, auth=auth)
|
|
62
|
+
else:
|
|
63
|
+
self.client = httpx.AsyncClient(auth=auth)
|
|
64
|
+
|
|
65
|
+
async def close(self) -> None:
|
|
66
|
+
try:
|
|
67
|
+
await self.client.aclose()
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
async def __aenter__(self) -> Self:
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
async def __aexit__(
|
|
75
|
+
self,
|
|
76
|
+
exc_type: type | None,
|
|
77
|
+
exc_value: BaseException | None,
|
|
78
|
+
traceback: TracebackType | None,
|
|
79
|
+
) -> None:
|
|
80
|
+
await self.close()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class PlatformAuthDiscoveryClient(ClientContextManager):
|
|
84
|
+
"""Client for ad hoc auth endpoints under /api/v1/auth."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, base_url: str) -> None:
|
|
87
|
+
super().__init__(base_url)
|
|
88
|
+
|
|
89
|
+
async def oidc_discovery(self) -> OidcDiscoveryResponse:
|
|
90
|
+
resp = await self.client.get("/api/v1/auth/oidc/discovery", timeout=10.0)
|
|
91
|
+
resp.raise_for_status()
|
|
92
|
+
return OidcDiscoveryResponse.model_validate(resp.json())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class APIToken(BaseModel):
|
|
96
|
+
token: str
|
|
97
|
+
id: str
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class PlatformAuthClient(ClientContextManager):
|
|
101
|
+
"""Client for user introspection under /api/v1/auth/me."""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self, base_url: str, id_token: str | None = None, auth: httpx.Auth | None = None
|
|
105
|
+
) -> None:
|
|
106
|
+
self.id_token = id_token
|
|
107
|
+
super().__init__(base_url, auth=auth)
|
|
108
|
+
|
|
109
|
+
async def me(self) -> AuthMeResponse:
|
|
110
|
+
headers = (
|
|
111
|
+
{"Authorization": f"Bearer {self.id_token}"} if self.id_token else None
|
|
112
|
+
)
|
|
113
|
+
resp = await self.client.get("/api/v1/auth/me", headers=headers, timeout=10.0)
|
|
114
|
+
resp.raise_for_status()
|
|
115
|
+
return AuthMeResponse.model_validate(resp.json())
|
|
116
|
+
|
|
117
|
+
async def create_agent_api_key(self, name: str) -> APIToken:
|
|
118
|
+
resp = await self.client.post(
|
|
119
|
+
"/api/v1/api-keys",
|
|
120
|
+
json={"name": name, "project_id": None},
|
|
121
|
+
)
|
|
122
|
+
resp.raise_for_status()
|
|
123
|
+
json = resp.json()
|
|
124
|
+
token = json["redacted_api_key"]
|
|
125
|
+
id = json["id"]
|
|
126
|
+
return APIToken(token=token, id=id)
|
|
127
|
+
|
|
128
|
+
async def delete_api_key(self, id: str) -> None:
|
|
129
|
+
response = await self.client.delete(f"/api/v1/api-keys/{id}")
|
|
130
|
+
response.raise_for_status()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class RefreshMiddleware(httpx.Auth):
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
device_oidc: DeviceOIDC,
|
|
137
|
+
on_refresh: Callable[[DeviceOIDC], Awaitable[None]],
|
|
138
|
+
) -> None:
|
|
139
|
+
self.device_oidc = device_oidc
|
|
140
|
+
self.on_refresh = on_refresh
|
|
141
|
+
self.lock = asyncio.Lock()
|
|
142
|
+
|
|
143
|
+
async def _refresh_and_update(self) -> None:
|
|
144
|
+
new_device_oidc = await refresh(self.device_oidc)
|
|
145
|
+
self.device_oidc = new_device_oidc
|
|
146
|
+
try:
|
|
147
|
+
await self.on_refresh(new_device_oidc)
|
|
148
|
+
except Exception:
|
|
149
|
+
logger.exception("Error in on_refresh callback")
|
|
150
|
+
|
|
151
|
+
async def async_auth_flow(
|
|
152
|
+
self, request: httpx.Request
|
|
153
|
+
) -> AsyncGenerator[httpx.Request, httpx.Response]:
|
|
154
|
+
token = self.device_oidc.device_access_token
|
|
155
|
+
request.headers["Authorization"] = f"Bearer {token}"
|
|
156
|
+
|
|
157
|
+
response = yield request
|
|
158
|
+
if response.status_code == 401:
|
|
159
|
+
async with self.lock:
|
|
160
|
+
if token == self.device_oidc.device_access_token:
|
|
161
|
+
await self._refresh_and_update()
|
|
162
|
+
request.headers["Authorization"] = (
|
|
163
|
+
f"Bearer {self.device_oidc.device_access_token}"
|
|
164
|
+
)
|
|
165
|
+
yield request
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class DeviceAuthorizationRequest(BaseModel):
|
|
169
|
+
client_id: str
|
|
170
|
+
scope: str
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class DeviceAuthorizationResponse(BaseModel):
|
|
174
|
+
device_code: str
|
|
175
|
+
user_code: str
|
|
176
|
+
verification_uri: str
|
|
177
|
+
verification_uri_complete: str | None = None
|
|
178
|
+
expires_in: int
|
|
179
|
+
interval: int | None = None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class TokenRequestDeviceCode(BaseModel):
|
|
183
|
+
grant_type: str = "urn:ietf:params:oauth:grant-type:device_code"
|
|
184
|
+
device_code: str
|
|
185
|
+
client_id: str
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class TokenResponse(BaseModel):
|
|
189
|
+
# Success fields
|
|
190
|
+
id_token: str | None = None
|
|
191
|
+
access_token: str | None = None
|
|
192
|
+
refresh_token: str | None = None
|
|
193
|
+
expires_in: int | None = None
|
|
194
|
+
token_type: str | None = None
|
|
195
|
+
scope: str | None = None
|
|
196
|
+
# Error fields
|
|
197
|
+
error: str | None = None
|
|
198
|
+
error_description: str | None = None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class TokenRequestRefresh(BaseModel):
|
|
202
|
+
grant_type: str = "refresh_token"
|
|
203
|
+
refresh_token: str
|
|
204
|
+
client_id: str
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class OIDCClient(ClientContextManager):
|
|
208
|
+
def __init__(self) -> None:
|
|
209
|
+
super().__init__(None)
|
|
210
|
+
|
|
211
|
+
async def fetch_provider_configuration(
|
|
212
|
+
self, discovery_url: str
|
|
213
|
+
) -> OidcProviderConfiguration:
|
|
214
|
+
resp = await self.client.get(discovery_url, timeout=10.0)
|
|
215
|
+
resp.raise_for_status()
|
|
216
|
+
return OidcProviderConfiguration.model_validate(resp.json())
|
|
217
|
+
|
|
218
|
+
async def device_authorization(
|
|
219
|
+
self, device_endpoint: str, request: DeviceAuthorizationRequest
|
|
220
|
+
) -> DeviceAuthorizationResponse:
|
|
221
|
+
resp = await self.client.post(
|
|
222
|
+
device_endpoint,
|
|
223
|
+
data=request.model_dump(),
|
|
224
|
+
headers={
|
|
225
|
+
"Accept": "application/json",
|
|
226
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
227
|
+
},
|
|
228
|
+
timeout=10.0,
|
|
229
|
+
)
|
|
230
|
+
resp.raise_for_status()
|
|
231
|
+
return DeviceAuthorizationResponse.model_validate(resp.json())
|
|
232
|
+
|
|
233
|
+
async def token_with_device_code(
|
|
234
|
+
self, token_endpoint: str, request: TokenRequestDeviceCode
|
|
235
|
+
) -> TokenResponse:
|
|
236
|
+
resp = await self.client.post(
|
|
237
|
+
token_endpoint,
|
|
238
|
+
data=request.model_dump(),
|
|
239
|
+
headers={
|
|
240
|
+
"Accept": "application/json",
|
|
241
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
242
|
+
},
|
|
243
|
+
timeout=10.0,
|
|
244
|
+
)
|
|
245
|
+
# Do not raise for status; callers inspect error payloads during polling
|
|
246
|
+
try:
|
|
247
|
+
payload = resp.json()
|
|
248
|
+
except Exception:
|
|
249
|
+
# Fall back to minimal error information
|
|
250
|
+
return TokenResponse(error="invalid_response", error_description=resp.text)
|
|
251
|
+
return TokenResponse.model_validate(payload)
|
|
252
|
+
|
|
253
|
+
async def token_with_refresh(
|
|
254
|
+
self, token_endpoint: str, request: TokenRequestRefresh
|
|
255
|
+
) -> TokenResponse:
|
|
256
|
+
resp = await self.client.post(
|
|
257
|
+
token_endpoint,
|
|
258
|
+
data=request.model_dump(),
|
|
259
|
+
headers={
|
|
260
|
+
"Accept": "application/json",
|
|
261
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
262
|
+
},
|
|
263
|
+
timeout=10.0,
|
|
264
|
+
)
|
|
265
|
+
try:
|
|
266
|
+
payload = resp.json()
|
|
267
|
+
except Exception:
|
|
268
|
+
return TokenResponse(error="invalid_response", error_description=resp.text)
|
|
269
|
+
return TokenResponse.model_validate(payload)
|
|
270
|
+
|
|
271
|
+
async def get_jwks(self, jwks_uri: str) -> JsonWebKeySet:
|
|
272
|
+
resp = await self.client.get(jwks_uri, timeout=10.0)
|
|
273
|
+
resp.raise_for_status()
|
|
274
|
+
return JsonWebKeySet.model_validate(resp.json())
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
async def decode_jwt_claims_from_device_oidc(
|
|
278
|
+
oidc_device: DeviceOIDC,
|
|
279
|
+
verify_audience: bool = False,
|
|
280
|
+
verify_expiration: bool = False,
|
|
281
|
+
audience: str | None = None,
|
|
282
|
+
) -> dict[str, Any]:
|
|
283
|
+
"""Decode JWT claims by discovering provider and verifying via JWKS.
|
|
284
|
+
|
|
285
|
+
Assumes RSA signing. Audience verification can be toggled and, when enabled,
|
|
286
|
+
an audience value can be provided.
|
|
287
|
+
"""
|
|
288
|
+
if not oidc_device.device_id_token:
|
|
289
|
+
raise ValueError("Device ID token is missing. Cannot decode claims.")
|
|
290
|
+
async with OIDCClient() as oidc:
|
|
291
|
+
provider = await oidc.fetch_provider_configuration(oidc_device.discovery_url)
|
|
292
|
+
jwks_uri = provider.jwks_uri
|
|
293
|
+
if not jwks_uri:
|
|
294
|
+
raise ValueError("Provider does not expose jwks_uri")
|
|
295
|
+
return await decode_jwt_claims(
|
|
296
|
+
oidc_device.device_id_token,
|
|
297
|
+
jwks_uri,
|
|
298
|
+
verify_audience,
|
|
299
|
+
verify_expiration,
|
|
300
|
+
audience,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
async def decode_jwt_claims(
|
|
305
|
+
token: str,
|
|
306
|
+
jwks_uri: str,
|
|
307
|
+
verify_audience: bool = False,
|
|
308
|
+
verify_expiration: bool = False,
|
|
309
|
+
audience: str | None = None,
|
|
310
|
+
) -> dict[str, Any]:
|
|
311
|
+
async with OIDCClient() as oidc:
|
|
312
|
+
jwks = await oidc.get_jwks(jwks_uri)
|
|
313
|
+
|
|
314
|
+
# Select key
|
|
315
|
+
header = jwt.get_unverified_header(token)
|
|
316
|
+
kid = header.get("kid")
|
|
317
|
+
alg = header.get("alg", "RS256")
|
|
318
|
+
keys = jwks.keys
|
|
319
|
+
key = next((k for k in keys if k.kid == kid), None) or next(iter(keys), None)
|
|
320
|
+
if not key:
|
|
321
|
+
raise ValueError("Signing key not found in JWKS")
|
|
322
|
+
|
|
323
|
+
# Build public key (RSA-only)
|
|
324
|
+
if key.kty != "RSA":
|
|
325
|
+
raise ValueError("Unsupported JWK kty; only RSA is supported")
|
|
326
|
+
key_json = key.model_dump_json()
|
|
327
|
+
public_key = RSAAlgorithm.from_jwk(key_json)
|
|
328
|
+
|
|
329
|
+
return jwt.decode(
|
|
330
|
+
token,
|
|
331
|
+
public_key,
|
|
332
|
+
algorithms=[alg],
|
|
333
|
+
options={"verify_aud": verify_audience, "verify_exp": verify_expiration},
|
|
334
|
+
audience=audience,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
async def refresh(device_oidc: DeviceOIDC) -> DeviceOIDC:
|
|
339
|
+
"""
|
|
340
|
+
Run a refresh on the access token, storing updated tokens in a new DeviceOIDC.
|
|
341
|
+
"""
|
|
342
|
+
async with OIDCClient() as oidc:
|
|
343
|
+
provider = await oidc.fetch_provider_configuration(device_oidc.discovery_url)
|
|
344
|
+
token_endpoint = provider.token_endpoint
|
|
345
|
+
if not token_endpoint:
|
|
346
|
+
raise ValueError("Provider does not expose token_endpoint")
|
|
347
|
+
if not device_oidc.device_refresh_token:
|
|
348
|
+
raise ValueError("Device refresh token is missing. Cannot refresh.")
|
|
349
|
+
token = await oidc.token_with_refresh(
|
|
350
|
+
token_endpoint,
|
|
351
|
+
TokenRequestRefresh(
|
|
352
|
+
refresh_token=device_oidc.device_refresh_token,
|
|
353
|
+
client_id=device_oidc.client_id,
|
|
354
|
+
),
|
|
355
|
+
)
|
|
356
|
+
copy = device_oidc.model_copy()
|
|
357
|
+
if not token.access_token:
|
|
358
|
+
raise ValueError("Refresh failed: token response missing access_token")
|
|
359
|
+
copy.device_access_token = token.access_token
|
|
360
|
+
copy.device_refresh_token = token.refresh_token or copy.device_refresh_token
|
|
361
|
+
copy.device_id_token = token.id_token or copy.device_id_token
|
|
362
|
+
return copy
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from contextlib import asynccontextmanager
|
|
2
|
+
from typing import AsyncGenerator
|
|
3
|
+
|
|
4
|
+
from llama_deploy.cli.config.env_service import service
|
|
5
|
+
from llama_deploy.core.client.manage_client import ControlPlaneClient, ProjectClient
|
|
6
|
+
from rich import print as rprint
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_control_plane_client() -> ControlPlaneClient:
|
|
10
|
+
auth_svc = service.current_auth_service()
|
|
11
|
+
profile = service.current_auth_service().get_current_profile()
|
|
12
|
+
if profile:
|
|
13
|
+
resolved_base_url = profile.api_url.rstrip("/")
|
|
14
|
+
resolved_api_key = profile.api_key
|
|
15
|
+
return ControlPlaneClient(
|
|
16
|
+
resolved_base_url, resolved_api_key, auth_svc.auth_middleware()
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Fallback: allow env-scoped client construction for env operations
|
|
20
|
+
env = service.get_current_environment()
|
|
21
|
+
resolved_base_url = env.api_url.rstrip("/")
|
|
22
|
+
return ControlPlaneClient(resolved_base_url)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_project_client() -> ProjectClient:
|
|
26
|
+
auth_svc = service.current_auth_service()
|
|
27
|
+
profile = auth_svc.get_current_profile()
|
|
28
|
+
if not profile:
|
|
29
|
+
rprint("\n[bold red]No profile configured![/bold red]")
|
|
30
|
+
rprint("\nTo get started, create a profile with:")
|
|
31
|
+
if auth_svc.env.requires_auth:
|
|
32
|
+
rprint("[cyan]llamactl auth login[/cyan]")
|
|
33
|
+
else:
|
|
34
|
+
rprint("[cyan]llamactl auth token[/cyan]")
|
|
35
|
+
raise SystemExit(1)
|
|
36
|
+
return ProjectClient(
|
|
37
|
+
profile.api_url, profile.project_id, profile.api_key, auth_svc.auth_middleware()
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@asynccontextmanager
|
|
42
|
+
async def project_client_context() -> AsyncGenerator[ProjectClient, None]:
|
|
43
|
+
client = get_project_client()
|
|
44
|
+
try:
|
|
45
|
+
yield client
|
|
46
|
+
finally:
|
|
47
|
+
try:
|
|
48
|
+
await client.aclose()
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Fully lifted from https://click.palletsprojects.com/en/stable/extending-click/"""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AliasedGroup(click.Group):
|
|
7
|
+
"""
|
|
8
|
+
Implements a subclass of Group that accepts a prefix for a command.
|
|
9
|
+
If there was a command called push, it would accept pus as an alias (so long as it was unique):
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
|
|
13
|
+
rv = super().get_command(ctx, cmd_name)
|
|
14
|
+
|
|
15
|
+
if rv is not None:
|
|
16
|
+
return rv
|
|
17
|
+
|
|
18
|
+
matches = [x for x in self.list_commands(ctx) if x.startswith(cmd_name)]
|
|
19
|
+
|
|
20
|
+
if not matches:
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
if len(matches) == 1:
|
|
24
|
+
return click.Group.get_command(self, ctx, matches[0])
|
|
25
|
+
|
|
26
|
+
ctx.fail(f"Too many matches: {', '.join(sorted(matches))}")
|
|
27
|
+
|
|
28
|
+
def resolve_command(
|
|
29
|
+
self, ctx: click.Context, args: list[str]
|
|
30
|
+
) -> tuple[str, click.Command, list[str]]:
|
|
31
|
+
# always return the full command name
|
|
32
|
+
_, cmd, args = super().resolve_command(ctx, args)
|
|
33
|
+
return cmd.name, cmd, args
|