krons 0.1.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.
- kronos/__init__.py +0 -0
- kronos/core/__init__.py +145 -0
- kronos/core/broadcaster.py +116 -0
- kronos/core/element.py +225 -0
- kronos/core/event.py +316 -0
- kronos/core/eventbus.py +116 -0
- kronos/core/flow.py +356 -0
- kronos/core/graph.py +442 -0
- kronos/core/node.py +982 -0
- kronos/core/pile.py +575 -0
- kronos/core/processor.py +494 -0
- kronos/core/progression.py +296 -0
- kronos/enforcement/__init__.py +57 -0
- kronos/enforcement/common/__init__.py +34 -0
- kronos/enforcement/common/boolean.py +85 -0
- kronos/enforcement/common/choice.py +97 -0
- kronos/enforcement/common/mapping.py +118 -0
- kronos/enforcement/common/model.py +102 -0
- kronos/enforcement/common/number.py +98 -0
- kronos/enforcement/common/string.py +140 -0
- kronos/enforcement/context.py +129 -0
- kronos/enforcement/policy.py +80 -0
- kronos/enforcement/registry.py +153 -0
- kronos/enforcement/rule.py +312 -0
- kronos/enforcement/service.py +370 -0
- kronos/enforcement/validator.py +198 -0
- kronos/errors.py +146 -0
- kronos/operations/__init__.py +32 -0
- kronos/operations/builder.py +228 -0
- kronos/operations/flow.py +398 -0
- kronos/operations/node.py +101 -0
- kronos/operations/registry.py +92 -0
- kronos/protocols.py +414 -0
- kronos/py.typed +0 -0
- kronos/services/__init__.py +81 -0
- kronos/services/backend.py +286 -0
- kronos/services/endpoint.py +608 -0
- kronos/services/hook.py +471 -0
- kronos/services/imodel.py +465 -0
- kronos/services/registry.py +115 -0
- kronos/services/utilities/__init__.py +36 -0
- kronos/services/utilities/header_factory.py +87 -0
- kronos/services/utilities/rate_limited_executor.py +271 -0
- kronos/services/utilities/rate_limiter.py +180 -0
- kronos/services/utilities/resilience.py +414 -0
- kronos/session/__init__.py +41 -0
- kronos/session/exchange.py +258 -0
- kronos/session/message.py +60 -0
- kronos/session/session.py +411 -0
- kronos/specs/__init__.py +25 -0
- kronos/specs/adapters/__init__.py +0 -0
- kronos/specs/adapters/_utils.py +45 -0
- kronos/specs/adapters/dataclass_field.py +246 -0
- kronos/specs/adapters/factory.py +56 -0
- kronos/specs/adapters/pydantic_adapter.py +309 -0
- kronos/specs/adapters/sql_ddl.py +946 -0
- kronos/specs/catalog/__init__.py +36 -0
- kronos/specs/catalog/_audit.py +39 -0
- kronos/specs/catalog/_common.py +43 -0
- kronos/specs/catalog/_content.py +59 -0
- kronos/specs/catalog/_enforcement.py +70 -0
- kronos/specs/factory.py +120 -0
- kronos/specs/operable.py +314 -0
- kronos/specs/phrase.py +405 -0
- kronos/specs/protocol.py +140 -0
- kronos/specs/spec.py +506 -0
- kronos/types/__init__.py +60 -0
- kronos/types/_sentinel.py +311 -0
- kronos/types/base.py +369 -0
- kronos/types/db_types.py +260 -0
- kronos/types/identity.py +66 -0
- kronos/utils/__init__.py +40 -0
- kronos/utils/_hash.py +234 -0
- kronos/utils/_json_dump.py +392 -0
- kronos/utils/_lazy_init.py +63 -0
- kronos/utils/_to_list.py +165 -0
- kronos/utils/_to_num.py +85 -0
- kronos/utils/_utils.py +375 -0
- kronos/utils/concurrency/__init__.py +205 -0
- kronos/utils/concurrency/_async_call.py +333 -0
- kronos/utils/concurrency/_cancel.py +122 -0
- kronos/utils/concurrency/_errors.py +96 -0
- kronos/utils/concurrency/_patterns.py +363 -0
- kronos/utils/concurrency/_primitives.py +328 -0
- kronos/utils/concurrency/_priority_queue.py +135 -0
- kronos/utils/concurrency/_resource_tracker.py +110 -0
- kronos/utils/concurrency/_run_async.py +67 -0
- kronos/utils/concurrency/_task.py +95 -0
- kronos/utils/concurrency/_utils.py +79 -0
- kronos/utils/fuzzy/__init__.py +14 -0
- kronos/utils/fuzzy/_extract_json.py +90 -0
- kronos/utils/fuzzy/_fuzzy_json.py +288 -0
- kronos/utils/fuzzy/_fuzzy_match.py +149 -0
- kronos/utils/fuzzy/_string_similarity.py +187 -0
- kronos/utils/fuzzy/_to_dict.py +396 -0
- kronos/utils/sql/__init__.py +13 -0
- kronos/utils/sql/_sql_validation.py +142 -0
- krons-0.1.0.dist-info/METADATA +70 -0
- krons-0.1.0.dist-info/RECORD +101 -0
- krons-0.1.0.dist-info/WHEEL +4 -0
- krons-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""HTTP endpoint backend for kron services.
|
|
5
|
+
|
|
6
|
+
Provides HTTP/REST API integration with:
|
|
7
|
+
- EndpointConfig: URL, auth, headers, request validation
|
|
8
|
+
- Endpoint: HTTP client with circuit breaker and retry support
|
|
9
|
+
- APICalling: Event wrapper for HTTP requests with token estimation
|
|
10
|
+
|
|
11
|
+
Security:
|
|
12
|
+
API keys resolved from environment variables or passed as SecretStr.
|
|
13
|
+
Raw credentials cleared from config to prevent serialization leaks.
|
|
14
|
+
System env vars (PATH, HOME, etc.) blocked to prevent collision.
|
|
15
|
+
|
|
16
|
+
Example:
|
|
17
|
+
config = EndpointConfig(
|
|
18
|
+
provider="openai",
|
|
19
|
+
name="gpt-4",
|
|
20
|
+
base_url="https://api.openai.com/v1",
|
|
21
|
+
endpoint="chat/completions",
|
|
22
|
+
api_key="OPENAI_API_KEY", # env var name
|
|
23
|
+
request_options=ChatRequest,
|
|
24
|
+
)
|
|
25
|
+
endpoint = Endpoint(config=config)
|
|
26
|
+
calling = APICalling(backend=endpoint, payload={...})
|
|
27
|
+
await calling.invoke()
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import logging
|
|
33
|
+
import os
|
|
34
|
+
import re
|
|
35
|
+
from typing import Any, TypeVar
|
|
36
|
+
|
|
37
|
+
from pydantic import (
|
|
38
|
+
BaseModel,
|
|
39
|
+
Field,
|
|
40
|
+
PrivateAttr,
|
|
41
|
+
SecretStr,
|
|
42
|
+
field_serializer,
|
|
43
|
+
field_validator,
|
|
44
|
+
model_validator,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
from .backend import Calling, NormalizedResponse, ServiceBackend, ServiceConfig
|
|
48
|
+
from .utilities.header_factory import AUTH_TYPES, HeaderFactory
|
|
49
|
+
from .utilities.resilience import CircuitBreaker, RetryConfig, retry_with_backoff
|
|
50
|
+
|
|
51
|
+
logger = logging.getLogger(__name__)
|
|
52
|
+
|
|
53
|
+
# Blocked env vars to prevent collision with system paths/config.
|
|
54
|
+
SYSTEM_ENV_VARS = frozenset(
|
|
55
|
+
{
|
|
56
|
+
"HOME",
|
|
57
|
+
"PATH",
|
|
58
|
+
"USER",
|
|
59
|
+
"SHELL",
|
|
60
|
+
"PWD",
|
|
61
|
+
"LANG",
|
|
62
|
+
"TERM",
|
|
63
|
+
"TMPDIR",
|
|
64
|
+
"LOGNAME",
|
|
65
|
+
"HOSTNAME",
|
|
66
|
+
"PYTHONPATH",
|
|
67
|
+
"VIRTUAL_ENV",
|
|
68
|
+
"PS1",
|
|
69
|
+
"OLDPWD",
|
|
70
|
+
"EDITOR",
|
|
71
|
+
"PAGER",
|
|
72
|
+
"DISPLAY",
|
|
73
|
+
"SSH_AUTH_SOCK",
|
|
74
|
+
"XDG_RUNTIME_DIR",
|
|
75
|
+
"XDG_CONFIG_HOME",
|
|
76
|
+
"XDG_DATA_HOME",
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
B = TypeVar("B", bound=type[BaseModel])
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class EndpointConfig(ServiceConfig):
|
|
85
|
+
"""HTTP endpoint configuration with secure credential handling.
|
|
86
|
+
|
|
87
|
+
Extends ServiceConfig with HTTP-specific settings: URL construction,
|
|
88
|
+
authentication, headers, and request validation.
|
|
89
|
+
|
|
90
|
+
Credential Security:
|
|
91
|
+
- api_key accepts env var name (UPPERCASE_WITH_UNDERSCORES) or raw credential
|
|
92
|
+
- Env var names preserved in api_key for serialization
|
|
93
|
+
- Raw credentials cleared from api_key, stored only in _api_key (SecretStr)
|
|
94
|
+
- _api_key never serialized (PrivateAttr)
|
|
95
|
+
|
|
96
|
+
Attributes:
|
|
97
|
+
base_url: API base URL (e.g., "https://api.openai.com/v1").
|
|
98
|
+
endpoint: Path appended to base_url, supports {param} formatting.
|
|
99
|
+
endpoint_params: Expected URL parameter names for validation.
|
|
100
|
+
params: Param values for endpoint formatting.
|
|
101
|
+
method: HTTP method (default "POST").
|
|
102
|
+
content_type: Content-Type header (default "application/json").
|
|
103
|
+
auth_type: Auth header style ("bearer", "x-api-key", "basic", "none").
|
|
104
|
+
default_headers: Headers merged into every request.
|
|
105
|
+
api_key: Env var name (preserved) or raw credential (cleared after resolve).
|
|
106
|
+
api_key_is_env: True if api_key is env var reference (for deserialization).
|
|
107
|
+
openai_compatible: Enable OpenAI response parsing.
|
|
108
|
+
requires_tokens: Enable token estimation for rate limiting.
|
|
109
|
+
client_kwargs: Extra kwargs passed to httpx.AsyncClient.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
base_url: str | None = None
|
|
113
|
+
endpoint: str
|
|
114
|
+
endpoint_params: list[str] | None = None
|
|
115
|
+
method: str = "POST"
|
|
116
|
+
params: dict[str, str] = Field(default_factory=dict)
|
|
117
|
+
content_type: str | None = "application/json"
|
|
118
|
+
auth_type: AUTH_TYPES = "bearer"
|
|
119
|
+
default_headers: dict = Field(default_factory=dict)
|
|
120
|
+
api_key: str | None = Field(None, frozen=True)
|
|
121
|
+
api_key_is_env: bool = Field(False, frozen=True)
|
|
122
|
+
openai_compatible: bool = False
|
|
123
|
+
requires_tokens: bool = False
|
|
124
|
+
client_kwargs: dict = Field(default_factory=dict)
|
|
125
|
+
_api_key: SecretStr | None = PrivateAttr(None)
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def api_key_env(self) -> str | None:
|
|
129
|
+
"""Env var name if api_key_is_env=True, else None."""
|
|
130
|
+
return self.api_key
|
|
131
|
+
|
|
132
|
+
@model_validator(mode="after")
|
|
133
|
+
def _validate_api_key_n_params(self):
|
|
134
|
+
"""Resolve api_key and validate endpoint params.
|
|
135
|
+
|
|
136
|
+
API key resolution:
|
|
137
|
+
1. If api_key_is_env=True (deserialization): verify env var exists
|
|
138
|
+
2. If matches UPPERCASE_PATTERN and env var exists: mark as env var
|
|
139
|
+
3. Otherwise: treat as raw credential, clear api_key field
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
ValueError: If credential empty, system env var used, or invalid params.
|
|
143
|
+
"""
|
|
144
|
+
if self.api_key is not None:
|
|
145
|
+
if self.api_key_is_env:
|
|
146
|
+
if not os.getenv(self.api_key):
|
|
147
|
+
raise ValueError(
|
|
148
|
+
f"Environment variable '{self.api_key}' not found during deserialization. "
|
|
149
|
+
f"Model was serialized with env var reference that no longer exists."
|
|
150
|
+
)
|
|
151
|
+
resolved = os.getenv(self.api_key, None)
|
|
152
|
+
if resolved and resolved.strip():
|
|
153
|
+
object.__setattr__(self, "_api_key", SecretStr(resolved.strip()))
|
|
154
|
+
return self
|
|
155
|
+
|
|
156
|
+
if not self.api_key.strip():
|
|
157
|
+
raise ValueError("api_key cannot be empty or whitespace")
|
|
158
|
+
|
|
159
|
+
is_env_var_pattern = bool(re.match(r"^[A-Z][A-Z0-9_]*$", self.api_key))
|
|
160
|
+
|
|
161
|
+
if is_env_var_pattern:
|
|
162
|
+
if self.api_key in SYSTEM_ENV_VARS:
|
|
163
|
+
raise ValueError(
|
|
164
|
+
f"'{self.api_key}' is a system environment variable and cannot be used as api_key. "
|
|
165
|
+
f"If this is a raw credential, pass it as SecretStr('{self.api_key}')."
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
resolved = os.getenv(self.api_key, None)
|
|
169
|
+
if resolved is not None:
|
|
170
|
+
if not resolved.strip():
|
|
171
|
+
raise ValueError(
|
|
172
|
+
f"Environment variable '{self.api_key}' is empty or whitespace"
|
|
173
|
+
)
|
|
174
|
+
object.__setattr__(self, "api_key_is_env", True)
|
|
175
|
+
object.__setattr__(self, "_api_key", SecretStr(resolved.strip()))
|
|
176
|
+
else:
|
|
177
|
+
object.__setattr__(self, "api_key_is_env", False)
|
|
178
|
+
object.__setattr__(self, "_api_key", SecretStr(self.api_key.strip()))
|
|
179
|
+
object.__setattr__(self, "api_key", None)
|
|
180
|
+
else:
|
|
181
|
+
object.__setattr__(self, "api_key_is_env", False)
|
|
182
|
+
object.__setattr__(self, "_api_key", SecretStr(self.api_key.strip()))
|
|
183
|
+
object.__setattr__(self, "api_key", None)
|
|
184
|
+
|
|
185
|
+
if self.endpoint_params and self.params:
|
|
186
|
+
invalid_params = set(self.params.keys()) - set(self.endpoint_params)
|
|
187
|
+
if invalid_params:
|
|
188
|
+
raise ValueError(
|
|
189
|
+
f"Invalid params {invalid_params}. Must be subset of endpoint_params: {self.endpoint_params}"
|
|
190
|
+
)
|
|
191
|
+
missing_params = set(self.endpoint_params) - set(self.params.keys())
|
|
192
|
+
if missing_params:
|
|
193
|
+
logger.warning(
|
|
194
|
+
f"Endpoint expects params {missing_params} but they were not provided. "
|
|
195
|
+
f"URL formatting may fail."
|
|
196
|
+
)
|
|
197
|
+
return self
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def full_url(self) -> str:
|
|
201
|
+
"""Construct full URL: base_url/endpoint with params formatted."""
|
|
202
|
+
if not self.endpoint_params:
|
|
203
|
+
return f"{self.base_url}/{self.endpoint}"
|
|
204
|
+
return f"{self.base_url}/{self.endpoint.format(**self.params)}"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class Endpoint(ServiceBackend):
|
|
208
|
+
"""HTTP API backend with resilience patterns.
|
|
209
|
+
|
|
210
|
+
Wraps httpx.AsyncClient with circuit breaker and retry support.
|
|
211
|
+
Handles request validation, header construction, and response normalization.
|
|
212
|
+
|
|
213
|
+
Resilience Stack (outer to inner):
|
|
214
|
+
retry_config -> circuit_breaker -> _call_http
|
|
215
|
+
|
|
216
|
+
Attributes:
|
|
217
|
+
config: EndpointConfig with URL, auth, and request options.
|
|
218
|
+
circuit_breaker: Optional CircuitBreaker for fail-fast behavior.
|
|
219
|
+
retry_config: Optional RetryConfig for exponential backoff.
|
|
220
|
+
|
|
221
|
+
Example:
|
|
222
|
+
endpoint = Endpoint(
|
|
223
|
+
config={"provider": "openai", "name": "gpt-4", ...},
|
|
224
|
+
circuit_breaker=CircuitBreaker(failure_threshold=5),
|
|
225
|
+
retry_config=RetryConfig(max_attempts=3),
|
|
226
|
+
)
|
|
227
|
+
response = await endpoint.call(request={"model": "gpt-4", ...})
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
circuit_breaker: CircuitBreaker | None = None
|
|
231
|
+
retry_config: RetryConfig | None = None
|
|
232
|
+
config: EndpointConfig
|
|
233
|
+
|
|
234
|
+
def __init__(
|
|
235
|
+
self,
|
|
236
|
+
config: dict | EndpointConfig,
|
|
237
|
+
circuit_breaker: CircuitBreaker | None = None,
|
|
238
|
+
retry_config: RetryConfig | None = None,
|
|
239
|
+
**kwargs,
|
|
240
|
+
):
|
|
241
|
+
"""Initialize Endpoint with config and optional resilience patterns.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
config: EndpointConfig or dict with endpoint settings.
|
|
245
|
+
circuit_breaker: Optional circuit breaker for fail-fast.
|
|
246
|
+
retry_config: Optional retry configuration.
|
|
247
|
+
**kwargs: Additional config overrides merged into config.
|
|
248
|
+
|
|
249
|
+
Raises:
|
|
250
|
+
ValueError: If config invalid or api_key empty.
|
|
251
|
+
"""
|
|
252
|
+
secret_api_key = None
|
|
253
|
+
if isinstance(config, dict):
|
|
254
|
+
config_dict = {**config, **kwargs}
|
|
255
|
+
if "api_key" in config_dict and isinstance(config_dict["api_key"], SecretStr):
|
|
256
|
+
secret_api_key = config_dict.pop("api_key")
|
|
257
|
+
_config = EndpointConfig(**config_dict)
|
|
258
|
+
elif isinstance(config, EndpointConfig):
|
|
259
|
+
_config = (
|
|
260
|
+
config.model_copy(deep=True, update=kwargs)
|
|
261
|
+
if kwargs
|
|
262
|
+
else config.model_copy(deep=True)
|
|
263
|
+
)
|
|
264
|
+
else:
|
|
265
|
+
raise ValueError("Config must be a dict or EndpointConfig instance")
|
|
266
|
+
|
|
267
|
+
super().__init__( # type: ignore[call-arg]
|
|
268
|
+
config=_config,
|
|
269
|
+
circuit_breaker=circuit_breaker,
|
|
270
|
+
retry_config=retry_config,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
if secret_api_key is not None:
|
|
274
|
+
raw_value = secret_api_key.get_secret_value()
|
|
275
|
+
if not raw_value.strip():
|
|
276
|
+
raise ValueError("api_key cannot be empty or whitespace")
|
|
277
|
+
object.__setattr__(self.config, "_api_key", SecretStr(raw_value.strip()))
|
|
278
|
+
|
|
279
|
+
logger.debug(
|
|
280
|
+
f"Initialized Endpoint: provider={self.config.provider}, "
|
|
281
|
+
f"endpoint={self.config.endpoint}, cb={circuit_breaker is not None}, "
|
|
282
|
+
f"retry={retry_config is not None}"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
def _create_http_client(self):
|
|
286
|
+
"""Create httpx.AsyncClient with config timeout and client_kwargs."""
|
|
287
|
+
import httpx
|
|
288
|
+
|
|
289
|
+
return httpx.AsyncClient(
|
|
290
|
+
timeout=self.config.timeout,
|
|
291
|
+
**self.config.client_kwargs,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
@property
|
|
295
|
+
def event_type(self) -> type:
|
|
296
|
+
"""APICalling event type for this backend."""
|
|
297
|
+
return APICalling
|
|
298
|
+
|
|
299
|
+
@property
|
|
300
|
+
def full_url(self) -> str:
|
|
301
|
+
"""Full URL from config (base_url/endpoint with params)."""
|
|
302
|
+
return self.config.full_url
|
|
303
|
+
|
|
304
|
+
def create_payload(
|
|
305
|
+
self,
|
|
306
|
+
request: dict | BaseModel,
|
|
307
|
+
**kwargs,
|
|
308
|
+
) -> dict:
|
|
309
|
+
"""Build validated payload from request and config defaults.
|
|
310
|
+
|
|
311
|
+
Merges: config.kwargs <- request <- kwargs, then validates
|
|
312
|
+
against request_options schema.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
request: Request parameters (dict or Pydantic model).
|
|
316
|
+
**kwargs: Additional parameters merged last.
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
Validated payload dict filtered to schema fields.
|
|
320
|
+
|
|
321
|
+
Raises:
|
|
322
|
+
ValueError: If request_options not defined or validation fails.
|
|
323
|
+
"""
|
|
324
|
+
request = request if isinstance(request, dict) else request.model_dump(exclude_none=True)
|
|
325
|
+
|
|
326
|
+
payload = self.config.kwargs.copy()
|
|
327
|
+
payload.update(request)
|
|
328
|
+
if kwargs:
|
|
329
|
+
payload.update(kwargs)
|
|
330
|
+
|
|
331
|
+
if self.config.request_options is None:
|
|
332
|
+
raise ValueError(
|
|
333
|
+
f"Endpoint {self.config.name} must define request_options schema. "
|
|
334
|
+
"All endpoint backends must use proper request validation."
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
valid_fields = set(self.config.request_options.model_fields.keys())
|
|
338
|
+
filtered_payload = {k: v for k, v in payload.items() if k in valid_fields}
|
|
339
|
+
return self.config.validate_payload(filtered_payload)
|
|
340
|
+
|
|
341
|
+
def create_headers(self, extra_headers: dict | None = None) -> dict:
|
|
342
|
+
"""Build request headers with auth and content type.
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
extra_headers: Additional headers merged last.
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
Headers dict ready for HTTP request.
|
|
349
|
+
"""
|
|
350
|
+
headers = HeaderFactory.get_header(
|
|
351
|
+
auth_type=self.config.auth_type,
|
|
352
|
+
content_type=self.config.content_type,
|
|
353
|
+
api_key=self.config._api_key,
|
|
354
|
+
default_headers=self.config.default_headers,
|
|
355
|
+
)
|
|
356
|
+
if extra_headers:
|
|
357
|
+
headers.update(extra_headers)
|
|
358
|
+
return headers
|
|
359
|
+
|
|
360
|
+
async def _call(self, payload: dict, headers: dict | None = None, **kwargs):
|
|
361
|
+
"""Execute HTTP request (internal, no resilience wrapping)."""
|
|
362
|
+
if headers is None:
|
|
363
|
+
headers = self.create_headers()
|
|
364
|
+
return await self._call_http(payload=payload, headers=headers, **kwargs)
|
|
365
|
+
|
|
366
|
+
async def call(
|
|
367
|
+
self,
|
|
368
|
+
request: dict | BaseModel,
|
|
369
|
+
skip_payload_creation: bool = False,
|
|
370
|
+
extra_headers: dict | None = None,
|
|
371
|
+
**kwargs,
|
|
372
|
+
) -> NormalizedResponse:
|
|
373
|
+
"""Execute HTTP request with resilience patterns.
|
|
374
|
+
|
|
375
|
+
Applies retry -> circuit_breaker -> _call_http stack.
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
request: Request parameters or Pydantic model.
|
|
379
|
+
skip_payload_creation: Bypass create_payload validation.
|
|
380
|
+
extra_headers: Additional headers merged with defaults.
|
|
381
|
+
**kwargs: Extra httpx request kwargs.
|
|
382
|
+
|
|
383
|
+
Returns:
|
|
384
|
+
NormalizedResponse wrapping the API response.
|
|
385
|
+
"""
|
|
386
|
+
if skip_payload_creation:
|
|
387
|
+
payload = request if isinstance(request, dict) else request.model_dump()
|
|
388
|
+
else:
|
|
389
|
+
payload = self.create_payload(request, **kwargs)
|
|
390
|
+
|
|
391
|
+
headers = self.create_headers(extra_headers)
|
|
392
|
+
|
|
393
|
+
from collections.abc import Callable, Coroutine
|
|
394
|
+
|
|
395
|
+
base_call = self._call
|
|
396
|
+
inner_call: Callable[..., Coroutine[Any, Any, Any]]
|
|
397
|
+
|
|
398
|
+
if self.circuit_breaker:
|
|
399
|
+
|
|
400
|
+
async def cb_wrapped_call(p: dict[Any, Any], h: dict[Any, Any], **kw: Any) -> Any:
|
|
401
|
+
return await self.circuit_breaker.execute(base_call, p, h, **kw) # type: ignore[union-attr]
|
|
402
|
+
|
|
403
|
+
inner_call = cb_wrapped_call
|
|
404
|
+
else:
|
|
405
|
+
inner_call = base_call
|
|
406
|
+
|
|
407
|
+
if self.retry_config:
|
|
408
|
+
raw_response = await retry_with_backoff(
|
|
409
|
+
inner_call, payload, headers, **kwargs, **self.retry_config.as_kwargs()
|
|
410
|
+
)
|
|
411
|
+
else:
|
|
412
|
+
raw_response = await inner_call(payload, headers, **kwargs)
|
|
413
|
+
|
|
414
|
+
return self.normalize_response(raw_response)
|
|
415
|
+
|
|
416
|
+
async def _call_http(self, payload: dict, headers: dict, **kwargs):
|
|
417
|
+
"""Execute HTTP request and return JSON response.
|
|
418
|
+
|
|
419
|
+
Raises HTTPStatusError for 429 (rate limit) and 5xx (retryable).
|
|
420
|
+
Other non-200 responses raise with error body details.
|
|
421
|
+
"""
|
|
422
|
+
import httpx
|
|
423
|
+
|
|
424
|
+
async with self._create_http_client() as client:
|
|
425
|
+
response = await client.request(
|
|
426
|
+
method=self.config.method,
|
|
427
|
+
url=self.config.full_url,
|
|
428
|
+
headers=headers,
|
|
429
|
+
json=payload,
|
|
430
|
+
**kwargs,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
if response.status_code == 429 or response.status_code >= 500:
|
|
434
|
+
response.raise_for_status()
|
|
435
|
+
elif response.status_code != 200:
|
|
436
|
+
try:
|
|
437
|
+
error_body = response.json()
|
|
438
|
+
error_message = (
|
|
439
|
+
f"Request failed with status {response.status_code}: {error_body}"
|
|
440
|
+
)
|
|
441
|
+
except Exception:
|
|
442
|
+
error_message = f"Request failed with status {response.status_code}"
|
|
443
|
+
|
|
444
|
+
raise httpx.HTTPStatusError(
|
|
445
|
+
message=error_message,
|
|
446
|
+
request=response.request,
|
|
447
|
+
response=response,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
return response.json()
|
|
451
|
+
|
|
452
|
+
async def stream(
|
|
453
|
+
self,
|
|
454
|
+
request: dict | BaseModel,
|
|
455
|
+
extra_headers: dict | None = None,
|
|
456
|
+
**kwargs,
|
|
457
|
+
):
|
|
458
|
+
"""Stream responses from endpoint.
|
|
459
|
+
|
|
460
|
+
Args:
|
|
461
|
+
request: Request parameters or Pydantic model.
|
|
462
|
+
extra_headers: Additional headers merged with defaults.
|
|
463
|
+
**kwargs: Extra httpx request kwargs.
|
|
464
|
+
|
|
465
|
+
Yields:
|
|
466
|
+
Response lines from streaming API.
|
|
467
|
+
"""
|
|
468
|
+
payload, headers = self.create_payload(request, extra_headers, **kwargs)
|
|
469
|
+
|
|
470
|
+
async for chunk in self._stream_http(payload=payload, headers=headers, **kwargs):
|
|
471
|
+
yield chunk
|
|
472
|
+
|
|
473
|
+
async def _stream_http(self, payload: dict, headers: dict, **kwargs):
|
|
474
|
+
"""Stream HTTP response lines (internal)."""
|
|
475
|
+
import httpx
|
|
476
|
+
|
|
477
|
+
payload["stream"] = True
|
|
478
|
+
|
|
479
|
+
async with (
|
|
480
|
+
self._create_http_client() as client,
|
|
481
|
+
client.stream(
|
|
482
|
+
method=self.config.method,
|
|
483
|
+
url=self.config.full_url,
|
|
484
|
+
headers=headers,
|
|
485
|
+
json=payload,
|
|
486
|
+
**kwargs,
|
|
487
|
+
) as response,
|
|
488
|
+
):
|
|
489
|
+
if response.status_code != 200:
|
|
490
|
+
raise httpx.HTTPStatusError(
|
|
491
|
+
message=f"Request failed with status {response.status_code}",
|
|
492
|
+
request=response.request,
|
|
493
|
+
response=response,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
async for line in response.aiter_lines():
|
|
497
|
+
if line:
|
|
498
|
+
yield line
|
|
499
|
+
|
|
500
|
+
@field_serializer("circuit_breaker")
|
|
501
|
+
def _serialize_circuit_breaker(
|
|
502
|
+
self, circuit_breaker: CircuitBreaker | None
|
|
503
|
+
) -> dict[str, Any] | None:
|
|
504
|
+
"""Serialize CircuitBreaker to dict for transport."""
|
|
505
|
+
if circuit_breaker is None:
|
|
506
|
+
return None
|
|
507
|
+
return circuit_breaker.to_dict()
|
|
508
|
+
|
|
509
|
+
@field_serializer("retry_config")
|
|
510
|
+
def _serialize_retry_config(self, retry_config: RetryConfig | None) -> dict[str, Any] | None:
|
|
511
|
+
"""Serialize RetryConfig to dict for transport."""
|
|
512
|
+
if retry_config is None:
|
|
513
|
+
return None
|
|
514
|
+
return retry_config.to_dict()
|
|
515
|
+
|
|
516
|
+
@field_validator("circuit_breaker", mode="before")
|
|
517
|
+
@classmethod
|
|
518
|
+
def _deserialize_circuit_breaker(cls, v: Any) -> CircuitBreaker | None:
|
|
519
|
+
"""Accept CircuitBreaker instance or dict."""
|
|
520
|
+
if v is None:
|
|
521
|
+
return None
|
|
522
|
+
if isinstance(v, CircuitBreaker):
|
|
523
|
+
return v
|
|
524
|
+
if not isinstance(v, dict):
|
|
525
|
+
raise ValueError("circuit_breaker must be a dict or CircuitBreaker instance")
|
|
526
|
+
return CircuitBreaker(**v)
|
|
527
|
+
|
|
528
|
+
@field_validator("retry_config", mode="before")
|
|
529
|
+
@classmethod
|
|
530
|
+
def _deserialize_retry_config(cls, v: Any) -> RetryConfig | None:
|
|
531
|
+
"""Accept RetryConfig instance or dict."""
|
|
532
|
+
if v is None:
|
|
533
|
+
return None
|
|
534
|
+
if isinstance(v, RetryConfig):
|
|
535
|
+
return v
|
|
536
|
+
if not isinstance(v, dict):
|
|
537
|
+
raise ValueError("retry_config must be a dict or RetryConfig instance")
|
|
538
|
+
return RetryConfig(**v)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
class APICalling(Calling):
|
|
542
|
+
"""HTTP API calling event for Endpoint backend.
|
|
543
|
+
|
|
544
|
+
Wraps HTTP request with event lifecycle, token estimation for rate limiting,
|
|
545
|
+
and extra headers support.
|
|
546
|
+
|
|
547
|
+
Attributes:
|
|
548
|
+
backend: Endpoint instance performing the HTTP call.
|
|
549
|
+
extra_headers: Additional headers merged into request.
|
|
550
|
+
payload: Request payload (inherited from Calling).
|
|
551
|
+
|
|
552
|
+
Example:
|
|
553
|
+
endpoint = Endpoint(config=config)
|
|
554
|
+
calling = APICalling(
|
|
555
|
+
backend=endpoint,
|
|
556
|
+
payload={"model": "gpt-4", "messages": [...]},
|
|
557
|
+
timeout=30.0,
|
|
558
|
+
)
|
|
559
|
+
await calling.invoke()
|
|
560
|
+
response = calling.response # NormalizedResponse
|
|
561
|
+
"""
|
|
562
|
+
|
|
563
|
+
backend: Endpoint = Field(exclude=True)
|
|
564
|
+
extra_headers: dict | None = Field(default=None, exclude=True)
|
|
565
|
+
|
|
566
|
+
@property
|
|
567
|
+
def required_tokens(self) -> int | None:
|
|
568
|
+
"""Estimated tokens for rate limiting (None disables tracking)."""
|
|
569
|
+
if (
|
|
570
|
+
hasattr(self.backend.config, "requires_tokens")
|
|
571
|
+
and not self.backend.config.requires_tokens
|
|
572
|
+
):
|
|
573
|
+
return None
|
|
574
|
+
|
|
575
|
+
if "messages" in self.payload:
|
|
576
|
+
return self._estimate_message_tokens(self.payload["messages"])
|
|
577
|
+
if "input" in self.payload:
|
|
578
|
+
return self._estimate_text_tokens(self.payload["input"])
|
|
579
|
+
return None
|
|
580
|
+
|
|
581
|
+
def _estimate_message_tokens(self, messages: list[dict]) -> int:
|
|
582
|
+
"""Rough token estimate for chat messages (~4 chars/token)."""
|
|
583
|
+
total_chars = sum(len(str(m.get("content", ""))) for m in messages)
|
|
584
|
+
return total_chars // 4
|
|
585
|
+
|
|
586
|
+
def _estimate_text_tokens(self, text: str | list[str]) -> int:
|
|
587
|
+
"""Rough token estimate for text/embeddings (~4 chars/token)."""
|
|
588
|
+
inputs = [text] if isinstance(text, str) else text
|
|
589
|
+
total_chars = sum(len(t) for t in inputs)
|
|
590
|
+
return total_chars // 4
|
|
591
|
+
|
|
592
|
+
@property
|
|
593
|
+
def request(self) -> dict:
|
|
594
|
+
"""Permission request data for rate limiting checks."""
|
|
595
|
+
return {
|
|
596
|
+
"required_tokens": self.required_tokens,
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
@property
|
|
600
|
+
def call_args(self) -> dict:
|
|
601
|
+
"""Arguments for backend.call(**call_args)."""
|
|
602
|
+
args = {
|
|
603
|
+
"request": self.payload,
|
|
604
|
+
"skip_payload_creation": True,
|
|
605
|
+
}
|
|
606
|
+
if self.extra_headers:
|
|
607
|
+
args["extra_headers"] = self.extra_headers
|
|
608
|
+
return args
|