mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-22 09:35:07 +02:00
[CLI] Support http config (#3505)
Right now requires that the file be in one of the local packages/dependencies.
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from contextvars import ContextVar
|
||||
from typing import Any
|
||||
|
||||
from starlette.applications 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
|
||||
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):
|
||||
"""A great route."""
|
||||
assert my_context_var.get() == "Foobar"
|
||||
assert LIFESPAN_VAL == "foobar-lifespan"
|
||||
return JSONResponse({"foo": "bar"})
|
||||
|
||||
|
||||
async def runs_afakeroute(request):
|
||||
"""Another great route."""
|
||||
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,
|
||||
)
|
||||
@@ -650,6 +650,7 @@ def dev(
|
||||
store=config_json.get("store"),
|
||||
wait_for_client=wait_for_client,
|
||||
auth=config_json.get("auth"),
|
||||
http=config_json.get("http"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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 "<module>:<attribute>".'
|
||||
)
|
||||
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 (
|
||||
|
||||
Generated
+450
-345
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "langgraph-cli"
|
||||
version = "0.1.71"
|
||||
version = "0.1.72"
|
||||
description = "CLI for interacting with LangGraph API"
|
||||
authors = []
|
||||
license = "MIT"
|
||||
@@ -14,7 +14,7 @@ langgraph = "langgraph_cli.cli:cli"
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.9.0,<4.0"
|
||||
click = "^8.1.7"
|
||||
langgraph-api = { version = ">=0.0.12,<0.1.0", optional = true, python = ">=3.11,<4.0" }
|
||||
langgraph-api = { version = ">=0.0.24,<0.1.0", optional = true, python = ">=3.11,<4.0" }
|
||||
python-dotenv = { version = ">=0.8.0", optional = true }
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
from langgraph.func import entrypoint
|
||||
|
||||
|
||||
@entrypoint()
|
||||
def graph(state):
|
||||
return None
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user