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.
Files changed (101) hide show
  1. kronos/__init__.py +0 -0
  2. kronos/core/__init__.py +145 -0
  3. kronos/core/broadcaster.py +116 -0
  4. kronos/core/element.py +225 -0
  5. kronos/core/event.py +316 -0
  6. kronos/core/eventbus.py +116 -0
  7. kronos/core/flow.py +356 -0
  8. kronos/core/graph.py +442 -0
  9. kronos/core/node.py +982 -0
  10. kronos/core/pile.py +575 -0
  11. kronos/core/processor.py +494 -0
  12. kronos/core/progression.py +296 -0
  13. kronos/enforcement/__init__.py +57 -0
  14. kronos/enforcement/common/__init__.py +34 -0
  15. kronos/enforcement/common/boolean.py +85 -0
  16. kronos/enforcement/common/choice.py +97 -0
  17. kronos/enforcement/common/mapping.py +118 -0
  18. kronos/enforcement/common/model.py +102 -0
  19. kronos/enforcement/common/number.py +98 -0
  20. kronos/enforcement/common/string.py +140 -0
  21. kronos/enforcement/context.py +129 -0
  22. kronos/enforcement/policy.py +80 -0
  23. kronos/enforcement/registry.py +153 -0
  24. kronos/enforcement/rule.py +312 -0
  25. kronos/enforcement/service.py +370 -0
  26. kronos/enforcement/validator.py +198 -0
  27. kronos/errors.py +146 -0
  28. kronos/operations/__init__.py +32 -0
  29. kronos/operations/builder.py +228 -0
  30. kronos/operations/flow.py +398 -0
  31. kronos/operations/node.py +101 -0
  32. kronos/operations/registry.py +92 -0
  33. kronos/protocols.py +414 -0
  34. kronos/py.typed +0 -0
  35. kronos/services/__init__.py +81 -0
  36. kronos/services/backend.py +286 -0
  37. kronos/services/endpoint.py +608 -0
  38. kronos/services/hook.py +471 -0
  39. kronos/services/imodel.py +465 -0
  40. kronos/services/registry.py +115 -0
  41. kronos/services/utilities/__init__.py +36 -0
  42. kronos/services/utilities/header_factory.py +87 -0
  43. kronos/services/utilities/rate_limited_executor.py +271 -0
  44. kronos/services/utilities/rate_limiter.py +180 -0
  45. kronos/services/utilities/resilience.py +414 -0
  46. kronos/session/__init__.py +41 -0
  47. kronos/session/exchange.py +258 -0
  48. kronos/session/message.py +60 -0
  49. kronos/session/session.py +411 -0
  50. kronos/specs/__init__.py +25 -0
  51. kronos/specs/adapters/__init__.py +0 -0
  52. kronos/specs/adapters/_utils.py +45 -0
  53. kronos/specs/adapters/dataclass_field.py +246 -0
  54. kronos/specs/adapters/factory.py +56 -0
  55. kronos/specs/adapters/pydantic_adapter.py +309 -0
  56. kronos/specs/adapters/sql_ddl.py +946 -0
  57. kronos/specs/catalog/__init__.py +36 -0
  58. kronos/specs/catalog/_audit.py +39 -0
  59. kronos/specs/catalog/_common.py +43 -0
  60. kronos/specs/catalog/_content.py +59 -0
  61. kronos/specs/catalog/_enforcement.py +70 -0
  62. kronos/specs/factory.py +120 -0
  63. kronos/specs/operable.py +314 -0
  64. kronos/specs/phrase.py +405 -0
  65. kronos/specs/protocol.py +140 -0
  66. kronos/specs/spec.py +506 -0
  67. kronos/types/__init__.py +60 -0
  68. kronos/types/_sentinel.py +311 -0
  69. kronos/types/base.py +369 -0
  70. kronos/types/db_types.py +260 -0
  71. kronos/types/identity.py +66 -0
  72. kronos/utils/__init__.py +40 -0
  73. kronos/utils/_hash.py +234 -0
  74. kronos/utils/_json_dump.py +392 -0
  75. kronos/utils/_lazy_init.py +63 -0
  76. kronos/utils/_to_list.py +165 -0
  77. kronos/utils/_to_num.py +85 -0
  78. kronos/utils/_utils.py +375 -0
  79. kronos/utils/concurrency/__init__.py +205 -0
  80. kronos/utils/concurrency/_async_call.py +333 -0
  81. kronos/utils/concurrency/_cancel.py +122 -0
  82. kronos/utils/concurrency/_errors.py +96 -0
  83. kronos/utils/concurrency/_patterns.py +363 -0
  84. kronos/utils/concurrency/_primitives.py +328 -0
  85. kronos/utils/concurrency/_priority_queue.py +135 -0
  86. kronos/utils/concurrency/_resource_tracker.py +110 -0
  87. kronos/utils/concurrency/_run_async.py +67 -0
  88. kronos/utils/concurrency/_task.py +95 -0
  89. kronos/utils/concurrency/_utils.py +79 -0
  90. kronos/utils/fuzzy/__init__.py +14 -0
  91. kronos/utils/fuzzy/_extract_json.py +90 -0
  92. kronos/utils/fuzzy/_fuzzy_json.py +288 -0
  93. kronos/utils/fuzzy/_fuzzy_match.py +149 -0
  94. kronos/utils/fuzzy/_string_similarity.py +187 -0
  95. kronos/utils/fuzzy/_to_dict.py +396 -0
  96. kronos/utils/sql/__init__.py +13 -0
  97. kronos/utils/sql/_sql_validation.py +142 -0
  98. krons-0.1.0.dist-info/METADATA +70 -0
  99. krons-0.1.0.dist-info/RECORD +101 -0
  100. krons-0.1.0.dist-info/WHEEL +4 -0
  101. 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