frugal-sdk-python 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.
- frugal_metrics/__init__.py +13 -0
- frugal_metrics/block_hash_lru.py +75 -0
- frugal_metrics/caller.py +213 -0
- frugal_metrics/config.py +231 -0
- frugal_metrics/instrument.py +268 -0
- frugal_metrics/otel.py +321 -0
- frugal_metrics/prompt_analyzer.py +273 -0
- frugal_metrics/safe.py +34 -0
- frugal_metrics/semconv.py +70 -0
- frugal_metrics/streams.py +172 -0
- frugal_metrics/wrappers/__init__.py +1 -0
- frugal_metrics/wrappers/anthropic.py +277 -0
- frugal_metrics/wrappers/bedrock.py +358 -0
- frugal_metrics/wrappers/bedrock_models.py +430 -0
- frugal_metrics/wrappers/openai.py +465 -0
- frugal_sdk_python-0.1.0.dist-info/METADATA +195 -0
- frugal_sdk_python-0.1.0.dist-info/RECORD +18 -0
- frugal_sdk_python-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Lightweight per-request analyzer.
|
|
2
|
+
|
|
3
|
+
Inspects messages, tools, and response_format kwargs to derive low-cardinality
|
|
4
|
+
signals for the AI cost-trap detection catalogue. Never emits raw request
|
|
5
|
+
content; emits stats and (in-process-only) hashes that the instrument layer
|
|
6
|
+
turns into metrics.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any, Callable, TypeVar
|
|
16
|
+
|
|
17
|
+
from frugal_metrics import semconv
|
|
18
|
+
|
|
19
|
+
T = TypeVar("T")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class RequestPayload:
|
|
24
|
+
"""Captured kwargs from the wrapped SDK call."""
|
|
25
|
+
|
|
26
|
+
messages: Any = None
|
|
27
|
+
tools: Any = None
|
|
28
|
+
response_format: Any = None
|
|
29
|
+
max_tokens: int | None = None
|
|
30
|
+
system: Any = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class AnalysisResult:
|
|
35
|
+
system_hash: str | None = None
|
|
36
|
+
user_hash: str | None = None
|
|
37
|
+
tools_hash: str | None = None
|
|
38
|
+
schema_hash: str | None = None
|
|
39
|
+
history_depth: int | None = None
|
|
40
|
+
few_shot_count: int | None = None
|
|
41
|
+
noise_ratio: float | None = None
|
|
42
|
+
structured_output_hint: bool = False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def analyze_request(payload: RequestPayload) -> AnalysisResult:
|
|
46
|
+
return AnalysisResult(
|
|
47
|
+
system_hash=_safe(lambda: _hash_system_block(payload), None),
|
|
48
|
+
user_hash=_safe(lambda: _hash_user_block(payload), None),
|
|
49
|
+
tools_hash=_safe(lambda: _hash_json(payload.tools), None),
|
|
50
|
+
schema_hash=_safe(lambda: _hash_json(payload.response_format), None),
|
|
51
|
+
history_depth=_safe(lambda: _history_depth(payload), None),
|
|
52
|
+
few_shot_count=_safe(lambda: _few_shot_count(payload), None),
|
|
53
|
+
noise_ratio=_safe(lambda: _noise_ratio(payload), None),
|
|
54
|
+
structured_output_hint=_safe(
|
|
55
|
+
lambda: _structured_output_hint(payload), False
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def structured_output_mode(payload: RequestPayload) -> str:
|
|
61
|
+
if isinstance(payload.tools, list) and len(payload.tools) > 0:
|
|
62
|
+
return semconv.STRUCTURED_OUTPUT_TOOLS
|
|
63
|
+
rf = payload.response_format
|
|
64
|
+
if isinstance(rf, dict):
|
|
65
|
+
rf_type = rf.get("type")
|
|
66
|
+
if rf_type == "json_object":
|
|
67
|
+
return semconv.STRUCTURED_OUTPUT_JSON_OBJECT
|
|
68
|
+
if rf_type == "json_schema":
|
|
69
|
+
return semconv.STRUCTURED_OUTPUT_JSON_SCHEMA
|
|
70
|
+
elif rf is not None:
|
|
71
|
+
rf_type = getattr(rf, "type", None)
|
|
72
|
+
if rf_type == "json_object":
|
|
73
|
+
return semconv.STRUCTURED_OUTPUT_JSON_OBJECT
|
|
74
|
+
if rf_type == "json_schema":
|
|
75
|
+
return semconv.STRUCTURED_OUTPUT_JSON_SCHEMA
|
|
76
|
+
return semconv.STRUCTURED_OUTPUT_NONE
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _safe(fn: Callable[[], T], fallback: T) -> T:
|
|
80
|
+
try:
|
|
81
|
+
return fn()
|
|
82
|
+
except Exception: # noqa: BLE001
|
|
83
|
+
return fallback
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# --- hashing ---------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _sha256(data: str) -> str:
|
|
90
|
+
return hashlib.sha256(data.encode("utf-8")).hexdigest()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _canonicalize(value: Any) -> str:
|
|
94
|
+
"""Deterministic JSON serialization with sorted keys.
|
|
95
|
+
|
|
96
|
+
Pydantic-style objects are converted to dicts via __dict__ (best-effort);
|
|
97
|
+
anything else falls back to json.dumps(default=str).
|
|
98
|
+
"""
|
|
99
|
+
if hasattr(value, "model_dump") and callable(value.model_dump):
|
|
100
|
+
try:
|
|
101
|
+
value = value.model_dump()
|
|
102
|
+
except Exception: # noqa: BLE001
|
|
103
|
+
pass
|
|
104
|
+
return json.dumps(value, sort_keys=True, default=str, separators=(",", ":"))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _hash_json(value: Any) -> str | None:
|
|
108
|
+
if value is None:
|
|
109
|
+
return None
|
|
110
|
+
return _sha256(_canonicalize(value))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _hash_system_block(payload: RequestPayload) -> str | None:
|
|
114
|
+
blocks: list[Any] = []
|
|
115
|
+
if payload.system is not None:
|
|
116
|
+
blocks.append(payload.system)
|
|
117
|
+
if isinstance(payload.messages, list):
|
|
118
|
+
for m in payload.messages:
|
|
119
|
+
if _attr(m, "role") == "system":
|
|
120
|
+
blocks.append(_attr(m, "content"))
|
|
121
|
+
if not blocks:
|
|
122
|
+
return None
|
|
123
|
+
return _hash_json(blocks)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _hash_user_block(payload: RequestPayload) -> str | None:
|
|
127
|
+
if not isinstance(payload.messages, list):
|
|
128
|
+
return None
|
|
129
|
+
blocks = [
|
|
130
|
+
_attr(m, "content")
|
|
131
|
+
for m in payload.messages
|
|
132
|
+
if _attr(m, "role") == "user"
|
|
133
|
+
]
|
|
134
|
+
if not blocks:
|
|
135
|
+
return None
|
|
136
|
+
return _hash_json(blocks)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# --- structural counts -----------------------------------------------------
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _history_depth(payload: RequestPayload) -> int | None:
|
|
143
|
+
if not isinstance(payload.messages, list):
|
|
144
|
+
return None
|
|
145
|
+
return len(payload.messages)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _few_shot_count(payload: RequestPayload) -> int | None:
|
|
149
|
+
if not isinstance(payload.messages, list):
|
|
150
|
+
return None
|
|
151
|
+
msgs = [m for m in payload.messages if m is not None]
|
|
152
|
+
if not msgs or _attr(msgs[-1], "role") != "user":
|
|
153
|
+
return None
|
|
154
|
+
# If any tool-call turns exist, treat this as a real conversation.
|
|
155
|
+
for m in msgs:
|
|
156
|
+
if _attr(m, "role") == "tool":
|
|
157
|
+
return None
|
|
158
|
+
tool_calls = _attr(m, "tool_calls")
|
|
159
|
+
if isinstance(tool_calls, list) and len(tool_calls) > 0:
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
# Skip leading system messages.
|
|
163
|
+
i = 0
|
|
164
|
+
while i < len(msgs) and _attr(msgs[i], "role") == "system":
|
|
165
|
+
i += 1
|
|
166
|
+
|
|
167
|
+
pairs = 0
|
|
168
|
+
last = len(msgs) - 1
|
|
169
|
+
while i + 1 < last:
|
|
170
|
+
if _attr(msgs[i], "role") == "user" and _attr(msgs[i + 1], "role") == "assistant":
|
|
171
|
+
pairs += 1
|
|
172
|
+
i += 2
|
|
173
|
+
else:
|
|
174
|
+
break
|
|
175
|
+
return pairs
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# --- noise ratio -----------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
_HTML_TAG = re.compile(r"<[^>]+>")
|
|
181
|
+
_BASE64_RUN = re.compile(r"[A-Za-z0-9+/]{40,}={0,2}")
|
|
182
|
+
_WHITESPACE_RUN = re.compile(r"\s{4,}")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _noise_ratio(payload: RequestPayload) -> float | None:
|
|
186
|
+
text = _collect_text(payload)
|
|
187
|
+
if not text:
|
|
188
|
+
return None
|
|
189
|
+
noise = 0
|
|
190
|
+
for match in _HTML_TAG.findall(text):
|
|
191
|
+
noise += len(match)
|
|
192
|
+
for match in _BASE64_RUN.findall(text):
|
|
193
|
+
noise += len(match)
|
|
194
|
+
for match in _WHITESPACE_RUN.findall(text):
|
|
195
|
+
noise += len(match)
|
|
196
|
+
return min(1.0, noise / len(text))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _collect_text(payload: RequestPayload) -> str:
|
|
200
|
+
parts: list[str] = []
|
|
201
|
+
if isinstance(payload.system, str):
|
|
202
|
+
parts.append(payload.system)
|
|
203
|
+
if isinstance(payload.messages, list):
|
|
204
|
+
for m in payload.messages:
|
|
205
|
+
if m is None:
|
|
206
|
+
continue
|
|
207
|
+
content = _attr(m, "content")
|
|
208
|
+
if isinstance(content, str):
|
|
209
|
+
parts.append(content)
|
|
210
|
+
elif isinstance(content, list):
|
|
211
|
+
for c in content:
|
|
212
|
+
text = _attr(c, "text")
|
|
213
|
+
if isinstance(text, str):
|
|
214
|
+
parts.append(text)
|
|
215
|
+
return "\n".join(parts)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# --- structured output hint ------------------------------------------------
|
|
219
|
+
|
|
220
|
+
_STRUCTURED_HINTS = [
|
|
221
|
+
re.compile(r"format\s+as\s+JSON", re.IGNORECASE),
|
|
222
|
+
re.compile(r"return\s+a\s+(JSON\s+)?(list|array|object)", re.IGNORECASE),
|
|
223
|
+
re.compile(r"respond\s+with\s+a\s+JSON", re.IGNORECASE),
|
|
224
|
+
re.compile(r"output\s+(must\s+be|should\s+be|in)\s+JSON", re.IGNORECASE),
|
|
225
|
+
]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _structured_output_hint(payload: RequestPayload) -> bool:
|
|
229
|
+
if isinstance(payload.tools, list) and len(payload.tools) > 0:
|
|
230
|
+
return False
|
|
231
|
+
rf = payload.response_format
|
|
232
|
+
if rf is not None:
|
|
233
|
+
rf_type = rf.get("type") if isinstance(rf, dict) else getattr(rf, "type", None)
|
|
234
|
+
if rf_type is not None:
|
|
235
|
+
return False
|
|
236
|
+
text = _collect_text(payload)
|
|
237
|
+
if not text:
|
|
238
|
+
return False
|
|
239
|
+
return any(p.search(text) for p in _STRUCTURED_HINTS)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# --- attribute helper ------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _attr(obj: Any, name: str, default: Any = None) -> Any:
|
|
246
|
+
if obj is None:
|
|
247
|
+
return default
|
|
248
|
+
if isinstance(obj, dict):
|
|
249
|
+
return obj.get(name, default)
|
|
250
|
+
return getattr(obj, name, default)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# --- sampling --------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
_BUCKET_MS = 60_000
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def should_sample(call_site_id: str, sample_rate: float, *, now_ms: int | None = None) -> bool:
|
|
259
|
+
if sample_rate >= 1:
|
|
260
|
+
return True
|
|
261
|
+
if sample_rate <= 0:
|
|
262
|
+
return False
|
|
263
|
+
if now_ms is None:
|
|
264
|
+
import time
|
|
265
|
+
|
|
266
|
+
now_ms = int(time.time() * 1000)
|
|
267
|
+
bucket = now_ms // _BUCKET_MS
|
|
268
|
+
key = f"{call_site_id}:{bucket}"
|
|
269
|
+
h = 0x811C9DC5
|
|
270
|
+
for ch in key.encode("utf-8"):
|
|
271
|
+
h ^= ch
|
|
272
|
+
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) & 0xFFFFFFFF
|
|
273
|
+
return h / 0xFFFFFFFF < sample_rate
|
frugal_metrics/safe.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Safe-call helper — wraps any function so it never throws.
|
|
2
|
+
|
|
3
|
+
Tries to emit an internal_errors metric on failure; if even that fails,
|
|
4
|
+
swallows silently. The customer's code path must never be interrupted by our
|
|
5
|
+
instrumentation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Callable
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("frugal_metrics")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def safe_call(fn: Callable[[], None]) -> None:
|
|
17
|
+
"""Call fn, recording an internal error metric on failure."""
|
|
18
|
+
try:
|
|
19
|
+
fn()
|
|
20
|
+
except Exception as exc: # noqa: BLE001
|
|
21
|
+
logger.debug("frugal-metrics: metric emission failed", exc_info=True)
|
|
22
|
+
try:
|
|
23
|
+
from frugal_metrics.otel import _instruments
|
|
24
|
+
|
|
25
|
+
if _instruments is not None:
|
|
26
|
+
_instruments.internal_errors.add(
|
|
27
|
+
1,
|
|
28
|
+
{
|
|
29
|
+
"frugal.internal_error.stage": "emission",
|
|
30
|
+
"frugal.internal_error.type": type(exc).__name__,
|
|
31
|
+
},
|
|
32
|
+
)
|
|
33
|
+
except Exception: # noqa: BLE001
|
|
34
|
+
pass
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""OpenTelemetry GenAI semantic convention constants + Frugal extensions."""
|
|
2
|
+
|
|
3
|
+
# GenAI semconv attribute names (stable as of semconv v1.28+).
|
|
4
|
+
GEN_AI_SYSTEM = "gen_ai.system"
|
|
5
|
+
GEN_AI_REQUEST_MODEL = "gen_ai.request.model"
|
|
6
|
+
GEN_AI_RESPONSE_MODEL = "gen_ai.response.model"
|
|
7
|
+
GEN_AI_OPERATION_NAME = "gen_ai.operation.name"
|
|
8
|
+
GEN_AI_TOKEN_TYPE = "gen_ai.token.type"
|
|
9
|
+
|
|
10
|
+
# GenAI semconv system identifiers.
|
|
11
|
+
SYSTEM_OPENAI = "openai"
|
|
12
|
+
SYSTEM_ANTHROPIC = "anthropic"
|
|
13
|
+
SYSTEM_AZURE_OPENAI = "az.ai.openai"
|
|
14
|
+
SYSTEM_AWS_BEDROCK = "aws.bedrock"
|
|
15
|
+
SYSTEM_VERTEX_AI = "vertex_ai"
|
|
16
|
+
|
|
17
|
+
# GenAI semconv operation names.
|
|
18
|
+
OP_CHAT = "chat"
|
|
19
|
+
OP_TEXT_COMPLETION = "text_completion"
|
|
20
|
+
OP_EMBEDDINGS = "embeddings"
|
|
21
|
+
|
|
22
|
+
# GenAI semconv token types.
|
|
23
|
+
TOKEN_INPUT = "input"
|
|
24
|
+
TOKEN_OUTPUT = "output"
|
|
25
|
+
|
|
26
|
+
# GenAI semconv metric names.
|
|
27
|
+
METRIC_OPERATION_DURATION = "gen_ai.client.operation.duration"
|
|
28
|
+
METRIC_TOKEN_USAGE = "gen_ai.client.token.usage"
|
|
29
|
+
|
|
30
|
+
# Frugal-namespaced metric extensions.
|
|
31
|
+
METRIC_CALLS = "frugal.gen_ai.calls"
|
|
32
|
+
METRIC_CACHE_READ_TOKENS = "frugal.gen_ai.cache_read_tokens"
|
|
33
|
+
METRIC_CACHE_WRITE_TOKENS = "frugal.gen_ai.cache_write_tokens"
|
|
34
|
+
METRIC_REASONING_TOKENS = "frugal.gen_ai.reasoning_tokens"
|
|
35
|
+
METRIC_ERRORS = "frugal.gen_ai.errors"
|
|
36
|
+
METRIC_INTERNAL_ERRORS = "frugal.gen_ai.internal_errors"
|
|
37
|
+
METRIC_OUTPUT_CAP_UTILIZATION = "frugal.gen_ai.output_cap_utilization"
|
|
38
|
+
METRIC_CACHE_PREFIX_STABLE = "frugal.gen_ai.cache_prefix_stable"
|
|
39
|
+
METRIC_HISTORY_DEPTH = "frugal.gen_ai.history_depth"
|
|
40
|
+
METRIC_FEW_SHOT_COUNT = "frugal.gen_ai.few_shot_count"
|
|
41
|
+
METRIC_NOISE_RATIO = "frugal.gen_ai.noise_ratio"
|
|
42
|
+
METRIC_STRUCTURED_OUTPUT_HINT = "frugal.gen_ai.structured_output_hint"
|
|
43
|
+
|
|
44
|
+
# Frugal-namespaced attributes.
|
|
45
|
+
FRUGAL_CUSTOMER_ID = "frugal.customer_id"
|
|
46
|
+
FRUGAL_PROJECT_ID = "frugal.project_id"
|
|
47
|
+
FRUGAL_COMPONENT = "frugal.component"
|
|
48
|
+
FRUGAL_CALLER_FILE = "frugal.caller_file"
|
|
49
|
+
FRUGAL_CALLER_FUNCTION = "frugal.caller_function"
|
|
50
|
+
FRUGAL_ERROR_TYPE = "frugal.error.type"
|
|
51
|
+
FRUGAL_BATCHED = "frugal.batched"
|
|
52
|
+
FRUGAL_STRUCTURED_OUTPUT_MODE = "frugal.structured_output_mode"
|
|
53
|
+
FRUGAL_BLOCK = "frugal.block"
|
|
54
|
+
FRUGAL_STABLE = "frugal.stable"
|
|
55
|
+
|
|
56
|
+
STRUCTURED_OUTPUT_NONE = "none"
|
|
57
|
+
STRUCTURED_OUTPUT_JSON_OBJECT = "json_object"
|
|
58
|
+
STRUCTURED_OUTPUT_JSON_SCHEMA = "json_schema"
|
|
59
|
+
STRUCTURED_OUTPUT_TOOLS = "tools"
|
|
60
|
+
|
|
61
|
+
BLOCK_SYSTEM = "system"
|
|
62
|
+
BLOCK_USER = "user"
|
|
63
|
+
BLOCK_TOOLS = "tools"
|
|
64
|
+
BLOCK_SCHEMA = "schema"
|
|
65
|
+
|
|
66
|
+
# Sentinel for calls whose caller could not be resolved to a file inside the repo.
|
|
67
|
+
CALLER_FILE_EXTERNAL = "<external>"
|
|
68
|
+
CALLER_FILE_UNKNOWN = "<unknown>"
|
|
69
|
+
# Placeholder for cardinality overflow (both file and function use the same value).
|
|
70
|
+
CALLER_OVERFLOW = "<overflow>"
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Stream proxies that pass events through while accumulating usage metrics.
|
|
2
|
+
|
|
3
|
+
Used by both OpenAI (stream=True returns an iterator of chunks) and Anthropic
|
|
4
|
+
(stream=True returns an iterator of MessageStreamEvent). The provider-specific
|
|
5
|
+
extractor inspects each event and mutates the accumulating UsageReport in
|
|
6
|
+
place. Metrics are emitted once the underlying iterator terminates, the proxy
|
|
7
|
+
is explicitly closed, or the customer exits a context manager we wrap.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Any, Callable, Iterator
|
|
14
|
+
|
|
15
|
+
from frugal_metrics.instrument import CallEmitter, UsageReport
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("frugal_metrics")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
StreamEventHandler = Callable[[Any, UsageReport], None]
|
|
21
|
+
"""Invoked for each event/chunk — mutates the passed UsageReport in place."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SyncStreamProxy:
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
wrapped: Any,
|
|
28
|
+
emit: CallEmitter,
|
|
29
|
+
handler: StreamEventHandler,
|
|
30
|
+
) -> None:
|
|
31
|
+
self._wrapped = wrapped
|
|
32
|
+
self._emit = emit
|
|
33
|
+
self._handler = handler
|
|
34
|
+
self._usage = UsageReport()
|
|
35
|
+
self._finalized = False
|
|
36
|
+
|
|
37
|
+
# --- iteration ---
|
|
38
|
+
|
|
39
|
+
def __iter__(self) -> Iterator[Any]:
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
def __next__(self) -> Any:
|
|
43
|
+
try:
|
|
44
|
+
chunk = next(self._wrapped)
|
|
45
|
+
except StopIteration:
|
|
46
|
+
self._finalize_success()
|
|
47
|
+
raise
|
|
48
|
+
except BaseException as exc:
|
|
49
|
+
self._finalize_error(exc)
|
|
50
|
+
raise
|
|
51
|
+
self._safe_handle(chunk)
|
|
52
|
+
return chunk
|
|
53
|
+
|
|
54
|
+
# --- context manager ---
|
|
55
|
+
|
|
56
|
+
def __enter__(self) -> "SyncStreamProxy":
|
|
57
|
+
enter = getattr(self._wrapped, "__enter__", None)
|
|
58
|
+
if enter is not None:
|
|
59
|
+
enter()
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
def __exit__(self, exc_type, exc, tb) -> Any:
|
|
63
|
+
exit_ = getattr(self._wrapped, "__exit__", None)
|
|
64
|
+
suppress = exit_(exc_type, exc, tb) if exit_ is not None else None
|
|
65
|
+
if exc is not None:
|
|
66
|
+
self._finalize_error(exc)
|
|
67
|
+
else:
|
|
68
|
+
self._finalize_success()
|
|
69
|
+
return suppress
|
|
70
|
+
|
|
71
|
+
# --- plumbing ---
|
|
72
|
+
|
|
73
|
+
def close(self) -> None:
|
|
74
|
+
close = getattr(self._wrapped, "close", None)
|
|
75
|
+
if callable(close):
|
|
76
|
+
close()
|
|
77
|
+
self._finalize_success()
|
|
78
|
+
|
|
79
|
+
def __getattr__(self, name: str) -> Any:
|
|
80
|
+
# Delegate everything else (e.g., .response, .text_stream helpers).
|
|
81
|
+
return getattr(self._wrapped, name)
|
|
82
|
+
|
|
83
|
+
def _safe_handle(self, chunk: Any) -> None:
|
|
84
|
+
try:
|
|
85
|
+
self._handler(chunk, self._usage)
|
|
86
|
+
except Exception: # noqa: BLE001
|
|
87
|
+
logger.debug("frugal-metrics: stream handler error", exc_info=True)
|
|
88
|
+
|
|
89
|
+
def _finalize_success(self) -> None:
|
|
90
|
+
if self._finalized:
|
|
91
|
+
return
|
|
92
|
+
self._finalized = True
|
|
93
|
+
self._emit.success(self._usage)
|
|
94
|
+
|
|
95
|
+
def _finalize_error(self, exc: BaseException) -> None:
|
|
96
|
+
if self._finalized:
|
|
97
|
+
return
|
|
98
|
+
self._finalized = True
|
|
99
|
+
self._emit.error(exc)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class AsyncStreamProxy:
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
wrapped: Any,
|
|
106
|
+
emit: CallEmitter,
|
|
107
|
+
handler: StreamEventHandler,
|
|
108
|
+
) -> None:
|
|
109
|
+
self._wrapped = wrapped
|
|
110
|
+
self._emit = emit
|
|
111
|
+
self._handler = handler
|
|
112
|
+
self._usage = UsageReport()
|
|
113
|
+
self._finalized = False
|
|
114
|
+
|
|
115
|
+
def __aiter__(self) -> "AsyncStreamProxy":
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
async def __anext__(self) -> Any:
|
|
119
|
+
try:
|
|
120
|
+
chunk = await self._wrapped.__anext__()
|
|
121
|
+
except StopAsyncIteration:
|
|
122
|
+
self._finalize_success()
|
|
123
|
+
raise
|
|
124
|
+
except BaseException as exc:
|
|
125
|
+
self._finalize_error(exc)
|
|
126
|
+
raise
|
|
127
|
+
self._safe_handle(chunk)
|
|
128
|
+
return chunk
|
|
129
|
+
|
|
130
|
+
async def __aenter__(self) -> "AsyncStreamProxy":
|
|
131
|
+
aenter = getattr(self._wrapped, "__aenter__", None)
|
|
132
|
+
if aenter is not None:
|
|
133
|
+
await aenter()
|
|
134
|
+
return self
|
|
135
|
+
|
|
136
|
+
async def __aexit__(self, exc_type, exc, tb) -> Any:
|
|
137
|
+
aexit = getattr(self._wrapped, "__aexit__", None)
|
|
138
|
+
suppress = (
|
|
139
|
+
await aexit(exc_type, exc, tb) if aexit is not None else None
|
|
140
|
+
)
|
|
141
|
+
if exc is not None:
|
|
142
|
+
self._finalize_error(exc)
|
|
143
|
+
else:
|
|
144
|
+
self._finalize_success()
|
|
145
|
+
return suppress
|
|
146
|
+
|
|
147
|
+
async def aclose(self) -> None:
|
|
148
|
+
aclose = getattr(self._wrapped, "aclose", None)
|
|
149
|
+
if callable(aclose):
|
|
150
|
+
await aclose()
|
|
151
|
+
self._finalize_success()
|
|
152
|
+
|
|
153
|
+
def __getattr__(self, name: str) -> Any:
|
|
154
|
+
return getattr(self._wrapped, name)
|
|
155
|
+
|
|
156
|
+
def _safe_handle(self, chunk: Any) -> None:
|
|
157
|
+
try:
|
|
158
|
+
self._handler(chunk, self._usage)
|
|
159
|
+
except Exception: # noqa: BLE001
|
|
160
|
+
logger.debug("frugal-metrics: async stream handler error", exc_info=True)
|
|
161
|
+
|
|
162
|
+
def _finalize_success(self) -> None:
|
|
163
|
+
if self._finalized:
|
|
164
|
+
return
|
|
165
|
+
self._finalized = True
|
|
166
|
+
self._emit.success(self._usage)
|
|
167
|
+
|
|
168
|
+
def _finalize_error(self, exc: BaseException) -> None:
|
|
169
|
+
if self._finalized:
|
|
170
|
+
return
|
|
171
|
+
self._finalized = True
|
|
172
|
+
self._emit.error(exc)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Client wrappers for supported AI SDKs."""
|