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.
@@ -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."""