Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
5778c87
got it working
timmarkhuff Mar 12, 2026
240f124
Automatically reformatting code
Mar 12, 2026
ab42ece
adding DetectorsConfig model
Mar 16, 2026
83fe037
Automatically reformatting code
Mar 16, 2026
3d2895d
removing unnecessary script
Mar 16, 2026
5ca292e
Merge branch 'tim/edge-config' of github.com:groundlight/python-sdk i…
Mar 16, 2026
7c2b321
fixing a linter error
Mar 16, 2026
ddb26a1
Automatically reformatting code
Mar 16, 2026
2f8a474
responding to AI PR feedback
Mar 16, 2026
b3a7f66
Merge branch 'tim/edge-config' of github.com:groundlight/python-sdk i…
Mar 16, 2026
8fcebb9
Automatically reformatting code
Mar 16, 2026
45feffa
responding to more AI PR feedback
Mar 16, 2026
18a33b2
Merge remote updates into tim/edge-config.
Mar 16, 2026
dbe714f
Automatically reformatting code
Mar 16, 2026
0fbd05e
code cleanup
Mar 17, 2026
cb9394a
Merge branch 'tim/edge-config' of github.com:groundlight/python-sdk i…
Mar 17, 2026
21c5cd8
Automatically reformatting code
Mar 17, 2026
a4469a8
Merge branch 'main' into tim/edge-config
timmarkhuff Mar 17, 2026
963b35b
more code cleanup
Mar 17, 2026
53af849
Merge origin/tim/edge-config into tim/edge-config
Mar 17, 2026
9c06701
Automatically reformatting code
Mar 17, 2026
db1d86b
responding to PR feedback
Mar 17, 2026
8d4a491
Merge branch 'tim/edge-config' of github.com:groundlight/python-sdk i…
Mar 17, 2026
79121f8
addressing linter error
Mar 17, 2026
418eecf
Automatically reformatting code
Mar 17, 2026
b0b53db
code cleanup
timmarkhuff Mar 18, 2026
bf0c284
Automatically reformatting code
Mar 18, 2026
d0608a1
responding to PR feedback
timmarkhuff Mar 18, 2026
83d8f75
addressing linter complaint
timmarkhuff Mar 18, 2026
8505790
Automatically reformatting code
Mar 18, 2026
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
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ packages = [
{include = "**/*.py", from = "src"},
]
readme = "README.md"
version = "0.24.0"
version = "0.25.0"

[tool.poetry.dependencies]
# For certifi, use ">=" instead of "^" since it upgrades its "major version" every year, not really following semver
Expand All @@ -22,6 +22,7 @@ python-dateutil = "^2.9.0"
requests = "^2.28.2"
typer = "^0.15.4"
urllib3 = "^2.6.1"
pyyaml = "^6.0.3"

[tool.poetry.group.dev.dependencies]
datamodel-code-generator = "^0.35.0"
Expand Down
23 changes: 23 additions & 0 deletions src/groundlight/edge/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from .config import (
DEFAULT,
DISABLED,
EDGE_ANSWERS_WITH_ESCALATION,
NO_CLOUD,
DetectorConfig,
DetectorsConfig,
EdgeEndpointConfig,
GlobalConfig,
InferenceConfig,
)

__all__ = [
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit, this is a little redundant since it includes all objects that could be importet

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ChatGPT says: "good point. all is kept intentionally to make the public API explicit/stable for this new module (and to control wildcard-import surface), but it can be removed if repo convention prefers omitting it."

I'm not really sure which way is best...

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now it's fine either way, worst that can happen leaving it in is that someone adds something later and gets caught off guard when it doesn't import the way they expect. Leave it since it's less keystrokes now that it's already in

"DEFAULT",
"DISABLED",
"EDGE_ANSWERS_WITH_ESCALATION",
"NO_CLOUD",
"DetectorsConfig",
"DetectorConfig",
"EdgeEndpointConfig",
"GlobalConfig",
"InferenceConfig",
]
215 changes: 215 additions & 0 deletions src/groundlight/edge/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
from typing import Any, Optional, Union

import yaml
from model import Detector
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from typing_extensions import Self


class GlobalConfig(BaseModel):
"""Global runtime settings for edge-endpoint behavior."""

model_config = ConfigDict(extra="forbid")

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


class InferenceConfig(BaseModel):
"""
Configuration for edge inference on a specific detector.
"""

# Keep shared presets immutable (DEFAULT/NO_CLOUD/etc.) so one mutation cannot globally change behavior.
model_config = ConfigDict(extra="forbid", frozen=True)

name: str = Field(..., exclude=True, description="A unique name for this inference config preset.")
enabled: bool = Field(
default=True, description="Whether the edge endpoint should accept image queries for this detector."
)
api_token: Optional[str] = Field(
default=None, description="API token used to fetch the inference model for this detector."
)
always_return_edge_prediction: bool = Field(
default=False,
description=(
"Indicates if the edge-endpoint should always provide edge ML predictions, regardless of confidence. "
"When this setting is true, whether or not the edge-endpoint should escalate low-confidence predictions "
"to the cloud is determined by `disable_cloud_escalation`."
),
)
disable_cloud_escalation: bool = Field(
default=False,
description=(
"Never escalate ImageQueries from the edge-endpoint to the cloud. "
"Requires `always_return_edge_prediction=True`."
),
)
min_time_between_escalations: float = Field(
default=2.0,
description=(
"The minimum time (in seconds) to wait between cloud escalations for a given detector. "
"Cannot be less than 0.0. "
"Only applies when `always_return_edge_prediction=True` and `disable_cloud_escalation=False`."
),
)

@model_validator(mode="after")
def validate_configuration(self) -> Self:
if self.disable_cloud_escalation and not self.always_return_edge_prediction:
raise ValueError(
"The `disable_cloud_escalation` flag is only valid when `always_return_edge_prediction` is set to True."
)
if self.min_time_between_escalations < 0.0:
raise ValueError("`min_time_between_escalations` cannot be less than 0.0.")
return self


class DetectorConfig(BaseModel):
"""
Configuration for a specific detector.
"""

model_config = ConfigDict(extra="forbid")

detector_id: str = Field(..., description="Detector ID")
edge_inference_config: str = Field(..., description="Config for edge inference.")


class ConfigBase(BaseModel):
"""Shared detector/inference configuration behavior for edge config models."""

model_config = ConfigDict(extra="forbid")

edge_inference_configs: dict[str, InferenceConfig] = Field(default_factory=dict)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just for internal bookkeeping? Users will never instantiate a DetectorsConfig object with populated arguments, but rather should create an empty DetectorsConfig and then use the add_detector method?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was planning to support both paths. I think the add_detector method is more ergonomic for SDK users, but there will be a method that fetches the config from the edge-endpoint over the wire, and we need to deserialize/hydrate that payload, and I think instantiating a DetectorsConfig object directly makes sense there.

detectors: list[DetectorConfig] = Field(default_factory=list)

@field_validator("edge_inference_configs", mode="before")
@classmethod
def hydrate_inference_config_names(
cls, value: dict[str, InferenceConfig | dict[str, Any]] | None
) -> dict[str, InferenceConfig | dict[str, Any]]:
"""Hydrate InferenceConfig.name from payload mapping keys."""
if value is None:
return {}
if not isinstance(value, dict):
return value

hydrated_configs: dict[str, InferenceConfig | dict[str, Any]] = {}
for name, config in value.items():
if isinstance(config, InferenceConfig):
hydrated_configs[name] = config
continue
if not isinstance(config, dict):
raise TypeError("Each edge inference config must be an object.")
hydrated_configs[name] = {"name": name, **config}
return hydrated_configs

@model_validator(mode="after")
def validate_inference_configs(self):
"""
Validates detector config state.
Raises ValueError if dict keys mismatch InferenceConfig.name, detector IDs are duplicated,
or any detector references an undefined inference config.
"""
for name, config in self.edge_inference_configs.items():
if name != config.name:
raise ValueError(f"Edge inference config key '{name}' must match InferenceConfig.name '{config.name}'.")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given this validation, why is this stored as a dict to begin with? It could be a list of InferenceConfigs and you scan the list for the config with the name you want. More expensive? Sure, but the configs should be optimized for readability rather than performance. If you have so many inference configs that you're worried about performance here then there are going to be a lot of other problems first

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was written to reconcile the YAML config format that edge-endpoint already uses (which I am intentionally not changing) and the SDK model shape, which is comprised of pydantic objects so the user doesn't have to work with dicts.


seen_detector_ids = set()
duplicate_detector_ids = set()
for detector_config in self.detectors:
detector_id = detector_config.detector_id
if detector_id in seen_detector_ids:
duplicate_detector_ids.add(detector_id)
else:
seen_detector_ids.add(detector_id)
if duplicate_detector_ids:
duplicates = ", ".join(sorted(duplicate_detector_ids))
raise ValueError(f"Duplicate detector IDs are not allowed: {duplicates}.")

for detector_config in self.detectors:
if detector_config.edge_inference_config not in self.edge_inference_configs:
raise ValueError(f"Edge inference config '{detector_config.edge_inference_config}' not defined.")
return self

def add_detector(self, detector: Union[str, Detector], edge_inference_config: InferenceConfig) -> None:
"""Add a detector with the given inference config. Accepts detector ID or Detector object."""
detector_id = detector.id if isinstance(detector, Detector) else detector
if any(existing.detector_id == detector_id for existing in self.detectors):
raise ValueError(f"A detector with ID '{detector_id}' already exists.")

existing = self.edge_inference_configs.get(edge_inference_config.name)
if existing is None:
self.edge_inference_configs[edge_inference_config.name] = edge_inference_config
elif existing != edge_inference_config:
raise ValueError(
f"A different inference config named '{edge_inference_config.name}' is already registered."
)

self.detectors.append(DetectorConfig(detector_id=detector_id, edge_inference_config=edge_inference_config.name))

def to_payload(self) -> dict[str, Any]:
"""Return this config as a payload dictionary."""
return self.model_dump()


class DetectorsConfig(ConfigBase):
"""
Detector and inference-config mappings for edge inference.
"""


class EdgeEndpointConfig(ConfigBase):
"""
Top-level edge endpoint configuration.
"""

global_config: GlobalConfig = Field(default_factory=GlobalConfig)

@classmethod
def from_yaml(
cls,
filename: Optional[str] = None,
yaml_str: Optional[str] = None,
) -> "EdgeEndpointConfig":
"""Create an EdgeEndpointConfig from a YAML filename or YAML string."""
if filename is None and yaml_str is None:
raise ValueError("Either filename or yaml_str must be provided.")
if filename is not None and yaml_str is not None:
raise ValueError("Only one of filename or yaml_str can be provided.")
if filename is not None:
if not filename.strip():
raise ValueError("filename must be a non-empty path when provided.")
with open(filename, "r") as f:
yaml_str = f.read()

yaml_text = yaml_str or ""
parsed = yaml.safe_load(yaml_text) or {}
return cls.model_validate(parsed)

@classmethod
def from_payload(cls, payload: dict[str, Any]) -> "EdgeEndpointConfig":
"""Construct an EdgeEndpointConfig from a payload dictionary."""
return cls.model_validate(payload)


# Preset inference configs matching the standard edge-endpoint defaults.
DEFAULT = InferenceConfig(name="default")
EDGE_ANSWERS_WITH_ESCALATION = InferenceConfig(
name="edge_answers_with_escalation",
always_return_edge_prediction=True,
min_time_between_escalations=2.0,
)
NO_CLOUD = InferenceConfig(
name="no_cloud",
always_return_edge_prediction=True,
disable_cloud_escalation=True,
)
DISABLED = InferenceConfig(name="disabled", enabled=False)
Loading