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.
- ganivra-0.1.0/.gitignore +16 -0
- ganivra-0.1.0/LICENSE +21 -0
- ganivra-0.1.0/PKG-INFO +111 -0
- ganivra-0.1.0/README.md +83 -0
- ganivra-0.1.0/ganivra/__init__.py +78 -0
- ganivra-0.1.0/ganivra/config.py +35 -0
- ganivra-0.1.0/ganivra/context.py +100 -0
- ganivra-0.1.0/ganivra/instrumentation.py +134 -0
- ganivra-0.1.0/ganivra/py.typed +1 -0
- ganivra-0.1.0/ganivra/sender.py +111 -0
- ganivra-0.1.0/pyproject.toml +43 -0
- ganivra-0.1.0/tests/test_sdk.py +182 -0
ganivra-0.1.0/.gitignore
ADDED
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.
|
ganivra-0.1.0/README.md
ADDED
|
@@ -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()
|