mcp-hangar 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.
- mcp_hangar/__init__.py +139 -0
- mcp_hangar/application/__init__.py +1 -0
- mcp_hangar/application/commands/__init__.py +67 -0
- mcp_hangar/application/commands/auth_commands.py +118 -0
- mcp_hangar/application/commands/auth_handlers.py +296 -0
- mcp_hangar/application/commands/commands.py +59 -0
- mcp_hangar/application/commands/handlers.py +189 -0
- mcp_hangar/application/discovery/__init__.py +21 -0
- mcp_hangar/application/discovery/discovery_metrics.py +283 -0
- mcp_hangar/application/discovery/discovery_orchestrator.py +497 -0
- mcp_hangar/application/discovery/lifecycle_manager.py +315 -0
- mcp_hangar/application/discovery/security_validator.py +414 -0
- mcp_hangar/application/event_handlers/__init__.py +50 -0
- mcp_hangar/application/event_handlers/alert_handler.py +191 -0
- mcp_hangar/application/event_handlers/audit_handler.py +203 -0
- mcp_hangar/application/event_handlers/knowledge_base_handler.py +120 -0
- mcp_hangar/application/event_handlers/logging_handler.py +69 -0
- mcp_hangar/application/event_handlers/metrics_handler.py +152 -0
- mcp_hangar/application/event_handlers/persistent_audit_store.py +217 -0
- mcp_hangar/application/event_handlers/security_handler.py +604 -0
- mcp_hangar/application/mcp/tooling.py +158 -0
- mcp_hangar/application/ports/__init__.py +9 -0
- mcp_hangar/application/ports/observability.py +237 -0
- mcp_hangar/application/queries/__init__.py +52 -0
- mcp_hangar/application/queries/auth_handlers.py +237 -0
- mcp_hangar/application/queries/auth_queries.py +118 -0
- mcp_hangar/application/queries/handlers.py +227 -0
- mcp_hangar/application/read_models/__init__.py +11 -0
- mcp_hangar/application/read_models/provider_views.py +139 -0
- mcp_hangar/application/sagas/__init__.py +11 -0
- mcp_hangar/application/sagas/group_rebalance_saga.py +137 -0
- mcp_hangar/application/sagas/provider_failover_saga.py +266 -0
- mcp_hangar/application/sagas/provider_recovery_saga.py +172 -0
- mcp_hangar/application/services/__init__.py +9 -0
- mcp_hangar/application/services/provider_service.py +208 -0
- mcp_hangar/application/services/traced_provider_service.py +211 -0
- mcp_hangar/bootstrap/runtime.py +328 -0
- mcp_hangar/context.py +178 -0
- mcp_hangar/domain/__init__.py +117 -0
- mcp_hangar/domain/contracts/__init__.py +57 -0
- mcp_hangar/domain/contracts/authentication.py +225 -0
- mcp_hangar/domain/contracts/authorization.py +229 -0
- mcp_hangar/domain/contracts/event_store.py +178 -0
- mcp_hangar/domain/contracts/metrics_publisher.py +59 -0
- mcp_hangar/domain/contracts/persistence.py +383 -0
- mcp_hangar/domain/contracts/provider_runtime.py +146 -0
- mcp_hangar/domain/discovery/__init__.py +20 -0
- mcp_hangar/domain/discovery/conflict_resolver.py +267 -0
- mcp_hangar/domain/discovery/discovered_provider.py +185 -0
- mcp_hangar/domain/discovery/discovery_service.py +412 -0
- mcp_hangar/domain/discovery/discovery_source.py +192 -0
- mcp_hangar/domain/events.py +433 -0
- mcp_hangar/domain/exceptions.py +525 -0
- mcp_hangar/domain/model/__init__.py +70 -0
- mcp_hangar/domain/model/aggregate.py +58 -0
- mcp_hangar/domain/model/circuit_breaker.py +152 -0
- mcp_hangar/domain/model/event_sourced_api_key.py +413 -0
- mcp_hangar/domain/model/event_sourced_provider.py +423 -0
- mcp_hangar/domain/model/event_sourced_role_assignment.py +268 -0
- mcp_hangar/domain/model/health_tracker.py +183 -0
- mcp_hangar/domain/model/load_balancer.py +185 -0
- mcp_hangar/domain/model/provider.py +810 -0
- mcp_hangar/domain/model/provider_group.py +656 -0
- mcp_hangar/domain/model/tool_catalog.py +105 -0
- mcp_hangar/domain/policies/__init__.py +19 -0
- mcp_hangar/domain/policies/provider_health.py +187 -0
- mcp_hangar/domain/repository.py +249 -0
- mcp_hangar/domain/security/__init__.py +85 -0
- mcp_hangar/domain/security/input_validator.py +710 -0
- mcp_hangar/domain/security/rate_limiter.py +387 -0
- mcp_hangar/domain/security/roles.py +237 -0
- mcp_hangar/domain/security/sanitizer.py +387 -0
- mcp_hangar/domain/security/secrets.py +501 -0
- mcp_hangar/domain/services/__init__.py +20 -0
- mcp_hangar/domain/services/audit_service.py +376 -0
- mcp_hangar/domain/services/image_builder.py +328 -0
- mcp_hangar/domain/services/provider_launcher.py +1046 -0
- mcp_hangar/domain/value_objects.py +1138 -0
- mcp_hangar/errors.py +818 -0
- mcp_hangar/fastmcp_server.py +1105 -0
- mcp_hangar/gc.py +134 -0
- mcp_hangar/infrastructure/__init__.py +79 -0
- mcp_hangar/infrastructure/async_executor.py +133 -0
- mcp_hangar/infrastructure/auth/__init__.py +37 -0
- mcp_hangar/infrastructure/auth/api_key_authenticator.py +388 -0
- mcp_hangar/infrastructure/auth/event_sourced_store.py +567 -0
- mcp_hangar/infrastructure/auth/jwt_authenticator.py +360 -0
- mcp_hangar/infrastructure/auth/middleware.py +340 -0
- mcp_hangar/infrastructure/auth/opa_authorizer.py +243 -0
- mcp_hangar/infrastructure/auth/postgres_store.py +659 -0
- mcp_hangar/infrastructure/auth/projections.py +366 -0
- mcp_hangar/infrastructure/auth/rate_limiter.py +311 -0
- mcp_hangar/infrastructure/auth/rbac_authorizer.py +323 -0
- mcp_hangar/infrastructure/auth/sqlite_store.py +624 -0
- mcp_hangar/infrastructure/command_bus.py +112 -0
- mcp_hangar/infrastructure/discovery/__init__.py +110 -0
- mcp_hangar/infrastructure/discovery/docker_source.py +289 -0
- mcp_hangar/infrastructure/discovery/entrypoint_source.py +249 -0
- mcp_hangar/infrastructure/discovery/filesystem_source.py +383 -0
- mcp_hangar/infrastructure/discovery/kubernetes_source.py +247 -0
- mcp_hangar/infrastructure/event_bus.py +260 -0
- mcp_hangar/infrastructure/event_sourced_repository.py +443 -0
- mcp_hangar/infrastructure/event_store.py +396 -0
- mcp_hangar/infrastructure/knowledge_base/__init__.py +259 -0
- mcp_hangar/infrastructure/knowledge_base/contracts.py +202 -0
- mcp_hangar/infrastructure/knowledge_base/memory.py +177 -0
- mcp_hangar/infrastructure/knowledge_base/postgres.py +545 -0
- mcp_hangar/infrastructure/knowledge_base/sqlite.py +513 -0
- mcp_hangar/infrastructure/metrics_publisher.py +36 -0
- mcp_hangar/infrastructure/observability/__init__.py +10 -0
- mcp_hangar/infrastructure/observability/langfuse_adapter.py +534 -0
- mcp_hangar/infrastructure/persistence/__init__.py +33 -0
- mcp_hangar/infrastructure/persistence/audit_repository.py +371 -0
- mcp_hangar/infrastructure/persistence/config_repository.py +398 -0
- mcp_hangar/infrastructure/persistence/database.py +333 -0
- mcp_hangar/infrastructure/persistence/database_common.py +330 -0
- mcp_hangar/infrastructure/persistence/event_serializer.py +280 -0
- mcp_hangar/infrastructure/persistence/event_upcaster.py +166 -0
- mcp_hangar/infrastructure/persistence/in_memory_event_store.py +150 -0
- mcp_hangar/infrastructure/persistence/recovery_service.py +312 -0
- mcp_hangar/infrastructure/persistence/sqlite_event_store.py +386 -0
- mcp_hangar/infrastructure/persistence/unit_of_work.py +409 -0
- mcp_hangar/infrastructure/persistence/upcasters/README.md +13 -0
- mcp_hangar/infrastructure/persistence/upcasters/__init__.py +7 -0
- mcp_hangar/infrastructure/query_bus.py +153 -0
- mcp_hangar/infrastructure/saga_manager.py +401 -0
- mcp_hangar/logging_config.py +209 -0
- mcp_hangar/metrics.py +1007 -0
- mcp_hangar/models.py +31 -0
- mcp_hangar/observability/__init__.py +54 -0
- mcp_hangar/observability/health.py +487 -0
- mcp_hangar/observability/metrics.py +319 -0
- mcp_hangar/observability/tracing.py +433 -0
- mcp_hangar/progress.py +542 -0
- mcp_hangar/retry.py +613 -0
- mcp_hangar/server/__init__.py +120 -0
- mcp_hangar/server/__main__.py +6 -0
- mcp_hangar/server/auth_bootstrap.py +340 -0
- mcp_hangar/server/auth_cli.py +335 -0
- mcp_hangar/server/auth_config.py +305 -0
- mcp_hangar/server/bootstrap.py +735 -0
- mcp_hangar/server/cli.py +161 -0
- mcp_hangar/server/config.py +224 -0
- mcp_hangar/server/context.py +215 -0
- mcp_hangar/server/http_auth_middleware.py +165 -0
- mcp_hangar/server/lifecycle.py +467 -0
- mcp_hangar/server/state.py +117 -0
- mcp_hangar/server/tools/__init__.py +16 -0
- mcp_hangar/server/tools/discovery.py +186 -0
- mcp_hangar/server/tools/groups.py +75 -0
- mcp_hangar/server/tools/health.py +301 -0
- mcp_hangar/server/tools/provider.py +939 -0
- mcp_hangar/server/tools/registry.py +320 -0
- mcp_hangar/server/validation.py +113 -0
- mcp_hangar/stdio_client.py +229 -0
- mcp_hangar-0.2.0.dist-info/METADATA +347 -0
- mcp_hangar-0.2.0.dist-info/RECORD +160 -0
- mcp_hangar-0.2.0.dist-info/WHEEL +4 -0
- mcp_hangar-0.2.0.dist-info/entry_points.txt +2 -0
- mcp_hangar-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Value Objects for the MCP Registry domain.
|
|
3
|
+
|
|
4
|
+
Value objects are immutable, validated domain primitives that encapsulate
|
|
5
|
+
business rules and prevent invalid states. They replace primitive obsession
|
|
6
|
+
with strongly-typed domain concepts.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from enum import Enum
|
|
11
|
+
import re
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
from urllib.parse import urlparse
|
|
14
|
+
import uuid
|
|
15
|
+
|
|
16
|
+
# --- Authentication & Authorization Value Objects ---
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PrincipalType(Enum):
|
|
20
|
+
"""Type of authenticated principal.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
USER: Human user authenticated via JWT/OIDC or session.
|
|
24
|
+
SERVICE_ACCOUNT: Non-human identity, typically authenticated via API key.
|
|
25
|
+
SYSTEM: Internal system principal for background operations.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
USER = "user"
|
|
29
|
+
SERVICE_ACCOUNT = "service_account"
|
|
30
|
+
SYSTEM = "system"
|
|
31
|
+
|
|
32
|
+
def __str__(self) -> str:
|
|
33
|
+
return self.value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class PrincipalId:
|
|
38
|
+
"""Unique identifier for an authenticated principal.
|
|
39
|
+
|
|
40
|
+
PrincipalIds follow the format: [type:]identifier
|
|
41
|
+
Examples: "user:john@example.com", "service:ci-pipeline", "system"
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
value: The identifier string (1-256 chars, alphanumeric + -_:@.)
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
value: str
|
|
48
|
+
|
|
49
|
+
def __post_init__(self) -> None:
|
|
50
|
+
if not self.value:
|
|
51
|
+
raise ValueError("PrincipalId cannot be empty")
|
|
52
|
+
if len(self.value) > 256:
|
|
53
|
+
raise ValueError("PrincipalId must be 1-256 characters")
|
|
54
|
+
# Allow alphanumeric and -_:@.
|
|
55
|
+
allowed_chars = set("-_:@.")
|
|
56
|
+
if not all(c.isalnum() or c in allowed_chars for c in self.value):
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"PrincipalId contains invalid characters: {self.value!r}. " "Only alphanumeric and -_:@. are allowed."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def __str__(self) -> str:
|
|
62
|
+
return self.value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class Principal:
|
|
67
|
+
"""Authenticated identity making a request.
|
|
68
|
+
|
|
69
|
+
Immutable value object representing a verified identity with associated
|
|
70
|
+
groups and metadata. Used throughout the authorization layer.
|
|
71
|
+
|
|
72
|
+
Attributes:
|
|
73
|
+
id: Unique identifier for the principal.
|
|
74
|
+
type: Classification of the principal (user, service_account, system).
|
|
75
|
+
tenant_id: Optional tenant identifier for multi-tenancy.
|
|
76
|
+
groups: Immutable set of group memberships.
|
|
77
|
+
metadata: Additional identity information (email, display_name, etc.).
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
id: PrincipalId
|
|
81
|
+
type: PrincipalType
|
|
82
|
+
tenant_id: str | None = None
|
|
83
|
+
groups: frozenset[str] = frozenset()
|
|
84
|
+
metadata: dict | None = None
|
|
85
|
+
|
|
86
|
+
def __post_init__(self) -> None:
|
|
87
|
+
# Ensure metadata is a new dict copy to maintain immutability semantics
|
|
88
|
+
if self.metadata is None:
|
|
89
|
+
object.__setattr__(self, "metadata", {})
|
|
90
|
+
else:
|
|
91
|
+
object.__setattr__(self, "metadata", dict(self.metadata))
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def system(cls) -> "Principal":
|
|
95
|
+
"""Return the system principal for internal operations.
|
|
96
|
+
|
|
97
|
+
The system principal has implicit full access and is used for
|
|
98
|
+
background tasks, health checks, and internal operations.
|
|
99
|
+
"""
|
|
100
|
+
return cls(
|
|
101
|
+
id=PrincipalId("system"),
|
|
102
|
+
type=PrincipalType.SYSTEM,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def anonymous(cls) -> "Principal":
|
|
107
|
+
"""Return anonymous principal for unauthenticated requests.
|
|
108
|
+
|
|
109
|
+
Used when authentication is optional and no credentials provided.
|
|
110
|
+
"""
|
|
111
|
+
return cls(
|
|
112
|
+
id=PrincipalId("anonymous"),
|
|
113
|
+
type=PrincipalType.USER,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def is_system(self) -> bool:
|
|
117
|
+
"""Check if this is the system principal."""
|
|
118
|
+
return self.type == PrincipalType.SYSTEM
|
|
119
|
+
|
|
120
|
+
def is_anonymous(self) -> bool:
|
|
121
|
+
"""Check if this is the anonymous principal."""
|
|
122
|
+
return self.id.value == "anonymous"
|
|
123
|
+
|
|
124
|
+
def in_group(self, group: str) -> bool:
|
|
125
|
+
"""Check if principal is a member of the specified group."""
|
|
126
|
+
return group in self.groups
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class Permission:
|
|
131
|
+
"""A specific permission that can be granted.
|
|
132
|
+
|
|
133
|
+
Permissions follow the format: resource_type:action:resource_id
|
|
134
|
+
Examples: "provider:read:*", "tool:invoke:math:add", "config:update:*"
|
|
135
|
+
|
|
136
|
+
Wildcard (*) matches any value for that component.
|
|
137
|
+
|
|
138
|
+
Attributes:
|
|
139
|
+
resource_type: Type of resource (provider, tool, config, audit, metrics).
|
|
140
|
+
action: Operation to perform (create, read, update, delete, invoke, list, start, stop).
|
|
141
|
+
resource_id: Specific resource or wildcard (*) for any.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
resource_type: str
|
|
145
|
+
action: str
|
|
146
|
+
resource_id: str = "*"
|
|
147
|
+
|
|
148
|
+
def __post_init__(self) -> None:
|
|
149
|
+
if not self.resource_type:
|
|
150
|
+
raise ValueError("Permission resource_type cannot be empty")
|
|
151
|
+
if not self.action:
|
|
152
|
+
raise ValueError("Permission action cannot be empty")
|
|
153
|
+
if not self.resource_id:
|
|
154
|
+
raise ValueError("Permission resource_id cannot be empty")
|
|
155
|
+
|
|
156
|
+
def matches(self, resource_type: str, action: str, resource_id: str) -> bool:
|
|
157
|
+
"""Check if this permission grants access to the requested operation.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
resource_type: The type of resource being accessed.
|
|
161
|
+
action: The action being performed.
|
|
162
|
+
resource_id: The specific resource identifier.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
True if this permission grants access, False otherwise.
|
|
166
|
+
"""
|
|
167
|
+
if self.resource_type != "*" and self.resource_type != resource_type:
|
|
168
|
+
return False
|
|
169
|
+
if self.action != "*" and self.action != action:
|
|
170
|
+
return False
|
|
171
|
+
if self.resource_id != "*" and self.resource_id != resource_id:
|
|
172
|
+
return False
|
|
173
|
+
return True
|
|
174
|
+
|
|
175
|
+
def __str__(self) -> str:
|
|
176
|
+
return f"{self.resource_type}:{self.action}:{self.resource_id}"
|
|
177
|
+
|
|
178
|
+
@classmethod
|
|
179
|
+
def parse(cls, permission_str: str) -> "Permission":
|
|
180
|
+
"""Parse permission from string format 'resource:action[:id]'.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
permission_str: Permission string to parse.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Parsed Permission object.
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
ValueError: If the format is invalid.
|
|
190
|
+
|
|
191
|
+
Examples:
|
|
192
|
+
>>> Permission.parse("provider:read")
|
|
193
|
+
Permission(resource_type='provider', action='read', resource_id='*')
|
|
194
|
+
>>> Permission.parse("tool:invoke:math:add")
|
|
195
|
+
Permission(resource_type='tool', action='invoke', resource_id='math:add')
|
|
196
|
+
"""
|
|
197
|
+
parts = permission_str.split(":", 2) # Split into at most 3 parts
|
|
198
|
+
if len(parts) < 2:
|
|
199
|
+
raise ValueError(
|
|
200
|
+
f"Invalid permission format: {permission_str!r}. " "Expected 'resource:action' or 'resource:action:id'."
|
|
201
|
+
)
|
|
202
|
+
if len(parts) == 2:
|
|
203
|
+
return cls(resource_type=parts[0], action=parts[1])
|
|
204
|
+
return cls(resource_type=parts[0], action=parts[1], resource_id=parts[2])
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@dataclass(frozen=True)
|
|
208
|
+
class Role:
|
|
209
|
+
"""Named collection of permissions.
|
|
210
|
+
|
|
211
|
+
Roles group related permissions for easier assignment and management.
|
|
212
|
+
Built-in roles include: admin, provider-admin, developer, viewer, auditor.
|
|
213
|
+
|
|
214
|
+
Attributes:
|
|
215
|
+
name: Unique role identifier.
|
|
216
|
+
permissions: Immutable set of permissions granted by this role.
|
|
217
|
+
description: Human-readable description of the role's purpose.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
name: str
|
|
221
|
+
permissions: frozenset[Permission]
|
|
222
|
+
description: str = ""
|
|
223
|
+
|
|
224
|
+
def __post_init__(self) -> None:
|
|
225
|
+
if not self.name:
|
|
226
|
+
raise ValueError("Role name cannot be empty")
|
|
227
|
+
if not self.name.replace("-", "").replace("_", "").isalnum():
|
|
228
|
+
raise ValueError(
|
|
229
|
+
f"Role name contains invalid characters: {self.name!r}. " "Only alphanumeric and -_ are allowed."
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def has_permission(self, resource_type: str, action: str, resource_id: str = "*") -> bool:
|
|
233
|
+
"""Check if this role grants the requested permission.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
resource_type: Type of resource being accessed.
|
|
237
|
+
action: Operation being performed.
|
|
238
|
+
resource_id: Specific resource or '*' for any.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
True if any permission in this role matches the request.
|
|
242
|
+
"""
|
|
243
|
+
return any(p.matches(resource_type, action, resource_id) for p in self.permissions)
|
|
244
|
+
|
|
245
|
+
def __str__(self) -> str:
|
|
246
|
+
return self.name
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# --- Enums ---
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class ProviderState(Enum):
|
|
253
|
+
"""Provider lifecycle states.
|
|
254
|
+
|
|
255
|
+
Represents the finite state machine for provider lifecycle management.
|
|
256
|
+
|
|
257
|
+
State machine transitions:
|
|
258
|
+
- COLD -> INITIALIZING (on start)
|
|
259
|
+
- INITIALIZING -> READY (on success) | DEAD (on failure) | DEGRADED (on max failures)
|
|
260
|
+
- READY -> COLD (on shutdown) | DEAD (on client death) | DEGRADED (on health failures)
|
|
261
|
+
- DEGRADED -> INITIALIZING (on retry) | COLD (on shutdown)
|
|
262
|
+
- DEAD -> INITIALIZING (on retry) | DEGRADED (on max failures)
|
|
263
|
+
|
|
264
|
+
Attributes:
|
|
265
|
+
COLD: Provider is not running, no resources allocated.
|
|
266
|
+
INITIALIZING: Provider is starting up, handshake in progress.
|
|
267
|
+
READY: Provider is running and accepting requests.
|
|
268
|
+
DEGRADED: Provider has failures but may recover after backoff.
|
|
269
|
+
DEAD: Provider has failed fatally and requires manual intervention or retry.
|
|
270
|
+
"""
|
|
271
|
+
|
|
272
|
+
COLD = "cold"
|
|
273
|
+
INITIALIZING = "initializing"
|
|
274
|
+
READY = "ready"
|
|
275
|
+
DEGRADED = "degraded"
|
|
276
|
+
DEAD = "dead"
|
|
277
|
+
|
|
278
|
+
def __str__(self) -> str:
|
|
279
|
+
"""Return the string representation of the state."""
|
|
280
|
+
return self.value
|
|
281
|
+
|
|
282
|
+
@property
|
|
283
|
+
def can_accept_requests(self) -> bool:
|
|
284
|
+
"""Check if provider can accept tool invocation requests.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
True if provider is in READY state, False otherwise.
|
|
288
|
+
"""
|
|
289
|
+
return self == ProviderState.READY
|
|
290
|
+
|
|
291
|
+
@property
|
|
292
|
+
def can_start(self) -> bool:
|
|
293
|
+
"""Check if provider can be started from this state.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
True if provider can transition to INITIALIZING, False otherwise.
|
|
297
|
+
"""
|
|
298
|
+
return self in (ProviderState.COLD, ProviderState.DEAD, ProviderState.DEGRADED)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class HealthStatus(Enum):
|
|
302
|
+
"""Health status for providers.
|
|
303
|
+
|
|
304
|
+
Represents the externally visible health classification of a provider.
|
|
305
|
+
|
|
306
|
+
Attributes:
|
|
307
|
+
HEALTHY: Provider is fully operational with no recent failures.
|
|
308
|
+
DEGRADED: Provider is operational but has experienced recent failures.
|
|
309
|
+
UNHEALTHY: Provider is not operational or has exceeded failure threshold.
|
|
310
|
+
UNKNOWN: Provider health cannot be determined (e.g., not started).
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
HEALTHY = "healthy"
|
|
314
|
+
DEGRADED = "degraded"
|
|
315
|
+
UNHEALTHY = "unhealthy"
|
|
316
|
+
UNKNOWN = "unknown"
|
|
317
|
+
|
|
318
|
+
def __str__(self) -> str:
|
|
319
|
+
"""Return the string representation of the status."""
|
|
320
|
+
return self.value
|
|
321
|
+
|
|
322
|
+
@classmethod
|
|
323
|
+
def from_state(cls, state: ProviderState, consecutive_failures: int = 0) -> "HealthStatus":
|
|
324
|
+
"""Derive health status from provider state and failure count.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
state: The current provider state.
|
|
328
|
+
consecutive_failures: Number of consecutive failures (default: 0).
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
The derived HealthStatus based on state and failures.
|
|
332
|
+
|
|
333
|
+
Example:
|
|
334
|
+
>>> HealthStatus.from_state(ProviderState.READY, 0)
|
|
335
|
+
<HealthStatus.HEALTHY: 'healthy'>
|
|
336
|
+
"""
|
|
337
|
+
if state == ProviderState.READY:
|
|
338
|
+
if consecutive_failures == 0:
|
|
339
|
+
return cls.HEALTHY
|
|
340
|
+
return cls.DEGRADED
|
|
341
|
+
elif state == ProviderState.DEGRADED:
|
|
342
|
+
return cls.UNHEALTHY
|
|
343
|
+
elif state == ProviderState.COLD:
|
|
344
|
+
return cls.UNKNOWN
|
|
345
|
+
else:
|
|
346
|
+
return cls.UNHEALTHY
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class ProviderMode(Enum):
|
|
350
|
+
"""Mode for running a provider."""
|
|
351
|
+
|
|
352
|
+
SUBPROCESS = "subprocess"
|
|
353
|
+
DOCKER = "docker"
|
|
354
|
+
CONTAINER = "container" # Alias for docker mode
|
|
355
|
+
REMOTE = "remote"
|
|
356
|
+
GROUP = "group" # Provider group with load balancing
|
|
357
|
+
|
|
358
|
+
def __str__(self) -> str:
|
|
359
|
+
return self.value
|
|
360
|
+
|
|
361
|
+
@classmethod
|
|
362
|
+
def normalize(cls, value: "str | ProviderMode") -> "ProviderMode":
|
|
363
|
+
"""Normalize mode value to ProviderMode enum."""
|
|
364
|
+
if isinstance(value, cls):
|
|
365
|
+
return value
|
|
366
|
+
# Handle string values - return corresponding enum
|
|
367
|
+
return cls(value)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class LoadBalancerStrategy(Enum):
|
|
371
|
+
"""Load balancing strategy for provider groups."""
|
|
372
|
+
|
|
373
|
+
ROUND_ROBIN = "round_robin"
|
|
374
|
+
WEIGHTED_ROUND_ROBIN = "weighted_round_robin"
|
|
375
|
+
LEAST_CONNECTIONS = "least_connections"
|
|
376
|
+
RANDOM = "random"
|
|
377
|
+
PRIORITY = "priority" # Always prefer lowest priority member
|
|
378
|
+
|
|
379
|
+
def __str__(self) -> str:
|
|
380
|
+
return self.value
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
class GroupState(Enum):
|
|
384
|
+
"""Provider group lifecycle states."""
|
|
385
|
+
|
|
386
|
+
INACTIVE = "inactive" # No members started
|
|
387
|
+
PARTIAL = "partial" # Some members healthy, below min_healthy
|
|
388
|
+
HEALTHY = "healthy" # >= min_healthy members ready
|
|
389
|
+
DEGRADED = "degraded" # Circuit breaker tripped
|
|
390
|
+
|
|
391
|
+
def __str__(self) -> str:
|
|
392
|
+
return self.value
|
|
393
|
+
|
|
394
|
+
@property
|
|
395
|
+
def can_accept_requests(self) -> bool:
|
|
396
|
+
"""Check if group can accept tool invocation requests."""
|
|
397
|
+
return self in (GroupState.HEALTHY, GroupState.PARTIAL)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# --- Identity Value Objects ---
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
class ProviderId:
|
|
404
|
+
"""Unique identifier for a provider.
|
|
405
|
+
|
|
406
|
+
Validates and encapsulates provider identity with strict rules:
|
|
407
|
+
- Non-empty string
|
|
408
|
+
- Alphanumeric, hyphens, underscores only
|
|
409
|
+
- Max 64 characters
|
|
410
|
+
|
|
411
|
+
Attributes:
|
|
412
|
+
value: The validated provider identifier string.
|
|
413
|
+
|
|
414
|
+
Raises:
|
|
415
|
+
ValueError: If the provided value violates validation rules.
|
|
416
|
+
|
|
417
|
+
Example:
|
|
418
|
+
>>> provider_id = ProviderId("my-provider-1")
|
|
419
|
+
>>> str(provider_id)
|
|
420
|
+
'my-provider-1'
|
|
421
|
+
"""
|
|
422
|
+
|
|
423
|
+
_VALID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$")
|
|
424
|
+
_MAX_LENGTH = 64
|
|
425
|
+
|
|
426
|
+
def __init__(self, value: str):
|
|
427
|
+
"""Initialize ProviderId with validation.
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
value: The provider identifier string to validate.
|
|
431
|
+
|
|
432
|
+
Raises:
|
|
433
|
+
ValueError: If value is empty, too long, or contains invalid characters.
|
|
434
|
+
"""
|
|
435
|
+
if not value:
|
|
436
|
+
raise ValueError("ProviderId cannot be empty")
|
|
437
|
+
if len(value) > self._MAX_LENGTH:
|
|
438
|
+
raise ValueError(f"ProviderId cannot exceed {self._MAX_LENGTH} characters")
|
|
439
|
+
if not self._VALID_PATTERN.match(value):
|
|
440
|
+
raise ValueError("ProviderId must contain only alphanumeric characters, hyphens, and underscores")
|
|
441
|
+
self._value = value
|
|
442
|
+
|
|
443
|
+
@property
|
|
444
|
+
def value(self) -> str:
|
|
445
|
+
"""Get the raw identifier string."""
|
|
446
|
+
return self._value
|
|
447
|
+
|
|
448
|
+
def __str__(self) -> str:
|
|
449
|
+
return self._value
|
|
450
|
+
|
|
451
|
+
def __repr__(self) -> str:
|
|
452
|
+
return f"ProviderId('{self._value}')"
|
|
453
|
+
|
|
454
|
+
def __eq__(self, other) -> bool:
|
|
455
|
+
if isinstance(other, str):
|
|
456
|
+
return self._value == other
|
|
457
|
+
if not isinstance(other, ProviderId):
|
|
458
|
+
return False
|
|
459
|
+
return self._value == other._value
|
|
460
|
+
|
|
461
|
+
def __hash__(self) -> int:
|
|
462
|
+
return hash(self._value)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
class ToolName:
|
|
466
|
+
"""Name of a tool provided by a provider.
|
|
467
|
+
|
|
468
|
+
Validates tool names with the following rules:
|
|
469
|
+
- Non-empty string
|
|
470
|
+
- Alphanumeric, hyphens, underscores, dots allowed (for namespaced tools)
|
|
471
|
+
- Max 128 characters
|
|
472
|
+
|
|
473
|
+
Attributes:
|
|
474
|
+
value: The validated tool name string.
|
|
475
|
+
|
|
476
|
+
Raises:
|
|
477
|
+
ValueError: If the provided value violates validation rules.
|
|
478
|
+
|
|
479
|
+
Example:
|
|
480
|
+
>>> tool = ToolName("math.add")
|
|
481
|
+
>>> str(tool)
|
|
482
|
+
'math.add'
|
|
483
|
+
"""
|
|
484
|
+
|
|
485
|
+
_VALID_PATTERN = re.compile(r"^[a-zA-Z0-9_.\-]+$")
|
|
486
|
+
_MAX_LENGTH = 128
|
|
487
|
+
|
|
488
|
+
def __init__(self, value: str):
|
|
489
|
+
"""Initialize ToolName with validation.
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
value: The tool name string to validate.
|
|
493
|
+
|
|
494
|
+
Raises:
|
|
495
|
+
ValueError: If value is empty, too long, or contains invalid characters.
|
|
496
|
+
"""
|
|
497
|
+
if not value:
|
|
498
|
+
raise ValueError("ToolName cannot be empty")
|
|
499
|
+
if len(value) > self._MAX_LENGTH:
|
|
500
|
+
raise ValueError(f"ToolName cannot exceed {self._MAX_LENGTH} characters")
|
|
501
|
+
if not self._VALID_PATTERN.match(value):
|
|
502
|
+
raise ValueError("ToolName must contain only alphanumeric characters, hyphens, underscores, and dots")
|
|
503
|
+
self._value = value
|
|
504
|
+
|
|
505
|
+
@property
|
|
506
|
+
def value(self) -> str:
|
|
507
|
+
"""Get the raw tool name string."""
|
|
508
|
+
return self._value
|
|
509
|
+
|
|
510
|
+
def __str__(self) -> str:
|
|
511
|
+
return self._value
|
|
512
|
+
|
|
513
|
+
def __repr__(self) -> str:
|
|
514
|
+
return f"ToolName('{self._value}')"
|
|
515
|
+
|
|
516
|
+
def __eq__(self, other) -> bool:
|
|
517
|
+
if isinstance(other, str):
|
|
518
|
+
return self._value == other
|
|
519
|
+
if not isinstance(other, ToolName):
|
|
520
|
+
return False
|
|
521
|
+
return self._value == other._value
|
|
522
|
+
|
|
523
|
+
def __hash__(self) -> int:
|
|
524
|
+
return hash(self._value)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
@dataclass(frozen=True)
|
|
528
|
+
class CorrelationId:
|
|
529
|
+
"""Correlation ID for tracing requests.
|
|
530
|
+
|
|
531
|
+
Rules:
|
|
532
|
+
- Non-empty string
|
|
533
|
+
- Valid UUID v4 format (or auto-generated)
|
|
534
|
+
"""
|
|
535
|
+
|
|
536
|
+
value: str
|
|
537
|
+
|
|
538
|
+
def __init__(self, value: Optional[str] = None):
|
|
539
|
+
if value is None:
|
|
540
|
+
# Generate new UUID
|
|
541
|
+
value = str(uuid.uuid4())
|
|
542
|
+
else:
|
|
543
|
+
# Validate existing UUID
|
|
544
|
+
if not value:
|
|
545
|
+
raise ValueError("CorrelationId cannot be empty")
|
|
546
|
+
try:
|
|
547
|
+
uuid.UUID(value, version=4)
|
|
548
|
+
except ValueError:
|
|
549
|
+
raise ValueError("CorrelationId must be a valid UUID v4")
|
|
550
|
+
|
|
551
|
+
object.__setattr__(self, "value", value)
|
|
552
|
+
|
|
553
|
+
def __str__(self) -> str:
|
|
554
|
+
return self.value
|
|
555
|
+
|
|
556
|
+
def __repr__(self) -> str:
|
|
557
|
+
return f"CorrelationId('{self.value}')"
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
# --- Configuration Value Objects ---
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
@dataclass(frozen=True)
|
|
564
|
+
class CommandLine:
|
|
565
|
+
"""Command line with arguments for subprocess providers.
|
|
566
|
+
|
|
567
|
+
Rules:
|
|
568
|
+
- Non-empty command list
|
|
569
|
+
- First element is the command/executable
|
|
570
|
+
- Remaining elements are arguments
|
|
571
|
+
"""
|
|
572
|
+
|
|
573
|
+
command: str
|
|
574
|
+
arguments: tuple
|
|
575
|
+
|
|
576
|
+
def __init__(self, command: str, *arguments: str):
|
|
577
|
+
if not command:
|
|
578
|
+
raise ValueError("Command cannot be empty")
|
|
579
|
+
# Use object.__setattr__ because dataclass is frozen
|
|
580
|
+
object.__setattr__(self, "command", command)
|
|
581
|
+
object.__setattr__(self, "arguments", tuple(arguments))
|
|
582
|
+
|
|
583
|
+
@classmethod
|
|
584
|
+
def from_list(cls, command_list: List[str]) -> "CommandLine":
|
|
585
|
+
"""Create from a list of strings."""
|
|
586
|
+
if not command_list:
|
|
587
|
+
raise ValueError("Command list cannot be empty")
|
|
588
|
+
return cls(command_list[0], *command_list[1:])
|
|
589
|
+
|
|
590
|
+
def to_list(self) -> List[str]:
|
|
591
|
+
"""Convert to list format."""
|
|
592
|
+
return [self.command, *self.arguments]
|
|
593
|
+
|
|
594
|
+
def __str__(self) -> str:
|
|
595
|
+
return " ".join(self.to_list())
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
@dataclass(frozen=True)
|
|
599
|
+
class DockerImage:
|
|
600
|
+
"""Docker image specification.
|
|
601
|
+
|
|
602
|
+
Rules:
|
|
603
|
+
- Non-empty string
|
|
604
|
+
- Valid docker image format (name:tag or registry/name:tag)
|
|
605
|
+
"""
|
|
606
|
+
|
|
607
|
+
value: str
|
|
608
|
+
|
|
609
|
+
def __init__(self, value: str):
|
|
610
|
+
if not value:
|
|
611
|
+
raise ValueError("DockerImage cannot be empty")
|
|
612
|
+
# Basic validation - could be more sophisticated
|
|
613
|
+
if not re.match(r"^[\w.\-/:]+$", value):
|
|
614
|
+
raise ValueError("Invalid docker image format")
|
|
615
|
+
object.__setattr__(self, "value", value)
|
|
616
|
+
|
|
617
|
+
@property
|
|
618
|
+
def name(self) -> str:
|
|
619
|
+
"""Extract image name without tag."""
|
|
620
|
+
return self.value.split(":")[0]
|
|
621
|
+
|
|
622
|
+
@property
|
|
623
|
+
def tag(self) -> str:
|
|
624
|
+
"""Extract tag, defaults to 'latest'."""
|
|
625
|
+
parts = self.value.split(":")
|
|
626
|
+
return parts[1] if len(parts) > 1 else "latest"
|
|
627
|
+
|
|
628
|
+
def __str__(self) -> str:
|
|
629
|
+
return self.value
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
@dataclass(frozen=True)
|
|
633
|
+
class Endpoint:
|
|
634
|
+
"""Remote endpoint URL.
|
|
635
|
+
|
|
636
|
+
Rules:
|
|
637
|
+
- Non-empty string
|
|
638
|
+
- Valid URL format
|
|
639
|
+
- Supported schemes: http, https, ws, wss
|
|
640
|
+
"""
|
|
641
|
+
|
|
642
|
+
value: str
|
|
643
|
+
|
|
644
|
+
def __init__(self, value: str):
|
|
645
|
+
if not value:
|
|
646
|
+
raise ValueError("Endpoint cannot be empty")
|
|
647
|
+
|
|
648
|
+
parsed = urlparse(value)
|
|
649
|
+
|
|
650
|
+
# Check for valid scheme - urlparse treats "localhost:8080" as scheme="localhost"
|
|
651
|
+
# If netloc is empty and path exists, it's likely missing scheme (e.g., "localhost:8080")
|
|
652
|
+
if not parsed.netloc and parsed.path:
|
|
653
|
+
raise ValueError("Endpoint must include scheme (http, https, ws, wss)")
|
|
654
|
+
|
|
655
|
+
# Check that we have a host
|
|
656
|
+
if not parsed.netloc:
|
|
657
|
+
raise ValueError("Endpoint must include host")
|
|
658
|
+
|
|
659
|
+
# Validate scheme
|
|
660
|
+
if parsed.scheme not in ["http", "https", "ws", "wss"]:
|
|
661
|
+
raise ValueError(f"Unsupported endpoint scheme: {parsed.scheme}")
|
|
662
|
+
|
|
663
|
+
object.__setattr__(self, "value", value)
|
|
664
|
+
|
|
665
|
+
@property
|
|
666
|
+
def scheme(self) -> str:
|
|
667
|
+
return urlparse(self.value).scheme
|
|
668
|
+
|
|
669
|
+
@property
|
|
670
|
+
def host(self) -> str:
|
|
671
|
+
return urlparse(self.value).netloc
|
|
672
|
+
|
|
673
|
+
def __str__(self) -> str:
|
|
674
|
+
return self.value
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
@dataclass(frozen=True)
|
|
678
|
+
class EnvironmentVariables:
|
|
679
|
+
"""Environment variables for provider execution.
|
|
680
|
+
|
|
681
|
+
Rules:
|
|
682
|
+
- Keys are non-empty strings
|
|
683
|
+
- Values are strings
|
|
684
|
+
- Immutable after creation
|
|
685
|
+
"""
|
|
686
|
+
|
|
687
|
+
variables: Dict[str, str]
|
|
688
|
+
|
|
689
|
+
def __init__(self, variables: Optional[Dict[str, str]] = None):
|
|
690
|
+
vars_dict = variables or {}
|
|
691
|
+
|
|
692
|
+
# Validate keys
|
|
693
|
+
for key in vars_dict.keys():
|
|
694
|
+
if not key:
|
|
695
|
+
raise ValueError("Environment variable key cannot be empty")
|
|
696
|
+
if not isinstance(key, str):
|
|
697
|
+
raise ValueError("Environment variable key must be a string")
|
|
698
|
+
|
|
699
|
+
# Create immutable copy
|
|
700
|
+
object.__setattr__(self, "variables", dict(vars_dict))
|
|
701
|
+
|
|
702
|
+
def get(self, key: str, default: Optional[str] = None) -> Optional[str]:
|
|
703
|
+
"""Get environment variable value."""
|
|
704
|
+
return self.variables.get(key, default)
|
|
705
|
+
|
|
706
|
+
def __getitem__(self, key: str) -> str:
|
|
707
|
+
return self.variables[key]
|
|
708
|
+
|
|
709
|
+
def __contains__(self, key: str) -> bool:
|
|
710
|
+
return key in self.variables
|
|
711
|
+
|
|
712
|
+
def __len__(self) -> int:
|
|
713
|
+
return len(self.variables)
|
|
714
|
+
|
|
715
|
+
def to_dict(self) -> Dict[str, str]:
|
|
716
|
+
"""Convert to dictionary (returns a copy)."""
|
|
717
|
+
return dict(self.variables)
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
# --- Timing Value Objects ---
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
@dataclass(frozen=True)
|
|
724
|
+
class IdleTTL:
|
|
725
|
+
"""Time-to-live for idle providers in seconds.
|
|
726
|
+
|
|
727
|
+
Rules:
|
|
728
|
+
- Positive integer
|
|
729
|
+
- Reasonable range: 1 to 86400 seconds (1 day)
|
|
730
|
+
"""
|
|
731
|
+
|
|
732
|
+
seconds: int
|
|
733
|
+
|
|
734
|
+
def __init__(self, seconds: int):
|
|
735
|
+
if seconds <= 0:
|
|
736
|
+
raise ValueError("IdleTTL must be positive")
|
|
737
|
+
if seconds > 86400:
|
|
738
|
+
raise ValueError("IdleTTL cannot exceed 86400 seconds (1 day)")
|
|
739
|
+
object.__setattr__(self, "seconds", seconds)
|
|
740
|
+
|
|
741
|
+
def __int__(self) -> int:
|
|
742
|
+
return self.seconds
|
|
743
|
+
|
|
744
|
+
def __str__(self) -> str:
|
|
745
|
+
return f"{self.seconds}s"
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
@dataclass(frozen=True)
|
|
749
|
+
class HealthCheckInterval:
|
|
750
|
+
"""Interval between health checks in seconds.
|
|
751
|
+
|
|
752
|
+
Rules:
|
|
753
|
+
- Positive integer
|
|
754
|
+
- Reasonable range: 5 to 3600 seconds (1 hour)
|
|
755
|
+
"""
|
|
756
|
+
|
|
757
|
+
seconds: int
|
|
758
|
+
|
|
759
|
+
def __init__(self, seconds: int):
|
|
760
|
+
if seconds <= 0:
|
|
761
|
+
raise ValueError("HealthCheckInterval must be positive")
|
|
762
|
+
if seconds < 5:
|
|
763
|
+
raise ValueError("HealthCheckInterval must be at least 5 seconds")
|
|
764
|
+
if seconds > 3600:
|
|
765
|
+
raise ValueError("HealthCheckInterval cannot exceed 3600 seconds (1 hour)")
|
|
766
|
+
object.__setattr__(self, "seconds", seconds)
|
|
767
|
+
|
|
768
|
+
def __int__(self) -> int:
|
|
769
|
+
return self.seconds
|
|
770
|
+
|
|
771
|
+
def __str__(self) -> str:
|
|
772
|
+
return f"{self.seconds}s"
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
@dataclass(frozen=True)
|
|
776
|
+
class MaxConsecutiveFailures:
|
|
777
|
+
"""Maximum consecutive failures before degradation.
|
|
778
|
+
|
|
779
|
+
Rules:
|
|
780
|
+
- Positive integer
|
|
781
|
+
- Reasonable range: 1 to 100
|
|
782
|
+
"""
|
|
783
|
+
|
|
784
|
+
count: int
|
|
785
|
+
|
|
786
|
+
def __init__(self, count: int):
|
|
787
|
+
if count <= 0:
|
|
788
|
+
raise ValueError("MaxConsecutiveFailures must be positive")
|
|
789
|
+
if count > 100:
|
|
790
|
+
raise ValueError("MaxConsecutiveFailures cannot exceed 100")
|
|
791
|
+
object.__setattr__(self, "count", count)
|
|
792
|
+
|
|
793
|
+
def __int__(self) -> int:
|
|
794
|
+
return self.count
|
|
795
|
+
|
|
796
|
+
def __str__(self) -> str:
|
|
797
|
+
return str(self.count)
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
@dataclass(frozen=True)
|
|
801
|
+
class TimeoutSeconds:
|
|
802
|
+
"""Timeout duration in seconds.
|
|
803
|
+
|
|
804
|
+
Rules:
|
|
805
|
+
- Positive number (int or float)
|
|
806
|
+
- Reasonable range: 0.1 to 3600 seconds
|
|
807
|
+
"""
|
|
808
|
+
|
|
809
|
+
seconds: float
|
|
810
|
+
|
|
811
|
+
def __init__(self, seconds: float):
|
|
812
|
+
if seconds <= 0:
|
|
813
|
+
raise ValueError("TimeoutSeconds must be positive")
|
|
814
|
+
if seconds > 3600:
|
|
815
|
+
raise ValueError("TimeoutSeconds cannot exceed 3600 seconds (1 hour)")
|
|
816
|
+
object.__setattr__(self, "seconds", float(seconds))
|
|
817
|
+
|
|
818
|
+
def __float__(self) -> float:
|
|
819
|
+
return self.seconds
|
|
820
|
+
|
|
821
|
+
def __str__(self) -> str:
|
|
822
|
+
return f"{self.seconds}s"
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
# --- Provider Configuration ---
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
@dataclass(frozen=True)
|
|
829
|
+
class ProviderConfig:
|
|
830
|
+
"""Complete configuration for a provider.
|
|
831
|
+
|
|
832
|
+
Encapsulates all configuration options in a validated, immutable object.
|
|
833
|
+
"""
|
|
834
|
+
|
|
835
|
+
provider_id: ProviderId
|
|
836
|
+
mode: ProviderMode
|
|
837
|
+
command: Optional[CommandLine] = None
|
|
838
|
+
image: Optional[DockerImage] = None
|
|
839
|
+
endpoint: Optional[Endpoint] = None
|
|
840
|
+
env: Optional[EnvironmentVariables] = None
|
|
841
|
+
idle_ttl: IdleTTL = None
|
|
842
|
+
health_check_interval: HealthCheckInterval = None
|
|
843
|
+
max_consecutive_failures: MaxConsecutiveFailures = None
|
|
844
|
+
|
|
845
|
+
def __init__(
|
|
846
|
+
self,
|
|
847
|
+
provider_id: str,
|
|
848
|
+
mode: str,
|
|
849
|
+
command: Optional[List[str]] = None,
|
|
850
|
+
image: Optional[str] = None,
|
|
851
|
+
endpoint: Optional[str] = None,
|
|
852
|
+
env: Optional[Dict[str, str]] = None,
|
|
853
|
+
idle_ttl_s: int = 300,
|
|
854
|
+
health_check_interval_s: int = 60,
|
|
855
|
+
max_consecutive_failures: int = 3,
|
|
856
|
+
):
|
|
857
|
+
# Validate and convert provider_id
|
|
858
|
+
object.__setattr__(self, "provider_id", ProviderId(provider_id))
|
|
859
|
+
|
|
860
|
+
# Validate and convert mode
|
|
861
|
+
try:
|
|
862
|
+
object.__setattr__(self, "mode", ProviderMode(mode))
|
|
863
|
+
except ValueError:
|
|
864
|
+
raise ValueError(f"Invalid provider mode: {mode}. Must be one of: subprocess, docker, remote")
|
|
865
|
+
|
|
866
|
+
# Validate mode-specific configuration
|
|
867
|
+
resolved_mode = ProviderMode(mode)
|
|
868
|
+
|
|
869
|
+
if resolved_mode == ProviderMode.SUBPROCESS:
|
|
870
|
+
if not command:
|
|
871
|
+
raise ValueError("Subprocess mode requires 'command' configuration")
|
|
872
|
+
object.__setattr__(self, "command", CommandLine.from_list(command))
|
|
873
|
+
object.__setattr__(self, "image", None)
|
|
874
|
+
object.__setattr__(self, "endpoint", None)
|
|
875
|
+
elif resolved_mode == ProviderMode.DOCKER:
|
|
876
|
+
if not image:
|
|
877
|
+
raise ValueError("Docker mode requires 'image' configuration")
|
|
878
|
+
object.__setattr__(self, "command", None)
|
|
879
|
+
object.__setattr__(self, "image", DockerImage(image))
|
|
880
|
+
object.__setattr__(self, "endpoint", None)
|
|
881
|
+
elif resolved_mode == ProviderMode.REMOTE:
|
|
882
|
+
if not endpoint:
|
|
883
|
+
raise ValueError("Remote mode requires 'endpoint' configuration")
|
|
884
|
+
object.__setattr__(self, "command", None)
|
|
885
|
+
object.__setattr__(self, "image", None)
|
|
886
|
+
object.__setattr__(self, "endpoint", Endpoint(endpoint))
|
|
887
|
+
|
|
888
|
+
# Environment variables
|
|
889
|
+
object.__setattr__(self, "env", EnvironmentVariables(env) if env else EnvironmentVariables())
|
|
890
|
+
|
|
891
|
+
# Timing configuration
|
|
892
|
+
object.__setattr__(self, "idle_ttl", IdleTTL(idle_ttl_s))
|
|
893
|
+
object.__setattr__(self, "health_check_interval", HealthCheckInterval(health_check_interval_s))
|
|
894
|
+
object.__setattr__(
|
|
895
|
+
self,
|
|
896
|
+
"max_consecutive_failures",
|
|
897
|
+
MaxConsecutiveFailures(max_consecutive_failures),
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
901
|
+
"""Convert to dictionary representation."""
|
|
902
|
+
result = {
|
|
903
|
+
"provider_id": str(self.provider_id),
|
|
904
|
+
"mode": str(self.mode),
|
|
905
|
+
"idle_ttl_s": self.idle_ttl.seconds,
|
|
906
|
+
"health_check_interval_s": self.health_check_interval.seconds,
|
|
907
|
+
"max_consecutive_failures": self.max_consecutive_failures.count,
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
if self.command:
|
|
911
|
+
result["command"] = self.command.to_list()
|
|
912
|
+
if self.image:
|
|
913
|
+
result["image"] = str(self.image)
|
|
914
|
+
if self.endpoint:
|
|
915
|
+
result["endpoint"] = str(self.endpoint)
|
|
916
|
+
if self.env and len(self.env) > 0:
|
|
917
|
+
result["env"] = self.env.to_dict()
|
|
918
|
+
|
|
919
|
+
return result
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
# --- Tool Arguments ---
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
class ToolArguments:
|
|
926
|
+
"""Validated tool invocation arguments.
|
|
927
|
+
|
|
928
|
+
Rules:
|
|
929
|
+
- Must be a dictionary
|
|
930
|
+
- Size limited to prevent DoS
|
|
931
|
+
- Keys must be strings
|
|
932
|
+
"""
|
|
933
|
+
|
|
934
|
+
MAX_SIZE_BYTES = 1_000_000 # 1MB limit
|
|
935
|
+
MAX_DEPTH = 10 # Maximum nesting depth
|
|
936
|
+
|
|
937
|
+
def __init__(self, arguments: Dict[str, Any]):
|
|
938
|
+
if not isinstance(arguments, dict):
|
|
939
|
+
raise ValueError("Tool arguments must be a dictionary")
|
|
940
|
+
|
|
941
|
+
self._validate_size(arguments)
|
|
942
|
+
self._validate_structure(arguments)
|
|
943
|
+
self._arguments = arguments
|
|
944
|
+
|
|
945
|
+
def _validate_size(self, arguments: Dict[str, Any]) -> None:
|
|
946
|
+
"""Validate arguments don't exceed size limit."""
|
|
947
|
+
import json
|
|
948
|
+
|
|
949
|
+
try:
|
|
950
|
+
size = len(json.dumps(arguments))
|
|
951
|
+
if size > self.MAX_SIZE_BYTES:
|
|
952
|
+
raise ValueError(f"Tool arguments exceed maximum size ({size} > {self.MAX_SIZE_BYTES} bytes)")
|
|
953
|
+
except (TypeError, ValueError) as e:
|
|
954
|
+
if "size" not in str(e):
|
|
955
|
+
raise ValueError(f"Tool arguments must be JSON-serializable: {e}")
|
|
956
|
+
raise
|
|
957
|
+
|
|
958
|
+
def _validate_structure(self, obj: Any, depth: int = 0) -> None:
|
|
959
|
+
"""Validate argument structure and depth."""
|
|
960
|
+
if depth > self.MAX_DEPTH:
|
|
961
|
+
raise ValueError(f"Tool arguments exceed maximum nesting depth ({self.MAX_DEPTH})")
|
|
962
|
+
|
|
963
|
+
if isinstance(obj, dict):
|
|
964
|
+
for key, value in obj.items():
|
|
965
|
+
if not isinstance(key, str):
|
|
966
|
+
raise ValueError("Tool argument keys must be strings")
|
|
967
|
+
self._validate_structure(value, depth + 1)
|
|
968
|
+
elif isinstance(obj, list):
|
|
969
|
+
for item in obj:
|
|
970
|
+
self._validate_structure(item, depth + 1)
|
|
971
|
+
|
|
972
|
+
@property
|
|
973
|
+
def value(self) -> Dict[str, Any]:
|
|
974
|
+
"""Get the validated arguments."""
|
|
975
|
+
return self._arguments
|
|
976
|
+
|
|
977
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
978
|
+
"""Convert to dictionary (returns a copy)."""
|
|
979
|
+
return dict(self._arguments)
|
|
980
|
+
|
|
981
|
+
def __getitem__(self, key: str) -> Any:
|
|
982
|
+
return self._arguments[key]
|
|
983
|
+
|
|
984
|
+
def __contains__(self, key: str) -> bool:
|
|
985
|
+
return key in self._arguments
|
|
986
|
+
|
|
987
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
988
|
+
return self._arguments.get(key, default)
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
# --- Group-related Value Objects ---
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
class GroupId:
|
|
995
|
+
"""Unique identifier for a provider group.
|
|
996
|
+
|
|
997
|
+
Rules:
|
|
998
|
+
- Same rules as ProviderId
|
|
999
|
+
- Non-empty string
|
|
1000
|
+
- Alphanumeric, hyphens, underscores only
|
|
1001
|
+
- Max 64 characters
|
|
1002
|
+
"""
|
|
1003
|
+
|
|
1004
|
+
_VALID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$")
|
|
1005
|
+
_MAX_LENGTH = 64
|
|
1006
|
+
|
|
1007
|
+
def __init__(self, value: str):
|
|
1008
|
+
if not value:
|
|
1009
|
+
raise ValueError("GroupId cannot be empty")
|
|
1010
|
+
if len(value) > self._MAX_LENGTH:
|
|
1011
|
+
raise ValueError(f"GroupId cannot exceed {self._MAX_LENGTH} characters")
|
|
1012
|
+
if not self._VALID_PATTERN.match(value):
|
|
1013
|
+
raise ValueError("GroupId must contain only alphanumeric characters, hyphens, and underscores")
|
|
1014
|
+
self._value = value
|
|
1015
|
+
|
|
1016
|
+
@property
|
|
1017
|
+
def value(self) -> str:
|
|
1018
|
+
return self._value
|
|
1019
|
+
|
|
1020
|
+
def __str__(self) -> str:
|
|
1021
|
+
return self._value
|
|
1022
|
+
|
|
1023
|
+
def __repr__(self) -> str:
|
|
1024
|
+
return f"GroupId('{self._value}')"
|
|
1025
|
+
|
|
1026
|
+
def __eq__(self, other) -> bool:
|
|
1027
|
+
if isinstance(other, str):
|
|
1028
|
+
return self._value == other
|
|
1029
|
+
if not isinstance(other, GroupId):
|
|
1030
|
+
return False
|
|
1031
|
+
return self._value == other._value
|
|
1032
|
+
|
|
1033
|
+
def __hash__(self) -> int:
|
|
1034
|
+
return hash(self._value)
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
class MemberWeight:
|
|
1038
|
+
"""Weight for a group member in weighted load balancing.
|
|
1039
|
+
|
|
1040
|
+
Rules:
|
|
1041
|
+
- Positive integer (>= 1)
|
|
1042
|
+
- Max value: 100
|
|
1043
|
+
- Higher weight = more traffic
|
|
1044
|
+
"""
|
|
1045
|
+
|
|
1046
|
+
MIN_WEIGHT = 1
|
|
1047
|
+
MAX_WEIGHT = 100
|
|
1048
|
+
|
|
1049
|
+
def __init__(self, value: int = 1):
|
|
1050
|
+
if not isinstance(value, int):
|
|
1051
|
+
raise ValueError("MemberWeight must be an integer")
|
|
1052
|
+
if value < self.MIN_WEIGHT:
|
|
1053
|
+
raise ValueError(f"MemberWeight must be at least {self.MIN_WEIGHT}")
|
|
1054
|
+
if value > self.MAX_WEIGHT:
|
|
1055
|
+
raise ValueError(f"MemberWeight cannot exceed {self.MAX_WEIGHT}")
|
|
1056
|
+
self._value = value
|
|
1057
|
+
|
|
1058
|
+
@property
|
|
1059
|
+
def value(self) -> int:
|
|
1060
|
+
return self._value
|
|
1061
|
+
|
|
1062
|
+
def __int__(self) -> int:
|
|
1063
|
+
return self._value
|
|
1064
|
+
|
|
1065
|
+
def __str__(self) -> str:
|
|
1066
|
+
return str(self._value)
|
|
1067
|
+
|
|
1068
|
+
def __repr__(self) -> str:
|
|
1069
|
+
return f"MemberWeight({self._value})"
|
|
1070
|
+
|
|
1071
|
+
def __eq__(self, other) -> bool:
|
|
1072
|
+
if isinstance(other, int):
|
|
1073
|
+
return self._value == other
|
|
1074
|
+
if not isinstance(other, MemberWeight):
|
|
1075
|
+
return False
|
|
1076
|
+
return self._value == other._value
|
|
1077
|
+
|
|
1078
|
+
def __hash__(self) -> int:
|
|
1079
|
+
return hash(self._value)
|
|
1080
|
+
|
|
1081
|
+
def __lt__(self, other) -> bool:
|
|
1082
|
+
if isinstance(other, int):
|
|
1083
|
+
return self._value < other
|
|
1084
|
+
if isinstance(other, MemberWeight):
|
|
1085
|
+
return self._value < other._value
|
|
1086
|
+
return NotImplemented
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
class MemberPriority:
|
|
1090
|
+
"""Priority for a group member in priority-based selection.
|
|
1091
|
+
|
|
1092
|
+
Rules:
|
|
1093
|
+
- Positive integer (>= 1)
|
|
1094
|
+
- Lower value = higher priority (1 is highest)
|
|
1095
|
+
- Max value: 100
|
|
1096
|
+
"""
|
|
1097
|
+
|
|
1098
|
+
MIN_PRIORITY = 1
|
|
1099
|
+
MAX_PRIORITY = 100
|
|
1100
|
+
|
|
1101
|
+
def __init__(self, value: int = 1):
|
|
1102
|
+
if not isinstance(value, int):
|
|
1103
|
+
raise ValueError("MemberPriority must be an integer")
|
|
1104
|
+
if value < self.MIN_PRIORITY:
|
|
1105
|
+
raise ValueError(f"MemberPriority must be at least {self.MIN_PRIORITY}")
|
|
1106
|
+
if value > self.MAX_PRIORITY:
|
|
1107
|
+
raise ValueError(f"MemberPriority cannot exceed {self.MAX_PRIORITY}")
|
|
1108
|
+
self._value = value
|
|
1109
|
+
|
|
1110
|
+
@property
|
|
1111
|
+
def value(self) -> int:
|
|
1112
|
+
return self._value
|
|
1113
|
+
|
|
1114
|
+
def __int__(self) -> int:
|
|
1115
|
+
return self._value
|
|
1116
|
+
|
|
1117
|
+
def __str__(self) -> str:
|
|
1118
|
+
return str(self._value)
|
|
1119
|
+
|
|
1120
|
+
def __repr__(self) -> str:
|
|
1121
|
+
return f"MemberPriority({self._value})"
|
|
1122
|
+
|
|
1123
|
+
def __eq__(self, other) -> bool:
|
|
1124
|
+
if isinstance(other, int):
|
|
1125
|
+
return self._value == other
|
|
1126
|
+
if not isinstance(other, MemberPriority):
|
|
1127
|
+
return False
|
|
1128
|
+
return self._value == other._value
|
|
1129
|
+
|
|
1130
|
+
def __hash__(self) -> int:
|
|
1131
|
+
return hash(self._value)
|
|
1132
|
+
|
|
1133
|
+
def __lt__(self, other) -> bool:
|
|
1134
|
+
if isinstance(other, int):
|
|
1135
|
+
return self._value < other
|
|
1136
|
+
if isinstance(other, MemberPriority):
|
|
1137
|
+
return self._value < other._value
|
|
1138
|
+
return NotImplemented
|