mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-27 12:04:58 +02:00
fix(cli): preserve hosted Studio URL overrides
This commit is contained in:
@@ -5,6 +5,7 @@ import pathlib
|
||||
import shutil
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from urllib.parse import SplitResult, urlencode, urlsplit, urlunsplit
|
||||
|
||||
import click
|
||||
import click.exceptions
|
||||
@@ -231,6 +232,66 @@ cli.add_command(deploy)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _validated_http_url(value: str, option_name: str) -> SplitResult:
|
||||
try:
|
||||
parsed = urlsplit(value)
|
||||
hostname = parsed.hostname
|
||||
_ = parsed.port
|
||||
except ValueError as exc:
|
||||
raise click.UsageError(
|
||||
f"{option_name} must be a valid HTTP(S) URL without credentials."
|
||||
) from exc
|
||||
|
||||
if (
|
||||
value != value.strip()
|
||||
or parsed.scheme not in {"http", "https"}
|
||||
or not parsed.netloc
|
||||
or not hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
):
|
||||
raise click.UsageError(
|
||||
f"{option_name} must be a valid HTTP(S) URL without credentials."
|
||||
)
|
||||
return parsed
|
||||
|
||||
|
||||
def _studio_link(
|
||||
*,
|
||||
port: int,
|
||||
studio_url: str | None,
|
||||
api_url: str | None,
|
||||
debugger_base_url: str | None,
|
||||
) -> str:
|
||||
if debugger_base_url is not None:
|
||||
if api_url is not None and api_url != debugger_base_url:
|
||||
raise click.UsageError(
|
||||
"--api-url and --debugger-base-url cannot specify different URLs."
|
||||
)
|
||||
click.echo(
|
||||
"Warning: --debugger-base-url is deprecated; use --api-url instead.",
|
||||
err=True,
|
||||
)
|
||||
api_url = debugger_base_url
|
||||
|
||||
studio_url = "https://smith.langchain.com" if studio_url is None else studio_url
|
||||
api_url = f"http://127.0.0.1:{port}" if api_url is None else api_url
|
||||
studio_parts = _validated_http_url(studio_url, "--studio-url")
|
||||
_validated_http_url(api_url, "--api-url")
|
||||
if studio_parts.query or studio_parts.fragment:
|
||||
raise click.UsageError(
|
||||
"--studio-url must not include a query string or fragment."
|
||||
)
|
||||
|
||||
studio_path = f"{studio_parts.path.rstrip('/')}/studio/"
|
||||
return urlunsplit(
|
||||
studio_parts._replace(
|
||||
path=studio_path,
|
||||
query=urlencode({"baseUrl": api_url}),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@OPT_RECREATE
|
||||
@OPT_PULL
|
||||
@OPT_PORT
|
||||
@@ -241,6 +302,24 @@ cli.add_command(deploy)
|
||||
@OPT_POSTGRES_URI
|
||||
@OPT_API_VERSION
|
||||
@OPT_ENGINE_RUNTIME_MODE
|
||||
@click.option(
|
||||
"--studio-url",
|
||||
type=str,
|
||||
default=None,
|
||||
help="URL of the LangGraph Studio instance. Defaults to https://smith.langchain.com",
|
||||
)
|
||||
@click.option(
|
||||
"--api-url",
|
||||
type=str,
|
||||
default=None,
|
||||
help="URL that LangGraph Studio uses to access the API. Defaults to http://127.0.0.1:[PORT]",
|
||||
)
|
||||
@click.option(
|
||||
"--debugger-base-url",
|
||||
type=str,
|
||||
default=None,
|
||||
hidden=True,
|
||||
)
|
||||
@click.option(
|
||||
"--image",
|
||||
type=str,
|
||||
@@ -274,9 +353,18 @@ def up(
|
||||
postgres_uri: str | None,
|
||||
api_version: str | None,
|
||||
engine_runtime_mode: str,
|
||||
studio_url: str | None,
|
||||
api_url: str | None,
|
||||
debugger_base_url: str | None,
|
||||
image: str | None,
|
||||
base_image: str | None,
|
||||
):
|
||||
studio_link = _studio_link(
|
||||
port=port,
|
||||
studio_url=studio_url,
|
||||
api_url=api_url,
|
||||
debugger_base_url=debugger_base_url,
|
||||
)
|
||||
click.secho("Starting LangGraph API server...", fg="green")
|
||||
click.secho(
|
||||
"""For local dev, requires env var LANGSMITH_API_KEY with access to LangSmith Deployment.
|
||||
@@ -325,7 +413,7 @@ For production use, requires a license key in env var LANGGRAPH_CLOUD_LICENSE_KE
|
||||
f"""Ready!
|
||||
- API: http://localhost:{port}
|
||||
- Docs: http://localhost:{port}/docs
|
||||
- LangGraph Studio: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:{port}
|
||||
- LangGraph Studio: {studio_link}
|
||||
"""
|
||||
)
|
||||
sys.stdout.flush()
|
||||
|
||||
@@ -8,10 +8,11 @@ from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
import langgraph_cli.deploy as deploy_module
|
||||
from langgraph_cli.cli import cli, prepare_args_and_stdin
|
||||
from langgraph_cli.cli import _studio_link, cli, prepare_args_and_stdin
|
||||
from langgraph_cli.config import Config, _get_pip_cleanup_lines, validate_config
|
||||
from langgraph_cli.docker import DEFAULT_POSTGRES_URI, DockerCapabilities, Version
|
||||
from langgraph_cli.util import clean_empty_lines
|
||||
@@ -261,6 +262,82 @@ def test_version_option() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_up_help_shows_hosted_studio_options() -> None:
|
||||
result = CliRunner().invoke(cli, ["up", "--help"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "--studio-url" in result.output
|
||||
assert "--api-url" in result.output
|
||||
assert "--debugger-port" not in result.output
|
||||
assert "--debugger-base-url" not in result.output
|
||||
|
||||
|
||||
def test_studio_link_defaults_to_hosted_studio() -> None:
|
||||
assert _studio_link(
|
||||
port=8123,
|
||||
studio_url=None,
|
||||
api_url=None,
|
||||
debugger_base_url=None,
|
||||
) == ("https://smith.langchain.com/studio/?baseUrl=http%3A%2F%2F127.0.0.1%3A8123")
|
||||
|
||||
|
||||
def test_studio_link_supports_self_hosted_and_remote_urls() -> None:
|
||||
assert _studio_link(
|
||||
port=8123,
|
||||
studio_url="https://langsmith.example.com/prefix/",
|
||||
api_url="https://api.example.com/graph?tenant=a®ion=eu",
|
||||
debugger_base_url=None,
|
||||
) == (
|
||||
"https://langsmith.example.com/prefix/studio/"
|
||||
"?baseUrl=https%3A%2F%2Fapi.example.com%2Fgraph%3Ftenant%3Da%26region%3Deu"
|
||||
)
|
||||
|
||||
|
||||
def test_studio_link_supports_deprecated_debugger_base_url(capsys) -> None:
|
||||
assert _studio_link(
|
||||
port=8123,
|
||||
studio_url=None,
|
||||
api_url=None,
|
||||
debugger_base_url="https://api.example.com",
|
||||
).endswith("?baseUrl=https%3A%2F%2Fapi.example.com")
|
||||
assert "--debugger-base-url is deprecated; use --api-url" in capsys.readouterr().err
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("studio_url", "api_url"),
|
||||
[
|
||||
("javascript:alert(1)", None),
|
||||
("https://user:password@example.com", None),
|
||||
("https://smith.langchain.com?workspace=test", None),
|
||||
(None, "file:///tmp/langgraph.sock"),
|
||||
(None, "https://user:password@example.com"),
|
||||
],
|
||||
)
|
||||
def test_studio_link_rejects_unsafe_urls(
|
||||
studio_url: str | None, api_url: str | None
|
||||
) -> None:
|
||||
with pytest.raises(click.UsageError):
|
||||
_studio_link(
|
||||
port=8123,
|
||||
studio_url=studio_url,
|
||||
api_url=api_url,
|
||||
debugger_base_url=None,
|
||||
)
|
||||
|
||||
|
||||
def test_studio_link_rejects_conflicting_api_url_aliases() -> None:
|
||||
with pytest.raises(
|
||||
click.UsageError,
|
||||
match="cannot specify different URLs",
|
||||
):
|
||||
_studio_link(
|
||||
port=8123,
|
||||
studio_url=None,
|
||||
api_url="https://api.example.com",
|
||||
debugger_base_url="https://other.example.com",
|
||||
)
|
||||
|
||||
|
||||
def test_top_level_help_shows_deploy_subcommands() -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user