From 10d9721aedb39d2bf576c7c56099e38a9f345da6 Mon Sep 17 00:00:00 2001 From: William Fu-Hinthorn <13333726+hinthornw@users.noreply.github.com> Date: Wed, 19 Feb 2025 05:46:05 -0800 Subject: [PATCH] Support http config --- libs/cli/examples/my_app.py | 61 ++++++++++++ libs/cli/langgraph_cli/config.py | 102 ++++++++++++++++++++- libs/cli/tests/unit_tests/test_config.json | 6 +- libs/cli/tests/unit_tests/test_config.py | 27 +++++- 4 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 libs/cli/examples/my_app.py diff --git a/libs/cli/examples/my_app.py b/libs/cli/examples/my_app.py new file mode 100644 index 000000000..d5048e377 --- /dev/null +++ b/libs/cli/examples/my_app.py @@ -0,0 +1,61 @@ +from contextlib import asynccontextmanager +from contextvars import ContextVar +from typing import Any + +from starlette import Starlette +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse +from starlette.routing import Route + +my_context_var: ContextVar[str] = ContextVar("my_context_var", default="") +LIFESPAN_VAL = "" +other_context_var = ContextVar("other_context_var", default="") + + +@asynccontextmanager +@asynccontextmanager +async def my_lifespan(app): + global LIFESPAN_VAL + LIFESPAN_VAL = "foobar-lifespan" + yield + assert LIFESPAN_VAL == "foobar-lifespan" + LIFESPAN_VAL = "" + + +class MyContextMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Any, call_next: Any) -> Any: + token = my_context_var.set("Foobar") + try: + response = await call_next(request) + return response + finally: + my_context_var.reset(token) + + +async def custom_my_route(request): + assert my_context_var.get() == "Foobar" + assert LIFESPAN_VAL == "foobar-lifespan" + return JSONResponse({"foo": "bar"}) + + +async def runs_afakeroute(request): + assert my_context_var.get() == "Foobar" + assert LIFESPAN_VAL == "foobar-lifespan" + return JSONResponse({"foo": "afakeroute"}) + + +async def other_middleware(request: Any, call_next: Any) -> Any: + other_context_var.set("foobar") + response = await call_next(request) + other_context_var.reset() + return response + + +app = Starlette( + middleware=[MyContextMiddleware], + routes=[ + Route("/custom/my-route", custom_my_route), + Route("/runs/afakeroute", runs_afakeroute), + ], + lifespan=my_lifespan, +) diff --git a/libs/cli/langgraph_cli/config.py b/libs/cli/langgraph_cli/config.py index 129271257..7fbe57056 100644 --- a/libs/cli/langgraph_cli/config.py +++ b/libs/cli/langgraph_cli/config.py @@ -86,6 +86,33 @@ class AuthConfig(TypedDict, total=False): """ +class CorsConfig(TypedDict, total=False): + allow_origins: list[str] + allow_methods: list[str] + allow_headers: list[str] + allow_credentials: bool + allow_origin_regex: str + expose_headers: list[str] + max_age: int + + +class HttpConfig(TypedDict, total=False): + app: str + """Import path for a custom Starlette/FastAPI app to mount""" + disable_assistants: bool + """Disable /assistants routes""" + disable_threads: bool + """Disable /threads routes""" + disable_runs: bool + """Disable /runs routes""" + disable_store: bool + """Disable /store routes""" + disable_meta: bool + """Disable /ok, /info, /metrics, and /docs routes""" + cors: Optional[CorsConfig] + """Cross-Origin Resource Sharing (CORS) configuration""" + + class Config(TypedDict, total=False): """Configuration for langgraph-cli.""" @@ -124,6 +151,9 @@ class Config(TypedDict, total=False): auth: Optional[AuthConfig] """Configuration for authentication.""" + http: Optional[HttpConfig] + """Configuration for HTTP server.""" + def _parse_version(version_str: str) -> tuple[int, int]: """Parse a version string into a tuple of (major, minor).""" @@ -158,6 +188,7 @@ def validate_config(config: Config) -> Config: "env": config.get("env", {}), "store": config.get("store"), "auth": config.get("auth"), + "http": config.get("http"), } if config.get("node_version") else { @@ -169,6 +200,7 @@ def validate_config(config: Config) -> Config: "env": config.get("env", {}), "store": config.get("store"), "auth": config.get("auth"), + "http": config.get("http"), } ) @@ -221,7 +253,13 @@ def validate_config(config: Config) -> Config: f"Invalid auth.path format: '{auth_conf['path']}'. " "Must be in format './path/to/file.py:attribute_name'" ) - + if http_conf := config.get("http"): + if "app" in http_conf: + if ":" not in http_conf["app"]: + raise ValueError( + f"Invalid http.app format: '{http_conf['app']}'. " + "Must be in format './path/to/file.py:attribute_name'" + ) return config @@ -567,6 +605,59 @@ def _update_auth_path( ) +def _update_http_app_path( + config_path: pathlib.Path, config: Config, local_deps: LocalDeps +) -> None: + """Update the HTTP app path to point to the correct location in the Docker container. + + Similar to _update_graph_paths, this ensures that if a custom app is specified via + a local file path, that file is included in the Docker build context and its path + is updated to point to the correct location in the container. + """ + if not (http_config := config.get("http")) or not ( + app_str := http_config.get("app") + ): + return + + module_str, _, attr_str = app_str.partition(":") + if not module_str or not attr_str: + message = ( + 'Import string "{import_str}" must be in format ":".' + ) + raise ValueError(message.format(import_str=app_str)) + + # Check if it's a file path + if "/" in module_str or "\\" in module_str: + # Resolve the local path properly on the current OS + resolved = (config_path.parent / module_str).resolve() + if not resolved.exists(): + raise FileNotFoundError(f"Could not find HTTP app module: {resolved}") + elif not resolved.is_file(): + raise IsADirectoryError(f"HTTP app module must be a file: {resolved}") + else: + for path in local_deps.real_pkgs: + if resolved.is_relative_to(path): + container_path = ( + pathlib.Path("/deps") / path.name / resolved.relative_to(path) + ) + module_str = container_path.as_posix() + break + else: + for faux_pkg, (_, destpath) in local_deps.faux_pkgs.items(): + if resolved.is_relative_to(faux_pkg): + container_subpath = resolved.relative_to(faux_pkg) + # Construct the final path, ensuring POSIX style + module_str = f"{destpath}/{container_subpath.as_posix()}" + break + else: + raise ValueError( + f"HTTP app module '{app_str}' not found in 'dependencies' list. " + "Add its containing package to 'dependencies' list." + ) + # update the config + http_config["app"] = f"{module_str}:{attr_str}" + + def python_config_to_docker( config_path: pathlib.Path, config: Config, base_image: str ) -> tuple[str, dict[str, str]]: @@ -590,6 +681,8 @@ def python_config_to_docker( _update_graph_paths(config_path, config, local_deps) # Rewrite auth path, so it points to the correct location in the Docker container _update_auth_path(config_path, config, local_deps) + # Rewrite HTTP app path, so it points to the correct location in the Docker container + _update_http_app_path(config_path, config, local_deps) pip_pkgs_str = f"RUN {pip_install} {' '.join(pypi_deps)}" if pypi_deps else "" if local_deps.pip_reqs: @@ -662,6 +755,9 @@ ADD {relpath} /deps/{name} if (auth_config := config.get("auth")) is not None: env_vars.append(f"ENV LANGGRAPH_AUTH='{json.dumps(auth_config)}'") + if (http_config := config.get("http")) is not None: + env_vars.append(f"ENV LANGGRAPH_HTTP='{json.dumps(http_config)}'") + graphs = config["graphs"] env_vars.append(f"ENV LANGSERVE_GRAPHS='{json.dumps(graphs)}'") @@ -733,6 +829,10 @@ ENV LANGGRAPH_STORE='{json.dumps(store_config)}' if (auth_config := config.get("auth")) is not None: env_additional_config += f""" ENV LANGGRAPH_AUTH='{json.dumps(auth_config)}' +""" + if (http_config := config.get("http")) is not None: + env_additional_config += f""" +ENV LANGGRAPH_HTTP='{json.dumps(http_config)}' """ return ( diff --git a/libs/cli/tests/unit_tests/test_config.json b/libs/cli/tests/unit_tests/test_config.json index 642b30ded..3ec8f0908 100644 --- a/libs/cli/tests/unit_tests/test_config.json +++ b/libs/cli/tests/unit_tests/test_config.json @@ -6,10 +6,14 @@ ], "dependencies": [ "langchain_openai", + "starlette", "." ], "graphs": { "agent": "graphs/agent.py:graph" }, - "env": ".env" + "env": ".env", + "http": { + "app": "../../examples/my_app.py:app" + } } diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py index e5ec0307b..95e20033e 100644 --- a/libs/cli/tests/unit_tests/test_config.py +++ b/libs/cli/tests/unit_tests/test_config.py @@ -32,6 +32,7 @@ def test_validate_config(): "env": {}, "store": None, "auth": None, + "http": None, **expected_config, } actual_config = validate_config(expected_config) @@ -50,6 +51,7 @@ def test_validate_config(): "env": env, "store": None, "auth": None, + "http": None, } actual_config = validate_config(expected_config) assert actual_config == expected_config @@ -108,6 +110,18 @@ def test_validate_config(): } ) assert config["python_version"] == "3.12-slim" + with pytest.raises( + ValueError, + match="Invalid http.app format", + ): + validate_config( + { + "python_version": "3.12", + "dependencies": ["."], + "graphs": {"agent": "./agent.py:graph"}, + "http": {"app": "../../examples/my_app.py"}, + } + ) def test_validate_config_file(): @@ -180,7 +194,11 @@ def test_config_to_docker_simple(): actual_docker_stdin, additional_contexts = config_to_docker( PATH_TO_CONFIG, validate_config( - {"dependencies": [".", "../../examples/graphs_reqs_a"], "graphs": graphs} + { + "dependencies": [".", "../../examples/graphs_reqs_a", "../../examples"], + "graphs": graphs, + "http": {"app": "../../examples/my_app.py:app"}, + } ), "langchain/langgraph-api", ) @@ -190,6 +208,9 @@ FROM langchain/langgraph-api:3.11 COPY --from=__outer_requirements.txt requirements.txt /deps/__outer_graphs_reqs_a/graphs_reqs_a/requirements.txt RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -r /deps/__outer_graphs_reqs_a/graphs_reqs_a/requirements.txt # -- End of local requirements install -- +# -- Adding local package ../../examples -- +COPY --from=examples . /deps/examples +# -- End of local package ../../examples -- # -- Adding non-package dependency unit_tests -- ADD . /deps/__outer_unit_tests/unit_tests RUN set -ex && \\ @@ -215,6 +236,7 @@ RUN set -ex && \\ # -- Installing all local dependencies -- RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/* # -- End of local dependencies install -- +ENV LANGGRAPH_HTTP='{"app": "/deps/examples/my_app.py:app"}' ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}' WORKDIR /deps/__outer_unit_tests/unit_tests\ """ @@ -223,7 +245,8 @@ WORKDIR /deps/__outer_unit_tests/unit_tests\ assert additional_contexts == { "__outer_graphs_reqs_a": str( (pathlib.Path(__file__).parent / "../../examples/graphs_reqs_a").resolve() - ) + ), + "examples": str((pathlib.Path(__file__).parent / "../../examples").resolve()), }