metergraph 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.
metergraph/_config.py ADDED
@@ -0,0 +1,144 @@
1
+ """ETag-aware, fail-open canary configuration polling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import threading
8
+ import time
9
+ import urllib.error
10
+ import urllib.request
11
+ from collections.abc import Mapping
12
+ from typing import Any
13
+
14
+
15
+ log = logging.getLogger("metergraph")
16
+
17
+
18
+ def _bucket(seed: str) -> float:
19
+ """FNV-1a/64 over UTF-8; kept byte-identical to the TypeScript SDK."""
20
+ value = 0xCBF29CE484222325
21
+ for byte in seed.encode():
22
+ value ^= byte
23
+ value = (value * 0x100000001B3) & 0xFFFF_FFFF_FFFF_FFFF
24
+ return value / 2**64 * 100
25
+
26
+
27
+ def choose_model(
28
+ route: str,
29
+ default: str,
30
+ session_key: str | None,
31
+ route_config: Mapping[str, Any] | None,
32
+ ) -> str:
33
+ """Deterministically assign a session to a canary arm."""
34
+ if not route_config or route_config.get("enabled", True) is False:
35
+ return default
36
+ incumbent = str(route_config.get("incumbent_model") or default)
37
+ challenger = route_config.get("challenger_model") or route_config.get("model")
38
+ if not challenger or not session_key:
39
+ return incumbent
40
+ try:
41
+ percent = float(
42
+ route_config.get(
43
+ "traffic_percent",
44
+ route_config.get("percentage", route_config.get("allocation", 0)),
45
+ )
46
+ )
47
+ except (TypeError, ValueError):
48
+ return incumbent
49
+ percent = max(0.0, min(percent, 100.0))
50
+ seed = ":".join(
51
+ (
52
+ route,
53
+ str(route_config.get("version", "")),
54
+ str(route_config.get("salt", "")),
55
+ str(session_key),
56
+ )
57
+ )
58
+ return str(challenger) if _bucket(seed) < percent else incumbent
59
+
60
+
61
+ class ConfigPoller:
62
+ def __init__(
63
+ self,
64
+ token: str,
65
+ base_url: str,
66
+ *,
67
+ poll_seconds: float = 30.0,
68
+ hard_ttl_seconds: float = 120.0,
69
+ ) -> None:
70
+ self._token = token
71
+ self._url = f"{base_url.rstrip('/')}/v1/config"
72
+ self._poll_seconds = max(1.0, poll_seconds)
73
+ self._hard_ttl = max(self._poll_seconds, hard_ttl_seconds)
74
+ self._etag: str | None = None
75
+ self._routes: dict[str, dict[str, Any]] = {}
76
+ self._last_success = 0.0
77
+ self._lock = threading.Lock()
78
+ self._stop = threading.Event()
79
+ self._thread = threading.Thread(
80
+ target=self._run, name="metergraph-config", daemon=True
81
+ )
82
+ self._thread.start()
83
+
84
+ def _run(self) -> None:
85
+ while not self._stop.is_set():
86
+ self.poll_once()
87
+ self._stop.wait(self._poll_seconds)
88
+
89
+ def poll_once(self) -> bool:
90
+ headers = {
91
+ "Authorization": f"Bearer {self._token}",
92
+ "Accept": "application/json",
93
+ }
94
+ if self._etag:
95
+ headers["If-None-Match"] = self._etag
96
+ request = urllib.request.Request(self._url, headers=headers)
97
+ try:
98
+ with urllib.request.urlopen(request, timeout=5) as response:
99
+ if response.status == 304:
100
+ with self._lock:
101
+ self._last_success = time.monotonic()
102
+ return True
103
+ doc = json.loads(response.read())
104
+ routes = doc.get("routes", {}) if isinstance(doc, dict) else {}
105
+ if not isinstance(routes, dict):
106
+ return False
107
+ with self._lock:
108
+ self._routes = {
109
+ str(k): dict(v)
110
+ for k, v in routes.items()
111
+ if isinstance(v, dict)
112
+ }
113
+ self._etag = response.headers.get("ETag")
114
+ self._last_success = time.monotonic()
115
+ return True
116
+ except urllib.error.HTTPError as exc:
117
+ # urllib raises for 304 even though it is a successful revalidation.
118
+ if exc.code == 304:
119
+ with self._lock:
120
+ self._last_success = time.monotonic()
121
+ return True
122
+ if exc.code in (401, 403):
123
+ log.warning(
124
+ "Metergraph config authentication failed; using default models"
125
+ )
126
+ self._stop.set()
127
+ return False
128
+ except Exception:
129
+ return False
130
+
131
+ def model_for(self, route: str, default: str, session_key: str | None) -> str:
132
+ with self._lock:
133
+ if (
134
+ not self._last_success
135
+ or time.monotonic() - self._last_success > self._hard_ttl
136
+ ):
137
+ return default
138
+ config = self._routes.get(route)
139
+ return choose_model(route, default, session_key, config)
140
+
141
+ def stop(self) -> None:
142
+ self._stop.set()
143
+ if threading.current_thread() is not self._thread:
144
+ self._thread.join(timeout=1)
metergraph/_context.py ADDED
@@ -0,0 +1,136 @@
1
+ """Ambient route/session metadata, safe across async call trees."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextvars
6
+ import functools
7
+ import inspect
8
+ from concurrent.futures import Executor
9
+ from dataclasses import dataclass, field, replace
10
+ from typing import Any, Callable, Mapping
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class CaptureContext:
15
+ route: str | None = None
16
+ session_id: str | None = None
17
+ tags: Mapping[str, str] = field(default_factory=dict)
18
+ unit_name: str | None = None
19
+ unit_count: float | None = None
20
+ capture_text: bool | None = None
21
+ func_name: str | None = None
22
+ func_module: str | None = None
23
+
24
+
25
+ _current: contextvars.ContextVar[CaptureContext] = contextvars.ContextVar(
26
+ "metergraph_context", default=CaptureContext()
27
+ )
28
+
29
+
30
+ def snapshot() -> CaptureContext:
31
+ return _current.get()
32
+
33
+
34
+ class route:
35
+ """Route context manager and sync/async decorator."""
36
+
37
+ def __init__(
38
+ self,
39
+ name: str,
40
+ *,
41
+ unit: str | None = None,
42
+ unit_count: float | None = None,
43
+ tags: Mapping[str, Any] | None = None,
44
+ capture_text: bool | None = None,
45
+ ) -> None:
46
+ self.name = str(name)
47
+ self.unit = str(unit) if unit is not None else None
48
+ self.unit_count = (
49
+ float(unit_count) if unit_count is not None else (1.0 if unit else None)
50
+ )
51
+ self.tags = {str(k): str(v) for k, v in (tags or {}).items()}
52
+ self.capture_text = (
53
+ bool(capture_text) if capture_text is not None else None
54
+ )
55
+ self._token: contextvars.Token[CaptureContext] | None = None
56
+
57
+ def __enter__(self) -> "route":
58
+ current = snapshot()
59
+ merged = {**current.tags, **self.tags}
60
+ self._token = _current.set(
61
+ replace(
62
+ current,
63
+ route=self.name,
64
+ tags=merged,
65
+ unit_name=self.unit if self.unit is not None else current.unit_name,
66
+ unit_count=self.unit_count
67
+ if self.unit is not None
68
+ else current.unit_count,
69
+ capture_text=self.capture_text
70
+ if self.capture_text is not None
71
+ else current.capture_text,
72
+ )
73
+ )
74
+ return self
75
+
76
+ def __exit__(self, exc_type, exc, tb) -> None:
77
+ if self._token is not None:
78
+ _current.reset(self._token)
79
+ self._token = None
80
+
81
+ def __call__(self, fn: Callable):
82
+ if inspect.iscoroutinefunction(fn):
83
+
84
+ @functools.wraps(fn)
85
+ async def async_wrapped(*args, **kwargs):
86
+ with type(self)(
87
+ self.name,
88
+ unit=self.unit,
89
+ unit_count=self.unit_count,
90
+ tags=self.tags,
91
+ capture_text=self.capture_text,
92
+ ):
93
+ return await fn(*args, **kwargs)
94
+
95
+ return async_wrapped
96
+
97
+ @functools.wraps(fn)
98
+ def wrapped(*args, **kwargs):
99
+ with type(self)(
100
+ self.name,
101
+ unit=self.unit,
102
+ unit_count=self.unit_count,
103
+ tags=self.tags,
104
+ capture_text=self.capture_text,
105
+ ):
106
+ return fn(*args, **kwargs)
107
+
108
+ return wrapped
109
+
110
+
111
+ def set_session(session_id: str | None) -> None:
112
+ _current.set(
113
+ replace(snapshot(), session_id=str(session_id) if session_id else None)
114
+ )
115
+
116
+
117
+ def set_tags(**tags: Any) -> None:
118
+ current = snapshot()
119
+ merged = {**current.tags, **{str(k): str(v) for k, v in tags.items()}}
120
+ _current.set(replace(current, tags=merged))
121
+
122
+
123
+ def wrap_executor(executor: Executor) -> Executor:
124
+ """Propagate the current context into executor submissions."""
125
+ if getattr(executor, "__metergraph__", False):
126
+ return executor
127
+ original_submit = executor.submit
128
+
129
+ @functools.wraps(original_submit)
130
+ def submit(fn, /, *args, **kwargs):
131
+ ctx = contextvars.copy_context()
132
+ return original_submit(ctx.run, fn, *args, **kwargs)
133
+
134
+ executor.submit = submit # type: ignore[method-assign]
135
+ executor.__metergraph__ = True # type: ignore[attr-defined]
136
+ return executor
@@ -0,0 +1,58 @@
1
+ """Stable, privacy-conscious request fingerprinting for untagged traffic."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+ from collections.abc import Mapping, Sequence
9
+ from typing import Any
10
+
11
+
12
+ _UUID = re.compile(r"\b[0-9a-f]{8}-[0-9a-f-]{27,}\b", re.I)
13
+ _EMAIL = re.compile(r"\b[^\s@]+@[^\s@]+\.[^\s@]+\b")
14
+ _URL = re.compile(r"\bhttps?://\S+")
15
+ _NUMBER = re.compile(r"(?<![A-Za-z])[-+]?\d+(?:\.\d+)?(?![A-Za-z])")
16
+ _LONG_TOKEN = re.compile(r"\b[A-Za-z0-9_-]{24,}\b")
17
+ _SENSITIVE_KEYS = {"api_key", "apikey", "authorization", "headers", "token", "secret"}
18
+
19
+
20
+ def _normalize_text(value: str) -> str:
21
+ value = _UUID.sub("<uuid>", value)
22
+ value = _EMAIL.sub("<email>", value)
23
+ value = _URL.sub("<url>", value)
24
+ value = _LONG_TOKEN.sub("<token>", value)
25
+ value = _NUMBER.sub("<n>", value)
26
+ return " ".join(value.split())
27
+
28
+
29
+ def scrub(value: Any) -> Any:
30
+ if isinstance(value, Mapping):
31
+ return {
32
+ str(k): scrub(v)
33
+ for k, v in value.items()
34
+ if str(k).lower() not in _SENSITIVE_KEYS
35
+ }
36
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
37
+ return [scrub(item) for item in value]
38
+ if isinstance(value, str):
39
+ return value
40
+ if isinstance(value, (int, float, bool)) or value is None:
41
+ return value
42
+ return repr(value)
43
+
44
+
45
+ def template_hash(request: Mapping[str, Any]) -> str:
46
+ def skeleton(value: Any) -> Any:
47
+ if isinstance(value, Mapping):
48
+ return {str(k): skeleton(v) for k, v in sorted(value.items())}
49
+ if isinstance(value, list):
50
+ return [skeleton(item) for item in value]
51
+ if isinstance(value, str):
52
+ return _normalize_text(value)
53
+ return value
54
+
55
+ encoded = json.dumps(
56
+ skeleton(scrub(request)), sort_keys=True, separators=(",", ":")
57
+ )
58
+ return hashlib.sha256(encoded.encode()).hexdigest()
metergraph/_track.py ADDED
@@ -0,0 +1,61 @@
1
+ """Explicit call-site attribution, overriding the default stack-walk."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextvars
6
+ import functools
7
+ import inspect
8
+ from dataclasses import replace
9
+ from typing import Callable
10
+
11
+ from ._context import CaptureContext, _current, snapshot
12
+
13
+
14
+ class track:
15
+ """Attribution context manager and sync/async decorator."""
16
+
17
+ def __new__(
18
+ cls, name: Callable | str | None = None, *, module: str | None = None
19
+ ):
20
+ if callable(name):
21
+ instance = super().__new__(cls)
22
+ instance.__init__(module=module)
23
+ return instance(name)
24
+ return super().__new__(cls)
25
+
26
+ def __init__(
27
+ self, name: str | None = None, *, module: str | None = None
28
+ ) -> None:
29
+ self.name = str(name) if name is not None else None
30
+ self.module = str(module) if module is not None else None
31
+ self._token: contextvars.Token[CaptureContext] | None = None
32
+
33
+ def __enter__(self) -> "track":
34
+ self._token = _current.set(
35
+ replace(snapshot(), func_name=self.name, func_module=self.module)
36
+ )
37
+ return self
38
+
39
+ def __exit__(self, exc_type, exc, tb) -> None:
40
+ if self._token is not None:
41
+ _current.reset(self._token)
42
+ self._token = None
43
+
44
+ def __call__(self, fn: Callable):
45
+ name = self.name or f"{fn.__module__}:{fn.__qualname__}"
46
+ module = self.module or fn.__module__
47
+ if inspect.iscoroutinefunction(fn):
48
+
49
+ @functools.wraps(fn)
50
+ async def async_wrapped(*args, **kwargs):
51
+ with type(self)(name, module=module):
52
+ return await fn(*args, **kwargs)
53
+
54
+ return async_wrapped
55
+
56
+ @functools.wraps(fn)
57
+ def wrapped(*args, **kwargs):
58
+ with type(self)(name, module=module):
59
+ return fn(*args, **kwargs)
60
+
61
+ return wrapped
@@ -0,0 +1,171 @@
1
+ """Bounded, fire-and-forget HTTP transport using only the stdlib."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import gzip
6
+ import json
7
+ import logging
8
+ import os
9
+ import queue
10
+ import threading
11
+ import time
12
+ import urllib.error
13
+ import urllib.request
14
+ from typing import Any
15
+
16
+ from ._version import SDK_VERSION
17
+
18
+
19
+ log = logging.getLogger("metergraph")
20
+ MAX_BATCH_BYTES = 512 * 1024
21
+
22
+
23
+ class Writer:
24
+ def __init__(
25
+ self,
26
+ token: str,
27
+ base_url: str,
28
+ *,
29
+ queue_size: int = 2000,
30
+ batch_size: int = 100,
31
+ flush_seconds: float = 5.0,
32
+ ) -> None:
33
+ self._token = token
34
+ self._url = f"{base_url.rstrip('/')}/v1/ingest"
35
+ self._queue_size = max(1, queue_size)
36
+ self._batch_size = max(1, min(batch_size, 1000))
37
+ self._flush_seconds = max(0.05, flush_seconds)
38
+ self._queue: queue.Queue[dict[str, Any]] = queue.Queue(self._queue_size)
39
+ self._stop = threading.Event()
40
+ self._flush_now = threading.Event()
41
+ self._fatal = False
42
+ self._dropped = 0
43
+ self._errors = 0
44
+ self._backoff = 1.0
45
+ self._retry_at = 0.0
46
+ self._thread = self._new_thread()
47
+ self._thread.start()
48
+ if hasattr(os, "register_at_fork"):
49
+ os.register_at_fork(after_in_child=self._after_fork)
50
+
51
+ @property
52
+ def dropped(self) -> int:
53
+ return self._dropped
54
+
55
+ def _new_thread(self) -> threading.Thread:
56
+ return threading.Thread(target=self._run, name="metergraph-writer", daemon=True)
57
+
58
+ def _after_fork(self) -> None:
59
+ # Inherited Queue locks and threads are not safe in the child. Pending
60
+ # parent rows stay with the parent; the child starts a clean writer.
61
+ self._queue = queue.Queue(self._queue_size)
62
+ self._stop = threading.Event()
63
+ self._flush_now = threading.Event()
64
+ self._thread = self._new_thread()
65
+ self._thread.start()
66
+
67
+ def enqueue(self, row: dict[str, Any]) -> bool:
68
+ if self._fatal or self._stop.is_set():
69
+ self._dropped += 1
70
+ return False
71
+ try:
72
+ self._queue.put_nowait(row)
73
+ return True
74
+ except queue.Full:
75
+ self._dropped += 1
76
+ return False
77
+
78
+ def _run(self) -> None:
79
+ while not self._stop.is_set() or not self._queue.empty():
80
+ try:
81
+ first = self._queue.get(timeout=min(self._flush_seconds, 0.1))
82
+ except queue.Empty:
83
+ continue
84
+ batch = [first]
85
+ deadline = time.monotonic() + self._flush_seconds
86
+ while len(batch) < self._batch_size:
87
+ if self._flush_now.is_set():
88
+ break
89
+ try:
90
+ batch.append(self._queue.get_nowait())
91
+ except queue.Empty:
92
+ remaining = deadline - time.monotonic()
93
+ if remaining <= 0 or self._stop.is_set():
94
+ break
95
+ try:
96
+ batch.append(self._queue.get(timeout=min(remaining, 0.05)))
97
+ except queue.Empty:
98
+ continue
99
+ try:
100
+ self._deliver(batch)
101
+ finally:
102
+ self._flush_now.clear()
103
+ for _ in batch:
104
+ self._queue.task_done()
105
+
106
+ def _deliver(self, rows: list[dict[str, Any]]) -> bool:
107
+ if self._fatal or time.monotonic() < self._retry_at:
108
+ self._dropped += len(rows)
109
+ return False
110
+ meta = {"dropped": self._dropped, "transport_errors": self._errors}
111
+ body = json.dumps(
112
+ {"schema_version": 1, "rows": rows, "meta": meta},
113
+ separators=(",", ":"),
114
+ ).encode()
115
+ headers = {
116
+ "Authorization": f"Bearer {self._token}",
117
+ "Content-Type": "application/json",
118
+ "User-Agent": f"metergraph-python/{SDK_VERSION}",
119
+ }
120
+ if len(body) > 32 * 1024:
121
+ body = gzip.compress(body)
122
+ headers["Content-Encoding"] = "gzip"
123
+ if len(body) > MAX_BATCH_BYTES:
124
+ if len(rows) == 1:
125
+ self._dropped += 1
126
+ return False
127
+ midpoint = len(rows) // 2
128
+ left = self._deliver(rows[:midpoint])
129
+ right = self._deliver(rows[midpoint:])
130
+ return left and right
131
+ request = urllib.request.Request(
132
+ self._url, data=body, headers=headers, method="POST"
133
+ )
134
+ try:
135
+ with urllib.request.urlopen(request, timeout=5) as response:
136
+ if response.status != 202:
137
+ raise OSError(f"unexpected ingest status {response.status}")
138
+ self._backoff = 1.0
139
+ self._retry_at = 0.0
140
+ return True
141
+ except urllib.error.HTTPError as exc:
142
+ if exc.code in (401, 403):
143
+ self._fatal = True
144
+ log.warning(
145
+ "Metergraph authentication failed; capture disabled for this process"
146
+ )
147
+ else:
148
+ self._failed(len(rows))
149
+ return False
150
+ except Exception:
151
+ self._failed(len(rows))
152
+ return False
153
+
154
+ def _failed(self, count: int) -> None:
155
+ self._errors += 1
156
+ self._dropped += count
157
+ self._retry_at = time.monotonic() + self._backoff
158
+ self._backoff = min(self._backoff * 2, 60.0)
159
+
160
+ def flush(self, timeout: float = 3.0) -> bool:
161
+ deadline = time.monotonic() + max(0.0, timeout)
162
+ self._flush_now.set()
163
+ while self._queue.unfinished_tasks and time.monotonic() < deadline:
164
+ time.sleep(0.01)
165
+ return self._queue.unfinished_tasks == 0
166
+
167
+ def shutdown(self) -> None:
168
+ self.flush(3.0)
169
+ self._stop.set()
170
+ if threading.current_thread() is not self._thread:
171
+ self._thread.join(timeout=max(1.0, self._flush_seconds + 0.5))
metergraph/_version.py ADDED
@@ -0,0 +1,11 @@
1
+ """Installed SDK version, resolved once from package metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.metadata
6
+
7
+
8
+ try:
9
+ SDK_VERSION = importlib.metadata.version("metergraph")
10
+ except Exception:
11
+ SDK_VERSION = "0.1.0"
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: metergraph
3
+ Version: 0.1.0
4
+ Summary: Fire-and-forget LLM spend capture for Metergraph
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=8; extra == "dev"
10
+
11
+ # metergraph (Python)
12
+
13
+ Zero-runtime-dependency capture for OpenAI, Anthropic, and Gemini clients.
14
+ `wrap()` initializes capture from the environment, so setup is one line per
15
+ client; call `metergraph.init(...)` before the first `wrap()` only to pass
16
+ options in code.
17
+
18
+ ```python
19
+ import metergraph
20
+ from openai import OpenAI
21
+
22
+ # Anthropic() and google-genai's genai.Client() wrap the same way.
23
+ client = metergraph.wrap(OpenAI())
24
+ metergraph.set_session("ticket-123")
25
+
26
+ with metergraph.route("ticket-classifier", unit="answer", capture_text=True):
27
+ model = metergraph.model_for("ticket-classifier", default="gpt-4.1-mini")
28
+ client.chat.completions.create(model=model, messages=[...])
29
+
30
+ # Emit this after the user-visible task resolves. It shares the bounded async
31
+ # transport and contains no prompt or output content.
32
+ metergraph.record_outcome(
33
+ "ticket-classifier",
34
+ model=model,
35
+ task_completed=True,
36
+ feedback_score=1,
37
+ turns_to_resolution=2,
38
+ escalated=False,
39
+ )
40
+ ```
41
+
42
+ Configuration:
43
+
44
+ - `METERGRAPH_APP_TOKEN` — required bearer token
45
+ - `METERGRAPH_INGEST_URL` — optional override; defaults to the hosted HTTPS endpoint
46
+ - `METERGRAPH_CAPTURE_TEXT=1` — opt in to content capture globally; default is metadata-only
47
+ - `METERGRAPH_DISABLED=1` — process kill switch
48
+ - `METERGRAPH_QUEUE_SIZE`, `METERGRAPH_BATCH_SIZE`, `METERGRAPH_FLUSH_SECONDS`
49
+
50
+ Delivery is bounded and off the request path. Queue overflow or a collector
51
+ outage drops capture and increments internal counters; it never changes the
52
+ provider call. Each wire batch is bounded to 512 KiB after optional gzip.
53
+ `capture_text=True` or `False` on `route()` overrides the global content policy
54
+ for that route. Config reads are ETag-aware and fail open to the default model.
55
+ `record_outcome` requires a stable session ID and the model actually used so a
56
+ session-sticky canary can compare task completion and optional feedback,
57
+ turn-count, escalation, abandonment, edit-distance, and regeneration signals.
58
+
59
+ OpenAI Batch API output JSONL is captured per inference when a wrapped
60
+ `client.files.content()` / `retrieve_content()` result is read. Anthropic
61
+ message batches are captured per inference while iterating a wrapped
62
+ `client.messages.batches.results()` result. Run result consumption inside a
63
+ `route()` context so the asynchronous batch retains its product route. Batch
64
+ rows carry real per-result usage and the batch pricing flag; job-management
65
+ polls themselves are not miscounted as model calls.
66
+
67
+ ## Set up with an AI coding agent
68
+
69
+ Paste this into Claude Code, Codex, Cursor, or any coding agent inside the
70
+ codebase you want instrumented:
71
+
72
+ ```text
73
+ Instrument this codebase's LLM API costs with the `metergraph` PyPI package
74
+ (https://github.com/PioneerSquareLabs/metergraphsdk): pip install metergraph,
75
+ then wrap every OpenAI()/AsyncOpenAI(), Anthropic()/AsyncAnthropic(), and
76
+ genai.Client() construction in place, e.g. client = metergraph.wrap(OpenAI()).
77
+ wrap() returns the same client and initializes itself from the environment:
78
+ METERGRAPH_APP_TOKEN is required (capture is silently off without it) and
79
+ METERGRAPH_INGEST_URL is only for self-hosted servers. Add both to
80
+ .env.example, and never commit a real token. Capture is metadata-only (tokens,
81
+ latency, model, no prompt/completion content) and fail-open, so do not change
82
+ call sites, arguments, or error handling; sync, async, and streaming work
83
+ unchanged. Attribution to the calling function is automatic; optionally pin
84
+ stable names on key LLM-calling functions with @metergraph.track. On
85
+ serverless, call metergraph.flush() before the handler returns. When done,
86
+ list every client you wrapped and flag LLM calls made outside the official
87
+ openai / anthropic / google-genai SDKs, since those are not captured.
88
+ ```
@@ -0,0 +1,12 @@
1
+ metergraph/__init__.py,sha256=S7r5T2d92vW16oFXZe1snYzVbWcRny__sTIpxogZHs0,7242
2
+ metergraph/_capture.py,sha256=TLl45sI_7quu5KuJrqb-XH8GELthDjBN5t4u-TTksFQ,38265
3
+ metergraph/_config.py,sha256=6esfubFNAl-Sd9TGlI3WuDCA9iHZFLZfAYqN240pb-8,4820
4
+ metergraph/_context.py,sha256=csWDvHJD6jZbyPv_eRo6SMTl2AQr66tG0u7obbtnYOk,4182
5
+ metergraph/_template.py,sha256=KD5ClwacgrkBnXy7eoOECsZkOduGtiMHuDtrxE6lG7I,1921
6
+ metergraph/_track.py,sha256=kquEubKHOtjRlvvREooPebz0q-1NzXvRJv_w1NAZ7_w,1892
7
+ metergraph/_transport.py,sha256=KVx1ulAwY8chBI_TrPbflVw71idYXXoj3ro4THt6bQU,5921
8
+ metergraph/_version.py,sha256=M4KyFTC7UfwqnN6Sz9tqRIVDCN8lqnWhWmbAtJm4LR8,239
9
+ metergraph-0.1.0.dist-info/METADATA,sha256=cobBY3Xq9hMbLPtqeZxwKUodprtbBn9PsZgKBoBewrw,4022
10
+ metergraph-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
11
+ metergraph-0.1.0.dist-info/top_level.txt,sha256=g-B1703jmqYqbs8HfEd41NaGufAL-e95rhe2rU_iyzc,11
12
+ metergraph-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ metergraph