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.
- protocols_system/__init__.py +28 -0
- protocols_system/core/__init__.py +32 -0
- protocols_system/core/discovery.py +199 -0
- protocols_system/core/llm_support.py +194 -0
- protocols_system/core/registry.py +178 -0
- protocols_system/core/types.py +42 -0
- protocols_system/core/validation.py +13 -0
- protocols_system/protocols/__init__.py +40 -0
- protocols_system/protocols/cache.py +25 -0
- protocols_system/protocols/embedding.py +25 -0
- protocols_system/protocols/geo.py +46 -0
- protocols_system/protocols/health.py +28 -0
- protocols_system/protocols/image_library.py +105 -0
- protocols_system/protocols/llm.py +17 -0
- protocols_system/protocols/progress.py +20 -0
- protocols_system/providers/__init__.py +53 -0
- protocols_system/providers/google_places.py +335 -0
- protocols_system/providers/huggingface_embedding.py +405 -0
- protocols_system/providers/image_embedding.py +281 -0
- protocols_system/providers/immich_client.py +540 -0
- protocols_system/providers/llamacpp.py +392 -0
- protocols_system/providers/local_cache.py +106 -0
- protocols_system/providers/local_image.py +179 -0
- protocols_system/providers/local_library.py +481 -0
- protocols_system/providers/mux_image.py +298 -0
- protocols_system/providers/ollama.py +260 -0
- protocols_system/providers/ollama_embedding.py +136 -0
- protocols_system/providers/openai_llm.py +266 -0
- protocols_system/py.typed +0 -0
- protocols_system/webauth.py +98 -0
- rfx_protocols_system-0.2.0.dist-info/METADATA +100 -0
- rfx_protocols_system-0.2.0.dist-info/RECORD +34 -0
- rfx_protocols_system-0.2.0.dist-info/WHEEL +4 -0
- 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"]
|