Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/scripts/control_plane.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,18 +260,21 @@ def create_deployment(
source_config: Dict[str, Any],
source_revision_config: Dict[str, Any],
secrets: List[Dict[str, str]],
route_through_gateway: Optional[bool] = None,
) -> Dict[str, Any]:
"""Create a deployment and return the created resource."""
validate_new_deployment_name(
name, TARGET_SELF_HOSTED if source == "external_docker" else TARGET_SAAS
)
body = {
body: Dict[str, Any] = {
"name": name,
"source": source,
"source_config": source_config,
"source_revision_config": source_revision_config,
"secrets": secrets,
}
if route_through_gateway is not None:
body["route_through_gateway"] = route_through_gateway
try:
response = self._request(
"POST", self._url("deployments"), json=body, expected=(200, 201)
Expand All @@ -297,6 +300,7 @@ def patch_deployment(
source_revision_config: Dict[str, Any],
secrets: Optional[List[Dict[str, str]]] = None,
source_config: Optional[Dict[str, Any]] = None,
route_through_gateway: Optional[bool] = None,
) -> Dict[str, Any]:
"""Create a new revision of an existing deployment.

Expand All @@ -308,6 +312,8 @@ def patch_deployment(
body["secrets"] = secrets
if source_config is not None:
body["source_config"] = source_config
if route_through_gateway is not None:
body["route_through_gateway"] = route_through_gateway
response = self._request(
"PATCH",
self._url("deployments", deployment_id),
Expand Down
20 changes: 20 additions & 0 deletions .github/scripts/langgraph_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ def deploy(
payload["source_revision_config"],
secrets=secrets,
source_config=mutable_config,
route_through_gateway=args.route_through_gateway,
)
else:
print(f"🆕 Creating deployment {name}.")
Expand All @@ -174,6 +175,7 @@ def deploy(
payload["source_config"],
payload["source_revision_config"],
secrets,
route_through_gateway=args.route_through_gateway,
)

print_deployment(deployment)
Expand Down Expand Up @@ -387,6 +389,16 @@ def parse_args(argv: List[str]) -> argparse.Namespace:
f"Default: {', '.join(DEFAULT_SECRET_ENV_VARS)}. Values are read from the "
"environment and never logged.",
)
parser.add_argument(
"--route-through-gateway",
action=argparse.BooleanOptionalAction,
default=None,
help="Cloud only. Have the platform route OpenAI and Anthropic calls "
"through the LangSmith LLM Gateway, so the deployment needs no provider "
"key of its own. Self-hosted control planes do not implement this field "
"and silently ignore it; forward a Cloud key as LLM_GATEWAY_API_KEY "
"instead, or give the deployment a real provider key.",
)
parser.add_argument(
"--wait",
action="store_true",
Expand Down Expand Up @@ -428,6 +440,14 @@ def main(argv: List[str]) -> int:

print(f"🎯 Target: {args.target} • control plane: {client.host}")

if args.route_through_gateway and args.target != TARGET_SAAS:
print(
"⚠️ --route-through-gateway is a Cloud-only field. This self-hosted "
"control plane does not implement it and will ignore it silently. "
"Forward a Cloud key as LLM_GATEWAY_API_KEY, or set a provider key.",
file=sys.stderr,
)

try:
if args.action == "cleanup-preview":
# No target: an existing deployment whose name predates the length
Expand Down
20 changes: 20 additions & 0 deletions .github/workflows/DEPLOYMENT_PIPELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,26 @@ python .github/scripts/langgraph_api.py \
`--action status --name t2sql-demo` and removed with `--action cleanup-preview
--name t2sql-demo`. The self-hosted length limit still applies when creating.

## Model credentials differ by hosting model

The LLM Gateway is a **Cloud** service (`gateway.smith.langchain.com`). That
matters more than it sounds:

| | Cloud (SaaS) | Self-Hosted |
|---|---|---|
| Platform-managed gateway routing (`route_through_gateway`) | supported | **not implemented — silently ignored** |
| Gateway with a forwarded key | `LLM_GATEWAY_API_KEY`, or the injected `LANGSMITH_API_KEY` | `LLM_GATEWAY_API_KEY` must be a **Cloud** key |
| Direct provider | `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` | same |

A self-hosted instance's own API key **cannot** call the gateway; it returns
`403`. The gateway only accepts Cloud keys, which is why `LLM_GATEWAY_API_KEY`
exists separately from `LANGSMITH_API_KEY`.

So a self-hosted deployment has two real options: forward a Cloud key, or give
it a provider key. There is no "the platform handles it" path — that is Cloud
only, and passing `--route-through-gateway` to a self-hosted control plane is
accepted by the CLI and then dropped on the floor by the API, so the CLI warns.

## Naming

- Preview deployments: `<prefix>-pr-<pr-number>` (deployment type `dev`), default `text2sql-pr-<n>`
Expand Down
34 changes: 34 additions & 0 deletions tests/deployment/test_control_plane.py
Original file line number Diff line number Diff line change
Expand Up @@ -704,3 +704,37 @@ def test_self_hosted_serving_url_comes_from_custom_url():
)

assert control_plane.deployment_url({"url": None, "source_config": {}}) is None


@pytest.mark.deployment
def test_route_through_gateway_is_sent_when_requested():
"""Cloud accepts the flag; the client must actually include it."""
import inspect

src = inspect.getsource(control_plane.ControlPlaneClient.create_deployment)
assert "route_through_gateway" in src
src = inspect.getsource(control_plane.ControlPlaneClient.patch_deployment)
assert "route_through_gateway" in src


@pytest.mark.deployment
def test_route_through_gateway_help_states_it_is_cloud_only():
"""The flag is silently ignored by self-hosted, so the help must say so."""
import pathlib
import subprocess
import sys as _s

out = subprocess.run(
[
_s.executable,
str(
pathlib.Path(__file__).resolve().parents[2]
/ ".github/scripts/langgraph_api.py"
),
"--help",
],
capture_output=True,
text=True,
).stdout
assert "Cloud only" in out
assert "silently ignore" in out