get-systems 0.2.21__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.
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib import import_module
4
+ from importlib.util import find_spec
5
+ from importlib.metadata import version, PackageNotFoundError
6
+ from typing import Any
7
+
8
+ try:
9
+ __version__ = version("get-systems") # package name in pyproject
10
+ except PackageNotFoundError:
11
+ # fallback for local/dev run (not installed via pip)
12
+ __version__ = "0.0.0"
13
+
14
+ # Какие "пространства" сканируем.
15
+ # Можешь расширять: plugins, integrations и т.п.
16
+ _NAMESPACES = ("models", "llm", "http")
17
+
18
+ # Здесь будет: name -> (module_path, attr_name)
19
+ _EXPORTS: dict[str, tuple[str, str]] = {}
20
+
21
+
22
+ def _discover_exports() -> None:
23
+ """
24
+ Discover exports from get_systems.<namespace> packages.
25
+
26
+ Strategy:
27
+ - For each namespace package (models/llm/http), import only its __init__
28
+ (not every submodule), read __all__ and register lazy exports.
29
+ - The heavy dependencies should be imported inside that namespace package,
30
+ or inside its submodules, not here.
31
+ """
32
+ pkg_root = "get_systems"
33
+
34
+ for ns in _NAMESPACES:
35
+ ns_mod_path = f"{pkg_root}.{ns}"
36
+
37
+ # Namespace may not exist if extra isn't installed or package not shipped
38
+ if find_spec(ns_mod_path) is None:
39
+ continue
40
+
41
+ try:
42
+ ns_mod = import_module(ns_mod_path) # imports get_systems.<ns>.__init__
43
+ except ImportError:
44
+ # If optional deps of that namespace are missing, just skip.
45
+ # Real error will appear when user tries to access something.
46
+ continue
47
+
48
+ ns_all = getattr(ns_mod, "__all__", None)
49
+ if not ns_all:
50
+ continue
51
+
52
+ for name in ns_all:
53
+ # Detect collisions early
54
+ if name in _EXPORTS:
55
+ prev = _EXPORTS[name][0]
56
+ raise RuntimeError(
57
+ f"Export name collision: '{name}' provided by both '{prev}' and '{ns_mod_path}'. "
58
+ f"Rename one of them or avoid exporting it at top-level."
59
+ )
60
+
61
+ _EXPORTS[name] = (ns_mod_path, name)
62
+
63
+
64
+ _discover_exports()
65
+
66
+ # Public API is whatever we discovered
67
+ __all__ = sorted(_EXPORTS.keys())
68
+
69
+
70
+ def __getattr__(name: str) -> Any:
71
+ """
72
+ Lazy attribute resolver for dynamically discovered exports.
73
+ """
74
+ if name not in _EXPORTS:
75
+ raise AttributeError(f"module 'get_systems' has no attribute '{name}'")
76
+
77
+ module_name, attr_name = _EXPORTS[name]
78
+
79
+ # Optional nice hints based on namespace
80
+ if module_name.endswith(".llm"):
81
+ # if llm extra not installed, most likely it fails inside llm import
82
+ pass
83
+ if module_name.endswith(".http"):
84
+ pass
85
+ if module_name.endswith(".models"):
86
+ pass
87
+
88
+ mod = import_module(module_name)
89
+ value = getattr(mod, attr_name)
90
+
91
+ # Cache for next time
92
+ globals()[name] = value
93
+ return value
@@ -0,0 +1,15 @@
1
+ """
2
+ HTTP authentication and request module - Prefect blocks for HTTP operations
3
+
4
+ Usage:
5
+ from get_systems.http import HttpAuth, HttpBlock
6
+ # or
7
+ from get_systems.http.http_block import HttpAuth
8
+ """
9
+
10
+ from get_systems.http.http_block import HttpAuth, HttpBlock
11
+
12
+ __all__ = [
13
+ "HttpAuth",
14
+ "HttpBlock",
15
+ ]
@@ -0,0 +1,69 @@
1
+ from typing import Any, Literal, Optional
2
+
3
+ import httpx
4
+ from pydantic import Field, SecretStr
5
+ from prefect.blocks.core import Block
6
+
7
+
8
+ class HttpAuth(Block):
9
+ """HTTP authentication configuration and async client factory."""
10
+
11
+ auth_type: Literal["none", "basic", "token", "bearer"] = Field(
12
+ default="none",
13
+ description="Authentication type for HTTP requests",
14
+ )
15
+ username: Optional[str] = Field(default=None, description="Basic auth username")
16
+ password: Optional[SecretStr] = Field(default=None, description="Basic auth password")
17
+ token: Optional[SecretStr] = Field(default=None, description="Token or bearer value")
18
+ headers: dict[str, str] = Field(default_factory=dict, description="Extra headers")
19
+
20
+ def _auth(self) -> Optional[httpx.Auth]:
21
+ if self.auth_type != "basic":
22
+ return None
23
+ if not self.username or not self.password:
24
+ raise ValueError("Basic auth requires username and password.")
25
+ return httpx.BasicAuth(self.username, self.password.get_secret_value())
26
+
27
+ def _headers(self) -> dict[str, str]:
28
+ headers = dict(self.headers)
29
+ if self.auth_type in ("token", "bearer"):
30
+ if not self.token:
31
+ raise ValueError("Token auth requires token.")
32
+ prefix = "Token" if self.auth_type == "token" else "Bearer"
33
+ headers["Authorization"] = f"{prefix} {self.token.get_secret_value()}"
34
+ return headers
35
+
36
+ def get_async_client(
37
+ self,
38
+ base_url: Optional[str] = None,
39
+ timeout_s: float = 60.0,
40
+ extra_headers: Optional[dict[str, str]] = None,
41
+ ) -> httpx.AsyncClient:
42
+ headers = self._headers()
43
+ if extra_headers:
44
+ headers.update(extra_headers)
45
+
46
+ return httpx.AsyncClient(
47
+ base_url=base_url or "",
48
+ timeout=httpx.Timeout(timeout_s),
49
+ headers=headers,
50
+ auth=self._auth(),
51
+ )
52
+
53
+
54
+ class HttpBlock(Block):
55
+ """Base class for HTTP-based blocks, providing common functionality for API interactions."""
56
+
57
+ url: str = "https://api.example.com" # Default URL, can be overridden by subclasses
58
+ extract_token: bool = False # Whether to extract token from response for chaining
59
+ auth: Optional[HttpAuth] = None
60
+ timeout_s: float = 60.0
61
+
62
+ def get_async_client(self) -> httpx.AsyncClient:
63
+ if self.auth:
64
+ return self.auth.get_async_client(base_url=self.url, timeout_s=self.timeout_s)
65
+ return httpx.AsyncClient(base_url=self.url, timeout=httpx.Timeout(self.timeout_s))
66
+
67
+ async def request(self, method: str, path: str = "", **kwargs: Any) -> httpx.Response:
68
+ async with self.get_async_client() as client:
69
+ return await client.request(method=method, url=path, **kwargs)
@@ -0,0 +1,22 @@
1
+ """
2
+ LLM operations module - OpenAI and Azure OpenAI Prefect blocks
3
+
4
+ Usage:
5
+ from get_systems.llm import GptCompletionBlock, GptAuth, LlmRuntime, LlmResult
6
+ # or
7
+ from get_systems.llm.gpt_blocks import GptCompletionBlock
8
+ """
9
+
10
+ from get_systems.llm.gpt_blocks import (
11
+ GptAuth,
12
+ GptCompletionBlock,
13
+ LlmResult,
14
+ LlmRuntime,
15
+ )
16
+
17
+ __all__ = [
18
+ "GptAuth",
19
+ "GptCompletionBlock",
20
+ "LlmResult",
21
+ "LlmRuntime",
22
+ ]
@@ -0,0 +1,424 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import random
6
+ import time
7
+ from typing import Any, Optional
8
+
9
+ import httpx
10
+ from openai import AsyncOpenAI, AsyncAzureOpenAI
11
+ from pydantic import BaseModel, Field, SecretStr
12
+ from prefect.blocks.core import Block
13
+ from prefect.logging import get_run_logger
14
+ from prefect.exceptions import MissingContextError
15
+
16
+ from .utils import _env, _env_secret, _env_bool, _safe_preview, _hash_payload, ResolvedAuth
17
+
18
+
19
+ # ----------------------------
20
+ # Blocks
21
+ # ----------------------------
22
+
23
+ class GptAuth(Block):
24
+ """
25
+ Enterprise auth/config holder for OpenAI client.
26
+ Env fallback is supported by GptCompletionBlock.
27
+
28
+ Example:
29
+
30
+ ```python
31
+ from get_systems.llm import GptAuth
32
+
33
+ auth = GptAuth.load("BLOCK_NAME")
34
+ ```
35
+ """
36
+
37
+ _block_type_name = "GPT Auth"
38
+ _block_type_slug = "gpt-auth"
39
+ _logo_url = "https://upload.wikimedia.org/wikipedia/commons/thumb/e/ef/ChatGPT-Logo.svg/960px-ChatGPT-Logo.svg.png?20240214002031"
40
+ _description = "Enterprise auth/config holder for OpenAI client with environment fallback support."
41
+ _code_example = """from get_systems.llm import GptAuth
42
+
43
+ auth = GptAuth.load("BLOCK_NAME")"""
44
+
45
+ api_key: Optional[SecretStr] = Field(default_factory=lambda: _env_secret("OPENAI_API_KEY"), description="OpenAI API key")
46
+ model: Optional[str] = Field(default_factory=lambda: _env("OPENAI_MODEL"), description="Default model")
47
+ base_url: Optional[str] = Field(default_factory=lambda: _env("OPENAI_BASE_URL"), description="Custom base URL (optional)")
48
+ organization: Optional[str] = Field(default_factory=lambda: _env("OPENAI_ORG"), description="OpenAI organization (optional)")
49
+ project: Optional[str] = Field(default_factory=lambda: _env("OPENAI_PROJECT"), description="OpenAI project (optional)")
50
+ api_version: Optional[str] = Field(default_factory=lambda: _env("OPENAI_API_VERSION"), description="Azure OpenAI API version (optional)")
51
+ is_azure: Optional[bool] = Field(default_factory=lambda: _env_bool("OPENAI_IS_AZURE"), description="Set to True for Azure OpenAI, False for standard OpenAI (auto-detected if None)")
52
+
53
+
54
+ class LlmRuntime(Block):
55
+ """
56
+ Runtime policies: retry/backoff, timeouts, caching, concurrency hints.
57
+
58
+ Example:
59
+ ```python
60
+ from get_systems.llm import LlmRuntime
61
+
62
+ runtime = LlmRuntime.load("BLOCK_NAME")
63
+ ```
64
+ """
65
+
66
+ _block_type_name = "LLM Runtime Policy"
67
+ _block_type_slug = "llm-runtime"
68
+ _logo_url = "https://upload.wikimedia.org/wikipedia/commons/thumb/e/ef/ChatGPT-Logo.svg/960px-ChatGPT-Logo.svg.png?20240214002031"
69
+ _description = "Runtime policies for LLM operations including retry logic, backoff strategy, timeouts, caching, and logging controls."
70
+ _code_example = """from get_systems.llm import LlmRuntime
71
+
72
+ runtime = LlmRuntime.load("BLOCK_NAME")"""
73
+
74
+ request_timeout_s: float = Field(default=60.0, description="HTTP request timeout seconds")
75
+ max_retries: int = Field(default=5, description="Max retries on transient errors")
76
+ base_backoff_s: float = Field(default=0.5, description="Base backoff seconds")
77
+ max_backoff_s: float = Field(default=8.0, description="Max backoff seconds")
78
+
79
+ # Basic in-memory cache (per-process). Good for dev; for prod use Redis.
80
+ enable_cache: bool = Field(default=False, description="Enable in-memory response cache")
81
+ cache_ttl_s: int = Field(default=300, description="Cache TTL seconds")
82
+
83
+ # Logging controls
84
+ log_prompts_preview: bool = Field(default=True, description="Log prompt preview (redacted/trimmed)")
85
+ log_response_preview: bool = Field(default=True, description="Log response preview (trimmed)")
86
+
87
+
88
+ class LlmResult(BaseModel):
89
+ """
90
+ Unified response shape (handy for orchestration).
91
+ """
92
+ content: Optional[str] = None
93
+ tool_calls: Optional[Any] = None
94
+ model: Optional[str] = None
95
+ request_id: Optional[str] = None
96
+ finish_reason: Optional[str] = None
97
+ usage: Optional[Any] = None
98
+
99
+ cached: bool = False
100
+ meta: dict[str, Any] = Field(default_factory=dict)
101
+
102
+
103
+ # Very small in-memory cache (process-local)
104
+ _CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
105
+
106
+
107
+ class GptCompletionBlock(Block):
108
+ """
109
+ Enterprise-grade completion block:
110
+ - Auth from block OR env
111
+ - Retry with exponential backoff + jitter
112
+ - Timeouts via httpx
113
+ - Optional in-memory cache
114
+ - Safe logging (no api key leak)
115
+ [boto3 docs](https://github.com/get-systems/prefect_blocks)
116
+
117
+ Example:
118
+ Load GptCompletionBlock:
119
+ ```python
120
+ from get_systems.llm import GptCompletionBlock
121
+
122
+ block = GptCompletionBlock.load("BLOCK_NAME")
123
+ result = await block.run()
124
+ ```
125
+ """# noqa E501
126
+
127
+ _block_type_name = "GPT Completion"
128
+ _block_type_slug = "gpt-completion"
129
+ _logo_url = "https://upload.wikimedia.org/wikipedia/commons/thumb/e/ef/ChatGPT-Logo.svg/960px-ChatGPT-Logo.svg.png?20240214002031"
130
+ _description = "Enterprise-grade OpenAI completion block with auth management, retry logic, caching, and safe logging."
131
+ # _code_example = """from get_systems.llm import GptCompletionBlock
132
+
133
+ # block = GptCompletionBlock.load("BLOCK_NAME")
134
+ # # Pass extra kwargs like top_p, frequency_penalty, etc.
135
+ # result = await block.run(top_p=0.9, frequency_penalty=0.5)
136
+ # print(result.content)"""
137
+
138
+ auth: GptAuth = Field(default_factory=GptAuth)
139
+ runtime: Optional[LlmRuntime] = None
140
+
141
+ # Input
142
+ system_prompt: Optional[str] = None
143
+ _messages: Optional[list[dict[str, Any]]] = None
144
+ tools: Optional[list[dict[str, Any]]] = None
145
+
146
+ # Model params
147
+ model: Optional[str] = None
148
+ temperature: Optional[float] = 0.7
149
+ max_tokens: Optional[int] = None
150
+
151
+ # Orchestration metadata
152
+ correlation_id: Optional[str] = Field(default=None, description="Trace/correlation id across systems")
153
+ tags: dict[str, Any] = Field(default_factory=dict)
154
+
155
+ def _resolve(self) -> tuple[ResolvedAuth, LlmRuntime]:
156
+ # Runtime defaults
157
+ rt = self.runtime or LlmRuntime()
158
+
159
+ # Block first
160
+ api_key = None
161
+ base_url = None
162
+ model = None
163
+ org = None
164
+ project = None
165
+ api_version = None
166
+ is_azure = None
167
+
168
+ if self.auth:
169
+ if self.auth.api_key:
170
+ api_key = self.auth.api_key.get_secret_value()
171
+ base_url = self.auth.base_url
172
+ model = self.auth.model
173
+ org = self.auth.organization
174
+ project = self.auth.project
175
+ api_version = self.auth.api_version
176
+ is_azure = self.auth.is_azure
177
+
178
+ # Model precedence: block -> field (required)
179
+ model = model or self.model
180
+
181
+ if not api_key:
182
+ raise ValueError("No API key provided. Set auth.api_key or OPENAI_API_KEY env.")
183
+
184
+ if not model:
185
+ raise ValueError("No model provided. Set auth.model or model on the block.")
186
+
187
+ return ResolvedAuth(
188
+ api_key=api_key,
189
+ base_url=base_url,
190
+ model=model,
191
+ organization=org,
192
+ project=project,
193
+ api_version=api_version,
194
+ is_azure=is_azure,
195
+ ), rt
196
+
197
+ def _build_messages(self, user_message: Optional[str] = None) -> list[dict[str, Any]]:
198
+ messages: list[dict[str, Any]] = []
199
+
200
+ # System prompt always first
201
+ if self.system_prompt:
202
+ messages.append({
203
+ "role": "system",
204
+ "content": self.system_prompt.strip()
205
+ })
206
+
207
+ # User message
208
+ if user_message:
209
+ messages.append({
210
+ "role": "user",
211
+ "content": user_message.strip()
212
+ })
213
+
214
+ if not messages:
215
+ raise ValueError("Provide either system_prompt or user_message")
216
+
217
+ return messages
218
+
219
+ def _client(self, ra: ResolvedAuth, rt: LlmRuntime) -> AsyncOpenAI:
220
+ # httpx timeout and pool config (safe baseline)
221
+ timeout = httpx.Timeout(rt.request_timeout_s)
222
+ http_client = httpx.AsyncClient(timeout=timeout)
223
+
224
+ if ra.is_azure:
225
+ # Azure OpenAI uses different client and parameters
226
+ logger = logging.getLogger(__name__)
227
+ logger.info(
228
+ f"Creating Azure OpenAI client: endpoint={ra.base_url}, api_version={ra.api_version}, model/deployment={ra.model}"
229
+ )
230
+ return AsyncAzureOpenAI(
231
+ api_key=ra.api_key,
232
+ azure_endpoint=ra.base_url,
233
+ api_version=ra.api_version or "2024-02-15-preview",
234
+ http_client=http_client,
235
+ )
236
+ else:
237
+ # Standard OpenAI
238
+ return AsyncOpenAI(
239
+ api_key=ra.api_key,
240
+ base_url=ra.base_url,
241
+ organization=ra.organization,
242
+ project=ra.project,
243
+ http_client=http_client,
244
+ )
245
+
246
+ def _should_retry(self, exc: Exception) -> bool:
247
+ # Conservative: retry on network/timeouts and OpenAI transient errors.
248
+ # openai-python raises different exception types; we do string-based fallback too.
249
+ name = exc.__class__.__name__.lower()
250
+ msg = str(exc).lower()
251
+
252
+ transient_markers = [
253
+ "timeout",
254
+ "temporarily",
255
+ "rate limit",
256
+ "429",
257
+ "503",
258
+ "502",
259
+ "connection",
260
+ "dns",
261
+ "server error",
262
+ "gateway",
263
+ ]
264
+ return any(m in name or m in msg for m in transient_markers)
265
+
266
+ def _backoff(self, attempt: int, rt: LlmRuntime) -> float:
267
+ # Exponential backoff with jitter
268
+ base = min(rt.max_backoff_s, rt.base_backoff_s * (2 ** attempt))
269
+ jitter = random.uniform(0, base * 0.2)
270
+ return min(rt.max_backoff_s, base + jitter)
271
+
272
+ def _cache_get(self, key: str, rt: LlmRuntime) -> Optional[dict[str, Any]]:
273
+ if not rt.enable_cache:
274
+ return None
275
+ item = _CACHE.get(key)
276
+ if not item:
277
+ return None
278
+ ts, val = item
279
+ if (time.time() - ts) > rt.cache_ttl_s:
280
+ _CACHE.pop(key, None)
281
+ return None
282
+ return val
283
+
284
+ def _cache_set(self, key: str, rt: LlmRuntime, value: dict[str, Any]) -> None:
285
+ if not rt.enable_cache:
286
+ return
287
+ _CACHE[key] = (time.time(), value)
288
+
289
+ async def run(self, user_message: Optional[str] = None, **extra_kwargs) -> LlmResult:
290
+ """
291
+ Execute LLM completion request.
292
+
293
+ Args:
294
+ **extra_kwargs: Additional parameters to pass to OpenAI API
295
+ (e.g., top_p, frequency_penalty, presence_penalty, response_format, etc.)
296
+
297
+ Returns:
298
+ LlmResult with completion response
299
+ """
300
+ # Try to get Prefect logger, fallback to standard Python logger
301
+ try:
302
+ logger = get_run_logger()
303
+ except MissingContextError:
304
+ logger = logging.getLogger(__name__)
305
+
306
+ ra, rt = self._resolve()
307
+ messages = self._build_messages(user_message)
308
+
309
+ # Payload for caching/idempotency-ish key (include extra_kwargs)
310
+ payload_fingerprint = {
311
+ "model": ra.model,
312
+ "messages": messages,
313
+ "tools": self.tools,
314
+ "temperature": self.temperature,
315
+ "max_tokens": self.max_tokens,
316
+ "extra": extra_kwargs, # Include extra params in cache key
317
+ }
318
+ cache_key = _hash_payload(payload_fingerprint)
319
+
320
+ cached = self._cache_get(cache_key, rt)
321
+ if cached:
322
+ return LlmResult(**cached, cached=True)
323
+
324
+ # Safe previews for logs
325
+ if rt.log_prompts_preview:
326
+ preview = _safe_preview(self.system_prompt) if self.system_prompt else _safe_preview(json.dumps(messages, ensure_ascii=False))
327
+ logger.info(
328
+ "LLM request prepared",
329
+ extra={
330
+ "correlation_id": self.correlation_id,
331
+ "model": ra.model,
332
+ "prompt_preview": preview,
333
+ "tags": self.tags,
334
+ },
335
+ )
336
+
337
+ client = self._client(ra, rt)
338
+
339
+ last_exc: Optional[Exception] = None
340
+ for attempt in range(rt.max_retries + 1):
341
+ try:
342
+ logger.debug(
343
+ f"Attempting LLM call: model/deployment={ra.model}, is_azure={ra.is_azure}, attempt={attempt}"
344
+ )
345
+
346
+ # Build API call kwargs - only include non-None values
347
+ api_kwargs: dict[str, Any] = {
348
+ "model": ra.model,
349
+ "messages": messages,
350
+ }
351
+
352
+ if self.tools is not None:
353
+ api_kwargs["tools"] = self.tools
354
+ if self.temperature is not None:
355
+ api_kwargs["temperature"] = self.temperature
356
+ if self.max_tokens is not None:
357
+ api_kwargs["max_tokens"] = self.max_tokens
358
+
359
+ # Add extra kwargs (e.g., top_p, frequency_penalty, response_format, etc.)
360
+ api_kwargs.update(extra_kwargs)
361
+
362
+ logger.debug(f"API call parameters: {', '.join(api_kwargs.keys())}")
363
+
364
+ resp = await client.chat.completions.create(**api_kwargs)
365
+
366
+ choice = resp.choices[0]
367
+ msg = choice.message
368
+
369
+ result = LlmResult(
370
+ content=msg.content,
371
+ tool_calls=getattr(msg, "tool_calls", None),
372
+ model=resp.model,
373
+ request_id=getattr(resp, "id", None),
374
+ finish_reason=choice.finish_reason,
375
+ usage=getattr(resp, "usage", None),
376
+ meta={
377
+ "correlation_id": self.correlation_id,
378
+ "tags": self.tags,
379
+ },
380
+ )
381
+
382
+ # Cache result as dict (avoid model_dump() issues with some Pydantic versions)
383
+ out_dict = {
384
+ "content": result.content,
385
+ "tool_calls": result.tool_calls,
386
+ "model": result.model,
387
+ "request_id": result.request_id,
388
+ "finish_reason": result.finish_reason,
389
+ "usage": result.usage,
390
+ "meta": result.meta,
391
+ }
392
+ self._cache_set(cache_key, rt, out_dict)
393
+
394
+ if rt.log_response_preview:
395
+ logger.info(
396
+ "LLM response received",
397
+ extra={
398
+ "correlation_id": self.correlation_id,
399
+ "request_id": result.request_id,
400
+ "finish_reason": result.finish_reason,
401
+ "response_preview": _safe_preview(result.content),
402
+ },
403
+ )
404
+
405
+ return result
406
+
407
+ except Exception as exc:
408
+ last_exc = exc
409
+ retry = self._should_retry(exc) and attempt < rt.max_retries
410
+ logger.warning(
411
+ "LLM call failed",
412
+ extra={
413
+ "correlation_id": self.correlation_id,
414
+ "attempt": attempt,
415
+ "will_retry": retry,
416
+ "error_type": exc.__class__.__name__,
417
+ "error": str(exc)[:500],
418
+ },
419
+ )
420
+ if not retry:
421
+ break
422
+ time.sleep(self._backoff(attempt, rt))
423
+
424
+ raise RuntimeError(f"LLM call failed after retries: {last_exc}") from last_exc
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ from dataclasses import dataclass
7
+ from typing import Any, Optional
8
+
9
+ from pydantic import SecretStr
10
+
11
+
12
+ def _env(name: str, default: Optional[str] = None) -> Optional[str]:
13
+ v = os.getenv(name)
14
+ return v if v not in (None, "") else default
15
+
16
+
17
+ def _env_secret(name: str) -> Optional[SecretStr]:
18
+ v = _env(name)
19
+ return SecretStr(v) if v else None
20
+
21
+
22
+ def _env_bool(name: str) -> Optional[bool]:
23
+ v = _env(name)
24
+ if v is None:
25
+ return None
26
+ return v.lower() in ("true", "1", "yes")
27
+
28
+
29
+ def _safe_preview(text: Optional[str], limit: int = 200) -> str:
30
+ if not text:
31
+ return ""
32
+ t = text.replace("\n", "\\n")
33
+ return t[:limit] + ("..." if len(t) > limit else "")
34
+
35
+
36
+ def _hash_payload(payload: Any) -> str:
37
+ raw = json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str).encode("utf-8")
38
+ return hashlib.sha256(raw).hexdigest()
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class ResolvedAuth:
43
+ api_key: str
44
+ base_url: Optional[str]
45
+ model: str
46
+ organization: Optional[str] = None
47
+ project: Optional[str] = None
48
+ api_version: Optional[str] = None
49
+ is_azure: bool = False