diff --git a/agentkit/sdk/runtime/gateway.py b/agentkit/sdk/runtime/gateway.py new file mode 100644 index 00000000..6af62d70 --- /dev/null +++ b/agentkit/sdk/runtime/gateway.py @@ -0,0 +1,90 @@ +# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import Any, Optional + +RUNTIME_GATEWAY_MODES = {"Shared", "Exclusive"} + + +def normalize_runtime_gateway_mode( + value: Any, + label: str = "runtime.gateway_mode", +) -> Optional[str]: + if value is None or value == "": + return None + if not isinstance(value, str): + raise ValueError(f"{label} must be Shared or Exclusive.") + + normalized = value.strip().lower() + if normalized == "shared": + return "Shared" + if normalized == "exclusive": + return "Exclusive" + raise ValueError(f"{label} must be Shared or Exclusive.") + + +def effective_runtime_gateway_mode(mode: Optional[str]) -> str: + return str(mode).strip() if mode and str(mode).strip() else "Shared" + + +def validate_runtime_gateway_create_config( + *, + gateway_mode: Optional[str], + gateway_instance_id: Optional[str], + has_network_configuration: bool = False, + label: str = "runtime", +) -> None: + mode = ( + normalize_runtime_gateway_mode(gateway_mode, f"{label}.gateway_mode") + or "Shared" + ) + gateway_instance_id = (gateway_instance_id or "").strip() + + if mode == "Exclusive": + if not gateway_instance_id: + raise ValueError( + f"{label}.gateway_instance_id is required when gateway_mode is Exclusive." + ) + if has_network_configuration: + raise ValueError( + f"{label}.network cannot be used when gateway_mode is Exclusive; " + "the Runtime follows the selected gateway instance network." + ) + return + + if gateway_instance_id: + raise ValueError( + f"{label}.gateway_instance_id is only valid when gateway_mode is Exclusive." + ) + + +def runtime_gateway_binding_changed( + *, + desired_mode: Optional[str], + desired_instance_id: Optional[str], + current_mode: Optional[str], + current_instance_id: Optional[str], +) -> bool: + desired_mode = effective_runtime_gateway_mode(desired_mode) + current_mode = effective_runtime_gateway_mode(current_mode) + + if desired_mode not in RUNTIME_GATEWAY_MODES or current_mode not in RUNTIME_GATEWAY_MODES: + return desired_mode != current_mode + if desired_mode != current_mode: + return True + if desired_mode != "Exclusive": + return False + return (desired_instance_id or "").strip() != (current_instance_id or "").strip() diff --git a/agentkit/sdk/runtime/types.py b/agentkit/sdk/runtime/types.py index 5f6b7459..98bf3df8 100644 --- a/agentkit/sdk/runtime/types.py +++ b/agentkit/sdk/runtime/types.py @@ -40,6 +40,11 @@ class AgentKitRuntimeVersionsForListRuntimeVersions(RuntimeTypeBaseModel): created_at: Optional[str] = Field(default=None, alias="CreatedAt") description: Optional[str] = Field(default=None, alias="Description") envs: Optional[list[EnvsForListRuntimeVersions]] = Field(default=None, alias="Envs") + gateway_instance_id: Optional[str] = Field(default=None, alias="GatewayInstanceId") + gateway_instance_name: Optional[str] = Field( + default=None, alias="GatewayInstanceName" + ) + gateway_mode: Optional[str] = Field(default=None, alias="GatewayMode") memory_mb: Optional[int] = Field(default=None, alias="MemoryMb") role_name: Optional[str] = Field(default=None, alias="RoleName") runtime_id: Optional[str] = Field(default=None, alias="RuntimeId") @@ -63,6 +68,11 @@ class AgentKitRuntimesForListRuntimes(RuntimeTypeBaseModel): ) description: Optional[str] = Field(default=None, alias="Description") envs: Optional[list[EnvsForListRuntimes]] = Field(default=None, alias="Envs") + gateway_instance_id: Optional[str] = Field(default=None, alias="GatewayInstanceId") + gateway_instance_name: Optional[str] = Field( + default=None, alias="GatewayInstanceName" + ) + gateway_mode: Optional[str] = Field(default=None, alias="GatewayMode") knowledge_id: Optional[str] = Field(default=None, alias="KnowledgeId") mcp_toolset_id: Optional[str] = Field(default=None, alias="MCPToolsetId") memory_id: Optional[str] = Field(default=None, alias="MemoryId") @@ -326,6 +336,8 @@ class CreateRuntimeRequest(RuntimeTypeBaseModel): client_token: Optional[str] = Field(default=None, alias="ClientToken") cpu_milli: Optional[int] = Field(default=None, alias="CpuMilli") description: Optional[str] = Field(default=None, alias="Description") + gateway_instance_id: Optional[str] = Field(default=None, alias="GatewayInstanceId") + gateway_mode: Optional[str] = Field(default=None, alias="GatewayMode") knowledge_id: Optional[str] = Field(default=None, alias="KnowledgeId") mcp_toolset_id: Optional[str] = Field(default=None, alias="MCPToolsetId") max_concurrency: Optional[int] = Field(default=None, alias="MaxConcurrency") @@ -388,6 +400,11 @@ class GetRuntimeResponse(RuntimeTypeBaseModel): description: Optional[str] = Field(default=None, alias="Description") envs: Optional[list[EnvsForGetRuntime]] = Field(default=None, alias="Envs") failed_log_file_url: Optional[str] = Field(default=None, alias="FailedLogFileUrl") + gateway_instance_id: Optional[str] = Field(default=None, alias="GatewayInstanceId") + gateway_instance_name: Optional[str] = Field( + default=None, alias="GatewayInstanceName" + ) + gateway_mode: Optional[str] = Field(default=None, alias="GatewayMode") knowledge_id: Optional[str] = Field(default=None, alias="KnowledgeId") mcp_toolset_id: Optional[str] = Field(default=None, alias="MCPToolsetId") max_concurrency: Optional[int] = Field(default=None, alias="MaxConcurrency") @@ -455,6 +472,11 @@ class GetRuntimeVersionResponse(RuntimeTypeBaseModel): description: Optional[str] = Field(default=None, alias="Description") endpoint: Optional[str] = Field(default=None, alias="Endpoint") envs: Optional[list[EnvsForGetRuntimeVersion]] = Field(default=None, alias="Envs") + gateway_instance_id: Optional[str] = Field(default=None, alias="GatewayInstanceId") + gateway_instance_name: Optional[str] = Field( + default=None, alias="GatewayInstanceName" + ) + gateway_mode: Optional[str] = Field(default=None, alias="GatewayMode") max_concurrency: Optional[int] = Field(default=None, alias="MaxConcurrency") memory_mb: Optional[int] = Field(default=None, alias="MemoryMb") model_agent_name: Optional[str] = Field(default=None, alias="ModelAgentName") diff --git a/agentkit/toolkit/cli/cli_config.py b/agentkit/toolkit/cli/cli_config.py index 73f58796..b1af823a 100644 --- a/agentkit/toolkit/cli/cli_config.py +++ b/agentkit/toolkit/cli/cli_config.py @@ -192,6 +192,18 @@ def config_command( "--runtime_binding_mcp_toolset_id", help="Bind Runtime to an MCP Toolset ID (cloud/hybrid only)", ), + runtime_gateway_mode: Optional[str] = typer.Option( + None, + "--runtime_gateway_mode", + "--runtime-gateway-mode", + help="Runtime gateway mode (cloud/hybrid, CreateRuntime only): Shared|Exclusive", + ), + runtime_gateway_instance_id: Optional[str] = typer.Option( + None, + "--runtime_gateway_instance_id", + "--runtime-gateway-instance-id", + help="Exclusive Runtime gateway instance ID (cloud/hybrid, CreateRuntime only)", + ), # Runtime network configuration (advanced, CreateRuntime only) runtime_network_mode: Optional[str] = typer.Option( None, @@ -344,6 +356,8 @@ def config_command( runtime_vpc_id=runtime_vpc_id, runtime_subnet_ids=runtime_subnet_ids, runtime_enable_shared_internet_access=runtime_enable_shared_internet_access, + runtime_gateway_mode=runtime_gateway_mode, + runtime_gateway_instance_id=runtime_gateway_instance_id, ) has_cli_params = ConfigParamHandler.has_cli_params(cli_params) diff --git a/agentkit/toolkit/cli/cli_runtime.py b/agentkit/toolkit/cli/cli_runtime.py index d8321211..b1ed5411 100644 --- a/agentkit/toolkit/cli/cli_runtime.py +++ b/agentkit/toolkit/cli/cli_runtime.py @@ -25,6 +25,10 @@ from rich.panel import Panel from agentkit.sdk.runtime.client import AgentkitRuntimeClient +from agentkit.sdk.runtime.gateway import ( + normalize_runtime_gateway_mode, + validate_runtime_gateway_create_config, +) from agentkit.sdk.runtime import types as rt console = Console() @@ -92,6 +96,60 @@ def _build_network_for_create_runtime( ) +def _has_network_configuration_for_create_runtime( + vpc_id: Optional[str], + subnet_ids: Optional[str], + enable_private_network: bool, + enable_public_network: bool, + enable_shared_internet_access: bool, +) -> bool: + return ( + enable_private_network + or bool((vpc_id or "").strip()) + or bool((subnet_ids or "").strip()) + or enable_shared_internet_access + or enable_public_network is False + ) + + +def _payload_value(payload: object, *keys: str) -> object: + if not isinstance(payload, dict): + return None + for key in keys: + if key in payload: + return payload[key] + return None + + +def _payload_has_non_null_key(payload: object, *keys: str) -> bool: + if not isinstance(payload, dict): + return False + return any(key in payload and payload[key] is not None for key in keys) + + +def _unsupported_runtime_update_fields_message(payload: object) -> Optional[str]: + if not isinstance(payload, dict): + return None + unsupported = [ + "GatewayMode", + "GatewayInstanceId", + "NetworkConfiguration", + "gateway_mode", + "gateway_instance_id", + "network_configuration", + "gatewayMode", + "gatewayInstanceId", + "networkConfiguration", + ] + found = [key for key in unsupported if key in payload] + if not found: + return None + return ( + f"{', '.join(found)} can only be set when creating a Runtime; " + "gateway mode, gateway instance, and network configuration cannot be updated." + ) + + def _validate_runtime_create_authorizer_options( api_key_name: Optional[str], jwt_discovery_url: Optional[str], @@ -132,6 +190,16 @@ def create_runtime_command( model_agent_name: Optional[str] = typer.Option( None, "--model-agent-name", help="Model agent name" ), + gateway_mode: Optional[str] = typer.Option( + None, + "--gateway-mode", + help="Runtime gateway mode: Shared | Exclusive", + ), + gateway_instance_id: Optional[str] = typer.Option( + None, + "--gateway-instance-id", + help="Exclusive Runtime gateway instance ID", + ), vpc_id: Optional[str] = typer.Option(None, "--vpc-id", help="VPC ID"), subnet_ids: Optional[str] = typer.Option( None, "--subnet-ids", help="Subnet IDs (comma-separated)" @@ -187,12 +255,49 @@ def create_runtime_command( try: if json_body: payload = json.loads(json_body) + validate_runtime_gateway_create_config( + gateway_mode=_payload_value( + payload, + "GatewayMode", + "gateway_mode", + "gatewayMode", + ), + gateway_instance_id=_payload_value( + payload, + "GatewayInstanceId", + "gateway_instance_id", + "gatewayInstanceId", + ), + has_network_configuration=_payload_has_non_null_key( + payload, + "NetworkConfiguration", + "network_configuration", + "networkConfiguration", + ), + label="runtime", + ) req = rt.CreateRuntimeRequest(**payload) else: _validate_runtime_create_authorizer_options( api_key_name=api_key_name, jwt_discovery_url=jwt_discovery_url, ) + normalized_gateway_mode = normalize_runtime_gateway_mode( + gateway_mode, + "--gateway-mode", + ) + validate_runtime_gateway_create_config( + gateway_mode=normalized_gateway_mode, + gateway_instance_id=gateway_instance_id, + has_network_configuration=_has_network_configuration_for_create_runtime( + vpc_id=vpc_id, + subnet_ids=subnet_ids, + enable_private_network=enable_private_network, + enable_public_network=enable_public_network, + enable_shared_internet_access=enable_shared_internet_access, + ), + label="runtime", + ) authorizer = None if any([api_key_name, api_key_location, jwt_discovery_url]): @@ -253,6 +358,10 @@ def create_runtime_command( tool_id=tool_id, mcp_toolset_id=mcp_toolset_id, model_agent_name=model_agent_name, + gateway_mode=normalized_gateway_mode, + gateway_instance_id=( + gateway_instance_id.strip() if gateway_instance_id else None + ), authorizer_configuration=authorizer, network_configuration=network, envs=envs, @@ -343,6 +452,9 @@ def update_runtime_command( client = AgentkitRuntimeClient(region=(region or "").strip()) if json_body: payload = json.loads(json_body) + unsupported = _unsupported_runtime_update_fields_message(payload) + if unsupported: + raise ValueError(unsupported) req = rt.UpdateRuntimeRequest(**payload) else: envs = None @@ -467,6 +579,16 @@ def list_runtimes_command( project_name: Optional[str] = typer.Option( None, "--project-name", help="Filter by project name" ), + gateway_mode: Optional[str] = typer.Option( + None, + "--gateway-mode", + help="Filter by runtime gateway mode: Shared | Exclusive", + ), + gateway_instance_id: Optional[str] = typer.Option( + None, + "--gateway-instance-id", + help="Filter by runtime gateway instance ID", + ), # Sorting options sort_by: Optional[str] = typer.Option( None, "--sort-by", help="Sort by field: Name|Status|CreatedAt|UpdatedAt" @@ -618,6 +740,26 @@ def list_runtimes_command( rt.FiltersItemForListRuntimes(name="Status", values=statuses) ) + if gateway_mode: + filters.append( + rt.FiltersItemForListRuntimes( + name="GatewayMode", + values=[ + normalize_runtime_gateway_mode( + gateway_mode, + "--gateway-mode", + ) + ], + ) + ) + if gateway_instance_id: + filters.append( + rt.FiltersItemForListRuntimes( + name="GatewayInstanceId", + values=[gateway_instance_id], + ) + ) + # Setup console with color control local_console = console if not no_color else Console(no_color=True) @@ -696,6 +838,8 @@ def build_request(next_token_val): ("Name", "Name", "white"), ("Status", "Status", "green"), ("ProjectName", "ProjectName", "yellow"), + ("GatewayMode", "GatewayMode", "cyan"), + ("GatewayInstanceName", "GatewayInstanceName", "blue"), ("UpdatedAt", "UpdatedAt", "magenta"), ] diff --git a/agentkit/toolkit/config/config_handler.py b/agentkit/toolkit/config/config_handler.py index 47861880..933cfa6e 100644 --- a/agentkit/toolkit/config/config_handler.py +++ b/agentkit/toolkit/config/config_handler.py @@ -21,6 +21,7 @@ from agentkit.toolkit.config.config import get_config from agentkit.toolkit.config.config_validator import ConfigValidator +from agentkit.sdk.runtime.gateway import normalize_runtime_gateway_mode console = Console() @@ -113,6 +114,8 @@ def collect_cli_params( runtime_vpc_id: Optional[str], runtime_subnet_ids: Optional[List[str]], runtime_enable_shared_internet_access: Optional[bool], + runtime_gateway_mode: Optional[str] = None, + runtime_gateway_instance_id: Optional[str] = None, ) -> Dict[str, Any]: """Collect all CLI parameters. @@ -201,6 +204,17 @@ def collect_cli_params( if runtime_bindings: strategy_params["runtime_bindings"] = runtime_bindings + if runtime_gateway_mode is not None: + strategy_params["runtime_gateway_mode"] = ( + normalize_runtime_gateway_mode( + runtime_gateway_mode, + "runtime_gateway_mode", + ) + or "" + ) + if runtime_gateway_instance_id is not None: + strategy_params["runtime_gateway_instance_id"] = runtime_gateway_instance_id + # Runtime network configuration (advanced, CreateRuntime only) runtime_network: Dict[str, Any] = {} if runtime_network_mode is not None: diff --git a/agentkit/toolkit/config/strategy_configs.py b/agentkit/toolkit/config/strategy_configs.py index 3f0f0f6e..549d3efc 100644 --- a/agentkit/toolkit/config/strategy_configs.py +++ b/agentkit/toolkit/config/strategy_configs.py @@ -14,6 +14,7 @@ from dataclasses import dataclass, field from typing import Any, Dict, List, Optional +from agentkit.sdk.runtime.gateway import normalize_runtime_gateway_mode from .dataclass_utils import AutoSerializableMixin from .constants import ( AUTH_TYPE_CUSTOM_JWT, @@ -344,6 +345,32 @@ class HybridStrategyConfig(AutoSerializableMixin): "examples": "{mode: private, vpc_id: vpc-xxx, subnet_ids: [subnet-aaa, subnet-bbb], enable_shared_internet_access: true}", }, ) + runtime_gateway_mode: str = field( + default="", + metadata={ + "hidden": True, + "description": "Runtime gateway mode: Shared or Exclusive", + "examples": "Exclusive", + }, + ) + runtime_gateway_instance_id: str = field( + default="", + metadata={ + "hidden": True, + "description": "Exclusive Runtime gateway instance ID", + "examples": "g-xxx", + }, + ) + + def __post_init__(self): + self.runtime_gateway_mode = ( + normalize_runtime_gateway_mode( + self.runtime_gateway_mode, + "runtime_gateway_mode", + ) + or "" + ) + _config_metadata = { "name": "Hybrid Strategy Configuration", "welcome_message": "Welcome to AgentKit Hybrid Deployment Mode Configuration Wizard", @@ -611,6 +638,31 @@ class CloudStrategyConfig(AutoSerializableMixin): "examples": "{mode: private, vpc_id: vpc-xxx, subnet_ids: [subnet-aaa, subnet-bbb], enable_shared_internet_access: true}", }, ) + runtime_gateway_mode: str = field( + default="", + metadata={ + "hidden": True, + "description": "Runtime gateway mode: Shared or Exclusive", + "examples": "Exclusive", + }, + ) + runtime_gateway_instance_id: str = field( + default="", + metadata={ + "hidden": True, + "description": "Exclusive Runtime gateway instance ID", + "examples": "g-xxx", + }, + ) + + def __post_init__(self): + self.runtime_gateway_mode = ( + normalize_runtime_gateway_mode( + self.runtime_gateway_mode, + "runtime_gateway_mode", + ) + or "" + ) # Deployment metadata build_timestamp: str = field( diff --git a/agentkit/toolkit/runners/ve_agentkit.py b/agentkit/toolkit/runners/ve_agentkit.py index 8f8b65b7..874b8c1f 100644 --- a/agentkit/toolkit/runners/ve_agentkit.py +++ b/agentkit/toolkit/runners/ve_agentkit.py @@ -43,6 +43,11 @@ retry, ) from agentkit.sdk.runtime.client import AgentkitRuntimeClient +from agentkit.sdk.runtime.gateway import ( + normalize_runtime_gateway_mode, + runtime_gateway_binding_changed, + validate_runtime_gateway_create_config, +) from agentkit.toolkit.volcengine.iam import VeIAM from .base import Runner @@ -110,6 +115,14 @@ class VeAgentkitRunnerConfig(AutoSerializableMixin): "description": "Runtime network configuration (advanced, CreateRuntime only)", }, ) + runtime_gateway_mode: str = field( + default="", + metadata={"description": "Runtime gateway mode: Shared or Exclusive"}, + ) + runtime_gateway_instance_id: str = field( + default="", + metadata={"description": "Exclusive Runtime gateway instance ID"}, + ) # Authentication configuration runtime_auth_type: str = field( @@ -754,6 +767,36 @@ def _build_network_config_for_create( enable_public_network=enable_public, ) + @staticmethod + def _validate_runtime_gateway_config_for_create( + config: VeAgentkitRunnerConfig, + ) -> None: + validate_runtime_gateway_create_config( + gateway_mode=config.runtime_gateway_mode, + gateway_instance_id=config.runtime_gateway_instance_id, + has_network_configuration=bool(config.runtime_network), + label="runtime", + ) + + @staticmethod + def _assert_runtime_gateway_update_allowed( + config: VeAgentkitRunnerConfig, + current: Optional[runtime_types.GetRuntimeResponse], + ) -> None: + if current is None: + return + if not runtime_gateway_binding_changed( + desired_mode=config.runtime_gateway_mode, + desired_instance_id=config.runtime_gateway_instance_id, + current_mode=getattr(current, "gateway_mode", None), + current_instance_id=getattr(current, "gateway_instance_id", None), + ): + return + raise ConfigError( + "Runtime gateway_mode/gateway_instance_id cannot be changed after creation. " + "Delete and recreate the Runtime, or set runtime_id to Auto to create a new Runtime." + ) + def _create_new_runtime(self, config: VeAgentkitRunnerConfig) -> DeployResult: """Create a new Runtime instance. @@ -787,8 +830,11 @@ def _create_new_runtime(self, config: VeAgentkitRunnerConfig) -> DeployResult: # Build authorizer configuration based on auth type authorizer_config = self._build_authorizer_config_for_create(config) + self._validate_runtime_gateway_config_for_create(config) + # Network configuration is only supported during CreateRuntime. network_configuration = self._build_network_config_for_create(config) + gateway_mode = normalize_runtime_gateway_mode(config.runtime_gateway_mode) create_request = runtime_types.CreateRuntimeRequest( name=config.runtime_name, @@ -798,6 +844,12 @@ def _create_new_runtime(self, config: VeAgentkitRunnerConfig) -> DeployResult: artifact_type=ARTIFACT_TYPE_DOCKER_IMAGE, artifact_url=config.image_url, role_name=config.runtime_role_name, + gateway_mode=gateway_mode, + gateway_instance_id=( + config.runtime_gateway_instance_id.strip() + if config.runtime_gateway_instance_id + else None + ), memory_id=(memory_id if is_valid_config(memory_id) else None), knowledge_id=(knowledge_id if is_valid_config(knowledge_id) else None), tool_id=(tool_id if is_valid_config(tool_id) else None), @@ -899,6 +951,8 @@ def _create_new_runtime(self, config: VeAgentkitRunnerConfig) -> DeployResult: "runtime_apikey": config.runtime_apikey, "runtime_apikey_name": config.runtime_apikey_name, "runtime_role_name": config.runtime_role_name, + "runtime_gateway_mode": gateway_mode or "", + "runtime_gateway_instance_id": config.runtime_gateway_instance_id, "runtime_auth_type": config.runtime_auth_type, "runtime_jwt_discovery_url": config.runtime_jwt_discovery_url, "runtime_jwt_allowed_clients": config.runtime_jwt_allowed_clients, @@ -1188,6 +1242,8 @@ def _update_existing_runtime(self, config: VeAgentkitRunnerConfig) -> DeployResu success=False, error=error_msg, error_code=ErrorCode.CONFIG_INVALID ) + self._assert_runtime_gateway_update_allowed(config, runtime) + # Check if update is needed # needs_update, update_reason = self._needs_runtime_update(runtime, config) needs_update = True # Always update for now @@ -1220,6 +1276,12 @@ def _update_existing_runtime(self, config: VeAgentkitRunnerConfig) -> DeployResu "runtime_name": config.runtime_name, "runtime_apikey": config.runtime_apikey, "runtime_auth_type": config.runtime_auth_type, + "runtime_gateway_mode": getattr(runtime, "gateway_mode", None) + or "", + "runtime_gateway_instance_id": getattr( + runtime, "gateway_instance_id", None + ) + or "", "message": "Runtime configuration is up-to-date", }, ) @@ -1369,6 +1431,12 @@ def _binding_update_value(key: str) -> Optional[str]: "runtime_apikey": config.runtime_apikey, "runtime_apikey_name": config.runtime_apikey_name, "runtime_role_name": config.runtime_role_name, + "runtime_gateway_mode": getattr(updated_runtime, "gateway_mode", None) + or config.runtime_gateway_mode, + "runtime_gateway_instance_id": getattr( + updated_runtime, "gateway_instance_id", None + ) + or config.runtime_gateway_instance_id, "runtime_auth_type": config.runtime_auth_type, "runtime_jwt_discovery_url": config.runtime_jwt_discovery_url, "runtime_jwt_allowed_clients": config.runtime_jwt_allowed_clients, diff --git a/agentkit/toolkit/strategies/cloud_strategy.py b/agentkit/toolkit/strategies/cloud_strategy.py index 27940c3d..f99a5fd3 100644 --- a/agentkit/toolkit/strategies/cloud_strategy.py +++ b/agentkit/toolkit/strategies/cloud_strategy.py @@ -185,6 +185,16 @@ def deploy( config_updates.add( "runtime_role_name", result.metadata["runtime_role_name"] ) + if result.metadata.get("runtime_gateway_mode"): + config_updates.add( + "runtime_gateway_mode", + result.metadata["runtime_gateway_mode"], + ) + if result.metadata.get("runtime_gateway_instance_id"): + config_updates.add( + "runtime_gateway_instance_id", + result.metadata["runtime_gateway_instance_id"], + ) result.config_updates = config_updates if config_updates.has_updates() else None return result @@ -352,6 +362,10 @@ def _to_runner_config( runtime_envs=merged_envs, runtime_bindings=getattr(strategy_config, "runtime_bindings", None) or {}, runtime_network=getattr(strategy_config, "runtime_network", None) or {}, + runtime_gateway_mode=getattr(strategy_config, "runtime_gateway_mode", ""), + runtime_gateway_instance_id=getattr( + strategy_config, "runtime_gateway_instance_id", "" + ), runtime_auth_type=strategy_config.runtime_auth_type, runtime_jwt_discovery_url=strategy_config.runtime_jwt_discovery_url, runtime_jwt_allowed_clients=strategy_config.runtime_jwt_allowed_clients, diff --git a/agentkit/toolkit/strategies/hybrid_strategy.py b/agentkit/toolkit/strategies/hybrid_strategy.py index f652c5c2..88085b9e 100644 --- a/agentkit/toolkit/strategies/hybrid_strategy.py +++ b/agentkit/toolkit/strategies/hybrid_strategy.py @@ -195,6 +195,16 @@ def deploy( config_updates.add( "runtime_role_name", result.metadata["runtime_role_name"] ) + if result.metadata.get("runtime_gateway_mode"): + config_updates.add( + "runtime_gateway_mode", + result.metadata["runtime_gateway_mode"], + ) + if result.metadata.get("runtime_gateway_instance_id"): + config_updates.add( + "runtime_gateway_instance_id", + result.metadata["runtime_gateway_instance_id"], + ) result.config_updates = config_updates if config_updates.has_updates() else None return result @@ -518,6 +528,10 @@ def _to_runner_config( runtime_envs=merged_envs, runtime_bindings=getattr(strategy_config, "runtime_bindings", None) or {}, runtime_network=getattr(strategy_config, "runtime_network", None) or {}, + runtime_gateway_mode=getattr(strategy_config, "runtime_gateway_mode", ""), + runtime_gateway_instance_id=getattr( + strategy_config, "runtime_gateway_instance_id", "" + ), runtime_auth_type=strategy_config.runtime_auth_type, runtime_jwt_discovery_url=strategy_config.runtime_jwt_discovery_url, runtime_jwt_allowed_clients=strategy_config.runtime_jwt_allowed_clients, diff --git a/docs/content/2.agentkit-cli/2.commands.md b/docs/content/2.agentkit-cli/2.commands.md index 86aa4827..0e9cbcf9 100644 --- a/docs/content/2.agentkit-cli/2.commands.md +++ b/docs/content/2.agentkit-cli/2.commands.md @@ -795,6 +795,30 @@ launch_types: > - 配置里 **不写某个 key**:表示不变更该绑定 > - 配置为 `""` 或 `null`:表示清空/解绑(会在更新 Runtime 时下发清空) +#### Runtime 网关配置(Cloud/Hybrid) + +Runtime 支持共享网关和专属网关模式。默认是共享网关。专属网关必须在创建 +Runtime 时配置,后续不能通过 UpdateRuntime 修改。 + +```bash +agentkit config \ + --runtime-gateway-mode Exclusive \ + --runtime-gateway-instance-id g-xxxxxxxx +``` + +**YAML 配置格式**(`agentkit.yaml`): + +```yaml +launch_types: + cloud: # 或 hybrid + runtime_gateway_mode: Exclusive + runtime_gateway_instance_id: g-xxxxxxxx +``` + +> ⚠️ **限制**:`runtime_gateway_instance_id` 仅在 +> `runtime_gateway_mode=Exclusive` 时有效。专属网关不能和 `runtime_network` +> 同时配置;Runtime 会跟随所选网关实例的网络配置。 + #### Runtime 网络配置(Cloud/Hybrid) 你可以通过 `agentkit config` 为 Runtime 设置网络(VPC/私网、公网或双栈)。该配置会在 **首次创建 Runtime(CreateRuntime)** 时生效。 diff --git a/docs/en/content/2.agentkit-cli/2.commands.md b/docs/en/content/2.agentkit-cli/2.commands.md index 276a1065..93a4c94e 100644 --- a/docs/en/content/2.agentkit-cli/2.commands.md +++ b/docs/en/content/2.agentkit-cli/2.commands.md @@ -543,6 +543,31 @@ launch_types: > - **Omit a key** in config: do not change that binding > - Set to `""` or `null`: clear/unbind (a clear operation will be sent when updating the Runtime) +#### Runtime gateway configuration (Cloud/Hybrid) + +Runtime supports shared and exclusive gateway modes. Shared is the default. Exclusive +mode must be configured when creating the Runtime and cannot be changed by +UpdateRuntime later. + +```bash +agentkit config \ + --runtime-gateway-mode Exclusive \ + --runtime-gateway-instance-id g-xxxxxxxx +``` + +**YAML format** (`agentkit.yaml`): + +```yaml +launch_types: + cloud: # or hybrid + runtime_gateway_mode: Exclusive + runtime_gateway_instance_id: g-xxxxxxxx +``` + +> ⚠️ **Limitation**: `runtime_gateway_instance_id` is only valid when +> `runtime_gateway_mode` is `Exclusive`. Exclusive gateway mode cannot be combined +> with `runtime_network`; the Runtime follows the selected gateway instance network. + #### Runtime network configuration (Cloud/Hybrid) You can use `agentkit config` to set runtime networking (VPC/private network, public network, or dual-stack). This configuration takes effect **only when creating a runtime for the first time (CreateRuntime)**. diff --git a/tests/toolkit/cli/test_cli_config_cloud_provider_noninteractive.py b/tests/toolkit/cli/test_cli_config_cloud_provider_noninteractive.py index a1a83392..4e428997 100644 --- a/tests/toolkit/cli/test_cli_config_cloud_provider_noninteractive.py +++ b/tests/toolkit/cli/test_cli_config_cloud_provider_noninteractive.py @@ -52,10 +52,14 @@ def test_collect_cli_params_accepts_cloud_provider_and_project_name() -> None: runtime_vpc_id=None, runtime_subnet_ids=None, runtime_enable_shared_internet_access=None, + runtime_gateway_mode="exclusive", + runtime_gateway_instance_id="g-1", ) assert params["common"]["cloud_provider"] == "byteplus" assert params["strategy"]["project_name"] == "lh-test" + assert params["strategy"]["runtime_gateway_mode"] == "Exclusive" + assert params["strategy"]["runtime_gateway_instance_id"] == "g-1" def test_noninteractive_config_updates_project_cloud_provider(tmp_path) -> None: diff --git a/tests/toolkit/cli/test_cli_runtime_network_config.py b/tests/toolkit/cli/test_cli_runtime_network_config.py index bb9feb9c..30de131e 100644 --- a/tests/toolkit/cli/test_cli_runtime_network_config.py +++ b/tests/toolkit/cli/test_cli_runtime_network_config.py @@ -25,6 +25,7 @@ class _FakeRuntimeClient: instances = [] last_request = None + last_list_request = None def __init__(self, **kwargs): self.region = kwargs.get("region", "") @@ -34,6 +35,14 @@ def create_runtime(self, request): _FakeRuntimeClient.last_request = request return SimpleNamespace(runtime_id="rt-created") + def update_runtime(self, request): + _FakeRuntimeClient.last_request = request + return SimpleNamespace(runtime_id=request.runtime_id) + + def list_runtimes(self, request): + _FakeRuntimeClient.last_list_request = request + return SimpleNamespace(agent_kit_runtimes=[], next_token="") + def _runtime_create_args(*extra_args): return [ @@ -57,6 +66,7 @@ def _fake_runtime_client(monkeypatch): _FakeRuntimeClient.instances = [] _FakeRuntimeClient.last_request = None + _FakeRuntimeClient.last_list_request = None monkeypatch.setattr(cli_runtime, "AgentkitRuntimeClient", _FakeRuntimeClient) @@ -175,6 +185,94 @@ def test_create_runtime_accepts_jwt_discovery_url_auth(): assert authorizer.custom_jwt_authorizer.allowed_clients == ["client-a", "client-b"] +def test_create_runtime_accepts_exclusive_gateway(): + from agentkit.toolkit.cli.cli import app + + result = runner.invoke( + app, + _runtime_create_args( + "--apikey-name", + "demo-key", + "--gateway-mode", + "exclusive", + "--gateway-instance-id", + "g-123", + ), + ) + + assert result.exit_code == 0 + assert _FakeRuntimeClient.last_request.gateway_mode == "Exclusive" + assert _FakeRuntimeClient.last_request.gateway_instance_id == "g-123" + + +def test_create_runtime_rejects_exclusive_gateway_with_network_before_client_init(): + from agentkit.toolkit.cli.cli import app + + result = runner.invoke( + app, + _runtime_create_args( + "--apikey-name", + "demo-key", + "--gateway-mode", + "Exclusive", + "--gateway-instance-id", + "g-123", + "--vpc-id", + "vpc-123", + ), + ) + + assert result.exit_code == 1 + assert "runtime.network cannot be used" in result.output + assert _FakeRuntimeClient.instances == [] + assert _FakeRuntimeClient.last_request is None + + +def test_update_runtime_rejects_gateway_fields_in_json(): + from agentkit.toolkit.cli.cli import app + + result = runner.invoke( + app, + [ + "runtime", + "update", + "--runtime-id", + "rt-1", + "--json", + '{"GatewayMode":"Exclusive"}', + ], + ) + + assert result.exit_code == 1 + assert "can only be set when creating a Runtime" in result.output + assert _FakeRuntimeClient.last_request is None + + +def test_list_runtime_accepts_gateway_filters(): + from agentkit.toolkit.cli.cli import app + + result = runner.invoke( + app, + [ + "runtime", + "list", + "--gateway-mode", + "exclusive", + "--gateway-instance-id", + "g-123", + "--output", + "json", + ], + ) + + assert result.exit_code == 0 + filters = _FakeRuntimeClient.last_list_request.filters + assert [(f.name, f.values) for f in filters] == [ + ("GatewayMode", ["Exclusive"]), + ("GatewayInstanceId", ["g-123"]), + ] + + def test_build_network_none_when_no_user_intent(): from agentkit.toolkit.cli.cli_runtime import _build_network_for_create_runtime diff --git a/tests/toolkit/runners/test_ve_agentkit_lifecycle.py b/tests/toolkit/runners/test_ve_agentkit_lifecycle.py index 6cf64912..a8e04f01 100644 --- a/tests/toolkit/runners/test_ve_agentkit_lifecycle.py +++ b/tests/toolkit/runners/test_ve_agentkit_lifecycle.py @@ -46,6 +46,9 @@ def _make_runtime( api_key=None, public_endpoint="https://public.example/", failed_log_file_url=None, + gateway_mode=None, + gateway_instance_id=None, + gateway_instance_name=None, ): """Build a real GetRuntimeResponse pydantic object matching the API shape.""" authorizer = None @@ -69,6 +72,9 @@ def _make_runtime( AuthorizerConfiguration=authorizer, NetworkConfigurations=net, FailedLogFileUrl=failed_log_file_url, + GatewayMode=gateway_mode, + GatewayInstanceId=gateway_instance_id, + GatewayInstanceName=gateway_instance_name, ) @@ -304,6 +310,58 @@ def test_create_new_runtime_custom_jwt_does_not_fetch_api_key(monkeypatch): assert cfg.runtime_apikey == "" +def test_create_new_runtime_passes_exclusive_gateway_settings(monkeypatch): + runner = VeAgentkitRuntimeRunner() + ready = _make_runtime( + runtime_id="rt-gateway", + gateway_mode="Exclusive", + gateway_instance_id="g-123", + ) + client = _FakeRuntimeClient( + create_response=runtime_types.CreateRuntimeResponse(RuntimeId="rt-gateway"), + get_runtime_responses=[ready], + ) + _install_client(monkeypatch, runner, client) + + cfg = _make_config( + runtime_gateway_mode="exclusive", + runtime_gateway_instance_id="g-123", + ) + result = runner._create_new_runtime(cfg) + + assert result.success is True + req = client.create_calls[0] + assert req.gateway_mode == "Exclusive" + assert req.gateway_instance_id == "g-123" + assert result.metadata["runtime_gateway_mode"] == "Exclusive" + assert result.metadata["runtime_gateway_instance_id"] == "g-123" + + +def test_create_new_runtime_rejects_invalid_exclusive_gateway_settings(monkeypatch): + runner = VeAgentkitRuntimeRunner() + client = _FakeRuntimeClient( + create_response=runtime_types.CreateRuntimeResponse(RuntimeId="unused"), + get_runtime_responses=[], + ) + _install_client(monkeypatch, runner, client) + + missing_id = _make_config(runtime_gateway_mode="Exclusive") + result = runner._create_new_runtime(missing_id) + assert result.success is False + assert "gateway_instance_id is required" in result.error + + with_network = _make_config( + runtime_gateway_mode="Exclusive", + runtime_gateway_instance_id="g-123", + runtime_network={"mode": "public"}, + ) + result = runner._create_new_runtime(with_network) + assert result.success is False + assert "runtime.network cannot be used" in result.error + + assert client.create_calls == [] + + def test_create_new_runtime_init_failure_downloads_logs_and_cleans_up_when_confirmed( monkeypatch, tmp_path ): @@ -607,6 +665,29 @@ def test_update_existing_runtime_direct_to_ready_submits_update_and_skips_releas assert cfg.runtime_apikey == "k-updated" +def test_update_existing_runtime_rejects_gateway_binding_change(monkeypatch): + runner = VeAgentkitRuntimeRunner() + existing = _make_runtime( + runtime_id="r-up", + status=RUNTIME_STATUS_READY, + gateway_mode="Shared", + ) + client = _FakeRuntimeClient(get_runtime_responses=[existing]) + _install_client(monkeypatch, runner, client) + + cfg = _make_config( + runtime_id="r-up", + runtime_gateway_mode="Exclusive", + runtime_gateway_instance_id="g-123", + ) + result = runner._update_existing_runtime(cfg) + + assert result.success is False + assert result.error_code == ErrorCode.CONFIG_INVALID + assert "cannot be changed after creation" in result.error + assert client.update_calls == [] + + def test_update_existing_runtime_unreleased_triggers_release_then_ready(monkeypatch): runner = VeAgentkitRuntimeRunner() existing = _make_runtime(runtime_id="r-rel", status=RUNTIME_STATUS_READY) diff --git a/tests/toolkit/test_runtime_gateway.py b/tests/toolkit/test_runtime_gateway.py new file mode 100644 index 00000000..ebcc82af --- /dev/null +++ b/tests/toolkit/test_runtime_gateway.py @@ -0,0 +1,78 @@ +# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from agentkit.sdk.runtime.gateway import ( + normalize_runtime_gateway_mode, + runtime_gateway_binding_changed, + validate_runtime_gateway_create_config, +) + + +def test_normalize_runtime_gateway_mode_values(): + assert normalize_runtime_gateway_mode(None) is None + assert normalize_runtime_gateway_mode("") is None + assert normalize_runtime_gateway_mode("shared") == "Shared" + assert normalize_runtime_gateway_mode(" Exclusive ") == "Exclusive" + + with pytest.raises(ValueError, match="Shared or Exclusive"): + normalize_runtime_gateway_mode("dedicated") + + +def test_validate_runtime_gateway_create_config(): + validate_runtime_gateway_create_config( + gateway_mode="Exclusive", + gateway_instance_id="g-1", + ) + + with pytest.raises(ValueError, match="gateway_instance_id is required"): + validate_runtime_gateway_create_config( + gateway_mode="Exclusive", + gateway_instance_id=None, + ) + + with pytest.raises(ValueError, match="network cannot be used"): + validate_runtime_gateway_create_config( + gateway_mode="Exclusive", + gateway_instance_id="g-1", + has_network_configuration=True, + ) + + with pytest.raises(ValueError, match="only valid when gateway_mode is Exclusive"): + validate_runtime_gateway_create_config( + gateway_mode="Shared", + gateway_instance_id="g-1", + ) + + +def test_runtime_gateway_binding_changed(): + assert not runtime_gateway_binding_changed( + desired_mode=None, + desired_instance_id=None, + current_mode=None, + current_instance_id=None, + ) + assert runtime_gateway_binding_changed( + desired_mode="Exclusive", + desired_instance_id="g-1", + current_mode="Shared", + current_instance_id=None, + ) + assert runtime_gateway_binding_changed( + desired_mode="Exclusive", + desired_instance_id="g-2", + current_mode="Exclusive", + current_instance_id="g-1", + )