rfx-protocols-system 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. protocols_system/__init__.py +28 -0
  2. protocols_system/core/__init__.py +32 -0
  3. protocols_system/core/discovery.py +199 -0
  4. protocols_system/core/llm_support.py +194 -0
  5. protocols_system/core/registry.py +178 -0
  6. protocols_system/core/types.py +42 -0
  7. protocols_system/core/validation.py +13 -0
  8. protocols_system/protocols/__init__.py +40 -0
  9. protocols_system/protocols/cache.py +25 -0
  10. protocols_system/protocols/embedding.py +25 -0
  11. protocols_system/protocols/geo.py +46 -0
  12. protocols_system/protocols/health.py +28 -0
  13. protocols_system/protocols/image_library.py +105 -0
  14. protocols_system/protocols/llm.py +17 -0
  15. protocols_system/protocols/progress.py +20 -0
  16. protocols_system/providers/__init__.py +53 -0
  17. protocols_system/providers/google_places.py +335 -0
  18. protocols_system/providers/huggingface_embedding.py +405 -0
  19. protocols_system/providers/image_embedding.py +281 -0
  20. protocols_system/providers/immich_client.py +540 -0
  21. protocols_system/providers/llamacpp.py +392 -0
  22. protocols_system/providers/local_cache.py +106 -0
  23. protocols_system/providers/local_image.py +179 -0
  24. protocols_system/providers/local_library.py +481 -0
  25. protocols_system/providers/mux_image.py +298 -0
  26. protocols_system/providers/ollama.py +260 -0
  27. protocols_system/providers/ollama_embedding.py +136 -0
  28. protocols_system/providers/openai_llm.py +266 -0
  29. protocols_system/py.typed +0 -0
  30. protocols_system/webauth.py +98 -0
  31. rfx_protocols_system-0.2.0.dist-info/METADATA +100 -0
  32. rfx_protocols_system-0.2.0.dist-info/RECORD +34 -0
  33. rfx_protocols_system-0.2.0.dist-info/WHEEL +4 -0
  34. rfx_protocols_system-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,28 @@
1
+ """Protocols System — A self-describing, pluggable service locator.
2
+
3
+ Provides decorator-based provider registration, singleton lifecycle management,
4
+ and introspection-driven GUI discovery. Protocol and provider additions
5
+ automatically surface in any UI that consumes the discovery layer.
6
+ """
7
+
8
+ from .core import (
9
+ ConfigParam,
10
+ ProtocolSlot,
11
+ ProtocolsRegistry,
12
+ classify_widget,
13
+ get_all_provider_schemas,
14
+ get_protocol_slots,
15
+ get_provider_schema,
16
+ set_excluded_protocols,
17
+ )
18
+
19
+ __all__ = [
20
+ "ProtocolsRegistry",
21
+ "get_protocol_slots",
22
+ "get_provider_schema",
23
+ "get_all_provider_schemas",
24
+ "classify_widget",
25
+ "set_excluded_protocols",
26
+ "ProtocolSlot",
27
+ "ConfigParam",
28
+ ]
@@ -0,0 +1,32 @@
1
+ """Protocols System core — registry, discovery, and types."""
2
+
3
+ from .discovery import (
4
+ classify_widget,
5
+ get_all_provider_schemas,
6
+ get_protocol_slots,
7
+ get_provider_schema,
8
+ set_excluded_protocols,
9
+ )
10
+ from .llm_support import (
11
+ ImageConversionError,
12
+ detect_mime,
13
+ prepare_image_for_vision,
14
+ validate_against_schema,
15
+ )
16
+ from .registry import ProtocolsRegistry
17
+ from .types import ConfigParam, ProtocolSlot
18
+
19
+ __all__ = [
20
+ "ProtocolsRegistry",
21
+ "get_protocol_slots",
22
+ "get_provider_schema",
23
+ "get_all_provider_schemas",
24
+ "classify_widget",
25
+ "set_excluded_protocols",
26
+ "ProtocolSlot",
27
+ "ConfigParam",
28
+ "validate_against_schema",
29
+ "prepare_image_for_vision",
30
+ "detect_mime",
31
+ "ImageConversionError",
32
+ ]
@@ -0,0 +1,199 @@
1
+ """Introspection-driven discovery for dynamic GUI rendering.
2
+
3
+ This module provides the bridge between the ProtocolsRegistry catalog and a UI
4
+ framework. It introspects protocols (via docstrings) and providers (via __init__
5
+ signatures) so that settings forms can be rendered without any per-provider UI code.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from inspect import Parameter, signature
12
+ from typing import Any
13
+
14
+ from .registry import ProtocolsRegistry
15
+ from .types import ConfigParam, ProtocolSlot
16
+
17
+ # Protocols in this set won't appear in user-facing settings UI.
18
+ _EXCLUDED_PROTOCOLS: set[type] = set()
19
+
20
+ # Hints that indicate a parameter should be rendered as a password field.
21
+ _SECRET_HINTS = ("api_key", "password", "secret", "token", "credentials")
22
+
23
+
24
+ def set_excluded_protocols(protocols: set[type]) -> None:
25
+ """Configure which protocols are excluded from the settings UI.
26
+
27
+ Call this once at app startup after defining your infrastructure protocols.
28
+ """
29
+ global _EXCLUDED_PROTOCOLS
30
+ _EXCLUDED_PROTOCOLS = protocols
31
+
32
+
33
+ def get_protocol_slots() -> list[ProtocolSlot]:
34
+ """Discover all configurable protocol slots from the registry.
35
+
36
+ Returns a list of ProtocolSlot objects suitable for rendering a settings page.
37
+ Infrastructure protocols in the exclusion set are filtered out.
38
+ """
39
+ slots: list[ProtocolSlot] = []
40
+ for protocol_cls in ProtocolsRegistry.get_protocols():
41
+ if protocol_cls in _EXCLUDED_PROTOCOLS:
42
+ continue
43
+ name = _derive_slot_name(protocol_cls)
44
+ label, description = _parse_protocol_doc(protocol_cls)
45
+ slots.append(ProtocolSlot(name, protocol_cls, label, description))
46
+ return slots
47
+
48
+
49
+ def get_provider_schema(protocol: type, provider_name: str) -> list[ConfigParam]:
50
+ """Introspect a provider's __init__ to produce a list of config parameters.
51
+
52
+ Args:
53
+ protocol: The protocol type.
54
+ provider_name: The registered provider name.
55
+
56
+ Returns:
57
+ List of ConfigParam objects describing each constructor parameter.
58
+
59
+ Raises:
60
+ ValueError: If the protocol or provider is not registered.
61
+ """
62
+ providers = ProtocolsRegistry.get_providers(protocol)
63
+ if provider_name not in providers:
64
+ raise ValueError(
65
+ f"No provider named '{provider_name}' for protocol {protocol.__name__}"
66
+ )
67
+ cls = providers[provider_name]
68
+ return _introspect_init(cls)
69
+
70
+
71
+ def get_all_provider_schemas(protocol: type) -> dict[str, list[ConfigParam]]:
72
+ """Return schemas for all providers registered under a protocol.
73
+
74
+ Returns:
75
+ Dict mapping provider_name -> list[ConfigParam].
76
+ """
77
+ providers = ProtocolsRegistry.get_providers(protocol)
78
+ return {name: _introspect_init(cls) for name, cls in providers.items()}
79
+
80
+
81
+ def classify_widget(key: str, annotation: str, default: Any) -> str:
82
+ """Classify a parameter into a UI widget kind.
83
+
84
+ Args:
85
+ key: Parameter name.
86
+ annotation: Type annotation as string.
87
+ default: Default value (or Parameter.empty).
88
+
89
+ Returns:
90
+ One of: "bool", "password", "float", "int", "str".
91
+ """
92
+ # Bool detection
93
+ if "bool" in annotation.lower() or isinstance(default, bool):
94
+ return "bool"
95
+
96
+ # Secret-looking parameter names
97
+ if any(hint in key.lower() for hint in _SECRET_HINTS):
98
+ return "password"
99
+
100
+ # Numeric types
101
+ if "float" in annotation.lower() or isinstance(default, float):
102
+ return "float"
103
+ if "int" in annotation.lower() or isinstance(default, int):
104
+ return "int"
105
+
106
+ return "str"
107
+
108
+
109
+ # ------------------------------------------------------------------
110
+ # Internal helpers
111
+ # ------------------------------------------------------------------
112
+
113
+
114
+ def _derive_slot_name(protocol_cls: type) -> str:
115
+ """Derive a config-friendly slot name from a protocol class name.
116
+
117
+ IImageGenerationClient -> "image_generation"
118
+ ILLMClient -> "llm"
119
+ IStorageClient -> "storage"
120
+ ICacheManager -> "cache_manager"
121
+ """
122
+ name = protocol_cls.__name__
123
+
124
+ # Strip leading "I"
125
+ if name.startswith("I") and len(name) > 1 and name[1].isupper():
126
+ name = name[1:]
127
+
128
+ # Strip trailing "Client" or "Manager" or "Provider"
129
+ for suffix in ("Client", "Manager", "Provider", "Service"):
130
+ if name.endswith(suffix) and len(name) > len(suffix):
131
+ name = name[: -len(suffix)]
132
+ break
133
+
134
+ # CamelCase to snake_case
135
+ name = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name)
136
+ name = re.sub(r"([a-z\d])([A-Z])", r"\1_\2", name)
137
+ return name.lower()
138
+
139
+
140
+ def _parse_protocol_doc(protocol_cls: type) -> tuple[str, str]:
141
+ """Parse protocol docstring into (label, description).
142
+
143
+ Expected format: "Label:Description text here."
144
+ Falls back to class name if docstring is missing or malformed.
145
+ """
146
+ doc = protocol_cls.__doc__ or ""
147
+ first_line = doc.strip().split("\n")[0].strip()
148
+
149
+ if ":" in first_line:
150
+ label, description = first_line.split(":", 1)
151
+ return label.strip(), description.strip()
152
+
153
+ # Fallback: derive label from class name
154
+ label = _derive_slot_name(protocol_cls).replace("_", " ").title()
155
+ return label, first_line or f"Provider for {label}."
156
+
157
+
158
+ def _introspect_init(cls: type) -> list[ConfigParam]:
159
+ """Extract ConfigParam list from a class's __init__ signature."""
160
+ try:
161
+ sig = signature(cls.__init__) # type: ignore[misc]
162
+ except (ValueError, TypeError):
163
+ return []
164
+
165
+ params: list[ConfigParam] = []
166
+ for name, param in sig.parameters.items():
167
+ if name == "self":
168
+ continue
169
+ if param.kind in (Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD):
170
+ continue
171
+
172
+ # Resolve annotation
173
+ if param.annotation is not Parameter.empty:
174
+ annotation = (
175
+ param.annotation.__name__
176
+ if hasattr(param.annotation, "__name__")
177
+ else str(param.annotation)
178
+ )
179
+ else:
180
+ annotation = "str"
181
+
182
+ # Resolve default
183
+ has_default = param.default is not Parameter.empty
184
+ default = param.default if has_default else None
185
+ required = not has_default
186
+
187
+ # Classify widget
188
+ widget_kind = classify_widget(name, annotation, default)
189
+
190
+ params.append(
191
+ ConfigParam(
192
+ key=name,
193
+ annotation=annotation,
194
+ default=default,
195
+ required=required,
196
+ widget_kind=widget_kind,
197
+ )
198
+ )
199
+ return params
@@ -0,0 +1,194 @@
1
+ """Shared helpers for the LLM vision providers (ollama, llamacpp, openai).
2
+
3
+ Centralizes two things that were previously copy-pasted (and subtly wrong) in
4
+ each provider client:
5
+
6
+ * :func:`validate_against_schema` — a stricter simplified-JSON-schema
7
+ validator. Earlier per-client copies didn't verify the result was a dict,
8
+ accepted ``bool`` as a number, ignored ``minimum``/``maximum``/``enum``/
9
+ ``integer``, and let NaN through.
10
+ * :func:`prepare_image_for_vision` — normalizes arbitrary image bytes to a
11
+ format the vision APIs actually accept. Earlier per-client code only
12
+ converted WebP, mislabeled everything else as ``image/jpeg`` while sending
13
+ the original (HEIC/TIFF/BMP/GIF) bytes, never registered ``pillow-heif`` (so
14
+ HEIC couldn't decode at all), and on conversion failure sent the bad bytes
15
+ anyway.
16
+
17
+ These helpers carry no network/transport concern — they are pure, importable
18
+ without any optional provider dependency, and shared by every LLM provider.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import io
24
+ import logging
25
+ import math
26
+ from typing import Any
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # Try to register HEIC/HEIF support so iPhone images can be decoded.
31
+ # pillow-heif is an optional dependency; if it isn't installed, HEIC input
32
+ # simply can't be converted and prepare_image_for_vision reports an error
33
+ # rather than silently sending undecodable bytes.
34
+ try: # pragma: no cover - depends on optional dependency being installed
35
+ import pillow_heif # type: ignore
36
+
37
+ pillow_heif.register_heif_opener()
38
+ _HEIF_AVAILABLE = True
39
+ except Exception: # pragma: no cover
40
+ _HEIF_AVAILABLE = False
41
+
42
+ # Formats the vision endpoints accept directly without re-encoding.
43
+ _PASSTHROUGH_MIME = ("image/jpeg", "image/png")
44
+
45
+ # Target max pixels when downscaling (~2 megapixels). Large images waste
46
+ # tokens/VRAM and some endpoints reject them outright.
47
+ _MAX_PIXELS = 2_000_000
48
+
49
+
50
+ class ImageConversionError(RuntimeError):
51
+ """Raised when image bytes can't be decoded/converted for a vision API."""
52
+
53
+
54
+ def detect_mime(image_bytes: bytes) -> str:
55
+ """Best-effort MIME detection from magic bytes.
56
+
57
+ Returns one of image/png, image/webp, image/jpeg, image/gif, image/bmp,
58
+ image/tiff, image/heic, or "application/octet-stream" when unknown.
59
+ """
60
+ if image_bytes[:4] == b"\x89PNG":
61
+ return "image/png"
62
+ if image_bytes[:4] == b"RIFF" and image_bytes[8:12] == b"WEBP":
63
+ return "image/webp"
64
+ if image_bytes[:2] == b"\xff\xd8":
65
+ return "image/jpeg"
66
+ if image_bytes[:6] in (b"GIF87a", b"GIF89a"):
67
+ return "image/gif"
68
+ if image_bytes[:2] == b"BM":
69
+ return "image/bmp"
70
+ if image_bytes[:4] in (b"II*\x00", b"MM\x00*"):
71
+ return "image/tiff"
72
+ # HEIC/HEIF: "ftyp" box with a heic/heif/mif1 brand near the start.
73
+ if image_bytes[4:8] == b"ftyp" and image_bytes[8:12] in (
74
+ b"heic", b"heix", b"hevc", b"heif", b"mif1", b"msf1",
75
+ ):
76
+ return "image/heic"
77
+ return "application/octet-stream"
78
+
79
+
80
+ def prepare_image_for_vision(image_bytes: bytes) -> tuple[bytes, str]:
81
+ """Return ``(bytes, mime_type)`` safe to send to a vision API.
82
+
83
+ JPEG/PNG are passed through unchanged. Everything else (WebP, HEIC, TIFF,
84
+ BMP, GIF, or unknown) is decoded via Pillow and re-encoded to JPEG, and the
85
+ result is downscaled to roughly 2 MP. A decode/convert failure raises
86
+ :class:`ImageConversionError` instead of returning the original bytes with
87
+ a bogus ``image/jpeg`` label — the caller turns that into a proper
88
+ per-image error rather than sending garbage to the model.
89
+ """
90
+ mime = detect_mime(image_bytes)
91
+ if mime in _PASSTHROUGH_MIME:
92
+ return image_bytes, mime
93
+
94
+ if mime == "image/heic" and not _HEIF_AVAILABLE:
95
+ raise ImageConversionError(
96
+ "HEIC image received but pillow-heif is not installed, so it "
97
+ "cannot be converted for the vision API. Install the 'heif' "
98
+ "extra (pillow-heif) or supply JPEG/PNG."
99
+ )
100
+
101
+ try:
102
+ from PIL import Image
103
+
104
+ img = Image.open(io.BytesIO(image_bytes)).convert("RGB")
105
+ # Downscale large images to ~2 MP to bound token/VRAM cost.
106
+ pixels = img.width * img.height
107
+ if pixels > _MAX_PIXELS:
108
+ scale = math.sqrt(_MAX_PIXELS / pixels)
109
+ new_size = (max(1, int(img.width * scale)), max(1, int(img.height * scale)))
110
+ img = img.resize(new_size)
111
+ buf = io.BytesIO()
112
+ img.save(buf, format="JPEG", quality=90)
113
+ return buf.getvalue(), "image/jpeg"
114
+ except ImageConversionError:
115
+ raise
116
+ except Exception as exc:
117
+ raise ImageConversionError(
118
+ f"Failed to convert {mime} image to JPEG for the vision API: {exc}"
119
+ ) from exc
120
+
121
+
122
+ def validate_against_schema(data: Any, schema: dict[str, Any]) -> str | None:
123
+ """Validate ``data`` against a simplified JSON schema.
124
+
125
+ Checks required keys and basic type constraints from the schema's
126
+ ``properties`` definitions. Supports types: integer, number, string,
127
+ boolean, array, object. Also enforces ``minimum``/``maximum`` and ``enum``.
128
+
129
+ Returns ``None`` if valid, else an error message string.
130
+ """
131
+ if not isinstance(data, dict):
132
+ return f"Expected a JSON object, got {type(data).__name__}"
133
+
134
+ required_keys = schema.get("required", [])
135
+ properties = schema.get("properties", {})
136
+
137
+ for key in required_keys:
138
+ if key not in data:
139
+ return f"Missing required key: '{key}'"
140
+
141
+ for key, prop_schema in properties.items():
142
+ if key not in data:
143
+ continue
144
+ value = data[key]
145
+ err = _validate_value(key, value, prop_schema)
146
+ if err is not None:
147
+ return err
148
+
149
+ return None
150
+
151
+
152
+ def _validate_value(key: str, value: Any, prop_schema: dict[str, Any]) -> str | None:
153
+ expected_type = prop_schema.get("type")
154
+
155
+ if expected_type == "integer":
156
+ # bool is a subclass of int but is not an integer value here.
157
+ if isinstance(value, bool) or not isinstance(value, int):
158
+ return f"Key '{key}' expected integer, got {type(value).__name__}"
159
+ elif expected_type == "number":
160
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
161
+ return f"Key '{key}' expected number, got {type(value).__name__}"
162
+ if not math.isfinite(float(value)):
163
+ return f"Key '{key}' must be a finite number, got {value!r}"
164
+ elif expected_type == "string":
165
+ if not isinstance(value, str):
166
+ return f"Key '{key}' expected string, got {type(value).__name__}"
167
+ elif expected_type == "boolean":
168
+ if not isinstance(value, bool):
169
+ return f"Key '{key}' expected boolean, got {type(value).__name__}"
170
+ elif expected_type == "array":
171
+ if not isinstance(value, list):
172
+ return f"Key '{key}' expected array, got {type(value).__name__}"
173
+ elif expected_type == "object":
174
+ if not isinstance(value, dict):
175
+ return f"Key '{key}' expected object, got {type(value).__name__}"
176
+
177
+ # Range + enum constraints.
178
+ if (
179
+ expected_type in ("number", "integer")
180
+ and isinstance(value, (int, float))
181
+ and not isinstance(value, bool)
182
+ ):
183
+ minimum = prop_schema.get("minimum")
184
+ maximum = prop_schema.get("maximum")
185
+ if minimum is not None and value < minimum:
186
+ return f"Key '{key}' must be >= {minimum}, got {value!r}"
187
+ if maximum is not None and value > maximum:
188
+ return f"Key '{key}' must be <= {maximum}, got {value!r}"
189
+
190
+ enum = prop_schema.get("enum")
191
+ if enum is not None and value not in enum:
192
+ return f"Key '{key}' must be one of {enum}, got {value!r}"
193
+
194
+ return None
@@ -0,0 +1,178 @@
1
+ """ProtocolsRegistry — Decorator-based service locator with singleton lifecycle.
2
+
3
+ Usage:
4
+ @ProtocolsRegistry.register("openai", ILLMClient)
5
+ class OpenAIClient:
6
+ def __init__(self, api_key: str, model: str = "gpt-4o-mini"): ...
7
+
8
+ # Later, instantiate with user config:
9
+ client = ProtocolsRegistry.create(ILLMClient, "openai", {"api_key": "sk-..."})
10
+
11
+ # Retrieve the live singleton:
12
+ client = ProtocolsRegistry.get_instance(ILLMClient)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any, TypeVar
18
+
19
+ T = TypeVar("T")
20
+
21
+
22
+ class ProtocolsRegistry:
23
+ """Static registry mapping protocol types to named provider classes.
24
+
25
+ Maintains two separate stores:
26
+ - _registry: catalog of protocol -> {provider_name: provider_class} (permanent)
27
+ - _instances: protocol -> live singleton instance (clearable between runs)
28
+ """
29
+
30
+ _registry: dict[type, dict[str, type]] = {}
31
+ _instances: dict[type, Any] = {}
32
+
33
+ # ------------------------------------------------------------------
34
+ # Registration
35
+ # ------------------------------------------------------------------
36
+
37
+ @staticmethod
38
+ def register(name: str, protocol: type) -> Any:
39
+ """Decorator that registers a class as a named provider for a protocol.
40
+
41
+ A single class can be registered under multiple protocols by stacking
42
+ decorators.
43
+
44
+ Args:
45
+ name: Provider name (e.g. "openai", "ollama", "s3").
46
+ protocol: The protocol type this class implements.
47
+
48
+ Returns:
49
+ A decorator that registers the class and returns it unchanged.
50
+ """
51
+
52
+ def decorator(cls: type) -> type:
53
+ if protocol not in ProtocolsRegistry._registry:
54
+ ProtocolsRegistry._registry[protocol] = {}
55
+ ProtocolsRegistry._registry[protocol][name] = cls
56
+ return cls
57
+
58
+ return decorator
59
+
60
+ # ------------------------------------------------------------------
61
+ # Catalog queries
62
+ # ------------------------------------------------------------------
63
+
64
+ @staticmethod
65
+ def get_registry() -> dict[type, dict[str, type]]:
66
+ """Return the full registry mapping (protocol -> {name: class})."""
67
+ return ProtocolsRegistry._registry
68
+
69
+ @staticmethod
70
+ def get_protocols() -> list[type]:
71
+ """Return all protocol types that have at least one registered provider."""
72
+ return list(ProtocolsRegistry._registry.keys())
73
+
74
+ @staticmethod
75
+ def get_providers(protocol: type) -> dict[str, type]:
76
+ """Return {name: class} for all providers registered under a protocol."""
77
+ return ProtocolsRegistry._registry.get(protocol, {})
78
+
79
+ # ------------------------------------------------------------------
80
+ # Instance lifecycle
81
+ # ------------------------------------------------------------------
82
+
83
+ @staticmethod
84
+ def create(protocol: type[T], name: str, config: dict[str, Any] | None = None) -> T:
85
+ """Instantiate a provider and store it as the active singleton for the protocol.
86
+
87
+ Args:
88
+ protocol: The protocol type to resolve.
89
+ name: The registered provider name.
90
+ config: Keyword arguments passed to the provider's __init__.
91
+
92
+ Returns:
93
+ The newly created instance.
94
+
95
+ Raises:
96
+ ValueError: If the protocol or provider name is not registered.
97
+ """
98
+ if protocol not in ProtocolsRegistry._registry:
99
+ raise ValueError(f"No providers registered for protocol {protocol.__name__}")
100
+ providers = ProtocolsRegistry._registry[protocol]
101
+ if name not in providers:
102
+ available = ", ".join(sorted(providers.keys())) or "(none)"
103
+ raise ValueError(
104
+ f"No provider named '{name}' for protocol {protocol.__name__}. "
105
+ f"Available: {available}"
106
+ )
107
+ instance = providers[name](**(config or {}))
108
+ ProtocolsRegistry._instances[protocol] = instance
109
+ return instance # type: ignore[no-any-return]
110
+
111
+ @staticmethod
112
+ def build_provider(
113
+ protocol: type[T], name: str, config: dict[str, Any] | None = None
114
+ ) -> T:
115
+ """Instantiate a provider WITHOUT storing it as the active singleton.
116
+
117
+ Use this when a composite provider (e.g. a multiplexer) needs to build
118
+ the sub-providers it delegates to, without replacing the protocol's live
119
+ singleton (which is the composite itself).
120
+
121
+ Args:
122
+ protocol: The protocol type to resolve.
123
+ name: The registered provider name.
124
+ config: Keyword arguments passed to the provider's __init__.
125
+
126
+ Returns:
127
+ The newly created instance.
128
+
129
+ Raises:
130
+ ValueError: If the protocol or provider name is not registered.
131
+ """
132
+ if protocol not in ProtocolsRegistry._registry:
133
+ raise ValueError(f"No providers registered for protocol {protocol.__name__}")
134
+ providers = ProtocolsRegistry._registry[protocol]
135
+ if name not in providers:
136
+ available = ", ".join(sorted(providers.keys())) or "(none)"
137
+ raise ValueError(
138
+ f"No provider named '{name}' for protocol {protocol.__name__}. "
139
+ f"Available: {available}"
140
+ )
141
+ return providers[name](**(config or {})) # type: ignore[no-any-return]
142
+
143
+ @staticmethod
144
+ def get_instance(protocol: type[T]) -> T | None:
145
+ """Retrieve the live singleton for a protocol, or None if not yet created."""
146
+ return ProtocolsRegistry._instances.get(protocol)
147
+
148
+ @staticmethod
149
+ def set_instance(protocol: type, instance: Any) -> None:
150
+ """Manually inject an instance (useful for testing or webgui overrides)."""
151
+ ProtocolsRegistry._instances[protocol] = instance
152
+
153
+ @staticmethod
154
+ def remove_instance(protocol: type) -> Any:
155
+ """Drop the active singleton for a SINGLE protocol.
156
+
157
+ Unlike ``clear_instances()`` (which wipes every protocol's instance),
158
+ this removes only the one protocol — e.g. clearing the Geo provider
159
+ without tearing down an unrelated live image mux.
160
+
161
+ Args:
162
+ protocol: The protocol type whose live instance should be removed.
163
+
164
+ Returns:
165
+ The removed instance, or None if no instance was registered.
166
+ """
167
+ return ProtocolsRegistry._instances.pop(protocol, None)
168
+
169
+ @staticmethod
170
+ def clear_instances() -> None:
171
+ """Clear all live instances. Catalog remains intact."""
172
+ ProtocolsRegistry._instances.clear()
173
+
174
+ @staticmethod
175
+ def reset() -> None:
176
+ """Clear both catalog and instances. Intended for test isolation."""
177
+ ProtocolsRegistry._registry.clear()
178
+ ProtocolsRegistry._instances.clear()
@@ -0,0 +1,42 @@
1
+ """Data classes used by the Protocols System discovery layer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ProtocolSlot:
11
+ """A discoverable protocol slot for the settings UI.
12
+
13
+ Attributes:
14
+ name: Derived config key (e.g. "llm", "image_generation").
15
+ protocol: The protocol type class.
16
+ label: Human-readable label (parsed from protocol docstring).
17
+ description: Description text (parsed from protocol docstring).
18
+ """
19
+
20
+ name: str
21
+ protocol: type
22
+ label: str
23
+ description: str
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ConfigParam:
28
+ """A single configuration parameter introspected from a provider's __init__.
29
+
30
+ Attributes:
31
+ key: Parameter name.
32
+ annotation: Type annotation string (e.g. "str", "int", "float").
33
+ default: Default value, or MISSING sentinel if required.
34
+ required: Whether the parameter must be supplied.
35
+ widget_kind: Suggested UI widget type ("str", "int", "float", "bool", "password").
36
+ """
37
+
38
+ key: str
39
+ annotation: str = "str"
40
+ default: Any = field(default=None)
41
+ required: bool = False
42
+ widget_kind: str = "str"
@@ -0,0 +1,13 @@
1
+ """Shared validation utilities for protocol providers.
2
+
3
+ The canonical implementation now lives in :mod:`protocols_system.core.llm_support`
4
+ (the stricter validator used by every LLM provider). This module re-exports it
5
+ so existing ``from protocols_system.core.validation import validate_against_schema``
6
+ imports keep working against a single source of truth.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .llm_support import validate_against_schema
12
+
13
+ __all__ = ["validate_against_schema"]