llama-deploy-appserver 0.3.0a9__tar.gz → 0.3.0a11__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.
- {llama_deploy_appserver-0.3.0a9 → llama_deploy_appserver-0.3.0a11}/PKG-INFO +4 -2
- {llama_deploy_appserver-0.3.0a9 → llama_deploy_appserver-0.3.0a11}/pyproject.toml +4 -2
- {llama_deploy_appserver-0.3.0a9 → llama_deploy_appserver-0.3.0a11}/src/llama_deploy/appserver/app.py +27 -1
- llama_deploy_appserver-0.3.0a11/src/llama_deploy/appserver/configure_logging.py +189 -0
- llama_deploy_appserver-0.3.0a11/src/llama_deploy/appserver/correlation_id.py +24 -0
- llama_deploy_appserver-0.3.0a11/src/llama_deploy/appserver/interrupts.py +55 -0
- {llama_deploy_appserver-0.3.0a9 → llama_deploy_appserver-0.3.0a11}/src/llama_deploy/appserver/process_utils.py +5 -1
- llama_deploy_appserver-0.3.0a11/src/llama_deploy/appserver/py.typed +0 -0
- {llama_deploy_appserver-0.3.0a9 → llama_deploy_appserver-0.3.0a11}/src/llama_deploy/appserver/routers/ui_proxy.py +77 -37
- {llama_deploy_appserver-0.3.0a9 → llama_deploy_appserver-0.3.0a11}/src/llama_deploy/appserver/workflow_loader.py +62 -7
- llama_deploy_appserver-0.3.0a9/src/llama_deploy/appserver/__main__.py +0 -10
- {llama_deploy_appserver-0.3.0a9 → llama_deploy_appserver-0.3.0a11}/README.md +0 -0
- {llama_deploy_appserver-0.3.0a9 → llama_deploy_appserver-0.3.0a11}/src/llama_deploy/appserver/__init__.py +0 -0
- {llama_deploy_appserver-0.3.0a9 → llama_deploy_appserver-0.3.0a11}/src/llama_deploy/appserver/bootstrap.py +0 -0
- {llama_deploy_appserver-0.3.0a9 → llama_deploy_appserver-0.3.0a11}/src/llama_deploy/appserver/deployment.py +0 -0
- {llama_deploy_appserver-0.3.0a9 → llama_deploy_appserver-0.3.0a11}/src/llama_deploy/appserver/deployment_config_parser.py +0 -0
- {llama_deploy_appserver-0.3.0a9 → llama_deploy_appserver-0.3.0a11}/src/llama_deploy/appserver/routers/__init__.py +0 -0
- {llama_deploy_appserver-0.3.0a9 → llama_deploy_appserver-0.3.0a11}/src/llama_deploy/appserver/routers/deployments.py +0 -0
- {llama_deploy_appserver-0.3.0a9 → llama_deploy_appserver-0.3.0a11}/src/llama_deploy/appserver/routers/status.py +0 -0
- {llama_deploy_appserver-0.3.0a9 → llama_deploy_appserver-0.3.0a11}/src/llama_deploy/appserver/settings.py +0 -0
- {llama_deploy_appserver-0.3.0a9 → llama_deploy_appserver-0.3.0a11}/src/llama_deploy/appserver/stats.py +0 -0
- {llama_deploy_appserver-0.3.0a9 → llama_deploy_appserver-0.3.0a11}/src/llama_deploy/appserver/types.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.3
|
2
2
|
Name: llama-deploy-appserver
|
3
|
-
Version: 0.3.
|
3
|
+
Version: 0.3.0a11
|
4
4
|
Summary: Application server components for LlamaDeploy
|
5
5
|
Author: Massimiliano Pippi
|
6
6
|
Author-email: Massimiliano Pippi <mpippi@gmail.com>
|
@@ -10,10 +10,12 @@ Requires-Dist: pydantic-settings>=2.10.1
|
|
10
10
|
Requires-Dist: uvicorn>=0.24.0
|
11
11
|
Requires-Dist: fastapi>=0.100.0
|
12
12
|
Requires-Dist: websockets>=12.0
|
13
|
-
Requires-Dist: llama-deploy-core>=0.3.
|
13
|
+
Requires-Dist: llama-deploy-core>=0.3.0a11,<0.4.0
|
14
14
|
Requires-Dist: httpx>=0.28.1
|
15
15
|
Requires-Dist: prometheus-fastapi-instrumentator>=7.1.0
|
16
16
|
Requires-Dist: packaging>=25.0
|
17
|
+
Requires-Dist: structlog>=25.4.0
|
18
|
+
Requires-Dist: rich>=14.1.0
|
17
19
|
Requires-Python: >=3.12, <4
|
18
20
|
Description-Content-Type: text/markdown
|
19
21
|
|
@@ -1,6 +1,6 @@
|
|
1
1
|
[project]
|
2
2
|
name = "llama-deploy-appserver"
|
3
|
-
version = "0.3.
|
3
|
+
version = "0.3.0a11"
|
4
4
|
description = "Application server components for LlamaDeploy"
|
5
5
|
readme = "README.md"
|
6
6
|
license = { text = "MIT" }
|
@@ -14,10 +14,12 @@ dependencies = [
|
|
14
14
|
"uvicorn>=0.24.0",
|
15
15
|
"fastapi>=0.100.0",
|
16
16
|
"websockets>=12.0",
|
17
|
-
"llama-deploy-core>=0.3.
|
17
|
+
"llama-deploy-core>=0.3.0a11,<0.4.0",
|
18
18
|
"httpx>=0.28.1",
|
19
19
|
"prometheus-fastapi-instrumentator>=7.1.0",
|
20
20
|
"packaging>=25.0",
|
21
|
+
"structlog>=25.4.0",
|
22
|
+
"rich>=14.1.0",
|
21
23
|
]
|
22
24
|
|
23
25
|
[build-system]
|
{llama_deploy_appserver-0.3.0a9 → llama_deploy_appserver-0.3.0a11}/src/llama_deploy/appserver/app.py
RENAMED
@@ -13,6 +13,10 @@ import uvicorn
|
|
13
13
|
from fastapi import FastAPI
|
14
14
|
from fastapi.middleware.cors import CORSMiddleware
|
15
15
|
from fastapi.openapi.utils import get_openapi
|
16
|
+
from llama_deploy.appserver.configure_logging import (
|
17
|
+
add_log_middleware,
|
18
|
+
setup_logging,
|
19
|
+
)
|
16
20
|
from llama_deploy.appserver.deployment_config_parser import (
|
17
21
|
get_deployment_config,
|
18
22
|
)
|
@@ -41,15 +45,21 @@ from starlette.schemas import SchemaGenerator
|
|
41
45
|
from workflows.server import WorkflowServer
|
42
46
|
|
43
47
|
from .deployment import Deployment
|
48
|
+
from .interrupts import shutdown_event
|
44
49
|
from .process_utils import run_process
|
45
50
|
from .routers import health_router
|
46
51
|
from .stats import apiserver_state
|
47
52
|
|
48
53
|
logger = logging.getLogger("uvicorn.info")
|
49
54
|
|
55
|
+
# Auto-configure logging on import when requested (e.g., uvicorn reload workers)
|
56
|
+
if os.getenv("LLAMA_DEPLOY_AUTO_LOGGING", "0") == "1":
|
57
|
+
setup_logging(os.getenv("LOG_LEVEL", "INFO"))
|
58
|
+
|
50
59
|
|
51
60
|
@asynccontextmanager
|
52
61
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, Any]:
|
62
|
+
shutdown_event.clear()
|
53
63
|
apiserver_state.state("starting")
|
54
64
|
config = get_deployment_config()
|
55
65
|
|
@@ -137,6 +147,7 @@ def _setup_openapi(name: str, app: FastAPI, server: WorkflowServer) -> None:
|
|
137
147
|
app = FastAPI(lifespan=lifespan)
|
138
148
|
Instrumentator().instrument(app).expose(app)
|
139
149
|
|
150
|
+
|
140
151
|
# Configure CORS middleware if the environment variable is set
|
141
152
|
if not os.environ.get("DISABLE_CORS", False):
|
142
153
|
app.add_middleware(
|
@@ -148,6 +159,7 @@ if not os.environ.get("DISABLE_CORS", False):
|
|
148
159
|
)
|
149
160
|
|
150
161
|
app.include_router(health_router)
|
162
|
+
add_log_middleware(app)
|
151
163
|
|
152
164
|
|
153
165
|
def open_browser_async(host: str, port: int) -> None:
|
@@ -181,6 +193,7 @@ def start_server(
|
|
181
193
|
cwd: Path | None = None,
|
182
194
|
deployment_file: Path | None = None,
|
183
195
|
open_browser: bool = False,
|
196
|
+
configure_logging: bool = True,
|
184
197
|
) -> None:
|
185
198
|
# Configure via environment so uvicorn reload workers inherit the values
|
186
199
|
configure_settings(
|
@@ -199,12 +212,19 @@ def start_server(
|
|
199
212
|
try:
|
200
213
|
if open_browser:
|
201
214
|
open_browser_async(settings.host, settings.port)
|
202
|
-
|
215
|
+
# Ensure reload workers configure logging on import
|
216
|
+
os.environ["LLAMA_DEPLOY_AUTO_LOGGING"] = "1"
|
217
|
+
# Configure logging for the launcher process as well
|
218
|
+
if configure_logging:
|
219
|
+
setup_logging(os.getenv("LOG_LEVEL", "INFO"))
|
203
220
|
uvicorn.run(
|
204
221
|
"llama_deploy.appserver.app:app",
|
205
222
|
host=settings.host,
|
206
223
|
port=settings.port,
|
207
224
|
reload=reload,
|
225
|
+
timeout_graceful_shutdown=60,
|
226
|
+
access_log=False,
|
227
|
+
log_config=None,
|
208
228
|
)
|
209
229
|
finally:
|
210
230
|
if ui_process is not None:
|
@@ -219,6 +239,8 @@ def start_server_in_target_venv(
|
|
219
239
|
open_browser: bool = False,
|
220
240
|
port: int | None = None,
|
221
241
|
ui_port: int | None = None,
|
242
|
+
log_level: str | None = None,
|
243
|
+
log_format: str | None = None,
|
222
244
|
) -> None:
|
223
245
|
# Ensure settings reflect the intended working directory before computing paths
|
224
246
|
|
@@ -246,6 +268,10 @@ def start_server_in_target_venv(
|
|
246
268
|
env["LLAMA_DEPLOY_APISERVER_PORT"] = str(port)
|
247
269
|
if ui_port:
|
248
270
|
env["LLAMA_DEPLOY_APISERVER_PROXY_UI_PORT"] = str(ui_port)
|
271
|
+
if log_level:
|
272
|
+
env["LOG_LEVEL"] = log_level
|
273
|
+
if log_format:
|
274
|
+
env["LOG_FORMAT"] = log_format
|
249
275
|
|
250
276
|
ret = run_process(
|
251
277
|
args,
|
@@ -0,0 +1,189 @@
|
|
1
|
+
import logging
|
2
|
+
import logging.config
|
3
|
+
import os
|
4
|
+
import time
|
5
|
+
from contextlib import asynccontextmanager
|
6
|
+
from contextvars import ContextVar
|
7
|
+
from typing import Any, AsyncGenerator, Awaitable, Callable
|
8
|
+
|
9
|
+
import structlog
|
10
|
+
from fastapi import FastAPI, Request, Response
|
11
|
+
from llama_deploy.appserver.correlation_id import (
|
12
|
+
create_correlation_id,
|
13
|
+
get_correlation_id,
|
14
|
+
set_correlation_id,
|
15
|
+
)
|
16
|
+
from llama_deploy.appserver.process_utils import should_use_color
|
17
|
+
|
18
|
+
access_logger = logging.getLogger("app.access")
|
19
|
+
|
20
|
+
|
21
|
+
def _get_or_create_correlation_id(request: Request) -> str:
|
22
|
+
return request.headers.get("X-Request-ID", create_correlation_id())
|
23
|
+
|
24
|
+
|
25
|
+
def add_log_middleware(app: FastAPI):
|
26
|
+
@app.middleware("http")
|
27
|
+
async def add_log_id(
|
28
|
+
request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
29
|
+
):
|
30
|
+
set_correlation_id(_get_or_create_correlation_id(request))
|
31
|
+
return await call_next(request)
|
32
|
+
|
33
|
+
@app.middleware("http")
|
34
|
+
async def access_log_middleware(
|
35
|
+
request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
36
|
+
):
|
37
|
+
if _is_proxy_request(request):
|
38
|
+
return await call_next(request)
|
39
|
+
start = time.perf_counter()
|
40
|
+
response = await call_next(request)
|
41
|
+
dur_ms = (time.perf_counter() - start) * 1000
|
42
|
+
qp = str(request.query_params)
|
43
|
+
if qp:
|
44
|
+
qp = f"?{qp}"
|
45
|
+
access_logger.info(
|
46
|
+
f"{request.method} {request.url.path}{qp}",
|
47
|
+
extra={
|
48
|
+
"duration_ms": round(dur_ms, 2),
|
49
|
+
"status_code": response.status_code,
|
50
|
+
},
|
51
|
+
)
|
52
|
+
return response
|
53
|
+
|
54
|
+
|
55
|
+
def _add_request_id(_: Any, __: str, event_dict: dict[str, Any]) -> dict[str, Any]:
|
56
|
+
req_id = get_correlation_id()
|
57
|
+
if req_id and "request_id" not in event_dict:
|
58
|
+
event_dict["request_id"] = req_id
|
59
|
+
return event_dict
|
60
|
+
|
61
|
+
|
62
|
+
def _drop_uvicorn_color_message(
|
63
|
+
_: Any, __: str, event_dict: dict[str, Any]
|
64
|
+
) -> dict[str, Any]:
|
65
|
+
# Uvicorn injects an ANSI-colored duplicate of the message under this key
|
66
|
+
event_dict.pop("color_message", None)
|
67
|
+
return event_dict
|
68
|
+
|
69
|
+
|
70
|
+
def setup_logging(level: str = "INFO") -> None:
|
71
|
+
"""
|
72
|
+
Configure console logging via structlog with a compact, dev-friendly format.
|
73
|
+
Includes request_id and respects logging.extra.
|
74
|
+
"""
|
75
|
+
# Choose renderer and timestamp format based on LOG_FORMAT
|
76
|
+
log_format = os.getenv("LOG_FORMAT", "console").lower()
|
77
|
+
is_console = log_format == "console"
|
78
|
+
|
79
|
+
if log_format == "json":
|
80
|
+
renderer = structlog.processors.JSONRenderer()
|
81
|
+
timestamper = structlog.processors.TimeStamper(fmt="iso", key="timestamp")
|
82
|
+
else:
|
83
|
+
renderer = structlog.dev.ConsoleRenderer(colors=should_use_color())
|
84
|
+
timestamper = structlog.processors.TimeStamper(fmt="%H:%M:%S", key="timestamp")
|
85
|
+
|
86
|
+
pre_chain = [
|
87
|
+
structlog.contextvars.merge_contextvars,
|
88
|
+
structlog.stdlib.add_logger_name,
|
89
|
+
structlog.stdlib.add_log_level,
|
90
|
+
timestamper,
|
91
|
+
_add_request_id,
|
92
|
+
]
|
93
|
+
|
94
|
+
# Ensure stdlib logs (foreign to structlog) also include `extra={...}` fields
|
95
|
+
# and that exceptions/stack info are rendered nicely (esp. for JSON format)
|
96
|
+
foreign_pre_chain = [
|
97
|
+
*pre_chain,
|
98
|
+
structlog.stdlib.ExtraAdder(),
|
99
|
+
*( # otherwise ConsoleRenderer will render nice rich stack traces
|
100
|
+
[
|
101
|
+
structlog.processors.StackInfoRenderer(),
|
102
|
+
structlog.processors.format_exc_info,
|
103
|
+
]
|
104
|
+
if not is_console
|
105
|
+
else []
|
106
|
+
),
|
107
|
+
_drop_uvicorn_color_message,
|
108
|
+
]
|
109
|
+
|
110
|
+
structlog.configure(
|
111
|
+
processors=[
|
112
|
+
*pre_chain,
|
113
|
+
structlog.stdlib.PositionalArgumentsFormatter(),
|
114
|
+
structlog.stdlib.ExtraAdder(),
|
115
|
+
structlog.processors.StackInfoRenderer(),
|
116
|
+
structlog.processors.format_exc_info,
|
117
|
+
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
118
|
+
],
|
119
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
120
|
+
cache_logger_on_first_use=True,
|
121
|
+
)
|
122
|
+
|
123
|
+
handler = {
|
124
|
+
"class": "logging.StreamHandler",
|
125
|
+
"level": level,
|
126
|
+
"formatter": "console",
|
127
|
+
"stream": "ext://sys.stdout",
|
128
|
+
}
|
129
|
+
|
130
|
+
logging.config.dictConfig(
|
131
|
+
{
|
132
|
+
"version": 1,
|
133
|
+
"disable_existing_loggers": False,
|
134
|
+
"formatters": {
|
135
|
+
"console": {
|
136
|
+
"()": structlog.stdlib.ProcessorFormatter,
|
137
|
+
# With Rich, let it handle the final formatting; otherwise use our renderer
|
138
|
+
"processor": renderer,
|
139
|
+
"foreign_pre_chain": foreign_pre_chain,
|
140
|
+
}
|
141
|
+
},
|
142
|
+
"handlers": {"console": handler, "default": handler},
|
143
|
+
"root": {
|
144
|
+
"handlers": ["console"],
|
145
|
+
"level": level,
|
146
|
+
},
|
147
|
+
"loggers": {
|
148
|
+
"uvicorn.access": { # disable access logging, we have our own access log
|
149
|
+
"level": "WARNING",
|
150
|
+
"handlers": ["console"],
|
151
|
+
"propagate": False,
|
152
|
+
},
|
153
|
+
},
|
154
|
+
}
|
155
|
+
)
|
156
|
+
|
157
|
+
# Reduce noise from httpx globally, with fine-grained suppression controlled per-request
|
158
|
+
logging.getLogger("httpx").addFilter(_HttpxProxyNoiseFilter())
|
159
|
+
|
160
|
+
|
161
|
+
#####################################################################################
|
162
|
+
### Proxying through the fastapi server in dev mode is noisy, various suppressions
|
163
|
+
###
|
164
|
+
def _is_proxy_request(request: Request) -> bool:
|
165
|
+
parts = request.url.path.split("/")
|
166
|
+
return len(parts) >= 4 and parts[1] == "deployments" and parts[3] == "ui"
|
167
|
+
|
168
|
+
|
169
|
+
_suppress_httpx_logging: ContextVar[bool] = ContextVar(
|
170
|
+
"suppress_httpx_logging", default=False
|
171
|
+
)
|
172
|
+
|
173
|
+
|
174
|
+
class _HttpxProxyNoiseFilter(logging.Filter):
|
175
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
176
|
+
"""Return False to drop httpx info/debug logs when suppression is active."""
|
177
|
+
try:
|
178
|
+
if record.name.startswith("httpx") and record.levelno <= logging.INFO:
|
179
|
+
return not _suppress_httpx_logging.get()
|
180
|
+
except Exception:
|
181
|
+
return True
|
182
|
+
return True
|
183
|
+
|
184
|
+
|
185
|
+
@asynccontextmanager
|
186
|
+
async def suppress_httpx_logs() -> AsyncGenerator[None, None]:
|
187
|
+
_suppress_httpx_logging.set(True)
|
188
|
+
yield
|
189
|
+
_suppress_httpx_logging.set(False)
|
@@ -0,0 +1,24 @@
|
|
1
|
+
import random
|
2
|
+
import string
|
3
|
+
from contextvars import ContextVar
|
4
|
+
|
5
|
+
correlation_id_var: ContextVar[str] = ContextVar("correlation_id", default="")
|
6
|
+
|
7
|
+
|
8
|
+
def get_correlation_id() -> str:
|
9
|
+
return correlation_id_var.get()
|
10
|
+
|
11
|
+
|
12
|
+
def set_correlation_id(correlation_id: str) -> None:
|
13
|
+
correlation_id_var.set(correlation_id)
|
14
|
+
|
15
|
+
|
16
|
+
def create_correlation_id() -> str:
|
17
|
+
return random_alphanumeric_string(8)
|
18
|
+
|
19
|
+
|
20
|
+
_alphanumeric_chars = string.ascii_letters + string.digits
|
21
|
+
|
22
|
+
|
23
|
+
def random_alphanumeric_string(length: int) -> str:
|
24
|
+
return "".join(random.choices(_alphanumeric_chars, k=length))
|
@@ -0,0 +1,55 @@
|
|
1
|
+
import asyncio
|
2
|
+
import signal
|
3
|
+
from asyncio import Event
|
4
|
+
from contextlib import suppress
|
5
|
+
from typing import Awaitable, TypeVar
|
6
|
+
|
7
|
+
shutdown_event = Event()
|
8
|
+
|
9
|
+
|
10
|
+
def setup_interrupts() -> None:
|
11
|
+
loop = asyncio.get_running_loop()
|
12
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
13
|
+
loop.add_signal_handler(sig, shutdown_event.set)
|
14
|
+
|
15
|
+
|
16
|
+
class OperationAborted(Exception):
|
17
|
+
"""Raised when an operation is aborted due to shutdown/interrupt."""
|
18
|
+
|
19
|
+
|
20
|
+
T = TypeVar("T")
|
21
|
+
|
22
|
+
|
23
|
+
async def wait_or_abort(
|
24
|
+
awaitable: Awaitable[T], shutdown_event: asyncio.Event = shutdown_event
|
25
|
+
) -> T:
|
26
|
+
"""Await an operation, aborting early if shutdown is requested.
|
27
|
+
|
28
|
+
If the shutdown event is set before the awaitable completes, cancel the
|
29
|
+
awaitable and raise OperationAborted. Otherwise, return the awaitable's result.
|
30
|
+
"""
|
31
|
+
event = shutdown_event
|
32
|
+
if event.is_set():
|
33
|
+
raise OperationAborted()
|
34
|
+
|
35
|
+
op_task = asyncio.create_task(awaitable)
|
36
|
+
stop_task = asyncio.create_task(event.wait())
|
37
|
+
try:
|
38
|
+
done, _ = await asyncio.wait(
|
39
|
+
{op_task, stop_task}, return_when=asyncio.FIRST_COMPLETED
|
40
|
+
)
|
41
|
+
if stop_task in done:
|
42
|
+
op_task.cancel()
|
43
|
+
with suppress(asyncio.CancelledError):
|
44
|
+
await op_task
|
45
|
+
raise OperationAborted()
|
46
|
+
# Operation finished first
|
47
|
+
stop_task.cancel()
|
48
|
+
with suppress(asyncio.CancelledError):
|
49
|
+
await stop_task
|
50
|
+
return await op_task
|
51
|
+
finally:
|
52
|
+
# Ensure no leaked tasks if an exception propagates
|
53
|
+
for t in (op_task, stop_task):
|
54
|
+
if not t.done():
|
55
|
+
t.cancel()
|
@@ -103,7 +103,11 @@ def _should_use_pty(use_tty: bool | None) -> bool:
|
|
103
103
|
return False
|
104
104
|
if use_tty is None:
|
105
105
|
return sys.stdout.isatty()
|
106
|
-
return use_tty and sys.stdout.isatty()
|
106
|
+
return use_tty and sys.stdout.isatty() and not os.environ.get("NO_COLOR")
|
107
|
+
|
108
|
+
|
109
|
+
def should_use_color() -> bool:
|
110
|
+
return _should_use_pty(None)
|
107
111
|
|
108
112
|
|
109
113
|
def _spawn_process(
|
File without changes
|
@@ -1,5 +1,6 @@
|
|
1
1
|
import asyncio
|
2
2
|
import logging
|
3
|
+
from contextlib import suppress
|
3
4
|
from typing import List
|
4
5
|
|
5
6
|
import httpx
|
@@ -13,51 +14,63 @@ from fastapi import (
|
|
13
14
|
)
|
14
15
|
from fastapi.responses import StreamingResponse
|
15
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
|
+
)
|
16
23
|
from llama_deploy.appserver.settings import ApiserverSettings
|
17
24
|
from llama_deploy.core.deployment_config import DeploymentConfig
|
18
|
-
from starlette.background import BackgroundTask
|
19
25
|
|
20
26
|
logger = logging.getLogger(__name__)
|
21
27
|
|
22
28
|
|
23
29
|
async def _ws_proxy(ws: WebSocket, upstream_url: str) -> None:
|
24
30
|
"""Proxy WebSocket connection to upstream server."""
|
25
|
-
|
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
|
26
36
|
|
27
37
|
# Forward most headers except WebSocket-specific ones
|
38
|
+
header_prefix_blacklist = ["sec-websocket-"]
|
28
39
|
header_blacklist = {
|
29
40
|
"host",
|
30
41
|
"connection",
|
31
42
|
"upgrade",
|
32
|
-
"sec-websocket-key",
|
33
|
-
"sec-websocket-version",
|
34
|
-
"sec-websocket-extensions",
|
35
43
|
}
|
36
|
-
hdrs = [
|
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))
|
37
52
|
|
38
53
|
try:
|
39
54
|
# Parse subprotocols if present
|
40
|
-
subprotocols: List[
|
41
|
-
|
42
|
-
|
43
|
-
subprotocols
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
# Open upstream WebSocket connection
|
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
|
49
62
|
async with websockets.connect(
|
50
63
|
upstream_url,
|
51
64
|
additional_headers=hdrs,
|
52
65
|
subprotocols=subprotocols,
|
53
|
-
open_timeout=
|
54
|
-
ping_interval=None,
|
66
|
+
open_timeout=5,
|
55
67
|
) as upstream:
|
68
|
+
await ws.accept(subprotocol=upstream.subprotocol)
|
56
69
|
|
57
70
|
async def client_to_upstream() -> None:
|
58
71
|
try:
|
59
72
|
while True:
|
60
|
-
msg = await ws.receive()
|
73
|
+
msg = await wait_or_abort(ws.receive(), shutdown_event)
|
61
74
|
if msg["type"] == "websocket.receive":
|
62
75
|
if "text" in msg:
|
63
76
|
await upstream.send(msg["text"])
|
@@ -65,26 +78,49 @@ async def _ws_proxy(ws: WebSocket, upstream_url: str) -> None:
|
|
65
78
|
await upstream.send(msg["bytes"])
|
66
79
|
elif msg["type"] == "websocket.disconnect":
|
67
80
|
break
|
68
|
-
except
|
69
|
-
|
81
|
+
except OperationAborted:
|
82
|
+
pass
|
83
|
+
except Exception:
|
84
|
+
pass
|
70
85
|
|
71
86
|
async def upstream_to_client() -> None:
|
72
87
|
try:
|
73
|
-
|
88
|
+
while True:
|
89
|
+
message = await wait_or_abort(upstream.recv(), shutdown_event)
|
74
90
|
if isinstance(message, str):
|
75
91
|
await ws.send_text(message)
|
76
92
|
else:
|
77
93
|
await ws.send_bytes(message)
|
78
|
-
except
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
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
|
84
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()
|
85
116
|
|
86
117
|
except Exception as e:
|
87
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()
|
88
124
|
finally:
|
89
125
|
try:
|
90
126
|
await ws.close()
|
@@ -105,7 +141,7 @@ def create_ui_proxy_router(name: str, port: int) -> APIRouter:
|
|
105
141
|
path: str | None = None,
|
106
142
|
) -> None:
|
107
143
|
# Build the upstream WebSocket URL using FastAPI's extracted path parameter
|
108
|
-
slash_path = f"/{path}" if path else ""
|
144
|
+
slash_path = f"/{path}" if path is not None else ""
|
109
145
|
upstream_path = f"/deployments/{name}/ui{slash_path}"
|
110
146
|
|
111
147
|
# Convert to WebSocket URL
|
@@ -113,8 +149,6 @@ def create_ui_proxy_router(name: str, port: int) -> APIRouter:
|
|
113
149
|
if websocket.url.query:
|
114
150
|
upstream_url += f"?{websocket.url.query}"
|
115
151
|
|
116
|
-
logger.debug(f"Proxying WebSocket {websocket.url} -> {upstream_url}")
|
117
|
-
|
118
152
|
await _ws_proxy(websocket, upstream_url)
|
119
153
|
|
120
154
|
@deployment_router.api_route(
|
@@ -167,22 +201,28 @@ def create_ui_proxy_router(name: str, port: int) -> APIRouter:
|
|
167
201
|
headers=headers,
|
168
202
|
content=request.stream(), # stream uploads
|
169
203
|
)
|
170
|
-
|
204
|
+
async with suppress_httpx_logs():
|
205
|
+
upstream = await client.send(req, stream=True)
|
171
206
|
|
172
207
|
resp_headers = {
|
173
208
|
k: v for k, v in upstream.headers.items() if k.lower() not in hop_by_hop
|
174
209
|
}
|
175
210
|
|
176
|
-
#
|
177
|
-
async def
|
178
|
-
|
179
|
-
|
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()
|
180
221
|
|
181
222
|
return StreamingResponse(
|
182
|
-
|
223
|
+
upstream_body(),
|
183
224
|
status_code=upstream.status_code,
|
184
225
|
headers=resp_headers,
|
185
|
-
background=BackgroundTask(cleanup), # tidy up when finished
|
186
226
|
)
|
187
227
|
|
188
228
|
except httpx.ConnectError:
|
@@ -1,3 +1,4 @@
|
|
1
|
+
import configparser
|
1
2
|
import functools
|
2
3
|
import importlib
|
3
4
|
import logging
|
@@ -5,8 +6,10 @@ import os
|
|
5
6
|
import socket
|
6
7
|
import subprocess
|
7
8
|
import sys
|
9
|
+
from dataclasses import dataclass
|
8
10
|
from importlib.metadata import version as pkg_version
|
9
11
|
from pathlib import Path
|
12
|
+
from textwrap import dedent
|
10
13
|
|
11
14
|
from dotenv import dotenv_values
|
12
15
|
from llama_deploy.appserver.deployment_config_parser import (
|
@@ -115,9 +118,16 @@ def _get_installed_version_within_target(path: Path) -> Version | None:
|
|
115
118
|
"run",
|
116
119
|
"python",
|
117
120
|
"-c",
|
118
|
-
|
121
|
+
dedent("""
|
122
|
+
from importlib.metadata import version
|
123
|
+
try:
|
124
|
+
print(version("llama-deploy-appserver"))
|
125
|
+
except Exception:
|
126
|
+
pass
|
127
|
+
"""),
|
119
128
|
],
|
120
129
|
cwd=path,
|
130
|
+
stderr=subprocess.DEVNULL,
|
121
131
|
)
|
122
132
|
try:
|
123
133
|
return Version(result.decode("utf-8").strip())
|
@@ -194,6 +204,14 @@ def _install_and_add_appserver_if_missing(
|
|
194
204
|
+ ["--prefix", str(venv_path)],
|
195
205
|
)
|
196
206
|
elif are_we_editable_mode():
|
207
|
+
same_python_version = _same_python_version(venv_path)
|
208
|
+
if not same_python_version.is_same:
|
209
|
+
logger.error(
|
210
|
+
f"Python version mismatch. Current: {same_python_version.current_version} != Project: {same_python_version.target_version}. During development, the target environment must be running the same Python version, otherwise the appserver cannot be installed."
|
211
|
+
)
|
212
|
+
raise RuntimeError(
|
213
|
+
f"Python version mismatch. Current: {same_python_version.current_version} != Project: {same_python_version.target_version}"
|
214
|
+
)
|
197
215
|
pyproject = _find_development_pyproject()
|
198
216
|
if pyproject is None:
|
199
217
|
raise RuntimeError("No pyproject.toml found in llama-deploy-appserver")
|
@@ -274,16 +292,53 @@ def _ensure_uv_available() -> None:
|
|
274
292
|
raise RuntimeError(msg)
|
275
293
|
|
276
294
|
|
295
|
+
@dataclass
|
296
|
+
class SamePythonVersionResult:
|
297
|
+
is_same: bool
|
298
|
+
current_version: str
|
299
|
+
target_version: str | None
|
300
|
+
|
301
|
+
|
302
|
+
def _same_python_version(venv_path: Path) -> SamePythonVersionResult:
|
303
|
+
current_version = f"{sys.version_info.major}.{sys.version_info.minor}"
|
304
|
+
target_version = None
|
305
|
+
cfg = venv_path / "pyvenv.cfg"
|
306
|
+
if cfg.exists():
|
307
|
+
parser = configparser.ConfigParser()
|
308
|
+
parser.read_string("[venv]\n" + cfg.read_text())
|
309
|
+
ver_str = parser["venv"].get("version_info", "").strip()
|
310
|
+
if ver_str:
|
311
|
+
try:
|
312
|
+
v = Version(ver_str)
|
313
|
+
target_version = f"{v.major}.{v.minor}"
|
314
|
+
except InvalidVersion:
|
315
|
+
pass
|
316
|
+
return SamePythonVersionResult(
|
317
|
+
is_same=current_version == target_version,
|
318
|
+
current_version=current_version,
|
319
|
+
target_version=target_version,
|
320
|
+
)
|
321
|
+
|
322
|
+
|
277
323
|
def install_ui(config: DeploymentConfig, config_parent: Path) -> None:
|
278
324
|
if config.ui is None:
|
279
325
|
return
|
280
326
|
package_manager = config.ui.package_manager
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
327
|
+
try:
|
328
|
+
run_process(
|
329
|
+
[package_manager, "install"],
|
330
|
+
cwd=config_parent / config.ui.directory,
|
331
|
+
prefix=f"[{package_manager} install]",
|
332
|
+
color_code="33",
|
333
|
+
# auto download the package manager
|
334
|
+
env={**os.environ.copy(), "COREPACK_ENABLE_DOWNLOAD_PROMPT": "0"},
|
335
|
+
)
|
336
|
+
except BaseException as e:
|
337
|
+
if "No such file or directory" in str(e):
|
338
|
+
raise RuntimeError(
|
339
|
+
f"Package manager {package_manager} not found. Please download and enable corepack, or install the package manager manually."
|
340
|
+
)
|
341
|
+
raise e
|
287
342
|
|
288
343
|
|
289
344
|
def _ui_env(config: DeploymentConfig, settings: ApiserverSettings) -> dict[str, str]:
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|