llama-deploy-appserver 0.2.7a1__py3-none-any.whl → 0.3.0__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.
Files changed (38) hide show
  1. llama_deploy/appserver/app.py +274 -26
  2. llama_deploy/appserver/bootstrap.py +55 -25
  3. llama_deploy/appserver/configure_logging.py +189 -0
  4. llama_deploy/appserver/correlation_id.py +24 -0
  5. llama_deploy/appserver/deployment.py +70 -412
  6. llama_deploy/appserver/deployment_config_parser.py +12 -130
  7. llama_deploy/appserver/interrupts.py +55 -0
  8. llama_deploy/appserver/process_utils.py +214 -0
  9. llama_deploy/appserver/py.typed +0 -0
  10. llama_deploy/appserver/routers/__init__.py +4 -3
  11. llama_deploy/appserver/routers/deployments.py +163 -382
  12. llama_deploy/appserver/routers/status.py +4 -31
  13. llama_deploy/appserver/routers/ui_proxy.py +255 -0
  14. llama_deploy/appserver/settings.py +99 -49
  15. llama_deploy/appserver/types.py +0 -3
  16. llama_deploy/appserver/workflow_loader.py +431 -0
  17. llama_deploy/appserver/workflow_store/agent_data_store.py +100 -0
  18. llama_deploy/appserver/workflow_store/keyed_lock.py +32 -0
  19. llama_deploy/appserver/workflow_store/lru_cache.py +49 -0
  20. llama_deploy_appserver-0.3.0.dist-info/METADATA +25 -0
  21. llama_deploy_appserver-0.3.0.dist-info/RECORD +24 -0
  22. {llama_deploy_appserver-0.2.7a1.dist-info → llama_deploy_appserver-0.3.0.dist-info}/WHEEL +1 -1
  23. llama_deploy/appserver/__main__.py +0 -14
  24. llama_deploy/appserver/client/__init__.py +0 -3
  25. llama_deploy/appserver/client/base.py +0 -30
  26. llama_deploy/appserver/client/client.py +0 -49
  27. llama_deploy/appserver/client/models/__init__.py +0 -4
  28. llama_deploy/appserver/client/models/apiserver.py +0 -356
  29. llama_deploy/appserver/client/models/model.py +0 -82
  30. llama_deploy/appserver/run_autodeploy.py +0 -141
  31. llama_deploy/appserver/server.py +0 -60
  32. llama_deploy/appserver/source_managers/__init__.py +0 -5
  33. llama_deploy/appserver/source_managers/base.py +0 -33
  34. llama_deploy/appserver/source_managers/git.py +0 -48
  35. llama_deploy/appserver/source_managers/local.py +0 -51
  36. llama_deploy/appserver/tracing.py +0 -237
  37. llama_deploy_appserver-0.2.7a1.dist-info/METADATA +0 -23
  38. llama_deploy_appserver-0.2.7a1.dist-info/RECORD +0 -28
@@ -0,0 +1,255 @@
1
+ import asyncio
2
+ import logging
3
+ from contextlib import suppress
4
+ from typing import List
5
+
6
+ import httpx
7
+ import websockets
8
+ from fastapi import (
9
+ APIRouter,
10
+ FastAPI,
11
+ HTTPException,
12
+ Request,
13
+ WebSocket,
14
+ )
15
+ from fastapi.responses import StreamingResponse
16
+ from fastapi.staticfiles import StaticFiles
17
+ from llama_deploy.appserver.configure_logging import suppress_httpx_logs
18
+ from llama_deploy.appserver.interrupts import (
19
+ OperationAborted,
20
+ shutdown_event,
21
+ wait_or_abort,
22
+ )
23
+ from llama_deploy.appserver.settings import ApiserverSettings
24
+ from llama_deploy.core.deployment_config import DeploymentConfig
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ async def _ws_proxy(ws: WebSocket, upstream_url: str) -> None:
30
+ """Proxy WebSocket connection to upstream server."""
31
+ if shutdown_event.is_set():
32
+ await ws.close()
33
+ return
34
+
35
+ # Defer accept until after upstream connects so we can mirror the selected subprotocol
36
+
37
+ # Forward most headers except WebSocket-specific ones
38
+ header_prefix_blacklist = ["sec-websocket-"]
39
+ header_blacklist = {
40
+ "host",
41
+ "connection",
42
+ "upgrade",
43
+ }
44
+ hdrs = []
45
+ for k, v in ws.headers.items():
46
+ if k.lower() not in header_blacklist:
47
+ for prefix in header_prefix_blacklist:
48
+ if k.lower().startswith(prefix):
49
+ break
50
+ else:
51
+ hdrs.append((k, v))
52
+
53
+ try:
54
+ # Parse subprotocols if present
55
+ subprotocols: List[str] | None = None
56
+ requested = ws.headers.get("sec-websocket-protocol")
57
+ if requested:
58
+ # Parse comma-separated subprotocols (as plain strings)
59
+ subprotocols = [p.strip() for p in requested.split(",")]
60
+
61
+ # Open upstream WebSocket connection, offering the same subprotocols
62
+ async with websockets.connect(
63
+ upstream_url,
64
+ additional_headers=hdrs,
65
+ subprotocols=subprotocols,
66
+ open_timeout=5,
67
+ ) as upstream:
68
+ await ws.accept(subprotocol=upstream.subprotocol)
69
+
70
+ async def client_to_upstream() -> None:
71
+ try:
72
+ while True:
73
+ msg = await wait_or_abort(ws.receive(), shutdown_event)
74
+ if msg["type"] == "websocket.receive":
75
+ if "text" in msg:
76
+ await upstream.send(msg["text"])
77
+ elif "bytes" in msg:
78
+ await upstream.send(msg["bytes"])
79
+ elif msg["type"] == "websocket.disconnect":
80
+ break
81
+ except OperationAborted:
82
+ pass
83
+ except Exception:
84
+ pass
85
+
86
+ async def upstream_to_client() -> None:
87
+ try:
88
+ while True:
89
+ message = await wait_or_abort(upstream.recv(), shutdown_event)
90
+ if isinstance(message, str):
91
+ await ws.send_text(message)
92
+ else:
93
+ await ws.send_bytes(message)
94
+ except OperationAborted:
95
+ pass
96
+ except Exception:
97
+ pass
98
+
99
+ # Pump both directions concurrently, cancel the peer when one side closes
100
+ t1 = asyncio.create_task(client_to_upstream())
101
+ t2 = asyncio.create_task(upstream_to_client())
102
+ _, pending = await asyncio.wait(
103
+ {t1, t2}, return_when=asyncio.FIRST_COMPLETED
104
+ )
105
+ for task in pending:
106
+ task.cancel()
107
+ with suppress(asyncio.CancelledError):
108
+ await task
109
+
110
+ # On shutdown, proactively close both sides to break any remaining waits
111
+ if shutdown_event.is_set():
112
+ with suppress(Exception):
113
+ await ws.close()
114
+ with suppress(Exception):
115
+ await upstream.close()
116
+
117
+ except Exception as e:
118
+ logger.error(f"WebSocket proxy error: {e}")
119
+ # Accept then close so clients (and TestClient) don't error on enter
120
+ with suppress(Exception):
121
+ await ws.accept()
122
+ with suppress(Exception):
123
+ await ws.close()
124
+ finally:
125
+ try:
126
+ await ws.close()
127
+ except Exception as e:
128
+ logger.debug(f"Error closing client connection: {e}")
129
+
130
+
131
+ def create_ui_proxy_router(name: str, port: int) -> APIRouter:
132
+ deployment_router = APIRouter(
133
+ prefix=f"/deployments/{name}",
134
+ tags=["deployments"],
135
+ )
136
+
137
+ @deployment_router.websocket("/ui/{path:path}")
138
+ @deployment_router.websocket("/ui")
139
+ async def websocket_proxy(
140
+ websocket: WebSocket,
141
+ path: str | None = None,
142
+ ) -> None:
143
+ # Build the upstream WebSocket URL using FastAPI's extracted path parameter
144
+ slash_path = f"/{path}" if path is not None else ""
145
+ upstream_path = f"/deployments/{name}/ui{slash_path}"
146
+
147
+ # Convert to WebSocket URL
148
+ upstream_url = f"ws://localhost:{port}{upstream_path}"
149
+ if websocket.url.query:
150
+ upstream_url += f"?{websocket.url.query}"
151
+
152
+ await _ws_proxy(websocket, upstream_url)
153
+
154
+ @deployment_router.api_route(
155
+ "/ui/{path:path}",
156
+ methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH"],
157
+ include_in_schema=False,
158
+ )
159
+ @deployment_router.api_route(
160
+ "/ui",
161
+ methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH"],
162
+ include_in_schema=False,
163
+ )
164
+ async def proxy(
165
+ request: Request,
166
+ path: str | None = None,
167
+ ) -> StreamingResponse:
168
+ # Build the upstream URL using FastAPI's extracted path parameter
169
+ slash_path = f"/{path}" if path else ""
170
+ upstream_path = f"/deployments/{name}/ui{slash_path}"
171
+
172
+ upstream_url = httpx.URL(f"http://localhost:{port}{upstream_path}").copy_with(
173
+ params=request.query_params
174
+ )
175
+
176
+ # Debug logging
177
+ logger.debug(f"Proxying {request.method} {request.url} -> {upstream_url}")
178
+
179
+ # Strip hop-by-hop headers + host
180
+ hop_by_hop = {
181
+ "connection",
182
+ "keep-alive",
183
+ "proxy-authenticate",
184
+ "proxy-authorization",
185
+ "te", # codespell:ignore
186
+ "trailers",
187
+ "transfer-encoding",
188
+ "upgrade",
189
+ "host",
190
+ }
191
+ headers = {
192
+ k: v for k, v in request.headers.items() if k.lower() not in hop_by_hop
193
+ }
194
+
195
+ try:
196
+ client = httpx.AsyncClient(timeout=None)
197
+
198
+ req = client.build_request(
199
+ request.method,
200
+ upstream_url,
201
+ headers=headers,
202
+ content=request.stream(), # stream uploads
203
+ )
204
+ async with suppress_httpx_logs():
205
+ upstream = await client.send(req, stream=True)
206
+
207
+ resp_headers = {
208
+ k: v for k, v in upstream.headers.items() if k.lower() not in hop_by_hop
209
+ }
210
+
211
+ # Stream downloads and ensure cleanup in the generator's finally block
212
+ async def upstream_body():
213
+ try:
214
+ async for chunk in upstream.aiter_raw():
215
+ yield chunk
216
+ finally:
217
+ try:
218
+ await upstream.aclose()
219
+ finally:
220
+ await client.aclose()
221
+
222
+ return StreamingResponse(
223
+ upstream_body(),
224
+ status_code=upstream.status_code,
225
+ headers=resp_headers,
226
+ )
227
+
228
+ except httpx.ConnectError:
229
+ raise HTTPException(status_code=502, detail="Upstream server unavailable")
230
+ except httpx.TimeoutException:
231
+ raise HTTPException(status_code=504, detail="Upstream server timeout")
232
+ except Exception as e:
233
+ logger.error(f"Proxy error: {e}")
234
+ raise HTTPException(status_code=502, detail="Proxy error")
235
+
236
+ return deployment_router
237
+
238
+
239
+ def mount_static_files(
240
+ app: FastAPI, config: DeploymentConfig, settings: ApiserverSettings
241
+ ) -> None:
242
+ path = settings.app_root / config.build_output_path()
243
+ if not path:
244
+ return
245
+
246
+ if not path.exists():
247
+ return
248
+
249
+ # Serve index.html when accessing the directory path
250
+ app.mount(
251
+ f"/deployments/{config.name}/ui",
252
+ StaticFiles(directory=str(path), html=True),
253
+ name=f"ui-static-{config.name}",
254
+ )
255
+ return None
@@ -1,9 +1,43 @@
1
+ import os
1
2
  from pathlib import Path
3
+ from typing import Literal
2
4
 
5
+ from llama_deploy.core.config import DEFAULT_DEPLOYMENT_FILE_PATH
6
+ from llama_deploy.core.deployment_config import resolve_config_parent
3
7
  from pydantic import Field
4
8
  from pydantic_settings import BaseSettings, SettingsConfigDict
5
9
 
6
10
 
11
+ class BootstrapSettings(BaseSettings):
12
+ """
13
+ Settings configurable via env vars for controlling how an application is
14
+ created from a git repository.
15
+ """
16
+
17
+ model_config = SettingsConfigDict(env_prefix="LLAMA_DEPLOY_")
18
+ repo_url: str | None = Field(
19
+ default=None, description="The URL of the git repository to clone"
20
+ )
21
+ auth_token: str | None = Field(
22
+ default=None, description="The token to use to clone the git repository"
23
+ )
24
+ git_ref: str | None = Field(
25
+ default=None, description="The git reference to checkout"
26
+ )
27
+ git_sha: str | None = Field(default=None, description="The git SHA to checkout")
28
+ deployment_file_path: str = Field(
29
+ default=".",
30
+ description="The path to the deployment file, relative to the root of the repository",
31
+ )
32
+ deployment_name: str | None = Field(
33
+ default=None, description="The name of the deployment"
34
+ )
35
+ bootstrap_sdists: str | None = Field(
36
+ default=None,
37
+ description="A directory containing tar.gz sdists to install instead of installing the appserver",
38
+ )
39
+
40
+
7
41
  class ApiserverSettings(BaseSettings):
8
42
  model_config = SettingsConfigDict(env_prefix="LLAMA_DEPLOY_APISERVER_")
9
43
 
@@ -15,69 +49,85 @@ class ApiserverSettings(BaseSettings):
15
49
  default=4501,
16
50
  description="The TCP port where to bind the API Server",
17
51
  )
18
- rc_path: Path = Field(
19
- default=Path("./.llama_deploy_rc"),
20
- description="Path to the folder containing the deployment configs that will be loaded at startup",
21
- )
22
- deployments_path: Path | None = Field(
23
- default=None,
24
- description="Path to the folder where deployments will create their root path, defaults to a temp dir",
25
- )
26
- deployment_file_path: str | None = Field(
27
- default=None,
28
- description="Optional path, relative to the rc_path, where the deployment file is located. If not provided, will glob all .yml/.yaml files in the rc_path",
29
- )
30
- use_tls: bool = Field(
31
- default=False,
32
- description="Use TLS (HTTPS) to communicate with the API Server",
33
- )
34
52
 
35
- # Metrics collection settings
36
- prometheus_enabled: bool = Field(
37
- default=True,
38
- description="Whether to enable the Prometheus metrics exporter along with the API Server",
53
+ app_root: Path = Field(
54
+ default=Path("."),
55
+ description="The root of the application",
39
56
  )
40
- prometheus_port: int = Field(
41
- default=9000,
42
- description="The port where to serve Prometheus metrics",
57
+
58
+ deployment_file_path: Path = Field(
59
+ default=Path(DEFAULT_DEPLOYMENT_FILE_PATH),
60
+ description="path, relative to the repository root, where the pyproject.toml file is located",
43
61
  )
44
62
 
45
- # Tracing settings
46
- tracing_enabled: bool = Field(
63
+ proxy_ui: bool = Field(
47
64
  default=False,
48
- description="Enable OpenTelemetry tracing. Defaults to False.",
65
+ description="If true, proxy a development UI server instead of serving built assets",
49
66
  )
50
- tracing_service_name: str = Field(
51
- default="llama-deploy-appserver",
52
- description="Service name for tracing. Defaults to 'llama-deploy-appserver'.",
67
+ proxy_ui_port: int = Field(
68
+ default=4502,
69
+ description="The TCP port where to bind the UI proxy server",
53
70
  )
54
- tracing_exporter: str = Field(
55
- default="console",
56
- description="Trace exporter type: 'console', 'jaeger', 'otlp'. Defaults to 'console'.",
71
+
72
+ reload: bool = Field(
73
+ default=False,
74
+ description="If true, reload the workflow modules, for use in a dev server environment",
57
75
  )
58
- tracing_endpoint: str | None = Field(
76
+
77
+ persistence: Literal["memory", "local", "cloud"] | None = Field(
59
78
  default=None,
60
- description="Trace exporter endpoint. Required for 'jaeger' and 'otlp' exporters.",
61
- )
62
- tracing_sample_rate: float = Field(
63
- default=1.0,
64
- description="Trace sampling rate (0.0 to 1.0). Defaults to 1.0 (100% sampling).",
79
+ description="The persistence mode to use for the workflow server",
65
80
  )
66
- tracing_insecure: bool = Field(
67
- default=True,
68
- description="Use insecure connection for OTLP exporter. Defaults to True.",
81
+ local_persistence_path: str | None = Field(
82
+ default=None,
83
+ description="The path to the sqlite database to use for the workflow server",
69
84
  )
70
- tracing_timeout: int = Field(
71
- default=30,
72
- description="Timeout in seconds for trace export. Defaults to 30.",
85
+ cloud_persistence_name: str | None = Field(
86
+ default=None,
87
+ description="Agent Data deployment name to use for workflow persistence. May optionally include a `:` delimited collection name, e.g. 'my_agent:my_collection'. Leave none to use the current deployment name. Recommended to override with _public if running locally, and specify a collection name",
73
88
  )
74
89
 
75
90
  @property
76
- def url(self) -> str:
77
- protocol = "https://" if self.use_tls else "http://"
78
- if self.port == 80:
79
- return f"{protocol}{self.host}"
80
- return f"{protocol}{self.host}:{self.port}"
91
+ def resolved_config_parent(self) -> Path:
92
+ return resolve_config_parent(self.app_root, self.deployment_file_path)
81
93
 
82
94
 
83
95
  settings = ApiserverSettings()
96
+
97
+
98
+ def configure_settings(
99
+ proxy_ui: bool | None = None,
100
+ deployment_file_path: Path | None = None,
101
+ app_root: Path | None = None,
102
+ reload: bool | None = None,
103
+ persistence: Literal["memory", "local", "cloud"] | None = None,
104
+ local_persistence_path: str | None = None,
105
+ cloud_persistence_name: str | None = None,
106
+ ) -> None:
107
+ if proxy_ui is not None:
108
+ settings.proxy_ui = proxy_ui
109
+ os.environ["LLAMA_DEPLOY_APISERVER_PROXY_UI"] = "true" if proxy_ui else "false"
110
+ if deployment_file_path is not None:
111
+ settings.deployment_file_path = deployment_file_path
112
+ os.environ["LLAMA_DEPLOY_APISERVER_DEPLOYMENT_FILE_PATH"] = str(
113
+ deployment_file_path
114
+ )
115
+ if app_root is not None:
116
+ settings.app_root = app_root
117
+ os.environ["LLAMA_DEPLOY_APISERVER_APP_ROOT"] = str(app_root)
118
+ if reload is not None:
119
+ settings.reload = reload
120
+ os.environ["LLAMA_DEPLOY_APISERVER_RELOAD"] = "true" if reload else "false"
121
+ if persistence is not None:
122
+ settings.persistence = persistence
123
+ os.environ["LLAMA_DEPLOY_APISERVER_PERSISTENCE"] = persistence
124
+ if local_persistence_path is not None:
125
+ settings.local_persistence_path = local_persistence_path
126
+ os.environ["LLAMA_DEPLOY_APISERVER_LOCAL_PERSISTENCE_PATH"] = (
127
+ local_persistence_path
128
+ )
129
+ if cloud_persistence_name is not None:
130
+ settings.cloud_persistence_name = cloud_persistence_name
131
+ os.environ["LLAMA_DEPLOY_APISERVER_CLOUD_PERSISTENCE_NAME"] = (
132
+ cloud_persistence_name
133
+ )
@@ -91,9 +91,6 @@ class StatusEnum(Enum):
91
91
 
92
92
  class Status(BaseModel):
93
93
  status: StatusEnum
94
- status_message: str
95
- max_deployments: int | None = None
96
- deployments: list[str] | None = None
97
94
 
98
95
 
99
96
  class DeploymentDefinition(BaseModel):