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.
- get_systems/__init__.py +93 -0
- get_systems/http/__init__.py +15 -0
- get_systems/http/http_block.py +69 -0
- get_systems/llm/__init__.py +22 -0
- get_systems/llm/gpt_blocks.py +424 -0
- get_systems/llm/utils.py +49 -0
- get_systems/models/__init__.py +32 -0
- get_systems/models/address.py +234 -0
- get_systems/models/bank_account.py +51 -0
- get_systems/models/base.py +182 -0
- get_systems/models/ccase.py +27 -0
- get_systems/models/communication.py +56 -0
- get_systems/models/contact.py +265 -0
- get_systems/models/contact_parser.py +234 -0
- get_systems/models/event.py +175 -0
- get_systems/models/file_aica.py +20 -0
- get_systems/models/import_data.py +16 -0
- get_systems/models/payment.py +11 -0
- get_systems/models/subcase.py +8 -0
- get_systems-0.2.21.dist-info/METADATA +202 -0
- get_systems-0.2.21.dist-info/RECORD +23 -0
- get_systems-0.2.21.dist-info/WHEEL +5 -0
- get_systems-0.2.21.dist-info/top_level.txt +1 -0
get_systems/__init__.py
ADDED
|
@@ -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
|
get_systems/llm/utils.py
ADDED
|
@@ -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
|