diff --git a/.github/scripts/control_plane.py b/.github/scripts/control_plane.py index e6f6e5d..81105d9 100644 --- a/.github/scripts/control_plane.py +++ b/.github/scripts/control_plane.py @@ -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) @@ -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. @@ -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), diff --git a/.github/scripts/langgraph_api.py b/.github/scripts/langgraph_api.py index 8e96d9c..fe552c8 100755 --- a/.github/scripts/langgraph_api.py +++ b/.github/scripts/langgraph_api.py @@ -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}.") @@ -174,6 +175,7 @@ def deploy( payload["source_config"], payload["source_revision_config"], secrets, + route_through_gateway=args.route_through_gateway, ) print_deployment(deployment) @@ -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", @@ -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 diff --git a/.github/workflows/DEPLOYMENT_PIPELINE.md b/.github/workflows/DEPLOYMENT_PIPELINE.md index 56ef7a3..fb5bcc0 100644 --- a/.github/workflows/DEPLOYMENT_PIPELINE.md +++ b/.github/workflows/DEPLOYMENT_PIPELINE.md @@ -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: `-pr-` (deployment type `dev`), default `text2sql-pr-` diff --git a/tests/deployment/test_control_plane.py b/tests/deployment/test_control_plane.py index 4934fe4..fdb6335 100644 --- a/tests/deployment/test_control_plane.py +++ b/tests/deployment/test_control_plane.py @@ -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