pulse-trace-sdk 0.2.4__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.
- pulse_trace_sdk-0.2.4/.gitignore +4 -0
- pulse_trace_sdk-0.2.4/PKG-INFO +117 -0
- pulse_trace_sdk-0.2.4/README.md +104 -0
- pulse_trace_sdk-0.2.4/pulse_sdk/__init__.py +41 -0
- pulse_trace_sdk-0.2.4/pulse_sdk/config.py +53 -0
- pulse_trace_sdk-0.2.4/pulse_sdk/normalize.py +75 -0
- pulse_trace_sdk-0.2.4/pulse_sdk/pricing.py +117 -0
- pulse_trace_sdk-0.2.4/pulse_sdk/providers/__init__.py +4 -0
- pulse_trace_sdk-0.2.4/pulse_sdk/providers/anthropic.py +85 -0
- pulse_trace_sdk-0.2.4/pulse_sdk/providers/openai.py +92 -0
- pulse_trace_sdk-0.2.4/pulse_sdk/state.py +105 -0
- pulse_trace_sdk-0.2.4/pulse_sdk/trace.py +121 -0
- pulse_trace_sdk-0.2.4/pulse_sdk/transport.py +28 -0
- pulse_trace_sdk-0.2.4/pulse_sdk/types.py +62 -0
- pulse_trace_sdk-0.2.4/pyproject.toml +55 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pulse-trace-sdk
|
|
3
|
+
Version: 0.2.4
|
|
4
|
+
Summary: Pulse Python SDK for tracing LLM providers
|
|
5
|
+
Author: Pulse
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: requests>=2.32.0
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: black>=26.1.0; extra == 'dev'
|
|
11
|
+
Requires-Dist: pytest>=8.3.0; extra == 'dev'
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# Pulse Python SDK
|
|
15
|
+
|
|
16
|
+
Python client helpers for Pulse trace ingestion. Wrap your LLM provider SDK (OpenAI, Anthropic) and Pulse automatically captures trace metadata and ships it to your trace-service instance.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
Install from PyPI:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install pulse-trace-sdk
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
For local development:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install -e .
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from openai import OpenAI
|
|
36
|
+
from pulse_sdk import init_pulse, observe, Provider
|
|
37
|
+
|
|
38
|
+
init_pulse({
|
|
39
|
+
"api_key": "pulse_sk_...",
|
|
40
|
+
"api_url": "http://localhost:3000",
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
client = OpenAI(api_key="your-openai-key")
|
|
44
|
+
observed = observe(client, Provider.OPENAI)
|
|
45
|
+
|
|
46
|
+
response = observed.chat.completions.create(
|
|
47
|
+
model="gpt-4o-mini",
|
|
48
|
+
messages=[{"role": "user", "content": "Hello Pulse"}],
|
|
49
|
+
pulse_session_id="session-123",
|
|
50
|
+
pulse_metadata={"feature": "chat"},
|
|
51
|
+
)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
To instrument Anthropic:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from anthropic import Anthropic
|
|
58
|
+
from pulse_sdk import observe, Provider
|
|
59
|
+
|
|
60
|
+
anthropic_client = Anthropic(api_key="anthropic-key")
|
|
61
|
+
observe(anthropic_client, Provider.ANTHROPIC)
|
|
62
|
+
|
|
63
|
+
anthropic_client.messages.create(
|
|
64
|
+
model="claude-3-5-haiku-20241022",
|
|
65
|
+
max_tokens=300,
|
|
66
|
+
messages=[{"role": "user", "content": "Summarize"}],
|
|
67
|
+
)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## API
|
|
71
|
+
|
|
72
|
+
- `init_pulse(config)` – configure API URL, key, batch size, and flush interval. Starts a background worker that periodically flushes traces.
|
|
73
|
+
- `observe(client, provider, options=None)` – wraps the provider SDK and returns the same client instance instrumented with tracing.
|
|
74
|
+
- `flush_buffer()` – (optional) force-send buffered traces, useful before process shutdown.
|
|
75
|
+
- `shutdown()` – stop the background worker and clear buffers.
|
|
76
|
+
|
|
77
|
+
### Config options
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
init_pulse({
|
|
81
|
+
"api_key": "pulse_sk_...", # required
|
|
82
|
+
"api_url": "https://api.example.com", # default http://localhost:3000
|
|
83
|
+
"batch_size": 10, # flush when buffer hits this size
|
|
84
|
+
"flush_interval": 5000, # milliseconds between automatic flushes
|
|
85
|
+
"enabled": True, # set False to disable tracing
|
|
86
|
+
})
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Pulse specific metadata
|
|
90
|
+
|
|
91
|
+
All `chat.completions.create` / `messages.create` calls support:
|
|
92
|
+
|
|
93
|
+
- `pulse_session_id` – associate traces with a session.
|
|
94
|
+
- `pulse_metadata` – arbitrary dictionary merged into trace metadata.
|
|
95
|
+
|
|
96
|
+
## Requirements
|
|
97
|
+
|
|
98
|
+
- Python 3.10+
|
|
99
|
+
- `requests`
|
|
100
|
+
- Corresponding provider SDK (`openai`, `anthropic`) for the helpers you use.
|
|
101
|
+
|
|
102
|
+
## Tests
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
uv run pytest
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Live integration check
|
|
109
|
+
|
|
110
|
+
To manually exercise the SDK against real providers, run:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
uv run python tests/test_server.py openai
|
|
114
|
+
uv run python tests/test_server.py anthropic
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Set `PULSE_API_KEY` and the relevant provider key (`OPENAI_API_KEY` or `ANTHROPIC_API_KEY`) before running. The script makes a single completion request and relies on `observe()` to push traces to your trace-service instance.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Pulse Python SDK
|
|
2
|
+
|
|
3
|
+
Python client helpers for Pulse trace ingestion. Wrap your LLM provider SDK (OpenAI, Anthropic) and Pulse automatically captures trace metadata and ships it to your trace-service instance.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Install from PyPI:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install pulse-trace-sdk
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
For local development:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install -e .
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from openai import OpenAI
|
|
23
|
+
from pulse_sdk import init_pulse, observe, Provider
|
|
24
|
+
|
|
25
|
+
init_pulse({
|
|
26
|
+
"api_key": "pulse_sk_...",
|
|
27
|
+
"api_url": "http://localhost:3000",
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
client = OpenAI(api_key="your-openai-key")
|
|
31
|
+
observed = observe(client, Provider.OPENAI)
|
|
32
|
+
|
|
33
|
+
response = observed.chat.completions.create(
|
|
34
|
+
model="gpt-4o-mini",
|
|
35
|
+
messages=[{"role": "user", "content": "Hello Pulse"}],
|
|
36
|
+
pulse_session_id="session-123",
|
|
37
|
+
pulse_metadata={"feature": "chat"},
|
|
38
|
+
)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
To instrument Anthropic:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from anthropic import Anthropic
|
|
45
|
+
from pulse_sdk import observe, Provider
|
|
46
|
+
|
|
47
|
+
anthropic_client = Anthropic(api_key="anthropic-key")
|
|
48
|
+
observe(anthropic_client, Provider.ANTHROPIC)
|
|
49
|
+
|
|
50
|
+
anthropic_client.messages.create(
|
|
51
|
+
model="claude-3-5-haiku-20241022",
|
|
52
|
+
max_tokens=300,
|
|
53
|
+
messages=[{"role": "user", "content": "Summarize"}],
|
|
54
|
+
)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## API
|
|
58
|
+
|
|
59
|
+
- `init_pulse(config)` – configure API URL, key, batch size, and flush interval. Starts a background worker that periodically flushes traces.
|
|
60
|
+
- `observe(client, provider, options=None)` – wraps the provider SDK and returns the same client instance instrumented with tracing.
|
|
61
|
+
- `flush_buffer()` – (optional) force-send buffered traces, useful before process shutdown.
|
|
62
|
+
- `shutdown()` – stop the background worker and clear buffers.
|
|
63
|
+
|
|
64
|
+
### Config options
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
init_pulse({
|
|
68
|
+
"api_key": "pulse_sk_...", # required
|
|
69
|
+
"api_url": "https://api.example.com", # default http://localhost:3000
|
|
70
|
+
"batch_size": 10, # flush when buffer hits this size
|
|
71
|
+
"flush_interval": 5000, # milliseconds between automatic flushes
|
|
72
|
+
"enabled": True, # set False to disable tracing
|
|
73
|
+
})
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Pulse specific metadata
|
|
77
|
+
|
|
78
|
+
All `chat.completions.create` / `messages.create` calls support:
|
|
79
|
+
|
|
80
|
+
- `pulse_session_id` – associate traces with a session.
|
|
81
|
+
- `pulse_metadata` – arbitrary dictionary merged into trace metadata.
|
|
82
|
+
|
|
83
|
+
## Requirements
|
|
84
|
+
|
|
85
|
+
- Python 3.10+
|
|
86
|
+
- `requests`
|
|
87
|
+
- Corresponding provider SDK (`openai`, `anthropic`) for the helpers you use.
|
|
88
|
+
|
|
89
|
+
## Tests
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
uv run pytest
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Live integration check
|
|
96
|
+
|
|
97
|
+
To manually exercise the SDK against real providers, run:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
uv run python tests/test_server.py openai
|
|
101
|
+
uv run python tests/test_server.py anthropic
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Set `PULSE_API_KEY` and the relevant provider key (`OPENAI_API_KEY` or `ANTHROPIC_API_KEY`) before running. The script makes a single completion request and relies on `observe()` to push traces to your trace-service instance.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .config import load_config
|
|
4
|
+
from .providers import observe_anthropic, observe_openai
|
|
5
|
+
from .state import (
|
|
6
|
+
flush_buffer,
|
|
7
|
+
reset_state,
|
|
8
|
+
set_config,
|
|
9
|
+
start_flush_worker,
|
|
10
|
+
stop_flush_worker,
|
|
11
|
+
)
|
|
12
|
+
from .types import ObserveOptions, Provider, PulseConfig
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def init_pulse(config: PulseConfig) -> None:
|
|
16
|
+
resolved = load_config(config)
|
|
17
|
+
set_config(resolved)
|
|
18
|
+
start_flush_worker()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def shutdown() -> None:
|
|
22
|
+
stop_flush_worker()
|
|
23
|
+
reset_state()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def observe(client, provider: Provider, options: ObserveOptions | None = None):
|
|
27
|
+
if provider in (Provider.OPENAI, Provider.OPENROUTER):
|
|
28
|
+
return observe_openai(client, provider, options)
|
|
29
|
+
if provider == Provider.ANTHROPIC:
|
|
30
|
+
return observe_anthropic(client, options)
|
|
31
|
+
raise ValueError(f"Unsupported provider: {provider}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"init_pulse",
|
|
36
|
+
"shutdown",
|
|
37
|
+
"observe",
|
|
38
|
+
"flush_buffer",
|
|
39
|
+
"Provider",
|
|
40
|
+
"ObserveOptions",
|
|
41
|
+
]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from .types import PulseConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ResolvedConfig:
|
|
11
|
+
api_key: str
|
|
12
|
+
api_url: str
|
|
13
|
+
batch_size: int
|
|
14
|
+
flush_interval: int
|
|
15
|
+
enabled: bool
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
DEFAULT_API_URL = "http://localhost:3000"
|
|
19
|
+
DEFAULT_BATCH_SIZE = 10
|
|
20
|
+
DEFAULT_FLUSH_INTERVAL = 5000 # ms
|
|
21
|
+
DEFAULT_ENABLED = True
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConfigError(ValueError):
|
|
25
|
+
"""Raised when the user passes invalid configuration."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_config(config: PulseConfig) -> ResolvedConfig:
|
|
29
|
+
api_key = config.get("api_key")
|
|
30
|
+
if not api_key:
|
|
31
|
+
raise ConfigError("Pulse SDK: api_key is required")
|
|
32
|
+
|
|
33
|
+
if not api_key.startswith("pulse_sk_"):
|
|
34
|
+
raise ConfigError("Pulse SDK: api_key must start with 'pulse_sk_'")
|
|
35
|
+
|
|
36
|
+
batch_size = int(config.get("batch_size", DEFAULT_BATCH_SIZE))
|
|
37
|
+
if batch_size < 1 or batch_size > 100:
|
|
38
|
+
raise ConfigError("Pulse SDK: batch_size must be between 1 and 100")
|
|
39
|
+
|
|
40
|
+
flush_interval = int(config.get("flush_interval", DEFAULT_FLUSH_INTERVAL))
|
|
41
|
+
if flush_interval < 1000:
|
|
42
|
+
raise ConfigError("Pulse SDK: flush_interval must be at least 1000ms")
|
|
43
|
+
|
|
44
|
+
api_url = config.get("api_url", DEFAULT_API_URL)
|
|
45
|
+
enabled = bool(config.get("enabled", DEFAULT_ENABLED))
|
|
46
|
+
|
|
47
|
+
return ResolvedConfig(
|
|
48
|
+
api_key=api_key,
|
|
49
|
+
api_url=api_url,
|
|
50
|
+
batch_size=batch_size,
|
|
51
|
+
flush_interval=flush_interval,
|
|
52
|
+
enabled=enabled,
|
|
53
|
+
)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from .types import NormalizedResponse
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from openai.types.chat import ChatCompletion
|
|
9
|
+
except Exception:
|
|
10
|
+
ChatCompletion = object # type: ignore
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from anthropic.types import Message
|
|
14
|
+
except Exception:
|
|
15
|
+
Message = object # type: ignore
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
ANTHROPIC_STOP_REASON_MAP = {
|
|
19
|
+
"end_turn": "stop",
|
|
20
|
+
"max_tokens": "length",
|
|
21
|
+
"stop_sequence": "stop",
|
|
22
|
+
"tool_use": "tool_calls",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def normalize_openai_response(response: "ChatCompletion") -> NormalizedResponse:
|
|
27
|
+
choice = response.choices[0] if response.choices else None
|
|
28
|
+
content = getattr(choice.message, "content", None) if choice else None
|
|
29
|
+
usage = getattr(response, "usage", None)
|
|
30
|
+
input_tokens = getattr(usage, "prompt_tokens", None)
|
|
31
|
+
output_tokens = getattr(usage, "completion_tokens", None)
|
|
32
|
+
finish_reason = getattr(choice, "finish_reason", None)
|
|
33
|
+
model = getattr(response, "model", "unknown")
|
|
34
|
+
provider_id = getattr(response, "id", None)
|
|
35
|
+
|
|
36
|
+
cost_cents = None
|
|
37
|
+
cost_value = getattr(response, "cost", None)
|
|
38
|
+
if isinstance(cost_value, (int, float)):
|
|
39
|
+
cost_cents = cost_value * 100
|
|
40
|
+
|
|
41
|
+
return NormalizedResponse(
|
|
42
|
+
model=model,
|
|
43
|
+
content=content if isinstance(content, str) else None,
|
|
44
|
+
input_tokens=input_tokens,
|
|
45
|
+
output_tokens=output_tokens,
|
|
46
|
+
finish_reason=finish_reason,
|
|
47
|
+
cost_cents=cost_cents,
|
|
48
|
+
provider_request_id=provider_id,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def normalize_anthropic_response(response: "Message") -> NormalizedResponse:
|
|
53
|
+
content_parts = []
|
|
54
|
+
for block in getattr(response, "content", []) or []:
|
|
55
|
+
if getattr(block, "type", None) == "text":
|
|
56
|
+
text_value = getattr(block, "text", None)
|
|
57
|
+
if text_value:
|
|
58
|
+
content_parts.append(text_value)
|
|
59
|
+
content = "".join(content_parts) if content_parts else None
|
|
60
|
+
|
|
61
|
+
usage = getattr(response, "usage", None)
|
|
62
|
+
input_tokens = getattr(usage, "input_tokens", None)
|
|
63
|
+
output_tokens = getattr(usage, "output_tokens", None)
|
|
64
|
+
|
|
65
|
+
stop_reason = getattr(response, "stop_reason", None)
|
|
66
|
+
finish_reason = ANTHROPIC_STOP_REASON_MAP.get(stop_reason, stop_reason)
|
|
67
|
+
model = getattr(response, "model", "unknown")
|
|
68
|
+
|
|
69
|
+
return NormalizedResponse(
|
|
70
|
+
model=model,
|
|
71
|
+
content=content,
|
|
72
|
+
input_tokens=input_tokens,
|
|
73
|
+
output_tokens=output_tokens,
|
|
74
|
+
finish_reason=finish_reason,
|
|
75
|
+
)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Optional
|
|
4
|
+
|
|
5
|
+
ModelPricing = Dict[str, float]
|
|
6
|
+
|
|
7
|
+
MODEL_PRICING: Dict[str, ModelPricing] = {
|
|
8
|
+
"gpt-5.1": {"input": 125, "output": 1000},
|
|
9
|
+
"gpt-5": {"input": 150, "output": 1000},
|
|
10
|
+
"gpt-5-mini": {"input": 90, "output": 400},
|
|
11
|
+
"gpt-5-nano": {"input": 20, "output": 100},
|
|
12
|
+
"gpt-5.1-chat-latest": {"input": 125, "output": 1000},
|
|
13
|
+
"gpt-5-chat-latest": {"input": 150, "output": 1000},
|
|
14
|
+
"gpt-5.1-codex-max": {"input": 250, "output": 1250},
|
|
15
|
+
"gpt-5.1-codex": {"input": 125, "output": 600},
|
|
16
|
+
"gpt-5-codex": {"input": 145, "output": 600},
|
|
17
|
+
"gpt-5.1-codex-mini": {"input": 40, "output": 160},
|
|
18
|
+
"codex-mini-latest": {"input": 40, "output": 160},
|
|
19
|
+
"gpt-5-pro": {"input": 250, "output": 1200},
|
|
20
|
+
"gpt-5-search-api": {"input": 400, "output": 1600},
|
|
21
|
+
"gpt-4.1": {"input": 250, "output": 1000},
|
|
22
|
+
"gpt-4.1-mini": {"input": 100, "output": 400},
|
|
23
|
+
"gpt-4.1-nano": {"input": 15, "output": 60},
|
|
24
|
+
"gpt-4o": {"input": 250, "output": 1000},
|
|
25
|
+
"gpt-4o-2024-05-13": {"input": 250, "output": 1000},
|
|
26
|
+
"gpt-4o-mini": {"input": 15, "output": 60},
|
|
27
|
+
"gpt-4o-mini-search-preview": {"input": 500, "output": 2000},
|
|
28
|
+
"gpt-4o-search-preview": {"input": 1000, "output": 4000},
|
|
29
|
+
"gpt-realtime": {"input": 500, "output": 2000},
|
|
30
|
+
"gpt-realtime-mini": {"input": 250, "output": 1000},
|
|
31
|
+
"gpt-4o-realtime-preview": {"input": 500, "output": 2000},
|
|
32
|
+
"gpt-4o-mini-realtime-preview": {"input": 15, "output": 60},
|
|
33
|
+
"gpt-audio": {"input": 250, "output": 1000},
|
|
34
|
+
"gpt-audio-mini": {"input": 60, "output": 250},
|
|
35
|
+
"gpt-4o-audio-preview": {"input": 250, "output": 1000},
|
|
36
|
+
"gpt-4o-mini-audio-preview": {"input": 15, "output": 60},
|
|
37
|
+
"o1": {"input": 1500, "output": 6000},
|
|
38
|
+
"o1-pro": {"input": 1800, "output": 7200},
|
|
39
|
+
"o1-mini": {"input": 350, "output": 1400},
|
|
40
|
+
"o3-pro": {"input": 1500, "output": 6000},
|
|
41
|
+
"o3": {"input": 600, "output": 2400},
|
|
42
|
+
"o3-mini": {"input": 200, "output": 800},
|
|
43
|
+
"o3-deep-research": {"input": 2500, "output": 10000},
|
|
44
|
+
"o4-mini": {"input": 300, "output": 1200},
|
|
45
|
+
"o4-mini-deep-research": {"input": 500, "output": 2000},
|
|
46
|
+
"computer-use-preview": {"input": 500, "output": 0},
|
|
47
|
+
"gpt-image-1": {"input": 5000, "output": 0},
|
|
48
|
+
"gpt-image-1-mini": {"input": 2000, "output": 0},
|
|
49
|
+
"gpt-4-turbo": {"input": 1000, "output": 3000},
|
|
50
|
+
"gpt-3.5-turbo": {"input": 50, "output": 150},
|
|
51
|
+
"claude-opus-4-5-20251101": {"input": 500, "output": 2500},
|
|
52
|
+
"claude-opus-4-1-20250805": {"input": 1500, "output": 7500},
|
|
53
|
+
"claude-opus-4-20250514": {"input": 1500, "output": 7500},
|
|
54
|
+
"claude-sonnet-4-5-20250929": {"input": 300, "output": 1500},
|
|
55
|
+
"claude-sonnet-4-20250514": {"input": 300, "output": 1500},
|
|
56
|
+
"claude-3-7-sonnet-20250219": {"input": 300, "output": 1500},
|
|
57
|
+
"claude-3-sonnet-20240229": {"input": 300, "output": 1500},
|
|
58
|
+
"claude-3-5-sonnet-20241022": {"input": 300, "output": 1500},
|
|
59
|
+
"claude-haiku-4-5-20251001": {"input": 100, "output": 500},
|
|
60
|
+
"claude-3-5-haiku-20241022": {"input": 80, "output": 400},
|
|
61
|
+
"claude-3-haiku-20240307": {"input": 25, "output": 125},
|
|
62
|
+
"claude-3-opus-20240229": {"input": 1500, "output": 7500},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
MODEL_ALIASES: Dict[str, str] = {
|
|
66
|
+
"gpt-5.1-latest": "gpt-5.1",
|
|
67
|
+
"gpt-5-latest": "gpt-5",
|
|
68
|
+
"gpt-5-mini-latest": "gpt-5-mini",
|
|
69
|
+
"gpt-5-nano-latest": "gpt-5-nano",
|
|
70
|
+
"gpt-5.1-codex-latest": "gpt-5.1-codex",
|
|
71
|
+
"gpt-5-codex-latest": "gpt-5-codex",
|
|
72
|
+
"gpt-5.1-codex-mini-latest": "gpt-5.1-codex-mini",
|
|
73
|
+
"gpt-4.1-latest": "gpt-4.1",
|
|
74
|
+
"gpt-4.1-mini-latest": "gpt-4.1-mini",
|
|
75
|
+
"gpt-4.1-nano-latest": "gpt-4.1-nano",
|
|
76
|
+
"gpt-4o-2024-11-20": "gpt-4o",
|
|
77
|
+
"gpt-4o-2024-08-06": "gpt-4o",
|
|
78
|
+
"gpt-4o-mini-2024-07-18": "gpt-4o-mini",
|
|
79
|
+
"gpt-4-turbo-2024-04-09": "gpt-4-turbo",
|
|
80
|
+
"gpt-4-turbo-preview": "gpt-4-turbo",
|
|
81
|
+
"gpt-3.5-turbo-0125": "gpt-3.5-turbo",
|
|
82
|
+
"gpt-3.5-turbo-1106": "gpt-3.5-turbo",
|
|
83
|
+
"claude-3-5-sonnet": "claude-3-5-sonnet-20241022",
|
|
84
|
+
"claude-3.5-sonnet": "claude-3-5-sonnet-20241022",
|
|
85
|
+
"claude-3-sonnet": "claude-3-sonnet-20240229",
|
|
86
|
+
"claude-3.0-sonnet": "claude-3-sonnet-20240229",
|
|
87
|
+
"claude-3-5-haiku": "claude-3-5-haiku-20241022",
|
|
88
|
+
"claude-3.5-haiku": "claude-3-5-haiku-20241022",
|
|
89
|
+
"claude-3-opus": "claude-3-opus-20240229",
|
|
90
|
+
"claude-opus-4-5": "claude-opus-4-5-20251101",
|
|
91
|
+
"claude-opus-4.5": "claude-opus-4-5-20251101",
|
|
92
|
+
"claude-opus-4-1": "claude-opus-4-1-20250805",
|
|
93
|
+
"claude-opus-4.1": "claude-opus-4-1-20250805",
|
|
94
|
+
"claude sonnet": "claude-3-sonnet-20240229",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _resolve_model(model: str) -> Optional[ModelPricing]:
|
|
99
|
+
if model in MODEL_PRICING:
|
|
100
|
+
return MODEL_PRICING[model]
|
|
101
|
+
alias = MODEL_ALIASES.get(model)
|
|
102
|
+
if alias and alias in MODEL_PRICING:
|
|
103
|
+
return MODEL_PRICING[alias]
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def calculate_cost(
|
|
108
|
+
model: str, input_tokens: int, output_tokens: int
|
|
109
|
+
) -> Optional[float]:
|
|
110
|
+
pricing = _resolve_model(model)
|
|
111
|
+
if not pricing:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
cost = 0.0
|
|
115
|
+
cost += (input_tokens * pricing["input"]) / 1_000_000
|
|
116
|
+
cost += (output_tokens * pricing["output"]) / 1_000_000
|
|
117
|
+
return round(cost, 6)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
|
|
7
|
+
from ..normalize import normalize_anthropic_response
|
|
8
|
+
from ..state import add_to_buffer, is_enabled
|
|
9
|
+
from ..trace import (
|
|
10
|
+
build_error_trace,
|
|
11
|
+
build_trace,
|
|
12
|
+
extract_pulse_params,
|
|
13
|
+
resolve_trace_metadata,
|
|
14
|
+
)
|
|
15
|
+
from ..types import ObserveOptions, Provider
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AnthropicIntegrationError(RuntimeError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def observe_anthropic(client: Any, options: ObserveOptions | None = None) -> Any:
|
|
23
|
+
try:
|
|
24
|
+
import anthropic # noqa: F401
|
|
25
|
+
except ImportError as exc:
|
|
26
|
+
raise AnthropicIntegrationError(
|
|
27
|
+
"anthropic package is required to observe Anthropic clients"
|
|
28
|
+
) from exc
|
|
29
|
+
|
|
30
|
+
messages = getattr(client, "messages", None)
|
|
31
|
+
if messages is None or not hasattr(messages, "create"):
|
|
32
|
+
raise AnthropicIntegrationError("Client is missing messages.create")
|
|
33
|
+
|
|
34
|
+
original_create = messages.create
|
|
35
|
+
|
|
36
|
+
def wrapped_create(*args: Any, **kwargs: Any):
|
|
37
|
+
if not is_enabled():
|
|
38
|
+
return original_create(*args, **kwargs)
|
|
39
|
+
|
|
40
|
+
if args:
|
|
41
|
+
return original_create(*args, **kwargs)
|
|
42
|
+
|
|
43
|
+
clean_payload, pulse_session_id, pulse_metadata = extract_pulse_params(kwargs)
|
|
44
|
+
request_payload: Dict[str, Any] = copy.deepcopy(clean_payload)
|
|
45
|
+
|
|
46
|
+
observe_session = options.session_id if options else None
|
|
47
|
+
observe_metadata = options.metadata if options else None
|
|
48
|
+
session_id, metadata = resolve_trace_metadata(
|
|
49
|
+
observe_session,
|
|
50
|
+
observe_metadata,
|
|
51
|
+
pulse_session_id,
|
|
52
|
+
pulse_metadata,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
start = time.perf_counter()
|
|
56
|
+
try:
|
|
57
|
+
response = original_create(**clean_payload)
|
|
58
|
+
except Exception as exc:
|
|
59
|
+
latency = (time.perf_counter() - start) * 1000
|
|
60
|
+
trace = build_error_trace(
|
|
61
|
+
request_payload,
|
|
62
|
+
exc,
|
|
63
|
+
Provider.ANTHROPIC,
|
|
64
|
+
latency,
|
|
65
|
+
session_id,
|
|
66
|
+
metadata,
|
|
67
|
+
)
|
|
68
|
+
add_to_buffer(trace)
|
|
69
|
+
raise
|
|
70
|
+
|
|
71
|
+
latency = (time.perf_counter() - start) * 1000
|
|
72
|
+
normalized = normalize_anthropic_response(response)
|
|
73
|
+
trace = build_trace(
|
|
74
|
+
request_payload,
|
|
75
|
+
normalized,
|
|
76
|
+
Provider.ANTHROPIC,
|
|
77
|
+
latency,
|
|
78
|
+
session_id,
|
|
79
|
+
metadata,
|
|
80
|
+
)
|
|
81
|
+
add_to_buffer(trace)
|
|
82
|
+
return response
|
|
83
|
+
|
|
84
|
+
messages.create = wrapped_create # type: ignore[assignment]
|
|
85
|
+
return client
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
|
|
7
|
+
from ..normalize import normalize_openai_response
|
|
8
|
+
from ..state import add_to_buffer, is_enabled
|
|
9
|
+
from ..trace import (
|
|
10
|
+
build_error_trace,
|
|
11
|
+
build_trace,
|
|
12
|
+
extract_pulse_params,
|
|
13
|
+
resolve_trace_metadata,
|
|
14
|
+
)
|
|
15
|
+
from ..types import ObserveOptions, Provider
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OpenAIIntegrationError(RuntimeError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def observe_openai(
|
|
23
|
+
client: Any, provider: Provider, options: ObserveOptions | None = None
|
|
24
|
+
) -> Any:
|
|
25
|
+
if provider not in (Provider.OPENAI, Provider.OPENROUTER):
|
|
26
|
+
raise ValueError("Provider must be openai or openrouter for observe_openai")
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
import openai # noqa: F401 # ensure dependency is available
|
|
30
|
+
except ImportError as exc:
|
|
31
|
+
raise OpenAIIntegrationError(
|
|
32
|
+
"openai package is required to observe OpenAI clients"
|
|
33
|
+
) from exc
|
|
34
|
+
|
|
35
|
+
chat = getattr(client, "chat", None)
|
|
36
|
+
completions = getattr(chat, "completions", None)
|
|
37
|
+
if completions is None or not hasattr(completions, "create"):
|
|
38
|
+
raise OpenAIIntegrationError("Client is missing chat.completions.create")
|
|
39
|
+
|
|
40
|
+
original_create = completions.create
|
|
41
|
+
|
|
42
|
+
def wrapped_create(*args: Any, **kwargs: Any):
|
|
43
|
+
if not is_enabled():
|
|
44
|
+
return original_create(*args, **kwargs)
|
|
45
|
+
|
|
46
|
+
if args:
|
|
47
|
+
# openai-python uses keyword-only API. Fall back if user passed args.
|
|
48
|
+
return original_create(*args, **kwargs)
|
|
49
|
+
|
|
50
|
+
clean_payload, pulse_session_id, pulse_metadata = extract_pulse_params(kwargs)
|
|
51
|
+
request_payload: Dict[str, Any] = copy.deepcopy(clean_payload)
|
|
52
|
+
|
|
53
|
+
observe_session = options.session_id if options else None
|
|
54
|
+
observe_metadata = options.metadata if options else None
|
|
55
|
+
session_id, metadata = resolve_trace_metadata(
|
|
56
|
+
observe_session,
|
|
57
|
+
observe_metadata,
|
|
58
|
+
pulse_session_id,
|
|
59
|
+
pulse_metadata,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
start = time.perf_counter()
|
|
63
|
+
try:
|
|
64
|
+
response = original_create(**clean_payload)
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
latency = (time.perf_counter() - start) * 1000
|
|
67
|
+
trace = build_error_trace(
|
|
68
|
+
request_payload,
|
|
69
|
+
exc,
|
|
70
|
+
provider,
|
|
71
|
+
latency,
|
|
72
|
+
session_id,
|
|
73
|
+
metadata,
|
|
74
|
+
)
|
|
75
|
+
add_to_buffer(trace)
|
|
76
|
+
raise
|
|
77
|
+
|
|
78
|
+
latency = (time.perf_counter() - start) * 1000
|
|
79
|
+
normalized = normalize_openai_response(response)
|
|
80
|
+
trace = build_trace(
|
|
81
|
+
request_payload,
|
|
82
|
+
normalized,
|
|
83
|
+
provider,
|
|
84
|
+
latency,
|
|
85
|
+
session_id,
|
|
86
|
+
metadata,
|
|
87
|
+
)
|
|
88
|
+
add_to_buffer(trace)
|
|
89
|
+
return response
|
|
90
|
+
|
|
91
|
+
completions.create = wrapped_create # type: ignore[assignment]
|
|
92
|
+
return client
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
from .config import ResolvedConfig
|
|
8
|
+
from .transport import send_traces
|
|
9
|
+
from .types import Trace
|
|
10
|
+
|
|
11
|
+
_config: ResolvedConfig | None = None
|
|
12
|
+
_buffer: List[Trace] = []
|
|
13
|
+
_buffer_lock = threading.Lock()
|
|
14
|
+
_flush_thread: threading.Thread | None = None
|
|
15
|
+
_stop_event: threading.Event | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def set_config(config: ResolvedConfig) -> None:
|
|
19
|
+
global _config
|
|
20
|
+
_config = config
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_config() -> ResolvedConfig:
|
|
24
|
+
if _config is None:
|
|
25
|
+
raise RuntimeError("Pulse SDK: init_pulse() must be called before use")
|
|
26
|
+
return _config
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_enabled() -> bool:
|
|
30
|
+
return bool(_config and _config.enabled)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def add_to_buffer(trace: Trace) -> None:
|
|
34
|
+
if not is_enabled():
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
with _buffer_lock:
|
|
38
|
+
_buffer.append(trace)
|
|
39
|
+
threshold = _config.batch_size if _config else 0
|
|
40
|
+
should_flush = len(_buffer) >= threshold > 0
|
|
41
|
+
|
|
42
|
+
if should_flush:
|
|
43
|
+
flush_buffer()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def flush_buffer() -> None:
|
|
47
|
+
if not is_enabled():
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
traces: List[Trace]
|
|
51
|
+
with _buffer_lock:
|
|
52
|
+
if not _buffer:
|
|
53
|
+
return
|
|
54
|
+
traces = list(_buffer)
|
|
55
|
+
_buffer.clear()
|
|
56
|
+
|
|
57
|
+
cfg = get_config()
|
|
58
|
+
try:
|
|
59
|
+
send_traces(cfg.api_url, cfg.api_key, traces)
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
print(f"Pulse SDK: failed to send traces: {exc}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _flush_loop(interval_ms: int, stop_event: threading.Event) -> None:
|
|
65
|
+
interval = max(interval_ms / 1000.0, 1.0)
|
|
66
|
+
while not stop_event.wait(interval):
|
|
67
|
+
flush_buffer()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def start_flush_worker() -> None:
|
|
71
|
+
global _flush_thread, _stop_event
|
|
72
|
+
|
|
73
|
+
if not is_enabled():
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
stop_flush_worker()
|
|
77
|
+
cfg = get_config()
|
|
78
|
+
stop_event = threading.Event()
|
|
79
|
+
thread = threading.Thread(
|
|
80
|
+
target=_flush_loop,
|
|
81
|
+
args=(cfg.flush_interval, stop_event),
|
|
82
|
+
name="pulse-sdk-flush",
|
|
83
|
+
daemon=True,
|
|
84
|
+
)
|
|
85
|
+
_stop_event = stop_event
|
|
86
|
+
_flush_thread = thread
|
|
87
|
+
thread.start()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def stop_flush_worker() -> None:
|
|
91
|
+
global _flush_thread, _stop_event
|
|
92
|
+
|
|
93
|
+
if _stop_event:
|
|
94
|
+
_stop_event.set()
|
|
95
|
+
if _flush_thread and _flush_thread.is_alive():
|
|
96
|
+
_flush_thread.join(timeout=1)
|
|
97
|
+
_stop_event = None
|
|
98
|
+
_flush_thread = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def reset_state() -> None:
|
|
102
|
+
global _buffer
|
|
103
|
+
stop_flush_worker()
|
|
104
|
+
with _buffer_lock:
|
|
105
|
+
_buffer = []
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import datetime
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
from .pricing import calculate_cost
|
|
10
|
+
from .types import NormalizedResponse, Provider, Trace, TraceStatus
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def generate_trace_id() -> str:
|
|
14
|
+
return str(uuid.uuid4())
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def current_timestamp() -> str:
|
|
18
|
+
return datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc).isoformat()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def extract_pulse_params(
|
|
22
|
+
payload: Dict[str, Any],
|
|
23
|
+
) -> tuple[Dict[str, Any], Optional[str], Optional[Dict[str, Any]]]:
|
|
24
|
+
clean = copy.deepcopy(payload)
|
|
25
|
+
session = None
|
|
26
|
+
metadata = None
|
|
27
|
+
|
|
28
|
+
for key in ("pulse_session_id", "pulseSessionId"):
|
|
29
|
+
if key in clean:
|
|
30
|
+
session = clean.pop(key)
|
|
31
|
+
break
|
|
32
|
+
|
|
33
|
+
for key in ("pulse_metadata", "pulseMetadata"):
|
|
34
|
+
if key in clean:
|
|
35
|
+
metadata = clean.pop(key)
|
|
36
|
+
break
|
|
37
|
+
|
|
38
|
+
return clean, session, metadata # type: ignore[return-value]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolve_trace_metadata(
|
|
42
|
+
observe_session: Optional[str],
|
|
43
|
+
observe_metadata: Optional[Dict[str, Any]],
|
|
44
|
+
pulse_session: Optional[str],
|
|
45
|
+
pulse_metadata: Optional[Dict[str, Any]],
|
|
46
|
+
) -> tuple[Optional[str], Optional[Dict[str, Any]]]:
|
|
47
|
+
session_id = pulse_session or observe_session
|
|
48
|
+
metadata = observe_metadata.copy() if observe_metadata else None
|
|
49
|
+
if pulse_metadata:
|
|
50
|
+
metadata = {**(metadata or {}), **pulse_metadata}
|
|
51
|
+
return session_id, metadata
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_trace(
|
|
55
|
+
request: Dict[str, Any],
|
|
56
|
+
response: Optional[NormalizedResponse],
|
|
57
|
+
provider: Provider,
|
|
58
|
+
latency_ms: float,
|
|
59
|
+
session_id: Optional[str] = None,
|
|
60
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
61
|
+
) -> Trace:
|
|
62
|
+
trace: Trace = {
|
|
63
|
+
"trace_id": generate_trace_id(),
|
|
64
|
+
"timestamp": current_timestamp(),
|
|
65
|
+
"provider": provider.value,
|
|
66
|
+
"model_requested": str(request.get("model", "unknown")),
|
|
67
|
+
"request_body": request,
|
|
68
|
+
"latency_ms": int(round(latency_ms)),
|
|
69
|
+
"status": TraceStatus.SUCCESS.value if response else TraceStatus.ERROR.value,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if response:
|
|
73
|
+
trace["model_used"] = response.model
|
|
74
|
+
trace["response_body"] = {
|
|
75
|
+
"content": response.content,
|
|
76
|
+
"inputTokens": response.input_tokens,
|
|
77
|
+
"outputTokens": response.output_tokens,
|
|
78
|
+
"finishReason": response.finish_reason,
|
|
79
|
+
"model": response.model,
|
|
80
|
+
}
|
|
81
|
+
trace["input_tokens"] = response.input_tokens
|
|
82
|
+
trace["output_tokens"] = response.output_tokens
|
|
83
|
+
trace["output_text"] = response.content
|
|
84
|
+
trace["finish_reason"] = response.finish_reason
|
|
85
|
+
if response.provider_request_id:
|
|
86
|
+
trace["provider_request_id"] = response.provider_request_id
|
|
87
|
+
|
|
88
|
+
if response.cost_cents is not None:
|
|
89
|
+
trace["cost_cents"] = response.cost_cents
|
|
90
|
+
elif response.input_tokens is not None and response.output_tokens is not None:
|
|
91
|
+
calculated = calculate_cost(
|
|
92
|
+
response.model, response.input_tokens, response.output_tokens
|
|
93
|
+
)
|
|
94
|
+
if calculated is not None:
|
|
95
|
+
trace["cost_cents"] = calculated
|
|
96
|
+
else:
|
|
97
|
+
trace["status"] = TraceStatus.ERROR.value
|
|
98
|
+
|
|
99
|
+
if session_id:
|
|
100
|
+
trace["session_id"] = session_id
|
|
101
|
+
if metadata:
|
|
102
|
+
trace["metadata"] = metadata
|
|
103
|
+
|
|
104
|
+
return trace
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def build_error_trace(
|
|
108
|
+
request: Dict[str, Any],
|
|
109
|
+
error: Exception,
|
|
110
|
+
provider: Provider,
|
|
111
|
+
latency_ms: float,
|
|
112
|
+
session_id: Optional[str] = None,
|
|
113
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
114
|
+
) -> Trace:
|
|
115
|
+
trace = build_trace(request, None, provider, latency_ms, session_id, metadata)
|
|
116
|
+
trace["status"] = TraceStatus.ERROR.value
|
|
117
|
+
trace["error"] = {
|
|
118
|
+
"name": error.__class__.__name__,
|
|
119
|
+
"message": str(error),
|
|
120
|
+
}
|
|
121
|
+
return trace
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from .types import Trace
|
|
9
|
+
|
|
10
|
+
DEFAULT_TIMEOUT = 10 # seconds
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def send_traces(api_url: str, api_key: str, traces: List[Trace]) -> None:
|
|
14
|
+
if not traces:
|
|
15
|
+
return
|
|
16
|
+
|
|
17
|
+
url = f"{api_url.rstrip('/')}/v1/traces/async"
|
|
18
|
+
headers = {
|
|
19
|
+
"Authorization": f"Bearer {api_key}",
|
|
20
|
+
"Content-Type": "application/json",
|
|
21
|
+
}
|
|
22
|
+
response = requests.post(
|
|
23
|
+
url, headers=headers, data=json.dumps(traces), timeout=DEFAULT_TIMEOUT
|
|
24
|
+
)
|
|
25
|
+
if not response.ok:
|
|
26
|
+
raise RuntimeError(
|
|
27
|
+
f"Pulse SDK: failed to send traces ({response.status_code}): {response.text}"
|
|
28
|
+
)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any, Dict, Optional, TypedDict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Provider(str, Enum):
|
|
9
|
+
OPENAI = "openai"
|
|
10
|
+
OPENROUTER = "openrouter"
|
|
11
|
+
ANTHROPIC = "anthropic"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TraceStatus(str, Enum):
|
|
15
|
+
SUCCESS = "success"
|
|
16
|
+
ERROR = "error"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Trace(TypedDict, total=False):
|
|
20
|
+
trace_id: str
|
|
21
|
+
timestamp: str
|
|
22
|
+
provider: str
|
|
23
|
+
model_requested: str
|
|
24
|
+
model_used: Optional[str]
|
|
25
|
+
provider_request_id: Optional[str]
|
|
26
|
+
request_body: Dict[str, Any]
|
|
27
|
+
response_body: Dict[str, Any]
|
|
28
|
+
input_tokens: Optional[int]
|
|
29
|
+
output_tokens: Optional[int]
|
|
30
|
+
output_text: Optional[str]
|
|
31
|
+
finish_reason: Optional[str]
|
|
32
|
+
status: str
|
|
33
|
+
error: Dict[str, Any]
|
|
34
|
+
cost_cents: Optional[float]
|
|
35
|
+
latency_ms: int
|
|
36
|
+
session_id: Optional[str]
|
|
37
|
+
metadata: Dict[str, Any]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PulseConfig(TypedDict, total=False):
|
|
41
|
+
api_key: str
|
|
42
|
+
api_url: str
|
|
43
|
+
batch_size: int
|
|
44
|
+
flush_interval: int
|
|
45
|
+
enabled: bool
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class ObserveOptions:
|
|
50
|
+
session_id: Optional[str] = None
|
|
51
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class NormalizedResponse:
|
|
56
|
+
model: str
|
|
57
|
+
content: Optional[str]
|
|
58
|
+
input_tokens: Optional[int]
|
|
59
|
+
output_tokens: Optional[int]
|
|
60
|
+
finish_reason: Optional[str]
|
|
61
|
+
cost_cents: Optional[float] = None
|
|
62
|
+
provider_request_id: Optional[str] = None
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pulse-trace-sdk"
|
|
7
|
+
version = "0.2.4"
|
|
8
|
+
description = "Pulse Python SDK for tracing LLM providers"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Pulse" }
|
|
13
|
+
]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"requests>=2.32.0",
|
|
16
|
+
]
|
|
17
|
+
license = { text = "MIT" }
|
|
18
|
+
|
|
19
|
+
[tool.hatch.build]
|
|
20
|
+
packages = ["src/pulse_sdk"]
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["src/pulse_sdk"]
|
|
24
|
+
exclude = [
|
|
25
|
+
"src/pulse_sdk/__pycache__",
|
|
26
|
+
"**/*.pyc",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.sdist]
|
|
30
|
+
include = [
|
|
31
|
+
"src/pulse_sdk",
|
|
32
|
+
"README.md",
|
|
33
|
+
"pyproject.toml",
|
|
34
|
+
]
|
|
35
|
+
exclude = [
|
|
36
|
+
"tests",
|
|
37
|
+
"**/__pycache__",
|
|
38
|
+
"**/*.pyc",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[dependency-groups]
|
|
42
|
+
dev = [
|
|
43
|
+
"anthropic>=0.79.0",
|
|
44
|
+
"openai>=2.20.0",
|
|
45
|
+
"pytest>=9.0.2",
|
|
46
|
+
"python-dotenv>=1.0.1",
|
|
47
|
+
"fastapi>=0.115.5",
|
|
48
|
+
"uvicorn>=0.32.0",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
[project.optional-dependencies]
|
|
52
|
+
dev = [
|
|
53
|
+
"black>=26.1.0",
|
|
54
|
+
"pytest>=8.3.0",
|
|
55
|
+
]
|