mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-17 21:25:46 +02:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8699989b1a | ||
|
|
468c7546c8 |
@@ -287,6 +287,18 @@ def validate_config(config: Config) -> Config:
|
||||
f"Invalid http.app format: '{http_conf['app']}'. "
|
||||
"Must be in format './path/to/file.py:attribute_name'"
|
||||
)
|
||||
if "apps" in http_conf and http_conf["apps"]:
|
||||
for prefix, app_path in http_conf["apps"].items():
|
||||
if not prefix.startswith("/"):
|
||||
raise ValueError(
|
||||
f"Invalid http.apps prefix: '{prefix}'. Must start with '/'"
|
||||
)
|
||||
if ":" not in app_path:
|
||||
raise ValueError(
|
||||
f"Invalid http.apps format for prefix '{prefix}': '{app_path}'. "
|
||||
"Must be in format './path/to/file:attribute_name' "
|
||||
"or 'package:attribute_name'"
|
||||
)
|
||||
if keep_pkg_tools := config.get("keep_pkg_tools"):
|
||||
if isinstance(keep_pkg_tools, list):
|
||||
for tool in keep_pkg_tools:
|
||||
@@ -807,6 +819,94 @@ def _update_http_app_path(
|
||||
http_config["app"] = f"{module_str}:{attr_str}"
|
||||
|
||||
|
||||
_JS_EXTENSIONS = frozenset({".js", ".ts", ".mts", ".mjs", ".cts", ".cjs"})
|
||||
|
||||
|
||||
def _is_js_http_app(app_path: str) -> bool:
|
||||
"""Check if an http.apps entry is a JavaScript/TypeScript app or npm package."""
|
||||
module_str = app_path.split(":")[0]
|
||||
ext = os.path.splitext(module_str)[1]
|
||||
if ext:
|
||||
return ext in _JS_EXTENSIONS
|
||||
# No extension and not a file path → npm package reference (JS)
|
||||
if not module_str.startswith((".", "/")):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _has_js_http_apps(config: Config) -> bool:
|
||||
"""Check if any http.apps entries are JavaScript apps."""
|
||||
http_config = config.get("http")
|
||||
if not http_config:
|
||||
return False
|
||||
apps = http_config.get("apps")
|
||||
if not apps:
|
||||
return False
|
||||
return any(_is_js_http_app(app_path) for app_path in apps.values())
|
||||
|
||||
|
||||
def _update_http_apps_paths(
|
||||
config_path: pathlib.Path, config: Config, local_deps: LocalDeps
|
||||
) -> None:
|
||||
"""Update HTTP apps paths to point to the correct location in the Docker container.
|
||||
|
||||
Similar to _update_http_app_path, but handles the `http.apps` dictionary
|
||||
which maps path prefixes to app module paths. Python app paths are rewritten
|
||||
to container paths; npm package references are left unchanged.
|
||||
"""
|
||||
if not (http_config := config.get("http")) or not (apps := http_config.get("apps")):
|
||||
return
|
||||
|
||||
for prefix, app_str in apps.items():
|
||||
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))
|
||||
|
||||
# Skip npm package references (bare module names without path separators)
|
||||
if (
|
||||
not module_str.startswith((".", "/"))
|
||||
and not os.path.splitext(module_str)[1]
|
||||
):
|
||||
continue
|
||||
|
||||
if "/" in module_str or "\\" in module_str:
|
||||
resolved = (config_path.parent / module_str).resolve()
|
||||
if not resolved.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Could not find HTTP app module for prefix '{prefix}': {resolved}"
|
||||
)
|
||||
elif not resolved.is_file():
|
||||
raise IsADirectoryError(
|
||||
f"HTTP app module must be a file for prefix '{prefix}': {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)
|
||||
module_str = f"{destpath}/{container_subpath.as_posix()}"
|
||||
break
|
||||
else:
|
||||
raise ValueError(
|
||||
f"HTTP app module '{app_str}' for prefix '{prefix}' "
|
||||
"not found in 'dependencies' list. "
|
||||
"Add its containing package to 'dependencies' list."
|
||||
)
|
||||
apps[prefix] = f"{module_str}:{attr_str}"
|
||||
|
||||
|
||||
def _get_node_pm_install_cmd(config_path: pathlib.Path, config: Config) -> str:
|
||||
def test_file(file_name):
|
||||
full_path = config_path.parent / file_name
|
||||
@@ -957,6 +1057,8 @@ def python_config_to_docker(
|
||||
_update_checkpointer_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)
|
||||
# Rewrite HTTP apps paths, so they point to the correct location in the Docker container
|
||||
_update_http_apps_paths(config_path, config, local_deps)
|
||||
|
||||
pip_pkgs_str = (
|
||||
f"RUN {local_reqs_pip_install} {' '.join(pypi_deps)}" if pypi_deps else ""
|
||||
@@ -1017,9 +1119,11 @@ ADD {relpath} /deps/{name}
|
||||
for fullpath, (relpath, name) in local_deps.real_pkgs.items()
|
||||
)
|
||||
|
||||
has_js_apps = _has_js_http_apps(config)
|
||||
install_node_str: str = (
|
||||
"RUN /storage/install-node.sh"
|
||||
if (config.get("ui") or config.get("node_version")) and local_deps.working_dir
|
||||
if ((config.get("ui") or config.get("node_version")) and local_deps.working_dir)
|
||||
or has_js_apps
|
||||
else ""
|
||||
)
|
||||
|
||||
@@ -1078,6 +1182,10 @@ ADD {relpath} /deps/{name}
|
||||
"# -- End of JS dependencies install --",
|
||||
]
|
||||
)
|
||||
elif has_js_apps:
|
||||
js_inst_str = (
|
||||
f"ENV NODE_VERSION={config.get('node_version') or DEFAULT_NODE_VERSION}"
|
||||
)
|
||||
image_str = docker_tag(config, base_image, api_version)
|
||||
|
||||
# Prepare docker file contents
|
||||
|
||||
@@ -446,6 +446,25 @@ class HttpConfig(TypedDict, total=False):
|
||||
Format: "path/to/module.py:app_var"
|
||||
If provided, it can override or extend the default routes.
|
||||
"""
|
||||
apps: dict[str, str] | None
|
||||
"""Optional. Record mapping path prefix to app module path.
|
||||
|
||||
Each key is a URL path prefix (e.g., "/dashboard"), and each value is an
|
||||
import path in "file:export" format (e.g., "./dashboard.py:app").
|
||||
|
||||
Python apps (.py) are mounted in-process as FastAPI/Starlette sub-applications.
|
||||
JS apps (.js, .ts, .mts, .mjs) are spawned as Node.js subprocess servers
|
||||
with a reverse proxy forwarding requests from the main server.
|
||||
npm package references (paths not starting with . or /) are resolved as
|
||||
npm imports in the JS subprocess.
|
||||
|
||||
Example:
|
||||
{
|
||||
"/dashboard": "./dashboard.py:app",
|
||||
"/ext": "./extension.js:app",
|
||||
"/conduit": "some-npm-package:app"
|
||||
}
|
||||
"""
|
||||
disable_assistants: bool
|
||||
"""Optional. If `True`, /assistants routes are removed from the server.
|
||||
|
||||
|
||||
@@ -687,7 +687,7 @@
|
||||
},
|
||||
"EncryptionConfig": {
|
||||
"title": "EncryptionConfig",
|
||||
"description": "Configuration for custom at-rest encryption logic.\n\nAllows you to implement custom encryption for sensitive data stored in the database,\nincluding metadata fields and checkpoint blobs.",
|
||||
"description": "Configuration for custom at-rest encryption logic.\n\n Allows you to implement custom encryption for sensitive data stored in the database,\n including metadata fields and checkpoint blobs.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
@@ -728,6 +728,20 @@
|
||||
"type": "string",
|
||||
"description": "Optional. Import path to a custom Starlette/FastAPI application to mount.\n"
|
||||
},
|
||||
"apps": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional. Record mapping path prefix to app module path.\n\nEach key is a URL path prefix (e.g., \"/dashboard\"), and each value is an\n\nPython apps (.py) are mounted in-process as FastAPI/Starlette sub-applications.\nJS apps (.js, .ts, .mts, .mjs) are spawned as Node.js subprocess servers\nwith a reverse proxy forwarding requests from the main server.\nnpm package references (paths not starting with . or /) are resolved as\nnpm imports in the JS subprocess.\n"
|
||||
},
|
||||
"configurable_headers": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
@@ -687,7 +687,7 @@
|
||||
},
|
||||
"EncryptionConfig": {
|
||||
"title": "EncryptionConfig",
|
||||
"description": "Configuration for custom at-rest encryption logic.\n\nAllows you to implement custom encryption for sensitive data stored in the database,\nincluding metadata fields and checkpoint blobs.",
|
||||
"description": "Configuration for custom at-rest encryption logic.\n\n Allows you to implement custom encryption for sensitive data stored in the database,\n including metadata fields and checkpoint blobs.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
@@ -728,6 +728,20 @@
|
||||
"type": "string",
|
||||
"description": "Optional. Import path to a custom Starlette/FastAPI application to mount.\n"
|
||||
},
|
||||
"apps": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional. Record mapping path prefix to app module path.\n\nEach key is a URL path prefix (e.g., \"/dashboard\"), and each value is an\n\nPython apps (.py) are mounted in-process as FastAPI/Starlette sub-applications.\nJS apps (.js, .ts, .mts, .mjs) are spawned as Node.js subprocess servers\nwith a reverse proxy forwarding requests from the main server.\nnpm package references (paths not starting with . or /) are resolved as\nnpm imports in the JS subprocess.\n"
|
||||
},
|
||||
"configurable_headers": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
@@ -11,6 +11,8 @@ import pytest
|
||||
from langgraph_cli.config import (
|
||||
_BUILD_TOOLS,
|
||||
_get_pip_cleanup_lines,
|
||||
_has_js_http_apps,
|
||||
_is_js_http_app,
|
||||
config_to_compose,
|
||||
config_to_docker,
|
||||
default_base_image,
|
||||
@@ -150,6 +152,148 @@ def test_validate_config():
|
||||
)
|
||||
|
||||
|
||||
def test_validate_config_http_apps():
|
||||
"""Test validation of http.apps field."""
|
||||
# Valid http.apps config
|
||||
config = validate_config(
|
||||
{
|
||||
"python_version": "3.12",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"http": {
|
||||
"apps": {
|
||||
"/dashboard": "./dashboard.py:app",
|
||||
"/ext": "./extension.js:app",
|
||||
"/conduit": "some-npm-package:app",
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
assert config["http"]["apps"] == {
|
||||
"/dashboard": "./dashboard.py:app",
|
||||
"/ext": "./extension.js:app",
|
||||
"/conduit": "some-npm-package:app",
|
||||
}
|
||||
|
||||
# Valid with both app and apps
|
||||
config = validate_config(
|
||||
{
|
||||
"python_version": "3.12",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"http": {
|
||||
"app": "./custom_routes.py:app",
|
||||
"apps": {
|
||||
"/dashboard": "./dashboard.py:app",
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
assert config["http"]["app"] == "./custom_routes.py:app"
|
||||
assert config["http"]["apps"] == {"/dashboard": "./dashboard.py:app"}
|
||||
|
||||
# Invalid prefix (doesn't start with /)
|
||||
with pytest.raises(ValueError, match="Invalid http.apps prefix"):
|
||||
validate_config(
|
||||
{
|
||||
"python_version": "3.12",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"http": {"apps": {"dashboard": "./dashboard.py:app"}},
|
||||
}
|
||||
)
|
||||
|
||||
# Invalid app path (no colon separator)
|
||||
with pytest.raises(ValueError, match="Invalid http.apps format"):
|
||||
validate_config(
|
||||
{
|
||||
"python_version": "3.12",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"http": {"apps": {"/dashboard": "./dashboard.py"}},
|
||||
}
|
||||
)
|
||||
|
||||
# Empty apps dict is fine (no-op)
|
||||
config = validate_config(
|
||||
{
|
||||
"python_version": "3.12",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"http": {"apps": {}},
|
||||
}
|
||||
)
|
||||
assert config["http"]["apps"] == {}
|
||||
|
||||
# None apps is fine
|
||||
config = validate_config(
|
||||
{
|
||||
"python_version": "3.12",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"http": {"apps": None},
|
||||
}
|
||||
)
|
||||
assert config["http"]["apps"] is None
|
||||
|
||||
|
||||
def test_is_js_http_app():
|
||||
"""Test _is_js_http_app detection logic."""
|
||||
# Python files are not JS
|
||||
assert not _is_js_http_app("./dashboard.py:app")
|
||||
assert not _is_js_http_app("./app.pyx:app")
|
||||
|
||||
# JS/TS files are JS
|
||||
assert _is_js_http_app("./extension.js:app")
|
||||
assert _is_js_http_app("./extension.ts:app")
|
||||
assert _is_js_http_app("./extension.mts:app")
|
||||
assert _is_js_http_app("./extension.mjs:app")
|
||||
assert _is_js_http_app("./extension.cts:app")
|
||||
assert _is_js_http_app("./extension.cjs:app")
|
||||
|
||||
# npm package references (bare module names) are JS
|
||||
assert _is_js_http_app("some-npm-package:app")
|
||||
assert _is_js_http_app("@scope/package:app")
|
||||
|
||||
# File paths without extensions that start with . or / are not JS
|
||||
assert not _is_js_http_app("./mymodule:app")
|
||||
|
||||
|
||||
def test_has_js_http_apps():
|
||||
"""Test _has_js_http_apps config detection."""
|
||||
# No http config
|
||||
assert not _has_js_http_apps({})
|
||||
|
||||
# No apps field
|
||||
assert not _has_js_http_apps({"http": {}})
|
||||
|
||||
# Empty apps
|
||||
assert not _has_js_http_apps({"http": {"apps": {}}})
|
||||
|
||||
# Python-only apps
|
||||
assert not _has_js_http_apps(
|
||||
{"http": {"apps": {"/dashboard": "./dashboard.py:app"}}}
|
||||
)
|
||||
|
||||
# JS apps
|
||||
assert _has_js_http_apps({"http": {"apps": {"/ext": "./extension.js:app"}}})
|
||||
|
||||
# npm package
|
||||
assert _has_js_http_apps({"http": {"apps": {"/conduit": "some-npm-package:app"}}})
|
||||
|
||||
# Mixed
|
||||
assert _has_js_http_apps(
|
||||
{
|
||||
"http": {
|
||||
"apps": {
|
||||
"/dashboard": "./dashboard.py:app",
|
||||
"/ext": "./extension.js:app",
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_validate_config_image_distro():
|
||||
"""Test validation of image_distro field."""
|
||||
# Valid image_distro values should work
|
||||
@@ -491,6 +635,111 @@ WORKDIR /deps/outer-unit_tests/unit_tests\
|
||||
}
|
||||
|
||||
|
||||
def test_config_to_docker_with_http_apps_python():
|
||||
"""Test Docker generation with http.apps containing Python apps."""
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
actual_docker_stdin, additional_contexts = config_to_docker(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config(
|
||||
{
|
||||
"dependencies": [".", "../../examples"],
|
||||
"graphs": graphs,
|
||||
"http": {
|
||||
"apps": {
|
||||
"/dashboard": "../../examples/my_app.py:app",
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
base_image="langchain/langgraph-api",
|
||||
)
|
||||
# Python http.apps paths should be rewritten to container paths
|
||||
assert "LANGGRAPH_HTTP" in actual_docker_stdin
|
||||
assert "/deps/examples/my_app.py:app" in actual_docker_stdin
|
||||
assert "/dashboard" in actual_docker_stdin
|
||||
# No Node.js installation for Python-only apps
|
||||
assert "install-node" not in actual_docker_stdin
|
||||
|
||||
|
||||
def test_config_to_docker_with_http_apps_js():
|
||||
"""Test Docker generation with http.apps containing JS apps triggers Node install."""
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
# Use npm package references to avoid needing actual JS files on disk
|
||||
actual_docker_stdin, _ = config_to_docker(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config(
|
||||
{
|
||||
"dependencies": ["."],
|
||||
"graphs": graphs,
|
||||
"http": {
|
||||
"apps": {
|
||||
"/ext": "my-js-package:app",
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
base_image="langchain/langgraph-api",
|
||||
)
|
||||
# JS http.apps should trigger Node.js installation
|
||||
assert "install-node" in actual_docker_stdin
|
||||
assert "NODE_VERSION=20" in actual_docker_stdin
|
||||
assert "LANGGRAPH_HTTP" in actual_docker_stdin
|
||||
|
||||
|
||||
def test_config_to_docker_with_http_apps_npm_package():
|
||||
"""Test Docker generation with http.apps containing npm package triggers Node install."""
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
actual_docker_stdin, _ = config_to_docker(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config(
|
||||
{
|
||||
"dependencies": ["."],
|
||||
"graphs": graphs,
|
||||
"http": {
|
||||
"apps": {
|
||||
"/conduit": "some-npm-package:app",
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
base_image="langchain/langgraph-api",
|
||||
)
|
||||
# npm package should trigger Node.js installation
|
||||
assert "install-node" in actual_docker_stdin
|
||||
assert "NODE_VERSION=20" in actual_docker_stdin
|
||||
assert "LANGGRAPH_HTTP" in actual_docker_stdin
|
||||
# npm package path should not be rewritten
|
||||
assert "some-npm-package:app" in actual_docker_stdin
|
||||
|
||||
|
||||
def test_config_to_docker_with_http_apps_mixed():
|
||||
"""Test Docker generation with mixed Python and JS http.apps."""
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
actual_docker_stdin, _ = config_to_docker(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config(
|
||||
{
|
||||
"dependencies": [".", "../../examples"],
|
||||
"graphs": graphs,
|
||||
"http": {
|
||||
"app": "../../examples/my_app.py:app",
|
||||
"apps": {
|
||||
"/dashboard": "../../examples/my_app.py:app",
|
||||
"/conduit": "some-npm-package:app",
|
||||
},
|
||||
},
|
||||
}
|
||||
),
|
||||
base_image="langchain/langgraph-api",
|
||||
)
|
||||
# Should have both Python path rewriting and Node.js installation
|
||||
assert "install-node" in actual_docker_stdin
|
||||
assert "NODE_VERSION=20" in actual_docker_stdin
|
||||
assert "LANGGRAPH_HTTP" in actual_docker_stdin
|
||||
# Python paths should be rewritten
|
||||
assert "/deps/examples/my_app.py:app" in actual_docker_stdin
|
||||
|
||||
|
||||
def test_config_to_docker_outside_path():
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
actual_docker_stdin, additional_contexts = config_to_docker(
|
||||
|
||||
Reference in New Issue
Block a user