llama-deploy-appserver 0.2.7a1__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.
File without changes
@@ -0,0 +1,14 @@
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
+ )
@@ -0,0 +1,49 @@
1
+ import logging
2
+ import os
3
+
4
+ from fastapi import FastAPI
5
+ from fastapi.middleware.cors import CORSMiddleware
6
+ from fastapi.requests import Request
7
+ from fastapi.responses import JSONResponse, RedirectResponse
8
+
9
+ from .routers import deployments_router, status_router
10
+ from .server import lifespan, manager
11
+ from .settings import settings
12
+ from .tracing import configure_tracing
13
+
14
+
15
+ logger = logging.getLogger("uvicorn.info")
16
+
17
+
18
+ app = FastAPI(lifespan=lifespan)
19
+
20
+ # Setup tracing
21
+ configure_tracing(settings)
22
+
23
+ # Configure CORS middleware if the environment variable is set
24
+ if not os.environ.get("DISABLE_CORS", False):
25
+ app.add_middleware(
26
+ CORSMiddleware,
27
+ allow_origins=["*"], # Allows all origins
28
+ allow_credentials=True,
29
+ allow_methods=["GET", "POST"],
30
+ allow_headers=["Content-Type", "Authorization"],
31
+ )
32
+
33
+ app.include_router(deployments_router)
34
+ app.include_router(status_router)
35
+
36
+
37
+ @app.get("/", response_model=None)
38
+ async def root(request: Request) -> JSONResponse | RedirectResponse:
39
+ # for local dev, just redirect to the one UI if we have one
40
+ if len(manager.deployment_names) == 1:
41
+ deployment = manager.get_deployment(manager.deployment_names[0])
42
+ if deployment is not None and deployment._ui_server_process is not None:
43
+ return RedirectResponse(f"deployments/{deployment.name}/ui")
44
+ return JSONResponse(
45
+ {
46
+ "swagger_docs": f"{request.base_url}docs",
47
+ "status": f"{request.base_url}status",
48
+ }
49
+ )
@@ -0,0 +1,43 @@
1
+ """
2
+ Bootstraps an application from a remote github repository given environment variables.
3
+
4
+ This just sets up the files from the repository. It's more of a build process, does not start an application.
5
+ """
6
+
7
+ import asyncio
8
+ from llama_deploy.core.git.git_util import (
9
+ clone_repo,
10
+ )
11
+ from pydantic import Field
12
+ from pydantic_settings import BaseSettings, SettingsConfigDict
13
+
14
+
15
+ class BootstrapSettings(BaseSettings):
16
+ model_config = SettingsConfigDict(env_prefix="LLAMA_DEPLOY_")
17
+ git_url: str = Field(..., description="The URL of the git repository to clone")
18
+ git_token: str | None = Field(
19
+ default=None, description="The token to use to clone the git repository"
20
+ )
21
+ git_ref: str | None = Field(
22
+ default=None, description="The git reference to checkout"
23
+ )
24
+ git_sha: str | None = Field(default=None, description="The git SHA to checkout")
25
+ deployment_file_path: str = Field(
26
+ default="llama_deploy.yaml", description="The path to the deployment file"
27
+ )
28
+ deployment_name: str | None = Field(
29
+ default=None, description="The name of the deployment"
30
+ )
31
+
32
+
33
+ async def main():
34
+ settings = BootstrapSettings()
35
+ # Needs the github url+auth, and the deployment file path
36
+ # clones the repo to a standard directory
37
+ # (eventually) runs the UI build process and moves that to a standard directory for a file server
38
+ clone_repo(settings.git_url, "/app/", settings.git_token)
39
+ pass
40
+
41
+
42
+ if __name__ == "__main__":
43
+ asyncio.run(main())
@@ -0,0 +1,3 @@
1
+ from .client import Client
2
+
3
+ __all__ = ["Client"]
@@ -0,0 +1,30 @@
1
+ from typing import Any
2
+
3
+ import httpx
4
+ from pydantic_settings import BaseSettings, SettingsConfigDict
5
+
6
+
7
+ class _BaseClient(BaseSettings):
8
+ """Base type for clients, to be used in Pydantic models to avoid circular imports.
9
+
10
+ Settings can be passed to the Client constructor when creating an instance, or defined with environment variables
11
+ having names prefixed with the string `LLAMA_DEPLOY_`, e.g. `LLAMA_DEPLOY_DISABLE_SSL`.
12
+ """
13
+
14
+ model_config = SettingsConfigDict(env_prefix="LLAMA_DEPLOY_")
15
+
16
+ api_server_url: str = "http://localhost:4501"
17
+ disable_ssl: bool = False
18
+ timeout: float | None = 120.0
19
+ poll_interval: float = 0.5
20
+
21
+ async def request(
22
+ self, method: str, url: str | httpx.URL, **kwargs: Any
23
+ ) -> httpx.Response:
24
+ """Performs an async HTTP request using httpx."""
25
+ verify = kwargs.pop("verify", True)
26
+ timeout = kwargs.pop("timeout", self.timeout)
27
+ async with httpx.AsyncClient(verify=verify) as client:
28
+ response = await client.request(method, url, timeout=timeout, **kwargs)
29
+ response.raise_for_status()
30
+ return response
@@ -0,0 +1,49 @@
1
+ import asyncio
2
+ from typing import Any
3
+
4
+ from .base import _BaseClient
5
+ from .models import ApiServer, make_sync
6
+
7
+
8
+ class Client(_BaseClient):
9
+ """The LlamaDeploy Python client.
10
+
11
+ The client is gives access to both the asyncio and non-asyncio APIs. To access the sync
12
+ API just use methods of `client.sync`.
13
+
14
+ Example usage:
15
+ ```py
16
+ from llama_deploy.client import Client
17
+
18
+ # Use the same client instance
19
+ c = Client()
20
+
21
+ async def an_async_function():
22
+ status = await client.apiserver.status()
23
+
24
+ def normal_function():
25
+ status = client.sync.apiserver.status()
26
+ ```
27
+ """
28
+
29
+ @property
30
+ def sync(self) -> "_SyncClient":
31
+ """Returns the sync version of the client API."""
32
+ try:
33
+ asyncio.get_running_loop()
34
+ except RuntimeError:
35
+ return _SyncClient(**self.model_dump())
36
+
37
+ msg = "You cannot use the sync client within an async event loop - just await the async methods directly."
38
+ raise RuntimeError(msg)
39
+
40
+ @property
41
+ def apiserver(self) -> ApiServer:
42
+ """Access the API Server functionalities."""
43
+ return ApiServer(client=self, id="apiserver")
44
+
45
+
46
+ class _SyncClient(_BaseClient):
47
+ @property
48
+ def apiserver(self) -> Any:
49
+ return make_sync(ApiServer)(client=self, id="apiserver")
@@ -0,0 +1,4 @@
1
+ from .apiserver import ApiServer
2
+ from .model import Collection, Model, make_sync
3
+
4
+ __all__ = ["ApiServer", "Collection", "Model", "make_sync"]
@@ -0,0 +1,356 @@
1
+ """Client functionalities to operate on the API Server.
2
+
3
+ This module allows the client to use all the functionalities
4
+ from the LlamaDeploy API Server. For this to work, the API
5
+ Server must be up and its URL (by default `http://localhost:4501`)
6
+ reachable by the host executing the client code.
7
+ """
8
+
9
+ import asyncio
10
+ import json
11
+ from typing import Any, AsyncGenerator, TextIO
12
+
13
+ import httpx
14
+ from llama_deploy.appserver.types import (
15
+ EventDefinition,
16
+ SessionDefinition,
17
+ Status,
18
+ StatusEnum,
19
+ TaskDefinition,
20
+ TaskResult,
21
+ )
22
+ from pydantic import Field
23
+ from workflows.context import JsonSerializer
24
+ from workflows.events import Event
25
+
26
+ from .model import Collection, Model
27
+
28
+
29
+ class SessionCollection(Collection):
30
+ """A model representing a collection of session for a given deployment."""
31
+
32
+ deployment_id: str = Field(
33
+ description="The ID of the deployment containing the sessions."
34
+ )
35
+
36
+ async def delete(self, session_id: str) -> None:
37
+ """Deletes the session with the provided `session_id`.
38
+
39
+ Args:
40
+ session_id: The id of the session that will be removed
41
+
42
+ Raises:
43
+ HTTPException: If the session couldn't be found with the id provided.
44
+ """
45
+ delete_url = f"{self.client.api_server_url}/deployments/{self.deployment_id}/sessions/delete"
46
+
47
+ await self.client.request(
48
+ "POST",
49
+ delete_url,
50
+ params={"session_id": session_id},
51
+ verify=not self.client.disable_ssl,
52
+ timeout=self.client.timeout,
53
+ )
54
+
55
+ async def create(self) -> SessionDefinition:
56
+ """Create a new session."""
57
+ create_url = f"{self.client.api_server_url}/deployments/{self.deployment_id}/sessions/create"
58
+
59
+ r = await self.client.request(
60
+ "POST",
61
+ create_url,
62
+ verify=not self.client.disable_ssl,
63
+ timeout=self.client.timeout,
64
+ )
65
+
66
+ return SessionDefinition(**r.json())
67
+
68
+ async def list(self) -> list[SessionDefinition]:
69
+ """Returns a collection of all the sessions in the given deployment."""
70
+ sessions_url = (
71
+ f"{self.client.api_server_url}/deployments/{self.deployment_id}/sessions"
72
+ )
73
+ r = await self.client.request(
74
+ "GET",
75
+ sessions_url,
76
+ verify=not self.client.disable_ssl,
77
+ timeout=self.client.timeout,
78
+ )
79
+
80
+ return r.json()
81
+
82
+ async def get(self, id: str) -> SessionDefinition:
83
+ """Gets a deployment by id."""
84
+ get_url = f"{self.client.api_server_url}/deployments/{self.deployment_id}/sessions/{id}"
85
+ await self.client.request(
86
+ "GET",
87
+ get_url,
88
+ verify=not self.client.disable_ssl,
89
+ timeout=self.client.timeout,
90
+ )
91
+ model_class = self._prepare(SessionDefinition)
92
+ return model_class(client=self.client, id=id)
93
+
94
+
95
+ class Task(Model):
96
+ """A model representing a task belonging to a given session in the given deployment."""
97
+
98
+ deployment_id: str = Field(
99
+ description="The ID of the deployment this task belongs to."
100
+ )
101
+ session_id: str = Field(description="The ID of the session this task belongs to.")
102
+
103
+ async def results(self) -> TaskResult | None:
104
+ """Returns the result of a given task."""
105
+ results_url = f"{self.client.api_server_url}/deployments/{self.deployment_id}/tasks/{self.id}/results"
106
+
107
+ r = await self.client.request(
108
+ "GET",
109
+ results_url,
110
+ verify=not self.client.disable_ssl,
111
+ params={"session_id": self.session_id},
112
+ timeout=self.client.timeout,
113
+ )
114
+ if r.json():
115
+ return TaskResult.model_validate(r.json())
116
+ return None
117
+
118
+ async def send_event(self, ev: Event, service_name: str) -> EventDefinition:
119
+ """Sends a human response event."""
120
+ url = f"{self.client.api_server_url}/deployments/{self.deployment_id}/tasks/{self.id}/events"
121
+
122
+ serializer = JsonSerializer()
123
+ event_def = EventDefinition(
124
+ event_obj_str=serializer.serialize(ev), service_id=service_name
125
+ )
126
+
127
+ r = await self.client.request(
128
+ "POST",
129
+ url,
130
+ verify=not self.client.disable_ssl,
131
+ params={"session_id": self.session_id},
132
+ json=event_def.model_dump(),
133
+ timeout=self.client.timeout,
134
+ )
135
+ return EventDefinition.model_validate(r.json())
136
+
137
+ async def events(self) -> AsyncGenerator[dict[str, Any], None]: # pragma: no cover
138
+ """Returns a generator object to consume the events streamed from a service."""
139
+ events_url = f"{self.client.api_server_url}/deployments/{self.deployment_id}/tasks/{self.id}/events"
140
+
141
+ while True:
142
+ try:
143
+ async with httpx.AsyncClient(
144
+ verify=not self.client.disable_ssl
145
+ ) as client:
146
+ async with client.stream(
147
+ "GET", events_url, params={"session_id": self.session_id}
148
+ ) as response:
149
+ response.raise_for_status()
150
+ async for line in response.aiter_lines():
151
+ json_line = json.loads(line)
152
+ yield json_line
153
+ break # Exit the function if successful
154
+ except httpx.HTTPStatusError as e:
155
+ if e.response.status_code != 404:
156
+ raise # Re-raise if it's not a 404 error
157
+ await asyncio.sleep(self.client.poll_interval)
158
+
159
+
160
+ class TaskCollection(Collection):
161
+ """A model representing a collection of tasks for a given deployment."""
162
+
163
+ deployment_id: str = Field(
164
+ description="The ID of the deployment these tasks belong to."
165
+ )
166
+
167
+ async def run(self, task: TaskDefinition) -> Any:
168
+ """Runs a task and returns the results once it's done.
169
+
170
+ Args:
171
+ task: The definition of the task we want to run.
172
+ """
173
+ run_url = (
174
+ f"{self.client.api_server_url}/deployments/{self.deployment_id}/tasks/run"
175
+ )
176
+ if task.session_id:
177
+ run_url += f"?session_id={task.session_id}"
178
+
179
+ r = await self.client.request(
180
+ "POST",
181
+ run_url,
182
+ verify=not self.client.disable_ssl,
183
+ json=task.model_dump(),
184
+ timeout=self.client.timeout,
185
+ )
186
+
187
+ return r.json()
188
+
189
+ async def create(self, task: TaskDefinition) -> Task:
190
+ """Runs a task returns it immediately, without waiting for the results."""
191
+ create_url = f"{self.client.api_server_url}/deployments/{self.deployment_id}/tasks/create"
192
+
193
+ r = await self.client.request(
194
+ "POST",
195
+ create_url,
196
+ verify=not self.client.disable_ssl,
197
+ json=task.model_dump(),
198
+ timeout=self.client.timeout,
199
+ )
200
+ response_fields = r.json()
201
+
202
+ model_class = self._prepare(Task)
203
+ return model_class(
204
+ client=self.client,
205
+ deployment_id=self.deployment_id,
206
+ id=response_fields["task_id"],
207
+ session_id=response_fields["session_id"],
208
+ )
209
+
210
+ async def list(self) -> list[Task]:
211
+ """Returns the list of tasks from this collection."""
212
+ tasks_url = (
213
+ f"{self.client.api_server_url}/deployments/{self.deployment_id}/tasks"
214
+ )
215
+ r = await self.client.request(
216
+ "GET",
217
+ tasks_url,
218
+ verify=not self.client.disable_ssl,
219
+ timeout=self.client.timeout,
220
+ )
221
+ task_model_class = self._prepare(Task)
222
+ items = {
223
+ "id": task_model_class(
224
+ client=self.client,
225
+ id=task_def.task_id,
226
+ session_id=task_def.session_id,
227
+ deployment_id=self.deployment_id,
228
+ )
229
+ for task_def in r.json()
230
+ }
231
+ model_class = self._prepare(TaskCollection)
232
+ return model_class(
233
+ client=self.client, deployment_id=self.deployment_id, items=items
234
+ )
235
+
236
+
237
+ class Deployment(Model):
238
+ """A model representing a deployment."""
239
+
240
+ @property
241
+ def tasks(self) -> TaskCollection:
242
+ """Returns a collection of tasks from all the sessions in the given deployment."""
243
+
244
+ model_class = self._prepare(TaskCollection)
245
+ return model_class(client=self.client, deployment_id=self.id, items={})
246
+
247
+ @property
248
+ def sessions(self) -> SessionCollection:
249
+ """Returns a collection of all the sessions in the given deployment."""
250
+
251
+ coll_model_class = self._prepare(SessionCollection)
252
+ return coll_model_class(client=self.client, deployment_id=self.id, items={})
253
+
254
+
255
+ class DeploymentCollection(Collection):
256
+ """A model representing a collection of deployments currently active."""
257
+
258
+ async def create(
259
+ self, config: TextIO, base_path: str, reload: bool = False, local: bool = False
260
+ ) -> Deployment:
261
+ """Creates a new deployment from a deployment file.
262
+
263
+ If `reload` is true, an existing deployment will be reloaded, otherwise
264
+ an error will be raised.
265
+
266
+ If `local` is true, the sync managers won't attempt at syncing data.
267
+ This is mostly for supporting local development.
268
+
269
+ Example:
270
+ ```
271
+ with open("deployment.yml") as f:
272
+ await client.apiserver.deployments.create(f)
273
+ ```
274
+ """
275
+ create_url = f"{self.client.api_server_url}/deployments/create"
276
+
277
+ files = {"config_file": config.read()}
278
+ r = await self.client.request(
279
+ "POST",
280
+ create_url,
281
+ files=files,
282
+ params={"reload": reload, "local": local, "base_path": base_path},
283
+ verify=not self.client.disable_ssl,
284
+ timeout=self.client.timeout,
285
+ )
286
+
287
+ model_class = self._prepare(Deployment)
288
+ return model_class(client=self.client, id=r.json().get("name"))
289
+
290
+ async def get(self, id: str) -> Deployment:
291
+ """Gets a deployment by id."""
292
+ get_url = f"{self.client.api_server_url}/deployments/{id}"
293
+ # Current version of apiserver doesn't returns anything useful in this endpoint, let's just ignore it
294
+ await self.client.request(
295
+ "GET",
296
+ get_url,
297
+ verify=not self.client.disable_ssl,
298
+ timeout=self.client.timeout,
299
+ )
300
+ model_class = self._prepare(Deployment)
301
+ return model_class(client=self.client, id=id)
302
+
303
+ async def list(self) -> list[Deployment]:
304
+ """Return a list of Deployment instances for this collection."""
305
+ deployments_url = f"{self.client.api_server_url}/deployments/"
306
+ r = await self.client.request("GET", deployments_url)
307
+ model_class = self._prepare(Deployment)
308
+ deployments = [model_class(client=self.client, id=name) for name in r.json()]
309
+ return deployments
310
+
311
+
312
+ class ApiServer(Model):
313
+ """A model representing the API Server instance."""
314
+
315
+ async def status(self) -> Status:
316
+ """Returns the status of the API Server."""
317
+ status_url = f"{self.client.api_server_url}/status/"
318
+
319
+ try:
320
+ r = await self.client.request(
321
+ "GET",
322
+ status_url,
323
+ verify=not self.client.disable_ssl,
324
+ timeout=self.client.timeout,
325
+ )
326
+ except httpx.ConnectError:
327
+ return Status(
328
+ status=StatusEnum.DOWN,
329
+ status_message="API Server is down",
330
+ )
331
+
332
+ if r.status_code >= 400:
333
+ body = r.json()
334
+ return Status(status=StatusEnum.UNHEALTHY, status_message=r.text)
335
+
336
+ description = "LlamaDeploy is up and running."
337
+ body = r.json()
338
+ deployments = body.get("deployments") or []
339
+ if deployments:
340
+ description += "\nActive deployments:"
341
+ for d in deployments:
342
+ description += f"\n- {d}"
343
+ else:
344
+ description += "\nCurrently there are no active deployments"
345
+
346
+ return Status(
347
+ status=StatusEnum.HEALTHY,
348
+ status_message=description,
349
+ deployments=deployments,
350
+ )
351
+
352
+ @property
353
+ def deployments(self) -> DeploymentCollection:
354
+ """Returns a collection of deployments currently active in the API Server."""
355
+ model_class = self._prepare(DeploymentCollection)
356
+ return model_class(client=self.client, items={})
@@ -0,0 +1,82 @@
1
+ import asyncio
2
+ import inspect
3
+ from typing import Any, AsyncGenerator, Callable, Generic, TypeVar
4
+
5
+ from asgiref.sync import async_to_sync
6
+ from llama_deploy.appserver.client.base import _BaseClient
7
+ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
8
+ from typing_extensions import ParamSpec
9
+
10
+
11
+ class _Base(BaseModel):
12
+ """The base model provides fields and functionalities common to derived models and collections."""
13
+
14
+ client: _BaseClient = Field(exclude=True)
15
+ _instance_is_sync: bool = PrivateAttr(default=False)
16
+
17
+ model_config = ConfigDict(arbitrary_types_allowed=True)
18
+
19
+ def _prepare(self, _class: type) -> type:
20
+ if self._instance_is_sync:
21
+ return make_sync(_class)
22
+ return _class
23
+
24
+
25
+ T = TypeVar("T", bound=_Base)
26
+
27
+
28
+ class Model(_Base):
29
+ id: str
30
+
31
+
32
+ class Collection(_Base, Generic[T]):
33
+ """A generic container of items of the same model type."""
34
+
35
+ items: dict[str, T]
36
+
37
+ def get(self, id: str) -> T:
38
+ """Returns an item from the collection."""
39
+ return self.items[id]
40
+
41
+ async def list(self) -> list[T]:
42
+ """Returns a list of all the items in the collection."""
43
+ return [self.get(id) for id in self.items.keys()]
44
+
45
+
46
+ # Generic type for what's returned by the async generator
47
+ _G = TypeVar("_G")
48
+ # Generic parameter for the wrapped generator method
49
+ _P = ParamSpec("_P")
50
+ # Generic parameter for the wrapped generator method return value
51
+ _R = TypeVar("_R")
52
+
53
+
54
+ async def _async_gen_to_list(async_gen: AsyncGenerator[_G, None]) -> list[_G]:
55
+ return [item async for item in async_gen]
56
+
57
+
58
+ def make_sync(_class: type[T]) -> Any:
59
+ """Wraps the methods of the given model class so that they can be called without `await`."""
60
+
61
+ class ModelWrapper(_class): # type: ignore
62
+ _instance_is_sync: bool = True
63
+
64
+ def generator_wrapper(
65
+ func: Callable[_P, AsyncGenerator[_G, None]], # ty: ignore[invalid-type-form] - https://github.com/astral-sh/ty/issues/157
66
+ /,
67
+ *args: Any,
68
+ **kwargs: Any,
69
+ ) -> Callable[_P, list[_G]]: # ty: ignore[invalid-type-form] - https://github.com/astral-sh/ty/issues/157
70
+ def new_func(*fargs: Any, **fkwargs: Any) -> list[_G]:
71
+ return asyncio.run(_async_gen_to_list(func(*fargs, **fkwargs)))
72
+
73
+ return new_func
74
+
75
+ for name, method in _class.__dict__.items():
76
+ # Only wrap async public methods
77
+ if inspect.isasyncgenfunction(method):
78
+ setattr(ModelWrapper, name, generator_wrapper(method))
79
+ elif asyncio.iscoroutinefunction(method) and not name.startswith("_"):
80
+ setattr(ModelWrapper, name, async_to_sync(method))
81
+
82
+ return ModelWrapper