ganivra 0.1.0__tar.gz

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,16 @@
1
+ .DS_Store
2
+ .env
3
+ .env.deploy
4
+ .venv/
5
+ __pycache__/
6
+ *.py[cod]
7
+ .pytest_cache/
8
+ *.db
9
+ *.egg-info/
10
+ .release-venv/
11
+ sdk/dist/
12
+ sdk/build/
13
+ frontend/node_modules/
14
+ frontend/.next/
15
+ frontend/dist/
16
+ frontend/.wrangler/
ganivra-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ganivra
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
ganivra-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: ganivra
3
+ Version: 0.1.0
4
+ Summary: AI execution cost and unit-economics telemetry for the OpenAI Python SDK
5
+ Project-URL: Homepage, https://ganivra.com
6
+ Project-URL: Documentation, https://ganivra.com
7
+ Author: Ganivra
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: ai,cost,llm,observability,openai,unit-economics
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: openai>=1.50
22
+ Provides-Extra: release
23
+ Requires-Dist: build>=1.2; extra == 'release'
24
+ Requires-Dist: twine>=6.0; extra == 'release'
25
+ Provides-Extra: test
26
+ Requires-Dist: pytest>=8.0; extra == 'test'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # Ganivra Python SDK
30
+
31
+ Ganivra measures the cost and unit economics of AI executions without proxying
32
+ model traffic or storing prompts and responses.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ python -m pip install ganivra
38
+ ```
39
+
40
+ Ganivra requires Python 3.10 or newer and currently instruments synchronous
41
+ OpenAI Responses API calls.
42
+
43
+ ## Initialize before creating the OpenAI client
44
+
45
+ ```python
46
+ import ganivra
47
+ from openai import OpenAI
48
+
49
+ ganivra.init(api_key="gv_live_your_workspace_key")
50
+ client = OpenAI()
51
+
52
+ response = client.responses.create(
53
+ model="gpt-5",
54
+ input="Explain our refund policy simply.",
55
+ )
56
+ ```
57
+
58
+ Initialization automatically captures model, input/output/cached token counts,
59
+ latency, status, error type, and environment. Ganivra creates execution and step
60
+ IDs and exports the metadata on a bounded background queue.
61
+
62
+ ## Add business context for unit economics
63
+
64
+ ```python
65
+ with ganivra.trace(
66
+ workflow="support_answer",
67
+ feature="support_chatbot",
68
+ customer_id="acme",
69
+ prompt_id="support_answer",
70
+ prompt_version="v1",
71
+ revenue_usd=0.10,
72
+ ):
73
+ response = client.responses.create(
74
+ model="gpt-5",
75
+ input="Explain our refund policy simply.",
76
+ )
77
+ ```
78
+
79
+ Trace metadata is optional. It enables customer, feature, workflow, prompt, and
80
+ margin reporting. Use pseudonymous identifiers where appropriate.
81
+
82
+ ## Configuration
83
+
84
+ ```python
85
+ ganivra.init(
86
+ api_key="gv_live_your_workspace_key",
87
+ endpoint="https://api.ganivra.com",
88
+ environment="production",
89
+ queue_size=1000,
90
+ batch_size=20,
91
+ timeout_seconds=2.0,
92
+ max_retries=2,
93
+ )
94
+ ```
95
+
96
+ For local Ganivra development, set `endpoint="http://127.0.0.1:8000"`.
97
+ Call `ganivra.flush()` in short-lived scripts when you want to wait briefly for
98
+ queued telemetry.
99
+
100
+ ## Privacy and failure behavior
101
+
102
+ - OpenAI requests continue going directly from your application to OpenAI.
103
+ - Prompt and response bodies are never collected.
104
+ - OpenAI API keys are never collected.
105
+ - Telemetry failures never change the OpenAI result or exception your code sees.
106
+ - A full queue or unavailable Ganivra endpoint drops telemetry instead of
107
+ blocking the application.
108
+
109
+ Prefer the language-independent REST API when automatic Python instrumentation
110
+ is not appropriate. The canonical API contract is documented at
111
+ `docs/API.md` in the Ganivra project.
@@ -0,0 +1,83 @@
1
+ # Ganivra Python SDK
2
+
3
+ Ganivra measures the cost and unit economics of AI executions without proxying
4
+ model traffic or storing prompts and responses.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ python -m pip install ganivra
10
+ ```
11
+
12
+ Ganivra requires Python 3.10 or newer and currently instruments synchronous
13
+ OpenAI Responses API calls.
14
+
15
+ ## Initialize before creating the OpenAI client
16
+
17
+ ```python
18
+ import ganivra
19
+ from openai import OpenAI
20
+
21
+ ganivra.init(api_key="gv_live_your_workspace_key")
22
+ client = OpenAI()
23
+
24
+ response = client.responses.create(
25
+ model="gpt-5",
26
+ input="Explain our refund policy simply.",
27
+ )
28
+ ```
29
+
30
+ Initialization automatically captures model, input/output/cached token counts,
31
+ latency, status, error type, and environment. Ganivra creates execution and step
32
+ IDs and exports the metadata on a bounded background queue.
33
+
34
+ ## Add business context for unit economics
35
+
36
+ ```python
37
+ with ganivra.trace(
38
+ workflow="support_answer",
39
+ feature="support_chatbot",
40
+ customer_id="acme",
41
+ prompt_id="support_answer",
42
+ prompt_version="v1",
43
+ revenue_usd=0.10,
44
+ ):
45
+ response = client.responses.create(
46
+ model="gpt-5",
47
+ input="Explain our refund policy simply.",
48
+ )
49
+ ```
50
+
51
+ Trace metadata is optional. It enables customer, feature, workflow, prompt, and
52
+ margin reporting. Use pseudonymous identifiers where appropriate.
53
+
54
+ ## Configuration
55
+
56
+ ```python
57
+ ganivra.init(
58
+ api_key="gv_live_your_workspace_key",
59
+ endpoint="https://api.ganivra.com",
60
+ environment="production",
61
+ queue_size=1000,
62
+ batch_size=20,
63
+ timeout_seconds=2.0,
64
+ max_retries=2,
65
+ )
66
+ ```
67
+
68
+ For local Ganivra development, set `endpoint="http://127.0.0.1:8000"`.
69
+ Call `ganivra.flush()` in short-lived scripts when you want to wait briefly for
70
+ queued telemetry.
71
+
72
+ ## Privacy and failure behavior
73
+
74
+ - OpenAI requests continue going directly from your application to OpenAI.
75
+ - Prompt and response bodies are never collected.
76
+ - OpenAI API keys are never collected.
77
+ - Telemetry failures never change the OpenAI result or exception your code sees.
78
+ - A full queue or unavailable Ganivra endpoint drops telemetry instead of
79
+ blocking the application.
80
+
81
+ Prefer the language-independent REST API when automatic Python instrumentation
82
+ is not appropriate. The canonical API contract is documented at
83
+ `docs/API.md` in the Ganivra project.
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ import atexit
4
+ import threading
5
+ from typing import Any
6
+
7
+ from .config import Config
8
+ from .context import trace
9
+ from .instrumentation import instrument_openai
10
+ from .sender import BackgroundSender
11
+
12
+ __all__ = ["init", "trace", "flush"]
13
+ __version__ = "0.1.0"
14
+
15
+ _lock = threading.Lock()
16
+ _config: Config | None = None
17
+ _sender: BackgroundSender | None = None
18
+
19
+
20
+ def init(
21
+ *,
22
+ api_key: str,
23
+ endpoint: str = "https://api.ganivra.com",
24
+ environment: str = "production",
25
+ queue_size: int = 1_000,
26
+ batch_size: int = 20,
27
+ timeout_seconds: float = 2.0,
28
+ max_retries: int = 2,
29
+ ) -> None:
30
+ """Initialize best-effort telemetry and instrument OpenAI once per process."""
31
+
32
+ global _config, _sender
33
+ config = Config(
34
+ api_key=api_key,
35
+ endpoint=endpoint,
36
+ environment=environment,
37
+ queue_size=queue_size,
38
+ batch_size=batch_size,
39
+ timeout_seconds=timeout_seconds,
40
+ max_retries=max_retries,
41
+ )
42
+ with _lock:
43
+ previous = _sender
44
+ _config = config
45
+ _sender = BackgroundSender(config)
46
+ instrument_openai(_get_environment, _enqueue)
47
+ if previous is not None:
48
+ previous.shutdown(timeout=0.2)
49
+
50
+
51
+ def _get_environment() -> str:
52
+ return _config.environment if _config else "production"
53
+
54
+
55
+ def _enqueue(event: dict[str, Any]) -> None:
56
+ sender = _sender
57
+ if sender is not None:
58
+ sender.enqueue(event)
59
+
60
+
61
+ def flush(timeout: float = 2.0) -> bool:
62
+ """Best-effort wait for queued telemetry, primarily useful in short scripts."""
63
+
64
+ sender = _sender
65
+ return True if sender is None else sender.flush(timeout)
66
+
67
+
68
+ def _shutdown() -> None:
69
+ sender = _sender
70
+ if sender is not None:
71
+ sender.shutdown(timeout=0.5)
72
+
73
+
74
+ def _state_for_testing() -> tuple[Config | None, BackgroundSender | None]:
75
+ return _config, _sender
76
+
77
+
78
+ atexit.register(_shutdown)
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from urllib.parse import urlparse
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class Config:
9
+ api_key: str
10
+ endpoint: str = "https://api.ganivra.com"
11
+ environment: str = "production"
12
+ queue_size: int = 1_000
13
+ batch_size: int = 20
14
+ timeout_seconds: float = 2.0
15
+ max_retries: int = 2
16
+
17
+ def __post_init__(self) -> None:
18
+ if not self.api_key.strip():
19
+ raise ValueError("api_key must not be empty")
20
+ parsed = urlparse(self.endpoint)
21
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
22
+ raise ValueError("endpoint must be an http(s) URL")
23
+ if not self.environment.strip():
24
+ raise ValueError("environment must not be empty")
25
+ if self.queue_size < 1 or self.batch_size < 1:
26
+ raise ValueError("queue_size and batch_size must be positive")
27
+ if self.timeout_seconds <= 0 or self.max_retries < 0:
28
+ raise ValueError("invalid timeout or retry configuration")
29
+
30
+ @property
31
+ def events_url(self) -> str:
32
+ endpoint = self.endpoint.rstrip("/")
33
+ if endpoint.endswith("/v1/events"):
34
+ return endpoint
35
+ return f"{endpoint}/v1/events"
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import contextmanager
4
+ from contextvars import ContextVar
5
+ from dataclasses import dataclass
6
+ from decimal import Decimal
7
+ from typing import Iterator
8
+ from uuid import uuid4
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class TraceContext:
13
+ execution_id: str
14
+ workflow: str | None = None
15
+ feature: str | None = None
16
+ customer_id: str | None = None
17
+ conversation_id: str | None = None
18
+ user_id: str | None = None
19
+ prompt_id: str | None = None
20
+ prompt_version: str | None = None
21
+ revenue_usd: Decimal | float | None = None
22
+
23
+ def attributes(self) -> dict[str, str | float]:
24
+ return {
25
+ key: value
26
+ for key, value in {
27
+ "workflow": self.workflow,
28
+ "feature": self.feature,
29
+ "customer_id": self.customer_id,
30
+ "conversation_id": self.conversation_id,
31
+ "user_id": self.user_id,
32
+ "prompt_id": self.prompt_id,
33
+ "prompt_version": self.prompt_version,
34
+ "revenue_usd": float(self.revenue_usd) if self.revenue_usd is not None else None,
35
+ }.items()
36
+ if value is not None
37
+ }
38
+
39
+
40
+ _current_trace: ContextVar[TraceContext | None] = ContextVar(
41
+ "ganivra_trace", default=None
42
+ )
43
+
44
+
45
+ def current_trace() -> TraceContext | None:
46
+ return _current_trace.get()
47
+
48
+
49
+ def new_id(prefix: str) -> str:
50
+ return f"{prefix}_{uuid4().hex}"
51
+
52
+
53
+ @contextmanager
54
+ def trace(
55
+ *,
56
+ workflow: str | None = None,
57
+ feature: str | None = None,
58
+ customer_id: str | None = None,
59
+ conversation_id: str | None = None,
60
+ user_id: str | None = None,
61
+ prompt_id: str | None = None,
62
+ prompt_version: str | None = None,
63
+ revenue_usd: Decimal | float | None = None,
64
+ execution_id: str | None = None,
65
+ ) -> Iterator[TraceContext]:
66
+ parent = current_trace()
67
+ context = TraceContext(
68
+ execution_id=execution_id or new_id("exec"),
69
+ workflow=workflow if workflow is not None else getattr(parent, "workflow", None),
70
+ feature=feature if feature is not None else getattr(parent, "feature", None),
71
+ customer_id=(
72
+ customer_id
73
+ if customer_id is not None
74
+ else getattr(parent, "customer_id", None)
75
+ ),
76
+ conversation_id=(
77
+ conversation_id
78
+ if conversation_id is not None
79
+ else getattr(parent, "conversation_id", None)
80
+ ),
81
+ user_id=user_id if user_id is not None else getattr(parent, "user_id", None),
82
+ prompt_id=(
83
+ prompt_id if prompt_id is not None else getattr(parent, "prompt_id", None)
84
+ ),
85
+ prompt_version=(
86
+ prompt_version
87
+ if prompt_version is not None
88
+ else getattr(parent, "prompt_version", None)
89
+ ),
90
+ revenue_usd=(
91
+ revenue_usd
92
+ if revenue_usd is not None
93
+ else getattr(parent, "revenue_usd", None)
94
+ ),
95
+ )
96
+ token = _current_trace.set(context)
97
+ try:
98
+ yield context
99
+ finally:
100
+ _current_trace.reset(token)
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import importlib
5
+ import time
6
+ from datetime import datetime, timezone
7
+ from typing import Any, Callable
8
+
9
+ from .context import current_trace, new_id
10
+
11
+
12
+ def _value(obj: Any, name: str, default: Any = None) -> Any:
13
+ if obj is None:
14
+ return default
15
+ if isinstance(obj, dict):
16
+ return obj.get(name, default)
17
+ return getattr(obj, name, default)
18
+
19
+
20
+ def extract_usage(response: Any) -> dict[str, int]:
21
+ usage = _value(response, "usage")
22
+ input_tokens = int(_value(usage, "input_tokens", 0) or 0)
23
+ output_tokens = int(_value(usage, "output_tokens", 0) or 0)
24
+ details = _value(usage, "input_tokens_details")
25
+ cached_tokens = int(_value(details, "cached_tokens", 0) or 0)
26
+ total_tokens = int(
27
+ _value(usage, "total_tokens", input_tokens + output_tokens)
28
+ or input_tokens + output_tokens
29
+ )
30
+ return {
31
+ "input_tokens": input_tokens,
32
+ "output_tokens": output_tokens,
33
+ "cached_tokens": cached_tokens,
34
+ "total_tokens": total_tokens,
35
+ }
36
+
37
+
38
+ def build_event(
39
+ *,
40
+ response: Any,
41
+ requested_model: str | None,
42
+ latency_ms: float,
43
+ environment: str,
44
+ error: BaseException | None = None,
45
+ ) -> dict[str, Any]:
46
+ active_trace = current_trace()
47
+ usage = extract_usage(response) if error is None else extract_usage(None)
48
+ response_model = _value(response, "model")
49
+ return {
50
+ "event_id": new_id("evt"),
51
+ "execution_id": (
52
+ active_trace.execution_id if active_trace else new_id("exec")
53
+ ),
54
+ "step_id": new_id("step"),
55
+ "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
56
+ "provider": "openai",
57
+ "model": str(response_model or requested_model or "unknown"),
58
+ "operation": "responses.create",
59
+ **usage,
60
+ "latency_ms": round(max(latency_ms, 0), 3),
61
+ "status": "error" if error else "success",
62
+ "error_type": type(error).__name__ if error else None,
63
+ "environment": environment,
64
+ "attributes": active_trace.attributes() if active_trace else {},
65
+ }
66
+
67
+
68
+ def _responses_class() -> type[Any] | None:
69
+ for module_name in (
70
+ "openai.resources.responses",
71
+ "openai.resources.responses.responses",
72
+ ):
73
+ try:
74
+ module = importlib.import_module(module_name)
75
+ candidate = getattr(module, "Responses", None)
76
+ if candidate is not None:
77
+ return candidate
78
+ except Exception:
79
+ continue
80
+ return None
81
+
82
+
83
+ def instrument_openai(
84
+ get_environment: Callable[[], str],
85
+ enqueue: Callable[[dict[str, Any]], None],
86
+ ) -> bool:
87
+ responses_class = _responses_class()
88
+ if responses_class is None:
89
+ return False
90
+
91
+ original = getattr(responses_class, "create", None)
92
+ if original is None:
93
+ return False
94
+ if getattr(original, "_ganivra_instrumented", False):
95
+ return True
96
+
97
+ @functools.wraps(original)
98
+ def wrapped(self: Any, *args: Any, **kwargs: Any) -> Any:
99
+ started = time.perf_counter()
100
+ requested_model = kwargs.get("model")
101
+ try:
102
+ response = original(self, *args, **kwargs)
103
+ except BaseException as error:
104
+ try:
105
+ enqueue(
106
+ build_event(
107
+ response=None,
108
+ requested_model=requested_model,
109
+ latency_ms=(time.perf_counter() - started) * 1_000,
110
+ environment=get_environment(),
111
+ error=error,
112
+ )
113
+ )
114
+ except Exception:
115
+ pass
116
+ raise
117
+
118
+ try:
119
+ enqueue(
120
+ build_event(
121
+ response=response,
122
+ requested_model=requested_model,
123
+ latency_ms=(time.perf_counter() - started) * 1_000,
124
+ environment=get_environment(),
125
+ )
126
+ )
127
+ except Exception:
128
+ pass
129
+ return response
130
+
131
+ setattr(wrapped, "_ganivra_instrumented", True)
132
+ setattr(wrapped, "_ganivra_original", original)
133
+ setattr(responses_class, "create", wrapped)
134
+ return True
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import queue
5
+ import threading
6
+ import time
7
+ from dataclasses import dataclass
8
+ from importlib.metadata import PackageNotFoundError, version
9
+ from typing import Any
10
+ from urllib.request import Request, urlopen
11
+
12
+ from .config import Config
13
+
14
+
15
+ def _user_agent() -> str:
16
+ try:
17
+ package_version = version("ganivra")
18
+ except PackageNotFoundError:
19
+ package_version = "development"
20
+ return f"ganivra-python/{package_version}"
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class SenderStats:
25
+ queued: int = 0
26
+ sent: int = 0
27
+ dropped: int = 0
28
+ failed_batches: int = 0
29
+
30
+
31
+ class BackgroundSender:
32
+ """Bounded, best-effort delivery that never raises on the caller thread."""
33
+
34
+ def __init__(self, config: Config) -> None:
35
+ self.config = config
36
+ self.queue: queue.Queue[dict[str, Any]] = queue.Queue(config.queue_size)
37
+ self.stats = SenderStats()
38
+ self._stop = threading.Event()
39
+ self._thread = threading.Thread(
40
+ target=self._run, name="ganivra-telemetry", daemon=True
41
+ )
42
+ self._thread.start()
43
+
44
+ def enqueue(self, event: dict[str, Any]) -> None:
45
+ try:
46
+ self.queue.put_nowait(event)
47
+ self.stats.queued += 1
48
+ except Exception:
49
+ self.stats.dropped += 1
50
+
51
+ def flush(self, timeout: float = 2.0) -> bool:
52
+ deadline = time.monotonic() + max(timeout, 0)
53
+ while self.queue.unfinished_tasks and time.monotonic() < deadline:
54
+ time.sleep(0.005)
55
+ return self.queue.unfinished_tasks == 0
56
+
57
+ def shutdown(self, timeout: float = 1.0) -> None:
58
+ self._stop.set()
59
+ self.flush(timeout)
60
+ self._thread.join(timeout=max(timeout, 0))
61
+
62
+ def _run(self) -> None:
63
+ while not self._stop.is_set() or not self.queue.empty():
64
+ try:
65
+ first = self.queue.get(timeout=0.1)
66
+ except queue.Empty:
67
+ continue
68
+ batch = [first]
69
+ while len(batch) < self.config.batch_size:
70
+ try:
71
+ batch.append(self.queue.get_nowait())
72
+ except queue.Empty:
73
+ break
74
+
75
+ delivered = False
76
+ try:
77
+ delivered = self._deliver(batch)
78
+ except Exception:
79
+ delivered = False
80
+ finally:
81
+ for _ in batch:
82
+ self.queue.task_done()
83
+
84
+ if delivered:
85
+ self.stats.sent += len(batch)
86
+ else:
87
+ self.stats.dropped += len(batch)
88
+ self.stats.failed_batches += 1
89
+
90
+ def _deliver(self, events: list[dict[str, Any]]) -> bool:
91
+ payload = json.dumps({"events": events}, separators=(",", ":")).encode()
92
+ request = Request(
93
+ self.config.events_url,
94
+ data=payload,
95
+ method="POST",
96
+ headers={
97
+ "Authorization": f"Bearer {self.config.api_key}",
98
+ "Content-Type": "application/json",
99
+ "User-Agent": _user_agent(),
100
+ },
101
+ )
102
+ for attempt in range(self.config.max_retries + 1):
103
+ try:
104
+ with urlopen(request, timeout=self.config.timeout_seconds) as response:
105
+ if 200 <= response.status < 300:
106
+ return True
107
+ except Exception:
108
+ pass
109
+ if attempt < self.config.max_retries:
110
+ time.sleep(0.1 * (2**attempt))
111
+ return False
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.26"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ganivra"
7
+ version = "0.1.0"
8
+ description = "AI execution cost and unit-economics telemetry for the OpenAI Python SDK"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Ganivra" }]
14
+ keywords = ["ai", "llm", "openai", "observability", "cost", "unit-economics"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Typing :: Typed",
25
+ ]
26
+ dependencies = ["openai>=1.50"]
27
+
28
+ [project.urls]
29
+ Homepage = "https://ganivra.com"
30
+ Documentation = "https://ganivra.com"
31
+
32
+ [project.optional-dependencies]
33
+ test = ["pytest>=8.0"]
34
+ release = ["build>=1.2", "twine>=6.0"]
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["ganivra"]
41
+
42
+ [tool.hatch.build.targets.sdist]
43
+ include = ["ganivra", "tests", "README.md", "LICENSE", "pyproject.toml"]
@@ -0,0 +1,182 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import threading
5
+ from types import SimpleNamespace
6
+
7
+ import pytest
8
+
9
+ import ganivra
10
+ from ganivra.config import Config
11
+ from ganivra.context import trace
12
+ from ganivra.instrumentation import build_event, extract_usage, instrument_openai
13
+ from ganivra.sender import BackgroundSender
14
+
15
+
16
+ def test_initialization_validates_configuration(monkeypatch: pytest.MonkeyPatch) -> None:
17
+ created: list[Config] = []
18
+
19
+ class FakeSender:
20
+ def __init__(self, config: Config) -> None:
21
+ created.append(config)
22
+
23
+ def shutdown(self, timeout: float) -> None:
24
+ pass
25
+
26
+ def flush(self, timeout: float) -> bool:
27
+ return True
28
+
29
+ monkeypatch.setattr(ganivra, "BackgroundSender", FakeSender)
30
+ monkeypatch.setattr(ganivra, "instrument_openai", lambda *_: True)
31
+ ganivra.init(api_key="test_key", endpoint="http://localhost:8000", environment="test")
32
+
33
+ assert created[-1].events_url == "http://localhost:8000/v1/events"
34
+ assert created[-1].environment == "test"
35
+ with pytest.raises(ValueError):
36
+ ganivra.init(api_key="", endpoint="http://localhost:8000")
37
+
38
+
39
+ def test_extracts_response_usage_including_cached_tokens() -> None:
40
+ response = SimpleNamespace(
41
+ usage=SimpleNamespace(
42
+ input_tokens=120,
43
+ output_tokens=30,
44
+ total_tokens=150,
45
+ input_tokens_details=SimpleNamespace(cached_tokens=80),
46
+ )
47
+ )
48
+ assert extract_usage(response) == {
49
+ "input_tokens": 120,
50
+ "output_tokens": 30,
51
+ "cached_tokens": 80,
52
+ "total_tokens": 150,
53
+ }
54
+
55
+
56
+ def test_trace_context_propagates_and_reuses_execution_id() -> None:
57
+ with trace(
58
+ workflow="support_answer",
59
+ feature="chat",
60
+ customer_id="acme",
61
+ prompt_id="support_answer",
62
+ prompt_version="v17",
63
+ revenue_usd=0.10,
64
+ ) as ctx:
65
+ first = build_event(
66
+ response={"model": "gpt-5", "usage": {}},
67
+ requested_model=None,
68
+ latency_ms=1,
69
+ environment="test",
70
+ )
71
+ second = build_event(
72
+ response={"model": "gpt-5", "usage": {}},
73
+ requested_model=None,
74
+ latency_ms=1,
75
+ environment="test",
76
+ )
77
+
78
+ assert first["execution_id"] == second["execution_id"] == ctx.execution_id
79
+ assert first["attributes"] == {
80
+ "workflow": "support_answer",
81
+ "feature": "chat",
82
+ "customer_id": "acme",
83
+ "prompt_id": "support_answer",
84
+ "prompt_version": "v17",
85
+ "revenue_usd": 0.10,
86
+ }
87
+ assert first["step_id"] != second["step_id"]
88
+
89
+
90
+ def test_call_without_trace_gets_an_execution_id() -> None:
91
+ event = build_event(
92
+ response={"model": "gpt-5", "usage": {}},
93
+ requested_model=None,
94
+ latency_ms=1,
95
+ environment="test",
96
+ )
97
+ assert event["execution_id"].startswith("exec_")
98
+
99
+
100
+ def test_openai_instrumentation_observes_success_and_error(
101
+ monkeypatch: pytest.MonkeyPatch,
102
+ ) -> None:
103
+ events: list[dict] = []
104
+
105
+ class Responses:
106
+ def create(self, **kwargs):
107
+ if kwargs.get("model") == "broken":
108
+ raise RuntimeError("provider failed")
109
+ return SimpleNamespace(
110
+ model=kwargs["model"],
111
+ usage=SimpleNamespace(input_tokens=10, output_tokens=2, total_tokens=12),
112
+ )
113
+
114
+ monkeypatch.setattr("ganivra.instrumentation._responses_class", lambda: Responses)
115
+ assert instrument_openai(lambda: "test", events.append)
116
+
117
+ response = Responses().create(model="gpt-5", input="secret prompt")
118
+ assert response.model == "gpt-5"
119
+ assert events[0]["operation"] == "responses.create"
120
+ assert events[0]["input_tokens"] == 10
121
+ assert "secret prompt" not in json.dumps(events[0])
122
+
123
+ with pytest.raises(RuntimeError, match="provider failed"):
124
+ Responses().create(model="broken", input="also secret")
125
+ assert events[1]["status"] == "error"
126
+ assert events[1]["error_type"] == "RuntimeError"
127
+
128
+
129
+ def test_telemetry_failure_never_changes_openai_result(
130
+ monkeypatch: pytest.MonkeyPatch,
131
+ ) -> None:
132
+ expected = object()
133
+
134
+ class Responses:
135
+ def create(self, **kwargs):
136
+ return expected
137
+
138
+ def unavailable(_: dict) -> None:
139
+ raise OSError("telemetry unavailable")
140
+
141
+ monkeypatch.setattr("ganivra.instrumentation._responses_class", lambda: Responses)
142
+ instrument_openai(lambda: "test", unavailable)
143
+ assert Responses().create(model="gpt-5", input="hello") is expected
144
+
145
+
146
+ def test_background_sender_posts_batches(monkeypatch: pytest.MonkeyPatch) -> None:
147
+ received: list[tuple[str | None, dict]] = []
148
+ ready = threading.Event()
149
+
150
+ class Response:
151
+ status = 202
152
+
153
+ def __enter__(self):
154
+ return self
155
+
156
+ def __exit__(self, *_):
157
+ return False
158
+
159
+ def fake_urlopen(request, timeout):
160
+ received.append(
161
+ (request.get_header("Authorization"), json.loads(request.data.decode()))
162
+ )
163
+ ready.set()
164
+ return Response()
165
+
166
+ monkeypatch.setattr("ganivra.sender.urlopen", fake_urlopen)
167
+ sender = BackgroundSender(
168
+ Config(
169
+ api_key="workspace_secret",
170
+ endpoint="http://telemetry.test",
171
+ max_retries=0,
172
+ )
173
+ )
174
+ try:
175
+ sender.enqueue({"event_id": "evt_1"})
176
+ assert ready.wait(2)
177
+ assert sender.flush(1)
178
+ assert received == [
179
+ ("Bearer workspace_secret", {"events": [{"event_id": "evt_1"}]})
180
+ ]
181
+ finally:
182
+ sender.shutdown()