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.
- llama_deploy/appserver/app.py +274 -26
- llama_deploy/appserver/bootstrap.py +55 -25
- llama_deploy/appserver/configure_logging.py +189 -0
- llama_deploy/appserver/correlation_id.py +24 -0
- llama_deploy/appserver/deployment.py +70 -412
- llama_deploy/appserver/deployment_config_parser.py +12 -130
- llama_deploy/appserver/interrupts.py +55 -0
- llama_deploy/appserver/process_utils.py +214 -0
- llama_deploy/appserver/py.typed +0 -0
- llama_deploy/appserver/routers/__init__.py +4 -3
- llama_deploy/appserver/routers/deployments.py +163 -382
- llama_deploy/appserver/routers/status.py +4 -31
- llama_deploy/appserver/routers/ui_proxy.py +255 -0
- llama_deploy/appserver/settings.py +99 -49
- llama_deploy/appserver/types.py +0 -3
- llama_deploy/appserver/workflow_loader.py +431 -0
- llama_deploy/appserver/workflow_store/agent_data_store.py +100 -0
- llama_deploy/appserver/workflow_store/keyed_lock.py +32 -0
- llama_deploy/appserver/workflow_store/lru_cache.py +49 -0
- llama_deploy_appserver-0.3.0.dist-info/METADATA +25 -0
- llama_deploy_appserver-0.3.0.dist-info/RECORD +24 -0
- {llama_deploy_appserver-0.2.7a1.dist-info → llama_deploy_appserver-0.3.0.dist-info}/WHEEL +1 -1
- llama_deploy/appserver/__main__.py +0 -14
- llama_deploy/appserver/client/__init__.py +0 -3
- llama_deploy/appserver/client/base.py +0 -30
- llama_deploy/appserver/client/client.py +0 -49
- llama_deploy/appserver/client/models/__init__.py +0 -4
- llama_deploy/appserver/client/models/apiserver.py +0 -356
- llama_deploy/appserver/client/models/model.py +0 -82
- llama_deploy/appserver/run_autodeploy.py +0 -141
- llama_deploy/appserver/server.py +0 -60
- llama_deploy/appserver/source_managers/__init__.py +0 -5
- llama_deploy/appserver/source_managers/base.py +0 -33
- llama_deploy/appserver/source_managers/git.py +0 -48
- llama_deploy/appserver/source_managers/local.py +0 -51
- llama_deploy/appserver/tracing.py +0 -237
- llama_deploy_appserver-0.2.7a1.dist-info/METADATA +0 -23
- llama_deploy_appserver-0.2.7a1.dist-info/RECORD +0 -28
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import configparser
|
|
2
|
+
import functools
|
|
3
|
+
import importlib
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import socket
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from importlib.metadata import version as pkg_version
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from textwrap import dedent
|
|
13
|
+
|
|
14
|
+
from dotenv import dotenv_values
|
|
15
|
+
from llama_deploy.appserver.deployment_config_parser import (
|
|
16
|
+
DeploymentConfig,
|
|
17
|
+
)
|
|
18
|
+
from llama_deploy.appserver.process_utils import run_process, spawn_process
|
|
19
|
+
from llama_deploy.appserver.settings import ApiserverSettings, settings
|
|
20
|
+
from llama_deploy.core.ui_build import ui_build_output_path
|
|
21
|
+
from packaging.version import InvalidVersion, Version
|
|
22
|
+
from workflows import Workflow
|
|
23
|
+
from workflows.server import WorkflowServer
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
DEFAULT_SERVICE_ID = "default"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_workflows(config: DeploymentConfig) -> dict[str, Workflow]:
|
|
31
|
+
"""
|
|
32
|
+
Creates WorkflowService instances according to the configuration object.
|
|
33
|
+
|
|
34
|
+
"""
|
|
35
|
+
workflow_services: dict[str, Workflow] = {}
|
|
36
|
+
|
|
37
|
+
if config.app:
|
|
38
|
+
module_name, app_name = config.app.split(":", 1)
|
|
39
|
+
module = importlib.import_module(module_name)
|
|
40
|
+
workflow = getattr(module, app_name)
|
|
41
|
+
if not isinstance(workflow, WorkflowServer):
|
|
42
|
+
raise ValueError(
|
|
43
|
+
f"Workflow {app_name} in {module_name} is not a WorkflowServer object"
|
|
44
|
+
)
|
|
45
|
+
# kludge to get the workflows
|
|
46
|
+
workflow_services = workflow._workflows
|
|
47
|
+
else:
|
|
48
|
+
for service_id, workflow_name in config.workflows.items():
|
|
49
|
+
module_name, workflow_name = workflow_name.split(":", 1)
|
|
50
|
+
module = importlib.import_module(module_name)
|
|
51
|
+
workflow = getattr(module, workflow_name)
|
|
52
|
+
if not isinstance(workflow, Workflow):
|
|
53
|
+
logger.warning(
|
|
54
|
+
f"Workflow {workflow_name} in {module_name} is not a Workflow object",
|
|
55
|
+
)
|
|
56
|
+
workflow_services[service_id] = workflow
|
|
57
|
+
|
|
58
|
+
return workflow_services
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_environment_variables(config: DeploymentConfig, source_root: Path) -> None:
|
|
62
|
+
"""
|
|
63
|
+
Load environment variables from the deployment config.
|
|
64
|
+
"""
|
|
65
|
+
for key, value in parse_environment_variables(config, source_root).items():
|
|
66
|
+
if value:
|
|
67
|
+
os.environ[key] = value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse_environment_variables(
|
|
71
|
+
config: DeploymentConfig, source_root: Path
|
|
72
|
+
) -> dict[str, str]:
|
|
73
|
+
"""
|
|
74
|
+
Parse environment variables from the deployment config.
|
|
75
|
+
"""
|
|
76
|
+
env_vars = {**config.env} if config.env else {}
|
|
77
|
+
for env_file in config.env_files or []:
|
|
78
|
+
env_file_path = source_root / env_file
|
|
79
|
+
values = dotenv_values(env_file_path)
|
|
80
|
+
env_vars.update(**values)
|
|
81
|
+
return env_vars
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@functools.cache
|
|
85
|
+
def are_we_editable_mode() -> bool:
|
|
86
|
+
"""
|
|
87
|
+
Check if we're in editable mode.
|
|
88
|
+
"""
|
|
89
|
+
# Heuristic: if the package path does not include 'site-packages', treat as editable
|
|
90
|
+
top_level_pkg = "llama_deploy.appserver"
|
|
91
|
+
try:
|
|
92
|
+
pkg = importlib.import_module(top_level_pkg)
|
|
93
|
+
pkg_path = Path(getattr(pkg, "__file__", "")).resolve()
|
|
94
|
+
if not pkg_path.exists():
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
return "site-packages" not in pkg_path.parts
|
|
98
|
+
except Exception:
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def inject_appserver_into_target(
|
|
103
|
+
config: DeploymentConfig, source_root: Path, sdists: list[Path] | None = None
|
|
104
|
+
) -> None:
|
|
105
|
+
"""
|
|
106
|
+
Ensures uv, and uses it to add the appserver as a dependency to the target app.
|
|
107
|
+
- If sdists are provided, they will be installed directly for offline-ish installs (still fetches dependencies)
|
|
108
|
+
- If the appserver is currently editable, it will be installed directly from the source repo
|
|
109
|
+
- otherwise fetches the current version from pypi
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
config: The deployment config
|
|
113
|
+
source_root: The root directory of the deployment
|
|
114
|
+
sdists: A list of tar.gz sdists files to install instead of installing the appserver
|
|
115
|
+
"""
|
|
116
|
+
path = settings.resolved_config_parent
|
|
117
|
+
logger.info(f"Installing ensuring venv at {path} and adding appserver to it")
|
|
118
|
+
_ensure_uv_available()
|
|
119
|
+
_install_and_add_appserver_if_missing(path, source_root, sdists=sdists)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _get_installed_version_within_target(path: Path) -> Version | None:
|
|
123
|
+
try:
|
|
124
|
+
result = subprocess.check_output(
|
|
125
|
+
[
|
|
126
|
+
"uv",
|
|
127
|
+
"run",
|
|
128
|
+
"python",
|
|
129
|
+
"-c",
|
|
130
|
+
dedent("""
|
|
131
|
+
from importlib.metadata import version
|
|
132
|
+
try:
|
|
133
|
+
print(version("llama-deploy-appserver"))
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
"""),
|
|
137
|
+
],
|
|
138
|
+
cwd=path,
|
|
139
|
+
stderr=subprocess.DEVNULL,
|
|
140
|
+
)
|
|
141
|
+
try:
|
|
142
|
+
return Version(result.decode("utf-8").strip())
|
|
143
|
+
except InvalidVersion:
|
|
144
|
+
return None
|
|
145
|
+
except subprocess.CalledProcessError:
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _get_current_version() -> Version:
|
|
150
|
+
return Version(pkg_version("llama-deploy-appserver"))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _is_missing_or_outdated(path: Path) -> Version | None:
|
|
154
|
+
"""
|
|
155
|
+
returns the current version if the installed version is missing or outdated, otherwise None
|
|
156
|
+
"""
|
|
157
|
+
installed = _get_installed_version_within_target(path)
|
|
158
|
+
current = _get_current_version()
|
|
159
|
+
if installed is None or installed < current:
|
|
160
|
+
return current
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _install_and_add_appserver_if_missing(
|
|
165
|
+
path: Path,
|
|
166
|
+
source_root: Path,
|
|
167
|
+
save_version: bool = False,
|
|
168
|
+
sdists: list[Path] | None = None,
|
|
169
|
+
) -> None:
|
|
170
|
+
"""
|
|
171
|
+
Ensure venv, install project deps, and add the appserver to the venv if it's missing or outdated
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
if not (source_root / path / "pyproject.toml").exists():
|
|
175
|
+
logger.warning(
|
|
176
|
+
f"No pyproject.toml found at {source_root / path}, skipping appserver injection. The server will likely not be able to install your workflows."
|
|
177
|
+
)
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
def run_uv(cmd: str, args: list[str] = [], extra_env: dict[str, str] | None = None):
|
|
181
|
+
env = os.environ.copy()
|
|
182
|
+
if extra_env:
|
|
183
|
+
env.update(extra_env)
|
|
184
|
+
run_process(
|
|
185
|
+
["uv", cmd] + args,
|
|
186
|
+
cwd=source_root / path,
|
|
187
|
+
prefix=f"[uv {cmd}]",
|
|
188
|
+
color_code="36",
|
|
189
|
+
use_tty=False,
|
|
190
|
+
line_transform=_exclude_venv_warning,
|
|
191
|
+
env=env,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def ensure_venv(path: Path, force: bool = False) -> Path:
|
|
195
|
+
venv_path = source_root / path / ".venv"
|
|
196
|
+
if force or not venv_path.exists():
|
|
197
|
+
run_uv("venv", [str(venv_path)])
|
|
198
|
+
return venv_path
|
|
199
|
+
|
|
200
|
+
editable = are_we_editable_mode()
|
|
201
|
+
venv_path = ensure_venv(path, force=editable)
|
|
202
|
+
run_uv(
|
|
203
|
+
"sync",
|
|
204
|
+
["--no-dev", "--inexact"],
|
|
205
|
+
extra_env={"UV_PROJECT_ENVIRONMENT": str(venv_path)},
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
if sdists:
|
|
209
|
+
run_uv(
|
|
210
|
+
"pip",
|
|
211
|
+
["install"]
|
|
212
|
+
+ [str(s.absolute()) for s in sdists]
|
|
213
|
+
+ ["--prefix", str(venv_path)],
|
|
214
|
+
)
|
|
215
|
+
elif are_we_editable_mode():
|
|
216
|
+
same_python_version = _same_python_version(venv_path)
|
|
217
|
+
if not same_python_version.is_same:
|
|
218
|
+
logger.error(
|
|
219
|
+
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."
|
|
220
|
+
)
|
|
221
|
+
raise RuntimeError(
|
|
222
|
+
f"Python version mismatch. Current: {same_python_version.current_version} != Project: {same_python_version.target_version}"
|
|
223
|
+
)
|
|
224
|
+
pyproject = _find_development_pyproject()
|
|
225
|
+
if pyproject is None:
|
|
226
|
+
raise RuntimeError("No pyproject.toml found in llama-deploy-appserver")
|
|
227
|
+
base = (source_root.resolve() / path).resolve()
|
|
228
|
+
rel = Path(os.path.relpath(pyproject, start=base))
|
|
229
|
+
target = f"file://{str(rel)}"
|
|
230
|
+
|
|
231
|
+
run_uv(
|
|
232
|
+
"pip",
|
|
233
|
+
[
|
|
234
|
+
"install",
|
|
235
|
+
"--reinstall-package",
|
|
236
|
+
"llama-deploy-appserver",
|
|
237
|
+
target,
|
|
238
|
+
"--prefix",
|
|
239
|
+
str(venv_path),
|
|
240
|
+
],
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
else:
|
|
244
|
+
version = _is_missing_or_outdated(path)
|
|
245
|
+
if version is not None:
|
|
246
|
+
if save_version:
|
|
247
|
+
run_uv("add", [f"llama-deploy-appserver>={version}"])
|
|
248
|
+
else:
|
|
249
|
+
run_uv(
|
|
250
|
+
"pip",
|
|
251
|
+
[
|
|
252
|
+
"install",
|
|
253
|
+
f"llama-deploy-appserver=={version}",
|
|
254
|
+
"--prefix",
|
|
255
|
+
str(venv_path),
|
|
256
|
+
],
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _find_development_pyproject() -> Path | None:
|
|
261
|
+
dir = Path(__file__).parent.resolve()
|
|
262
|
+
while not (dir / "pyproject.toml").exists():
|
|
263
|
+
dir = dir.parent
|
|
264
|
+
if dir == dir.root:
|
|
265
|
+
return None
|
|
266
|
+
return dir
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _exclude_venv_warning(line: str) -> str | None:
|
|
270
|
+
if "use `--active` to target the active environment instead" in line:
|
|
271
|
+
return None
|
|
272
|
+
return line
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _ensure_uv_available() -> None:
|
|
276
|
+
# Check if uv is available on the path
|
|
277
|
+
uv_available = False
|
|
278
|
+
try:
|
|
279
|
+
subprocess.check_call(
|
|
280
|
+
["uv", "--version"],
|
|
281
|
+
stdout=subprocess.DEVNULL,
|
|
282
|
+
stderr=subprocess.DEVNULL,
|
|
283
|
+
)
|
|
284
|
+
uv_available = True
|
|
285
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
286
|
+
pass
|
|
287
|
+
if not uv_available:
|
|
288
|
+
# bootstrap uv with pip
|
|
289
|
+
try:
|
|
290
|
+
run_process(
|
|
291
|
+
[
|
|
292
|
+
sys.executable,
|
|
293
|
+
"-m",
|
|
294
|
+
"pip",
|
|
295
|
+
"install",
|
|
296
|
+
"uv",
|
|
297
|
+
],
|
|
298
|
+
prefix="[python -m pip]",
|
|
299
|
+
color_code="31", # red
|
|
300
|
+
)
|
|
301
|
+
except subprocess.CalledProcessError as e:
|
|
302
|
+
msg = f"Unable to install uv. Environment must include uv, or uv must be installed with pip: {e.stderr}"
|
|
303
|
+
raise RuntimeError(msg)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@dataclass
|
|
307
|
+
class SamePythonVersionResult:
|
|
308
|
+
is_same: bool
|
|
309
|
+
current_version: str
|
|
310
|
+
target_version: str | None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _same_python_version(venv_path: Path) -> SamePythonVersionResult:
|
|
314
|
+
current_version = f"{sys.version_info.major}.{sys.version_info.minor}"
|
|
315
|
+
target_version = None
|
|
316
|
+
cfg = venv_path / "pyvenv.cfg"
|
|
317
|
+
if cfg.exists():
|
|
318
|
+
parser = configparser.ConfigParser()
|
|
319
|
+
parser.read_string("[venv]\n" + cfg.read_text())
|
|
320
|
+
ver_str = parser["venv"].get("version_info", "").strip()
|
|
321
|
+
if ver_str:
|
|
322
|
+
try:
|
|
323
|
+
v = Version(ver_str)
|
|
324
|
+
target_version = f"{v.major}.{v.minor}"
|
|
325
|
+
except InvalidVersion:
|
|
326
|
+
pass
|
|
327
|
+
return SamePythonVersionResult(
|
|
328
|
+
is_same=current_version == target_version,
|
|
329
|
+
current_version=current_version,
|
|
330
|
+
target_version=target_version,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def install_ui(config: DeploymentConfig, config_parent: Path) -> None:
|
|
335
|
+
if config.ui is None:
|
|
336
|
+
return
|
|
337
|
+
package_manager = config.ui.package_manager
|
|
338
|
+
try:
|
|
339
|
+
run_process(
|
|
340
|
+
[package_manager, "install"],
|
|
341
|
+
cwd=config_parent / config.ui.directory,
|
|
342
|
+
prefix=f"[{package_manager} install]",
|
|
343
|
+
color_code="33",
|
|
344
|
+
# auto download the package manager
|
|
345
|
+
env={**os.environ.copy(), "COREPACK_ENABLE_DOWNLOAD_PROMPT": "0"},
|
|
346
|
+
)
|
|
347
|
+
except BaseException as e:
|
|
348
|
+
if "No such file or directory" in str(e):
|
|
349
|
+
raise RuntimeError(
|
|
350
|
+
f"Package manager {package_manager} not found. Please download and enable corepack, or install the package manager manually."
|
|
351
|
+
)
|
|
352
|
+
raise e
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _ui_env(config: DeploymentConfig, settings: ApiserverSettings) -> dict[str, str]:
|
|
356
|
+
env = os.environ.copy()
|
|
357
|
+
env["LLAMA_DEPLOY_DEPLOYMENT_URL_ID"] = config.name
|
|
358
|
+
env["LLAMA_DEPLOY_DEPLOYMENT_BASE_PATH"] = f"/deployments/{config.name}/ui"
|
|
359
|
+
if config.ui is not None:
|
|
360
|
+
env["PORT"] = str(settings.proxy_ui_port)
|
|
361
|
+
return env
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def build_ui(
|
|
365
|
+
config_parent: Path, config: DeploymentConfig, settings: ApiserverSettings
|
|
366
|
+
) -> bool:
|
|
367
|
+
"""
|
|
368
|
+
Returns True if the UI was built (and supports building), otherwise False if there's no build command
|
|
369
|
+
"""
|
|
370
|
+
if config.ui is None:
|
|
371
|
+
return False
|
|
372
|
+
path = Path(config.ui.directory)
|
|
373
|
+
env = _ui_env(config, settings)
|
|
374
|
+
|
|
375
|
+
has_build = ui_build_output_path(config_parent, config)
|
|
376
|
+
if has_build is None:
|
|
377
|
+
return False
|
|
378
|
+
|
|
379
|
+
run_process(
|
|
380
|
+
["npm", "run", "build"],
|
|
381
|
+
cwd=config_parent / path,
|
|
382
|
+
env=env,
|
|
383
|
+
prefix="[npm run build]",
|
|
384
|
+
color_code="34",
|
|
385
|
+
)
|
|
386
|
+
return True
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def start_dev_ui_process(
|
|
390
|
+
root: Path, settings: ApiserverSettings, config: DeploymentConfig
|
|
391
|
+
) -> None | subprocess.Popen:
|
|
392
|
+
ui_port = settings.proxy_ui_port
|
|
393
|
+
ui = config.ui
|
|
394
|
+
if ui is None:
|
|
395
|
+
return None
|
|
396
|
+
|
|
397
|
+
# If a UI dev server is already listening on the configured port, do not start another
|
|
398
|
+
def _is_port_open(port: int) -> bool:
|
|
399
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
400
|
+
sock.settimeout(0.2)
|
|
401
|
+
try:
|
|
402
|
+
return sock.connect_ex(("127.0.0.1", port)) == 0
|
|
403
|
+
except Exception:
|
|
404
|
+
return False
|
|
405
|
+
|
|
406
|
+
if _is_port_open(ui_port):
|
|
407
|
+
logger.info(
|
|
408
|
+
f"Detected process already running on port {ui_port}; not starting a new one."
|
|
409
|
+
)
|
|
410
|
+
return None
|
|
411
|
+
# start the ui process
|
|
412
|
+
env = _ui_env(config, settings)
|
|
413
|
+
# Transform first 20 lines to replace the default UI port with the main server port
|
|
414
|
+
line_counter = 0
|
|
415
|
+
|
|
416
|
+
def _transform(line: str) -> str:
|
|
417
|
+
nonlocal line_counter
|
|
418
|
+
if line_counter < 20:
|
|
419
|
+
line = line.replace(f":{ui_port}", f":{settings.port}")
|
|
420
|
+
line_counter += 1
|
|
421
|
+
return line
|
|
422
|
+
|
|
423
|
+
return spawn_process(
|
|
424
|
+
["npm", "run", ui.serve_command],
|
|
425
|
+
cwd=root / (ui.directory),
|
|
426
|
+
env=env,
|
|
427
|
+
prefix=f"[npm run {ui.serve_command}]",
|
|
428
|
+
color_code="35",
|
|
429
|
+
line_transform=_transform,
|
|
430
|
+
use_tty=False,
|
|
431
|
+
)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
from llama_cloud.client import AsyncLlamaCloud, httpx
|
|
6
|
+
from llama_cloud_services.beta.agent_data import AsyncAgentDataClient
|
|
7
|
+
from llama_deploy.appserver.settings import ApiserverSettings
|
|
8
|
+
from llama_deploy.core.deployment_config import DeploymentConfig
|
|
9
|
+
from typing_extensions import override
|
|
10
|
+
from workflows.server import AbstractWorkflowStore, HandlerQuery, PersistentHandler
|
|
11
|
+
|
|
12
|
+
from .keyed_lock import AsyncKeyedLock
|
|
13
|
+
from .lru_cache import LRUCache
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AgentDataStore(AbstractWorkflowStore):
|
|
19
|
+
def __init__(
|
|
20
|
+
self, settings: DeploymentConfig, server_settings: ApiserverSettings
|
|
21
|
+
) -> None:
|
|
22
|
+
agent_url_id: str | None = server_settings.cloud_persistence_name
|
|
23
|
+
collection = "workflow_contexts"
|
|
24
|
+
if agent_url_id is not None:
|
|
25
|
+
parts = agent_url_id.split(":")
|
|
26
|
+
if len(parts) > 1:
|
|
27
|
+
collection = parts[1]
|
|
28
|
+
agent_url_id = parts[0]
|
|
29
|
+
else:
|
|
30
|
+
agent_url_id = settings.name
|
|
31
|
+
|
|
32
|
+
self.settings = settings
|
|
33
|
+
project_id = os.getenv("LLAMA_DEPLOY_PROJECT_ID")
|
|
34
|
+
self.client = AsyncAgentDataClient(
|
|
35
|
+
type=PersistentHandler,
|
|
36
|
+
collection=collection,
|
|
37
|
+
agent_url_id=agent_url_id,
|
|
38
|
+
client=AsyncLlamaCloud(
|
|
39
|
+
base_url=os.getenv("LLAMA_CLOUD_BASE_URL"),
|
|
40
|
+
token=os.getenv("LLAMA_CLOUD_API_KEY"),
|
|
41
|
+
httpx_client=httpx.AsyncClient(
|
|
42
|
+
headers={"Project-Id": project_id} if project_id else None,
|
|
43
|
+
),
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
self.lock = AsyncKeyedLock()
|
|
47
|
+
# workflow id -> agent data id
|
|
48
|
+
self.cache = LRUCache[str, str](maxsize=1024)
|
|
49
|
+
|
|
50
|
+
@override
|
|
51
|
+
async def query(self, query: HandlerQuery) -> List[PersistentHandler]:
|
|
52
|
+
filters = {}
|
|
53
|
+
if query.handler_id_in is not None:
|
|
54
|
+
filters["handler_id"] = {
|
|
55
|
+
"includes": query.handler_id_in,
|
|
56
|
+
}
|
|
57
|
+
if query.workflow_name_in is not None:
|
|
58
|
+
filters["workflow_name"] = {
|
|
59
|
+
"includes": query.workflow_name_in,
|
|
60
|
+
}
|
|
61
|
+
if query.status_in is not None:
|
|
62
|
+
filters["status"] = {
|
|
63
|
+
"includes": query.status_in,
|
|
64
|
+
}
|
|
65
|
+
results = await self.client.search(
|
|
66
|
+
filter=filters,
|
|
67
|
+
page_size=1000,
|
|
68
|
+
)
|
|
69
|
+
return [x.data for x in results.items]
|
|
70
|
+
|
|
71
|
+
@override
|
|
72
|
+
async def update(self, handler: PersistentHandler) -> None:
|
|
73
|
+
async with self.lock.acquire(handler.handler_id):
|
|
74
|
+
id = await self._get_item_id(handler)
|
|
75
|
+
if id is None:
|
|
76
|
+
item = await self.client.create_item(
|
|
77
|
+
data=handler,
|
|
78
|
+
)
|
|
79
|
+
if item.id is None:
|
|
80
|
+
raise ValueError(f"Failed to create handler {handler.handler_id}")
|
|
81
|
+
self.cache.set(handler.handler_id, item.id)
|
|
82
|
+
else:
|
|
83
|
+
await self.client.update_item(
|
|
84
|
+
item_id=id,
|
|
85
|
+
data=handler,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
async def _get_item_id(self, handler: PersistentHandler) -> str | None:
|
|
89
|
+
cached_id = self.cache.get(handler.handler_id, None)
|
|
90
|
+
if cached_id is not None:
|
|
91
|
+
return cached_id
|
|
92
|
+
results = await self.client.search(
|
|
93
|
+
filter={"handler_id": {"eq": handler.handler_id}},
|
|
94
|
+
page_size=1,
|
|
95
|
+
)
|
|
96
|
+
if not results.items:
|
|
97
|
+
return None
|
|
98
|
+
id = results.items[0].id
|
|
99
|
+
self.cache.set(handler.handler_id, id)
|
|
100
|
+
return id
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections import Counter
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AsyncKeyedLock:
|
|
7
|
+
def __init__(self):
|
|
8
|
+
self._locks: dict[str, asyncio.Lock] = {}
|
|
9
|
+
self._refcnt = Counter()
|
|
10
|
+
self._registry_lock = asyncio.Lock() # protects _locks/_refcnt
|
|
11
|
+
|
|
12
|
+
@asynccontextmanager
|
|
13
|
+
async def acquire(self, key: str):
|
|
14
|
+
async with self._registry_lock:
|
|
15
|
+
lock = self._locks.get(key)
|
|
16
|
+
if lock is None:
|
|
17
|
+
lock = asyncio.Lock()
|
|
18
|
+
self._locks[key] = lock
|
|
19
|
+
self._refcnt[key] += 1
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
await lock.acquire()
|
|
23
|
+
try:
|
|
24
|
+
yield
|
|
25
|
+
finally:
|
|
26
|
+
lock.release()
|
|
27
|
+
finally:
|
|
28
|
+
async with self._registry_lock:
|
|
29
|
+
self._refcnt[key] -= 1
|
|
30
|
+
if self._refcnt[key] == 0:
|
|
31
|
+
self._locks.pop(key, None)
|
|
32
|
+
del self._refcnt[key]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from collections import OrderedDict
|
|
2
|
+
from typing import Generic, TypeVar, overload
|
|
3
|
+
|
|
4
|
+
K = TypeVar("K")
|
|
5
|
+
V = TypeVar("V")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LRUCache(Generic[K, V]):
|
|
9
|
+
def __init__(self, maxsize: int = 128):
|
|
10
|
+
self.maxsize = maxsize
|
|
11
|
+
self._store: OrderedDict[K, V] = OrderedDict()
|
|
12
|
+
|
|
13
|
+
@overload
|
|
14
|
+
def get(self, key: K) -> V | None: ...
|
|
15
|
+
|
|
16
|
+
@overload
|
|
17
|
+
def get(self, key: K, default: V) -> V: ...
|
|
18
|
+
|
|
19
|
+
def get(self, key: K, default: V | None = None) -> V | None:
|
|
20
|
+
if key not in self._store:
|
|
21
|
+
return default
|
|
22
|
+
# mark as recently used
|
|
23
|
+
value = self._store.pop(key)
|
|
24
|
+
self._store[key] = value
|
|
25
|
+
return value
|
|
26
|
+
|
|
27
|
+
def set(self, key: K, value: V):
|
|
28
|
+
if key in self._store:
|
|
29
|
+
# remove old so we can push to end
|
|
30
|
+
self._store.pop(key)
|
|
31
|
+
elif len(self._store) >= self.maxsize:
|
|
32
|
+
# evict least recently used (first item)
|
|
33
|
+
self._store.popitem(last=False)
|
|
34
|
+
self._store[key] = value
|
|
35
|
+
|
|
36
|
+
def __contains__(self, key: K) -> bool:
|
|
37
|
+
return key in self._store
|
|
38
|
+
|
|
39
|
+
def __getitem__(self, key: K) -> V:
|
|
40
|
+
return self.get(key)
|
|
41
|
+
|
|
42
|
+
def __setitem__(self, key: K, value: V):
|
|
43
|
+
self.set(key, value)
|
|
44
|
+
|
|
45
|
+
def __len__(self) -> int:
|
|
46
|
+
return len(self._store)
|
|
47
|
+
|
|
48
|
+
def __iter__(self):
|
|
49
|
+
return iter(self._store)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: llama-deploy-appserver
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Application server components for LlamaDeploy
|
|
5
|
+
Author: Massimiliano Pippi, Adrian Lyjak
|
|
6
|
+
Author-email: Massimiliano Pippi <mpippi@gmail.com>, Adrian Lyjak <adrianlyjak@gmail.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Requires-Dist: llama-index-workflows[server]>=2.2.0
|
|
9
|
+
Requires-Dist: pydantic-settings>=2.10.1
|
|
10
|
+
Requires-Dist: uvicorn>=0.24.0
|
|
11
|
+
Requires-Dist: fastapi>=0.100.0
|
|
12
|
+
Requires-Dist: websockets>=12.0
|
|
13
|
+
Requires-Dist: llama-deploy-core>=0.3.0,<0.4.0
|
|
14
|
+
Requires-Dist: httpx>=0.24.0,<1.0.0
|
|
15
|
+
Requires-Dist: prometheus-fastapi-instrumentator>=7.1.0
|
|
16
|
+
Requires-Dist: packaging>=25.0
|
|
17
|
+
Requires-Dist: structlog>=25.4.0
|
|
18
|
+
Requires-Dist: rich>=14.1.0
|
|
19
|
+
Requires-Dist: pyyaml>=6.0.2
|
|
20
|
+
Requires-Dist: llama-cloud-services>=0.6.60
|
|
21
|
+
Requires-Python: >=3.11, <4
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
> [!WARNING]
|
|
25
|
+
> This repository contains pre-release software. It is unstable, incomplete, and subject to breaking changes. Not recommended for use.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
llama_deploy/appserver/__init__.py,sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855,0
|
|
2
|
+
llama_deploy/appserver/app.py,sha256=1150ac9d6b2e4bd0bbe4cc70a16249381887e86f49c68492a3ab61024d4567b7,9520
|
|
3
|
+
llama_deploy/appserver/bootstrap.py,sha256=fa32be007f18b4b3af92c878bac417416c9afb09b1beddf51b5cd73115e6b7c6,2453
|
|
4
|
+
llama_deploy/appserver/configure_logging.py,sha256=194dd1ebed3c1d9065d9174f7828d557a577eaac8fb0443b3102430b1f578c19,6329
|
|
5
|
+
llama_deploy/appserver/correlation_id.py,sha256=8ac5bc6160c707b93a9fb818b64dd369a4ef7a53f9f91a6b3d90c4cf446f7327,572
|
|
6
|
+
llama_deploy/appserver/deployment.py,sha256=8bc298caaf765b5fea345287de14f7b9a4aedc06261220f4ed00d1dde9bec502,6092
|
|
7
|
+
llama_deploy/appserver/deployment_config_parser.py,sha256=e2b6c483203d96ab795c4e55df15c694c20458d5a03fab89c2b71e481291a2d3,510
|
|
8
|
+
llama_deploy/appserver/interrupts.py,sha256=14f262a0cedc00bb3aecd3d6c14c41ba0e88e7d2a6df02cd35b5bea1940822a2,1622
|
|
9
|
+
llama_deploy/appserver/process_utils.py,sha256=befee4918c6cf72082dca8bf807afb61ad3d6c83f01bc0c007594b47930570d8,6056
|
|
10
|
+
llama_deploy/appserver/py.typed,sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855,0
|
|
11
|
+
llama_deploy/appserver/routers/__init__.py,sha256=ee2d14ebf4b067c844947ed1cc98186456e8bfa4919282722eaaf8cca345a138,214
|
|
12
|
+
llama_deploy/appserver/routers/deployments.py,sha256=e7bafd72c1b4b809e5ad57442594a997c85ecab998b8430da65899faa910db1c,7572
|
|
13
|
+
llama_deploy/appserver/routers/status.py,sha256=2af74bc40e52dc5944af2df98c6a021fea7b0cfcda88b56ac124dc383120758c,282
|
|
14
|
+
llama_deploy/appserver/routers/ui_proxy.py,sha256=f63c36c201070594a4011320192d724b1c534d0ec655c49ba65c4e9911dbdd97,8633
|
|
15
|
+
llama_deploy/appserver/settings.py,sha256=279dad9d80f4b54215cb8073bc46ee2beebfbc8ed75f40bccfbb387593f6975a,4984
|
|
16
|
+
llama_deploy/appserver/stats.py,sha256=1f3989f6705a6de3e4d61ee8cdd189fbe04a2c53ec5e720b2e5168acc331427f,691
|
|
17
|
+
llama_deploy/appserver/types.py,sha256=4edc991aafb6b8497f068d12387455df292da3ff8440223637641ab1632553ec,2133
|
|
18
|
+
llama_deploy/appserver/workflow_loader.py,sha256=c15890a00976e022edcdf2af04bf699c02fba020bb06c47960a4911e08255501,14146
|
|
19
|
+
llama_deploy/appserver/workflow_store/agent_data_store.py,sha256=7b8d1b8cb6f741ff631d668fc955ca76a82e8da0bf8a27ee3bc9a8ef71123701,3594
|
|
20
|
+
llama_deploy/appserver/workflow_store/keyed_lock.py,sha256=bb1504d9de09d51a8f60721cc77b14d4051ac5a897ace6f9d9cba494f068465e,950
|
|
21
|
+
llama_deploy/appserver/workflow_store/lru_cache.py,sha256=7511231b6aba81ea96044cf644cd9c1f11d78190b7b7f578b1b5a55e2c218f9f,1323
|
|
22
|
+
llama_deploy_appserver-0.3.0.dist-info/WHEEL,sha256=66530aef82d5020ef5af27ae0123c71abb9261377c5bc519376c671346b12918,79
|
|
23
|
+
llama_deploy_appserver-0.3.0.dist-info/METADATA,sha256=0ee9fc6166c50252991baad2f7186a6b58fc3321e349df0074f809e2a77d34ea,974
|
|
24
|
+
llama_deploy_appserver-0.3.0.dist-info/RECORD,,
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import uvicorn
|
|
2
|
-
from prometheus_client import start_http_server
|
|
3
|
-
|
|
4
|
-
from .settings import settings
|
|
5
|
-
|
|
6
|
-
if __name__ == "__main__":
|
|
7
|
-
if settings.prometheus_enabled:
|
|
8
|
-
start_http_server(settings.prometheus_port)
|
|
9
|
-
|
|
10
|
-
uvicorn.run(
|
|
11
|
-
"llama_deploy.appserver.app:app",
|
|
12
|
-
host=settings.host,
|
|
13
|
-
port=settings.port,
|
|
14
|
-
)
|