Skip to content

Commit bda1afb

Browse files
authored
SDK Configures Edge (#419)
1 parent 588406e commit bda1afb

9 files changed

Lines changed: 275 additions & 47 deletions

File tree

docs/docs/guide/8-edge.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,46 @@ python your_app.py
3838
In the above example, the `edge-endpoint` is running on the same machine as the application, so the endpoint URL is `http://localhost:30101`. If the `edge-endpoint` is running on a different machine, you should replace `localhost` with the IP address or hostname of the machine running the `edge-endpoint`.
3939
:::
4040

41+
## Configuring the Edge Endpoint at Runtime
42+
43+
:::note
44+
Runtime edge configuration is currently in beta and available through the `ExperimentalApi`.
45+
:::
46+
47+
You can programmatically configure which detectors run on the edge and how they behave, without redeploying.
48+
49+
This allows applications to define the desired state of the Edge Endpoint, thereby eliminating the need to manually configure the Edge Endpoint separately.
50+
51+
```python notest
52+
from groundlight import ExperimentalApi
53+
from groundlight.edge import EdgeEndpointConfig
54+
from groundlight.edge.config import NO_CLOUD, EDGE_ANSWERS_WITH_ESCALATION
55+
56+
# Connect to an Edge Endpoint
57+
gl = ExperimentalApi(endpoint="http://localhost:30101")
58+
59+
# Build a configuration with detectors and inference presets
60+
config = EdgeEndpointConfig()
61+
config.add_detector("det_YOUR_DETECTOR_ID_HERE_01", NO_CLOUD)
62+
config.add_detector("det_YOUR_DETECTOR_ID_HERE_02", EDGE_ANSWERS_WITH_ESCALATION)
63+
64+
# Apply the configuration and wait for detectors to be ready
65+
print("Applying configuration...")
66+
config = gl.edge.set_config(config)
67+
print(f"Applied config with {len(config.detectors)} detector(s)")
68+
```
69+
70+
`set_config` replaces the current configuration and blocks until all detectors have inference pods ready to serve requests (or until the timeout expires).
71+
72+
You can also inspect the current configuration:
73+
74+
```python notest
75+
# Retrieve the active configuration
76+
config = gl.edge.get_config()
77+
for det in config.detectors:
78+
print(f" {det.detector_id} -> {det.edge_inference_config}")
79+
```
80+
4181
## Edge Endpoint performance
4282

4383
We have benchmarked the `edge-endpoint` handling 500 requests/sec at a latency of less than 50ms on an off-the-shelf [Katana 15 B13VGK-1007US](https://us.msi.com/Laptop/Katana-15-B13VX/Specification) laptop (Intel® Core™ i9-13900H CPU, NVIDIA® GeForce RTX™ 4070 Laptop GPU, 32GB DDR5 5200MHz RAM) running Ubuntu 20.04.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ packages = [
99
{include = "**/*.py", from = "src"},
1010
]
1111
readme = "README.md"
12-
version = "0.25.1"
12+
version = "0.26.0"
1313

1414
[tool.poetry.dependencies]
1515
# For certifi, use ">=" instead of "^" since it upgrades its "major version" every year, not really following semver

src/groundlight/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
# Imports from our code
99
from .client import Groundlight
10-
from .client import GroundlightClientError, ApiTokenError, NotFoundError
10+
from .client import GroundlightClientError, ApiTokenError, EdgeNotAvailableError, NotFoundError
1111
from .experimental_api import ExperimentalApi
1212
from .binary_labels import Label
1313
from .version import get_version

src/groundlight/client.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ class ApiTokenError(GroundlightClientError):
6969
pass
7070

7171

72+
class EdgeNotAvailableError(GroundlightClientError):
73+
"""Raised when an edge-only method is called against a non-edge endpoint."""
74+
75+
7276
class Groundlight: # pylint: disable=too-many-instance-attributes,too-many-public-methods
7377
"""
7478
Client for accessing the Groundlight cloud service. Provides methods to create visual detectors,

src/groundlight/edge/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from .api import EdgeEndpointApi
12
from .config import (
23
DEFAULT,
34
DISABLED,
@@ -14,6 +15,7 @@
1415
"DEFAULT",
1516
"DISABLED",
1617
"EDGE_ANSWERS_WITH_ESCALATION",
18+
"EdgeEndpointApi",
1719
"NO_CLOUD",
1820
"DetectorsConfig",
1921
"DetectorConfig",

src/groundlight/edge/api.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import time
2+
from http import HTTPStatus
3+
4+
import requests
5+
6+
from groundlight.client import EdgeNotAvailableError
7+
from groundlight.edge.config import EdgeEndpointConfig
8+
9+
_EDGE_METHOD_UNAVAILABLE_HINT = (
10+
"Make sure the client is pointed at a running Edge Endpoint "
11+
"(via GROUNDLIGHT_ENDPOINT env var or the endpoint= constructor arg)."
12+
)
13+
14+
15+
class EdgeEndpointApi:
16+
"""
17+
Namespace for operations that are specific to the Edge Endpoint,
18+
such as setting and getting the EdgeEndpoint configuration.
19+
"""
20+
21+
def __init__(self, client) -> None:
22+
self._client = client
23+
24+
def _base_url(self) -> str:
25+
return self._client.edge_base_url()
26+
27+
def _request(self, method: str, path: str, **kwargs) -> requests.Response:
28+
url = f"{self._base_url()}{path}"
29+
headers = self._client.get_raw_headers()
30+
try:
31+
response = requests.request(
32+
method, url, headers=headers, verify=self._client.configuration.verify_ssl, timeout=10, **kwargs
33+
)
34+
response.raise_for_status()
35+
except requests.exceptions.HTTPError as e:
36+
if e.response is not None and e.response.status_code == HTTPStatus.NOT_FOUND:
37+
raise EdgeNotAvailableError(
38+
f"Edge method not available at {url}. {_EDGE_METHOD_UNAVAILABLE_HINT}"
39+
) from e
40+
raise
41+
except requests.exceptions.ConnectionError as e:
42+
raise EdgeNotAvailableError(
43+
f"Could not connect to {self._base_url()}. {_EDGE_METHOD_UNAVAILABLE_HINT}"
44+
) from e
45+
return response
46+
47+
def get_config(self) -> EdgeEndpointConfig:
48+
"""Retrieve the active edge endpoint configuration."""
49+
response = self._request("GET", "/edge-config")
50+
return EdgeEndpointConfig.from_payload(response.json())
51+
52+
def get_detector_readiness(self) -> dict[str, bool]:
53+
"""Check which configured detectors have inference pods ready to serve.
54+
55+
:return: Dict mapping detector_id to readiness (True/False).
56+
"""
57+
response = self._request("GET", "/edge-detector-readiness")
58+
return {det_id: info["ready"] for det_id, info in response.json().items()}
59+
60+
def set_config(
61+
self,
62+
config: EdgeEndpointConfig,
63+
timeout_sec: float = 600,
64+
) -> EdgeEndpointConfig:
65+
"""Replace the edge endpoint configuration and wait until all detectors are ready.
66+
67+
:param config: The new configuration to apply.
68+
:param timeout_sec: Max seconds to wait for all detectors to become ready.
69+
:return: The applied configuration as reported by the edge endpoint.
70+
"""
71+
self._request("PUT", "/edge-config", json=config.to_payload())
72+
73+
desired_ids = {d.detector_id for d in config.detectors}
74+
if not desired_ids:
75+
return self.get_config()
76+
77+
deadline = time.time() + timeout_sec
78+
while time.time() < deadline:
79+
readiness = self.get_detector_readiness()
80+
if all(readiness.get(did, False) for did in desired_ids):
81+
return self.get_config()
82+
time.sleep(1)
83+
84+
raise TimeoutError(
85+
f"Edge detectors were not all ready within {timeout_sec}s. "
86+
"The edge endpoint may still be converging, or may have encountered an error."
87+
)

src/groundlight/edge/config.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ class GlobalConfig(BaseModel): # pylint: disable=too-few-public-methods
1313

1414
refresh_rate: float = Field(
1515
default=60.0,
16+
gt=0,
1617
description="The interval (in seconds) at which the inference server checks for a new model binary update.",
1718
)
1819
confident_audit_rate: float = Field(
1920
default=1e-5, # A detector running at 1 FPS = ~100,000 IQ/day, so 1e-5 is ~1 confident IQ/day audited
21+
ge=0,
2022
description="The probability that any given confident prediction will be sent to the cloud for auditing.",
2123
)
2224

@@ -29,7 +31,7 @@ class InferenceConfig(BaseModel): # pylint: disable=too-few-public-methods
2931
# Keep shared presets immutable (DEFAULT/NO_CLOUD/etc.) so one mutation cannot globally change behavior.
3032
model_config = ConfigDict(extra="ignore", frozen=True)
3133

32-
name: str = Field(..., exclude=True, description="A unique name for this inference config preset.")
34+
name: str = Field(..., min_length=1, exclude=True, description="A unique name for this inference config preset.")
3335
enabled: bool = Field(
3436
default=True, description="Whether the edge endpoint should accept image queries for this detector."
3537
)
@@ -53,9 +55,9 @@ class InferenceConfig(BaseModel): # pylint: disable=too-few-public-methods
5355
)
5456
min_time_between_escalations: float = Field(
5557
default=2.0,
58+
gt=0,
5659
description=(
5760
"The minimum time (in seconds) to wait between cloud escalations for a given detector. "
58-
"Cannot be less than 0.0. "
5961
"Only applies when `always_return_edge_prediction=True` and `disable_cloud_escalation=False`."
6062
),
6163
)
@@ -66,8 +68,6 @@ def validate_configuration(self) -> Self:
6668
raise ValueError(
6769
"The `disable_cloud_escalation` flag is only valid when `always_return_edge_prediction` is set to True."
6870
)
69-
if self.min_time_between_escalations < 0.0:
70-
raise ValueError("`min_time_between_escalations` cannot be less than 0.0.")
7171
return self
7272

7373

@@ -78,7 +78,7 @@ class DetectorConfig(BaseModel): # pylint: disable=too-few-public-methods
7878

7979
model_config = ConfigDict(extra="ignore")
8080

81-
detector_id: str = Field(..., description="Detector ID")
81+
detector_id: str = Field(..., pattern=r"^det_[A-Za-z0-9]{27}$", description="Detector ID")
8282
edge_inference_config: str = Field(..., description="Config for edge inference.")
8383

8484

src/groundlight/experimental_api.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from io import BufferedReader, BytesIO
1212
from pathlib import Path
1313
from typing import Any, Dict, List, Optional, Union
14+
from urllib.parse import urlparse, urlunparse
1415

1516
import requests
1617
from groundlight_openapi_client.api.actions_api import ActionsApi
@@ -40,6 +41,7 @@
4041
)
4142
from urllib3.response import HTTPResponse
4243

44+
from groundlight.edge.api import EdgeEndpointApi
4345
from groundlight.images import parse_supported_image_types
4446
from groundlight.internalapi import _generate_request_id
4547
from groundlight.optional_imports import Image, np
@@ -102,7 +104,11 @@ def __init__(
102104
self.detector_group_api = DetectorGroupsApi(self.api_client)
103105
self.detector_reset_api = DetectorResetApi(self.api_client)
104106

105-
self.edge_api = EdgeApi(self.api_client)
107+
# API client for fetching Edge models
108+
self._edge_model_download_api = EdgeApi(self.api_client)
109+
110+
# API client for interacting with the EdgeEndpoint (getting/setting configuration, etc.)
111+
self.edge = EdgeEndpointApi(self)
106112

107113
ITEMS_PER_PAGE = 100
108114

@@ -704,7 +710,7 @@ def _download_mlbinary_url(self, detector: Union[str, Detector]) -> EdgeModelInf
704710
"""
705711
if isinstance(detector, Detector):
706712
detector = detector.id
707-
obj = self.edge_api.get_model_urls(detector)
713+
obj = self._edge_model_download_api.get_model_urls(detector)
708714
return EdgeModelInfo.parse_obj(obj.to_dict())
709715

710716
def download_mlbinary(self, detector: Union[str, Detector], output_dir: str) -> None:
@@ -817,3 +823,8 @@ def make_generic_api_request( # noqa: PLR0913 # pylint: disable=too-many-argume
817823
auth_settings=["ApiToken"],
818824
_preload_content=False, # This returns the urllib3 response rather than trying any type of processing
819825
)
826+
827+
def edge_base_url(self) -> str:
828+
"""Return the scheme+host+port of the configured endpoint, without the /device-api path."""
829+
parsed = urlparse(self.configuration.host)
830+
return urlunparse((parsed.scheme, parsed.netloc, "", "", "", ""))

0 commit comments

Comments
 (0)