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,13 @@
1
+ """frugal-metrics: call-site instrumentation for AI API calls."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from frugal_metrics.wrappers.openai import wrap_openai
6
+ from frugal_metrics.wrappers.anthropic import wrap_anthropic
7
+ from frugal_metrics.wrappers.bedrock import wrap_bedrock
8
+
9
+ __all__ = ["wrap_openai", "wrap_anthropic", "wrap_bedrock"]
10
+ try:
11
+ __version__ = version("frugal-sdk-python")
12
+ except PackageNotFoundError:
13
+ __version__ = "0+unknown"
@@ -0,0 +1,75 @@
1
+ """Per-call-site LRU storing the previous call's block hashes.
2
+
3
+ Used by the prompt analyzer to derive cache-prefix-stable signals without
4
+ leaving raw hashes anywhere outside the process. Default capacity matches the
5
+ existing call-site cardinality cap so a busy process can't blow memory by
6
+ tracking thousands of distinct sites.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import threading
12
+ from collections import OrderedDict
13
+ from dataclasses import dataclass
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class BlockHashes:
18
+ system_hash: str | None = None
19
+ user_hash: str | None = None
20
+ tools_hash: str | None = None
21
+ schema_hash: str | None = None
22
+
23
+
24
+ class BlockHashLRU:
25
+ def __init__(self, capacity: int = 500) -> None:
26
+ self._capacity = capacity
27
+ self._map: "OrderedDict[str, BlockHashes]" = OrderedDict()
28
+ self._lock = threading.Lock()
29
+
30
+ def get(self, call_site_id: str) -> BlockHashes | None:
31
+ with self._lock:
32
+ entry = self._map.get(call_site_id)
33
+ if entry is None:
34
+ return None
35
+ self._map.move_to_end(call_site_id)
36
+ return entry
37
+
38
+ def set(self, call_site_id: str, hashes: BlockHashes) -> None:
39
+ with self._lock:
40
+ if call_site_id in self._map:
41
+ self._map.move_to_end(call_site_id)
42
+ self._map[call_site_id] = hashes
43
+ return
44
+ self._map[call_site_id] = hashes
45
+ if self._capacity > 0:
46
+ while len(self._map) > self._capacity:
47
+ self._map.popitem(last=False)
48
+
49
+ def size(self) -> int:
50
+ with self._lock:
51
+ return len(self._map)
52
+
53
+ def clear(self) -> None:
54
+ with self._lock:
55
+ self._map.clear()
56
+
57
+
58
+ _shared: BlockHashLRU | None = None
59
+ _shared_lock = threading.Lock()
60
+
61
+
62
+ def get_shared_block_hash_lru(capacity: int | None = None) -> BlockHashLRU:
63
+ global _shared
64
+ if _shared is not None:
65
+ return _shared
66
+ with _shared_lock:
67
+ if _shared is None:
68
+ _shared = BlockHashLRU(capacity if capacity is not None else 500)
69
+ return _shared
70
+
71
+
72
+ def reset_shared_block_hash_lru_for_tests() -> None:
73
+ global _shared
74
+ with _shared_lock:
75
+ _shared = None
@@ -0,0 +1,213 @@
1
+ """Stack walking to resolve the call site of a wrapped AI call.
2
+
3
+ Algorithm: starting from the frame above the wrapper, walk upward until we find
4
+ a frame whose source file is not (a) inside our own package, (b) inside the
5
+ wrapped SDK's package, or (c) under any FRUGAL_PATH_BLOCK_LIST prefix. Return
6
+ the file relative to FRUGAL_REPO_ROOT plus the function name.
7
+
8
+ Performance: realpath results and full caller resolutions are cached so the
9
+ stack walk and path resolution only happen once per unique call site. Subsequent
10
+ calls from the same site are a dict lookup.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import sys
17
+ import threading
18
+ from dataclasses import dataclass
19
+
20
+ from frugal_metrics.semconv import (
21
+ CALLER_FILE_EXTERNAL,
22
+ CALLER_FILE_UNKNOWN,
23
+ CALLER_OVERFLOW,
24
+ )
25
+
26
+ # One entry per distinct (filename, function) pair that ever calls a wrapped AI
27
+ # method. Even a very large codebase has <10k unique sites.
28
+ _CACHE_MAX_SIZE = 10_000
29
+
30
+ _realpath_cache: dict[str, str] = {}
31
+ _realpath_lock = threading.Lock()
32
+
33
+ # Keyed on (raw_filename, function_name) — the skip_dirs, block_list, and
34
+ # repo_root are stable for the lifetime of a process (they come from env vars
35
+ # loaded once at config time), so we don't need them in the key.
36
+ _caller_cache: dict[tuple[str, str], CallerInfo | None] = {}
37
+ _caller_cache_lock = threading.Lock()
38
+
39
+ # Cardinality cap state — first-come-first-served. Once `_seen_call_sites`
40
+ # hits the configured max, subsequent unseen pairs overflow (partial if the
41
+ # file is already in `_seen_files`, full otherwise).
42
+ _seen_call_sites: set[tuple[str, str]] = set()
43
+ _seen_files: set[str] = set()
44
+ _cap_state_lock = threading.Lock()
45
+ _cap_hit_notified = False
46
+ _cap_hit_pending = False
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class CallerInfo:
51
+ file: str
52
+ function: str
53
+
54
+
55
+ def resolve_caller(
56
+ *,
57
+ skip_package_dirs: tuple[str, ...],
58
+ block_list: tuple[str, ...],
59
+ repo_root: str,
60
+ max_call_sites: int = 0,
61
+ ) -> CallerInfo:
62
+ """Walk the stack for the first frame outside our wrapper / wrapped SDK / block list.
63
+
64
+ Results are cached by (raw_filename, function_name) so the expensive path
65
+ resolution only runs once per call site.
66
+ """
67
+ frame = sys._getframe(1)
68
+ # Skip the wrapper frame that called us.
69
+ if frame is not None:
70
+ frame = frame.f_back
71
+
72
+ while frame is not None:
73
+ raw_filename = frame.f_code.co_filename
74
+ function = frame.f_code.co_name
75
+
76
+ # Fast path: check the caller cache before doing any path work.
77
+ cache_key = (raw_filename, function)
78
+ cached = _caller_cache.get(cache_key)
79
+ if cached is not None:
80
+ return cached
81
+
82
+ abs_filename = _cached_realpath(raw_filename) if raw_filename else ""
83
+
84
+ if _is_in_any(abs_filename, skip_package_dirs):
85
+ # Cache this as a "skip" so we don't realpath again next time.
86
+ _cache_put(cache_key, None)
87
+ frame = frame.f_back
88
+ continue
89
+
90
+ rel = _repo_relative(abs_filename, repo_root)
91
+ if rel is not None and _is_blocked(rel, block_list):
92
+ _cache_put(cache_key, None)
93
+ frame = frame.f_back
94
+ continue
95
+
96
+ resolved_file = CALLER_FILE_EXTERNAL if rel is None else rel
97
+ result = _apply_cap(resolved_file, function, max_call_sites)
98
+
99
+ _cache_put(cache_key, result)
100
+ return result
101
+
102
+ return CallerInfo(file=CALLER_FILE_UNKNOWN, function="<unknown>")
103
+
104
+
105
+ def _apply_cap(file: str, function: str, max_call_sites: int) -> CallerInfo:
106
+ global _cap_hit_notified, _cap_hit_pending
107
+ if max_call_sites <= 0:
108
+ return CallerInfo(file=file, function=function)
109
+ key = (file, function)
110
+ # Fast path — already known, no lock needed for read (sets are thread-safe
111
+ # for membership in CPython; we take the lock only on mutation).
112
+ if key in _seen_call_sites:
113
+ return CallerInfo(file=file, function=function)
114
+ with _cap_state_lock:
115
+ # Re-check under the lock.
116
+ if key in _seen_call_sites:
117
+ return CallerInfo(file=file, function=function)
118
+ if len(_seen_call_sites) < max_call_sites:
119
+ _seen_call_sites.add(key)
120
+ _seen_files.add(file)
121
+ return CallerInfo(file=file, function=function)
122
+ # At cap.
123
+ if not _cap_hit_notified:
124
+ _cap_hit_notified = True
125
+ _cap_hit_pending = True
126
+ if file in _seen_files:
127
+ return CallerInfo(file=file, function=CALLER_OVERFLOW)
128
+ return CallerInfo(file=CALLER_OVERFLOW, function=CALLER_OVERFLOW)
129
+
130
+
131
+ def consume_cap_hit_event() -> bool:
132
+ """Returns True once per process when the cap is first crossed.
133
+
134
+ The instrument layer calls this after each resolve; on True it emits a
135
+ single `frugal.gen_ai.internal_errors{stage=cardinality_cap_hit}`.
136
+ """
137
+ global _cap_hit_pending
138
+ with _cap_state_lock:
139
+ if _cap_hit_pending:
140
+ _cap_hit_pending = False
141
+ return True
142
+ return False
143
+
144
+
145
+ def _cached_realpath(path: str) -> str:
146
+ cached = _realpath_cache.get(path)
147
+ if cached is not None:
148
+ return cached
149
+ resolved = os.path.realpath(path)
150
+ with _realpath_lock:
151
+ if len(_realpath_cache) < _CACHE_MAX_SIZE:
152
+ _realpath_cache[path] = resolved
153
+ return resolved
154
+
155
+
156
+ def _cache_put(key: tuple[str, str], value: CallerInfo | None) -> None:
157
+ with _caller_cache_lock:
158
+ if len(_caller_cache) < _CACHE_MAX_SIZE:
159
+ _caller_cache[key] = value
160
+
161
+
162
+ def _is_in_any(abs_filename: str, dirs: tuple[str, ...]) -> bool:
163
+ if not abs_filename:
164
+ return False
165
+ for d in dirs:
166
+ if not d:
167
+ continue
168
+ if abs_filename == d or abs_filename.startswith(d.rstrip(os.sep) + os.sep):
169
+ return True
170
+ return False
171
+
172
+
173
+ def _repo_relative(abs_filename: str, repo_root: str) -> str | None:
174
+ if not abs_filename:
175
+ return None
176
+ root = repo_root.rstrip(os.sep)
177
+ if abs_filename == root:
178
+ return ""
179
+ prefix = root + os.sep
180
+ if abs_filename.startswith(prefix):
181
+ return abs_filename[len(prefix):]
182
+ return None
183
+
184
+
185
+ def _is_blocked(rel_path: str, block_list: tuple[str, ...]) -> bool:
186
+ for entry in block_list:
187
+ if not entry:
188
+ continue
189
+ if rel_path == entry or rel_path.startswith(entry.rstrip("/") + "/"):
190
+ return True
191
+ return False
192
+
193
+
194
+ def package_dir_for(module: object) -> str:
195
+ """Return the absolute directory of a module, suitable for skip_package_dirs."""
196
+ module_file = getattr(module, "__file__", None)
197
+ if not module_file:
198
+ return ""
199
+ return os.path.realpath(os.path.dirname(module_file))
200
+
201
+
202
+ def reset_caches_for_tests() -> None:
203
+ """Clear all caches — test-only hook."""
204
+ global _cap_hit_notified, _cap_hit_pending
205
+ with _realpath_lock:
206
+ _realpath_cache.clear()
207
+ with _caller_cache_lock:
208
+ _caller_cache.clear()
209
+ with _cap_state_lock:
210
+ _seen_call_sites.clear()
211
+ _seen_files.clear()
212
+ _cap_hit_notified = False
213
+ _cap_hit_pending = False
@@ -0,0 +1,231 @@
1
+ """Environment-variable configuration loading.
2
+
3
+ All configuration comes from env vars — no init() API. If any required variable
4
+ is missing, load_config() returns None and the wrappers no-op (returning the
5
+ client unwrapped). A single warning is logged on the first no-op.
6
+
7
+ Minimum required production config (two env vars):
8
+ FRUGAL_API_KEY — per-customer bearer token (Frugal mints these per tenant)
9
+ FRUGAL_PROJECT_ID — the Frugal project this deployment belongs to. Stats are
10
+ attributed per project (file/function is unique within a
11
+ project), so the same repo deployed across microservices
12
+ no longer collides. Frugal shows this value in the UI.
13
+
14
+ Everything else has sensible defaults:
15
+ - Endpoint defaults to https://metrics.frugal.co/opentelemetry/v1/metrics
16
+ - REPO_ROOT defaults to os.getcwd() (the strip prefix for caller_file)
17
+ - CUSTOMER_ID is optional; when unset, the tenant label (auto-injected
18
+ server-side by VMAuth from the bearer token) is the canonical customer
19
+ identifier and the frugal.customer_id label is omitted.
20
+ - COMPONENTS is optional; a comma-separated list (e.g. "ai-handler,reco").
21
+ When set, every metric is ALSO emitted once per component (tagged with
22
+ frugal.component) on top of the base project-level series, so usage can be
23
+ viewed for the whole project (component absent) or split by component.
24
+
25
+ The older env vars (FRUGAL_OTEL_EXPORTER_OTLP_ENDPOINT,
26
+ FRUGAL_OTEL_EXPORTER_OTLP_HEADERS, FRUGAL_REPO_ROOT, FRUGAL_CUSTOMER_ID,
27
+ plus the standard OTel ones) all still work — explicit values override the
28
+ defaults so existing customers don't need to change anything.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import logging
34
+ import os
35
+ import threading
36
+ from dataclasses import dataclass, field
37
+
38
+ logger = logging.getLogger("frugal_metrics")
39
+
40
+ _ENV_API_KEY = "FRUGAL_API_KEY"
41
+ _ENV_CUSTOMER_ID = "FRUGAL_CUSTOMER_ID"
42
+ _ENV_PROJECT_ID = "FRUGAL_PROJECT_ID"
43
+ _ENV_COMPONENTS = "FRUGAL_COMPONENTS"
44
+ _ENV_REPO_ROOT = "FRUGAL_REPO_ROOT"
45
+ _ENV_PATH_BLOCK_LIST = "FRUGAL_PATH_BLOCK_LIST"
46
+ _ENV_DISABLED = "FRUGAL_DISABLED"
47
+
48
+ _ENV_FRUGAL_OTLP_ENDPOINT = "FRUGAL_OTEL_EXPORTER_OTLP_ENDPOINT"
49
+ _ENV_FRUGAL_OTLP_HEADERS = "FRUGAL_OTEL_EXPORTER_OTLP_HEADERS"
50
+ _ENV_OTLP_ENDPOINT = "OTEL_EXPORTER_OTLP_ENDPOINT"
51
+ _ENV_OTLP_HEADERS = "OTEL_EXPORTER_OTLP_HEADERS"
52
+ _ENV_EXPORT_INTERVAL_MS = "FRUGAL_METRIC_EXPORT_INTERVAL_MS"
53
+ _ENV_MAX_CALL_SITES = "FRUGAL_MAX_CALL_SITES"
54
+ _ENV_PROMPT_ANALYSIS_SAMPLE_RATE = "FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE"
55
+
56
+ _DEFAULT_OTLP_ENDPOINT = "https://metrics.frugal.co/opentelemetry/v1/metrics"
57
+ _DEFAULT_MAX_CALL_SITES = 500
58
+ _DEFAULT_PROMPT_ANALYSIS_SAMPLE_RATE = 0.1
59
+
60
+ _warned_once = False
61
+ _warned_lock = threading.Lock()
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class Config:
66
+ # Optional now — when None the tenant label (auto-injected by VMAuth)
67
+ # identifies the customer.
68
+ customer_id: str | None
69
+ project_id: str
70
+ repo_root: str
71
+ # Optional logical components; metrics fan out one extra series per entry.
72
+ components: tuple[str, ...] = field(default_factory=tuple)
73
+ path_block_list: tuple[str, ...] = field(default_factory=tuple)
74
+ otlp_endpoint: str = _DEFAULT_OTLP_ENDPOINT
75
+ otlp_headers: str | None = None
76
+ export_interval_ms: int = 60_000
77
+ # Max distinct (file, function) combinations to attribute before overflow.
78
+ # 0 disables the cap.
79
+ max_call_sites: int = _DEFAULT_MAX_CALL_SITES
80
+ # Fraction of analyzed calls to emit prompt-analysis metrics for, 0..1.
81
+ prompt_analysis_sample_rate: float = _DEFAULT_PROMPT_ANALYSIS_SAMPLE_RATE
82
+
83
+
84
+ def _truthy(value: str | None) -> bool:
85
+ return value is not None and value.strip().lower() in {"1", "true", "yes", "on"}
86
+
87
+
88
+ def _warn_once(message: str) -> None:
89
+ global _warned_once
90
+ with _warned_lock:
91
+ if _warned_once:
92
+ return
93
+ _warned_once = True
94
+ logger.warning("frugal-metrics disabled: %s", message)
95
+
96
+
97
+ def load_config() -> Config | None:
98
+ """Read env vars and return a Config, or None if disabled or misconfigured.
99
+
100
+ Side effect: logs a single warning the first time we return None due to
101
+ missing required vars. Repeated calls stay silent.
102
+ """
103
+ if _truthy(os.environ.get(_ENV_DISABLED)):
104
+ return None
105
+
106
+ customer_id = os.environ.get(_ENV_CUSTOMER_ID) or None
107
+ project_id = os.environ.get(_ENV_PROJECT_ID)
108
+ repo_root_env = os.environ.get(_ENV_REPO_ROOT)
109
+ api_key = os.environ.get(_ENV_API_KEY)
110
+
111
+ # Explicit headers override anything we'd build from the API key — keeps
112
+ # existing deployments that set FRUGAL_OTEL_EXPORTER_OTLP_HEADERS directly
113
+ # working unchanged.
114
+ explicit_headers = (
115
+ os.environ.get(_ENV_FRUGAL_OTLP_HEADERS)
116
+ or os.environ.get(_ENV_OTLP_HEADERS)
117
+ )
118
+ if explicit_headers:
119
+ otlp_headers: str | None = explicit_headers
120
+ elif api_key:
121
+ otlp_headers = f"Authorization=Bearer {api_key}"
122
+ else:
123
+ otlp_headers = None
124
+
125
+ # Required for any useful run: PROJECT_ID (the attribution key) and SOME auth
126
+ # (either FRUGAL_API_KEY or an explicit OTLP_HEADERS containing the bearer).
127
+ missing = []
128
+ if not project_id:
129
+ missing.append(_ENV_PROJECT_ID)
130
+ if not otlp_headers:
131
+ missing.append(f"{_ENV_API_KEY} or {_ENV_FRUGAL_OTLP_HEADERS}")
132
+ if missing:
133
+ _warn_once(f"missing required env vars: {', '.join(missing)}")
134
+ return None
135
+
136
+ # Normalize repo_root to a real absolute path so caller-file relativization
137
+ # is consistent even when the process sees symlinked mounts (e.g. macOS
138
+ # /var -> /private/var). Default to os.getcwd() — works for the standard
139
+ # container pattern where WORKDIR is set to the source-code root.
140
+ repo_root_raw = repo_root_env or os.getcwd()
141
+ repo_root_abs = os.path.realpath(repo_root_raw)
142
+
143
+ block_list_raw = os.environ.get(_ENV_PATH_BLOCK_LIST, "")
144
+ block_list = tuple(
145
+ _normalize_block_entry(entry)
146
+ for entry in block_list_raw.split(",")
147
+ if entry.strip()
148
+ )
149
+
150
+ # Optional comma-separated logical components (e.g. "ai-handler,reco"). Each
151
+ # becomes an extra per-component series on top of the base project series.
152
+ components_raw = os.environ.get(_ENV_COMPONENTS, "")
153
+ components = tuple(
154
+ entry.strip() for entry in components_raw.split(",") if entry.strip()
155
+ )
156
+
157
+ otlp_endpoint = (
158
+ os.environ.get(_ENV_FRUGAL_OTLP_ENDPOINT)
159
+ or os.environ.get(_ENV_OTLP_ENDPOINT)
160
+ or _DEFAULT_OTLP_ENDPOINT
161
+ )
162
+
163
+ export_interval_ms = _parse_int(
164
+ os.environ.get(_ENV_EXPORT_INTERVAL_MS), default=60_000
165
+ )
166
+ max_call_sites = _parse_int(
167
+ os.environ.get(_ENV_MAX_CALL_SITES),
168
+ default=_DEFAULT_MAX_CALL_SITES,
169
+ )
170
+ if max_call_sites < 0:
171
+ max_call_sites = _DEFAULT_MAX_CALL_SITES
172
+
173
+ sample_rate = _parse_float(
174
+ os.environ.get(_ENV_PROMPT_ANALYSIS_SAMPLE_RATE),
175
+ default=_DEFAULT_PROMPT_ANALYSIS_SAMPLE_RATE,
176
+ )
177
+ if sample_rate < 0 or sample_rate > 1:
178
+ sample_rate = _DEFAULT_PROMPT_ANALYSIS_SAMPLE_RATE
179
+
180
+ return Config(
181
+ customer_id=customer_id,
182
+ project_id=project_id, # type: ignore[arg-type]
183
+ repo_root=repo_root_abs,
184
+ components=components,
185
+ path_block_list=block_list,
186
+ otlp_endpoint=otlp_endpoint,
187
+ otlp_headers=otlp_headers,
188
+ export_interval_ms=export_interval_ms,
189
+ max_call_sites=max_call_sites,
190
+ prompt_analysis_sample_rate=sample_rate,
191
+ )
192
+
193
+
194
+ def _normalize_block_entry(entry: str) -> str:
195
+ stripped = entry.strip().strip("/")
196
+ return stripped
197
+
198
+
199
+ def _parse_int(raw: str | None, *, default: int) -> int:
200
+ if raw is None or not raw.strip():
201
+ return default
202
+ try:
203
+ return int(raw)
204
+ except ValueError:
205
+ logger.warning(
206
+ "frugal-metrics: could not parse int env var value %r, using default %d",
207
+ raw,
208
+ default,
209
+ )
210
+ return default
211
+
212
+
213
+ def _parse_float(raw: str | None, *, default: float) -> float:
214
+ if raw is None or not raw.strip():
215
+ return default
216
+ try:
217
+ return float(raw)
218
+ except ValueError:
219
+ logger.warning(
220
+ "frugal-metrics: could not parse float env var value %r, using default %s",
221
+ raw,
222
+ default,
223
+ )
224
+ return default
225
+
226
+
227
+ def reset_warning_state_for_tests() -> None:
228
+ """Test-only hook to re-arm the one-shot warning."""
229
+ global _warned_once
230
+ with _warned_lock:
231
+ _warned_once = False