diff --git a/libs/cli/langgraph_cli/config.py b/libs/cli/langgraph_cli/config.py index f11850016..826ec3b89 100644 --- a/libs/cli/langgraph_cli/config.py +++ b/libs/cli/langgraph_cli/config.py @@ -326,6 +326,10 @@ class Config(TypedDict, total=False): Must be >= 20 if provided. """ + _INTERNAL_docker_tag: Optional[str] + """Optional. Internal use only. + """ + pip_config_file: Optional[str] """Optional. Path to a pip config file (e.g., "/etc/pip.conf" or "pip.ini") for controlling package installation (custom indices, credentials, etc.). @@ -480,6 +484,7 @@ def validate_config(config: Config) -> Config: "node_version": node_version, "python_version": python_version, "pip_config_file": config.get("pip_config_file"), + "_INTERNAL_docker_tag": config.get("_INTERNAL_docker_tag"), "dependencies": config.get("dependencies", []), "dockerfile_lines": config.get("dockerfile_lines", []), "graphs": config.get("graphs", {}), @@ -1025,7 +1030,9 @@ def _get_node_pm_install_cmd(config_path: pathlib.Path, config: Config) -> str: def python_config_to_docker( - config_path: pathlib.Path, config: Config, base_image: str + config_path: pathlib.Path, + config: Config, + base_image: str, ) -> tuple[str, dict[str, str]]: """Generate a Dockerfile from the configuration.""" # configure pip @@ -1040,6 +1047,8 @@ def python_config_to_docker( else "" ) + docker_tag = config.get("_INTERNAL_docker_tag") or config["python_version"] + # collect dependencies pypi_deps = [dep for dep in config["dependencies"] if not dep.startswith(".")] local_deps = _assemble_local_deps(config_path, config) @@ -1160,7 +1169,7 @@ ADD {relpath} /deps/{name} ) docker_file_contents = [ - f"FROM {base_image}:{config['python_version']}", + f"FROM {base_image}:{docker_tag}", "", os.linesep.join(config["dockerfile_lines"]), "", @@ -1192,10 +1201,13 @@ ADD {relpath} /deps/{name} def node_config_to_docker( - config_path: pathlib.Path, config: Config, base_image: str + config_path: pathlib.Path, + config: Config, + base_image: str, ) -> tuple[str, dict[str, str]]: faux_path = f"/deps/{config_path.parent.name}" install_cmd = _get_node_pm_install_cmd(config_path, config) + docker_tag = config.get("_INTERNAL_docker_tag") or config["node_version"] env_vars: list[str] = [] @@ -1222,7 +1234,7 @@ def node_config_to_docker( env_vars.append(f"ENV LANGSERVE_GRAPHS='{json.dumps(config['graphs'])}'") docker_file_contents = [ - f"FROM {base_image}:{config['node_version']}", + f"FROM {base_image}:{docker_tag}", "", os.linesep.join(config["dockerfile_lines"]), "", @@ -1246,8 +1258,13 @@ def default_base_image(config: Config) -> str: return "langchain/langgraph-api" -def docker_tag(config: Config, base_image: Optional[str] = None) -> str: +def docker_tag( + config: Config, + base_image: Optional[str] = None, +) -> str: base_image = base_image or default_base_image(config) + if config.get("_INTERNAL_docker_tag"): + return f"{base_image}:{config['_INTERNAL_docker_tag']}" if config.get("node_version") and not config.get("python_version"): return f"{base_image}:{config['node_version']}" @@ -1255,7 +1272,9 @@ def docker_tag(config: Config, base_image: Optional[str] = None) -> str: def config_to_docker( - config_path: pathlib.Path, config: Config, base_image: Optional[str] = None + config_path: pathlib.Path, + config: Config, + base_image: Optional[str] = None, ) -> tuple[str, dict[str, str]]: base_image = base_image or default_base_image(config) diff --git a/libs/cli/pyproject.toml b/libs/cli/pyproject.toml index a69df77ed..ccb8822fd 100644 --- a/libs/cli/pyproject.toml +++ b/libs/cli/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "langgraph-cli" -version = "0.2.4" +version = "0.2.5" description = "CLI for interacting with LangGraph API" authors = [] license = "MIT" diff --git a/libs/cli/schemas/schema.json b/libs/cli/schemas/schema.json index 015f0e209..845e5e0ab 100644 --- a/libs/cli/schemas/schema.json +++ b/libs/cli/schemas/schema.json @@ -29,6 +29,17 @@ ], "description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n" }, + "_INTERNAL_docker_tag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. Internal use only.\n" + }, "auth": { "anyOf": [ { @@ -145,6 +156,17 @@ ], "description": "Optional. Node.js version as a major version (e.g. '20'), if your deployment needs Node.\nMust be >= 20 if provided.\n" }, + "_INTERNAL_docker_tag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. Internal use only.\n" + }, "auth": { "anyOf": [ { diff --git a/libs/cli/schemas/schema.v0.json b/libs/cli/schemas/schema.v0.json index 015f0e209..845e5e0ab 100644 --- a/libs/cli/schemas/schema.v0.json +++ b/libs/cli/schemas/schema.v0.json @@ -29,6 +29,17 @@ ], "description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n" }, + "_INTERNAL_docker_tag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. Internal use only.\n" + }, "auth": { "anyOf": [ { @@ -145,6 +156,17 @@ ], "description": "Optional. Node.js version as a major version (e.g. '20'), if your deployment needs Node.\nMust be >= 20 if provided.\n" }, + "_INTERNAL_docker_tag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. Internal use only.\n" + }, "auth": { "anyOf": [ { diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py index 0249510ab..cb25eee30 100644 --- a/libs/cli/tests/unit_tests/test_config.py +++ b/libs/cli/tests/unit_tests/test_config.py @@ -29,6 +29,7 @@ def test_validate_config(): } actual_config = validate_config(expected_config) expected_config = { + "_INTERNAL_docker_tag": None, "python_version": "3.11", "node_version": None, "pip_config_file": None, @@ -47,6 +48,7 @@ def test_validate_config(): # full config env = ".env" expected_config = { + "_INTERNAL_docker_tag": None, "python_version": "3.12", "node_version": None, "pip_config_file": "pipconfig.txt", @@ -567,6 +569,39 @@ RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not foun assert additional_contexts == {} +def test_config_to_docker_nodejs_internal_docker_tag(): + graphs = {"agent": "./graphs/agent.js:graph"} + actual_docker_stdin, additional_contexts = config_to_docker( + PATH_TO_CONFIG, + validate_config( + { + "node_version": "20", + "graphs": graphs, + "dockerfile_lines": ["ARG meow", "ARG foo"], + "auth": {"path": "./graphs/auth.mts:auth"}, + "ui": {"agent": "./graphs/agent.ui.jsx"}, + "ui_config": {"shared": ["nuqs"]}, + "_INTERNAL_docker_tag": "my-tag", + } + ), + "langchain/langgraphjs-api", + ) + expected_docker_stdin = """FROM langchain/langgraphjs-api:my-tag +ARG meow +ARG foo +ADD . /deps/unit_tests +RUN cd /deps/unit_tests && npm i +ENV LANGGRAPH_AUTH='{"path": "./graphs/auth.mts:auth"}' +ENV LANGGRAPH_UI='{"agent": "./graphs/agent.ui.jsx"}' +ENV LANGGRAPH_UI_CONFIG='{"shared": ["nuqs"]}' +ENV LANGSERVE_GRAPHS='{"agent": "./graphs/agent.js:graph"}' +WORKDIR /deps/unit_tests +RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts""" + + assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin + assert additional_contexts == {} + + def test_config_to_docker_gen_ui_python(): graphs = {"agent": "./agent.py:graph"} actual_docker_stdin, additional_contexts = config_to_docker(