raindrop-bedrock 0.0.1__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,78 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: raindrop-bedrock
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Raindrop integration for AWS Bedrock
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: Raindrop AI
|
|
7
|
+
Author-email: sdk@raindrop.ai
|
|
8
|
+
Requires-Python: >=3.10,<4.0
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Requires-Dist: boto3 (>=1.34.0)
|
|
15
|
+
Requires-Dist: raindrop-ai (>=0.0.42)
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# raindrop-bedrock
|
|
19
|
+
|
|
20
|
+
Raindrop integration for AWS Bedrock (Python). Automatically captures `converse()` and `invoke_model()` calls by wrapping the boto3 bedrock-runtime client.
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install raindrop-bedrock boto3
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import boto3
|
|
32
|
+
from raindrop_bedrock import create_raindrop_bedrock
|
|
33
|
+
|
|
34
|
+
raindrop = create_raindrop_bedrock(
|
|
35
|
+
api_key="rk_...",
|
|
36
|
+
user_id="user-123",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
client = boto3.client("bedrock-runtime", region_name="us-east-1")
|
|
40
|
+
wrapped = raindrop["wrap"](client)
|
|
41
|
+
|
|
42
|
+
response = wrapped.converse(
|
|
43
|
+
modelId="anthropic.claude-3-5-sonnet-20241022-v2:0",
|
|
44
|
+
messages=[
|
|
45
|
+
{"role": "user", "content": [{"text": "Hello!"}]},
|
|
46
|
+
],
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
raindrop["flush"]()
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## What gets captured
|
|
53
|
+
|
|
54
|
+
- **converse()**: input messages, output text, model ID, token usage (inputTokens/outputTokens)
|
|
55
|
+
- **invoke_model()**: raw request/response bodies, model ID, token usage (Claude, Titan, and Llama formats)
|
|
56
|
+
- **Errors**: tracked and re-raised to the caller
|
|
57
|
+
|
|
58
|
+
## Options
|
|
59
|
+
|
|
60
|
+
| Option | Type | Default | Description |
|
|
61
|
+
|--------|------|---------|-------------|
|
|
62
|
+
| `api_key` | `str` | required | Raindrop API key |
|
|
63
|
+
| `user_id` | `str` | `None` | Associate all events with a user |
|
|
64
|
+
| `convo_id` | `str` | `None` | Group events into a conversation |
|
|
65
|
+
|
|
66
|
+
## Testing
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
cd packages/bedrock-python
|
|
70
|
+
pip install -e .
|
|
71
|
+
python -m pytest tests/ -v
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Known Limitations
|
|
75
|
+
|
|
76
|
+
- **InvokeModel body replacement**: After consuming the response body stream, it's replaced with a `BytesIO` object. Callers using `StreamingBody.read()` will get the same bytes, but the original `StreamingBody` API is not preserved.
|
|
77
|
+
- **No `events.*`, `users.*`, or `signals.*` APIs** — Python SDK limitation. Use `raindrop.analytics` directly for these features.
|
|
78
|
+
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# raindrop-bedrock
|
|
2
|
+
|
|
3
|
+
Raindrop integration for AWS Bedrock (Python). Automatically captures `converse()` and `invoke_model()` calls by wrapping the boto3 bedrock-runtime client.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install raindrop-bedrock boto3
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import boto3
|
|
15
|
+
from raindrop_bedrock import create_raindrop_bedrock
|
|
16
|
+
|
|
17
|
+
raindrop = create_raindrop_bedrock(
|
|
18
|
+
api_key="rk_...",
|
|
19
|
+
user_id="user-123",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
client = boto3.client("bedrock-runtime", region_name="us-east-1")
|
|
23
|
+
wrapped = raindrop["wrap"](client)
|
|
24
|
+
|
|
25
|
+
response = wrapped.converse(
|
|
26
|
+
modelId="anthropic.claude-3-5-sonnet-20241022-v2:0",
|
|
27
|
+
messages=[
|
|
28
|
+
{"role": "user", "content": [{"text": "Hello!"}]},
|
|
29
|
+
],
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
raindrop["flush"]()
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## What gets captured
|
|
36
|
+
|
|
37
|
+
- **converse()**: input messages, output text, model ID, token usage (inputTokens/outputTokens)
|
|
38
|
+
- **invoke_model()**: raw request/response bodies, model ID, token usage (Claude, Titan, and Llama formats)
|
|
39
|
+
- **Errors**: tracked and re-raised to the caller
|
|
40
|
+
|
|
41
|
+
## Options
|
|
42
|
+
|
|
43
|
+
| Option | Type | Default | Description |
|
|
44
|
+
|--------|------|---------|-------------|
|
|
45
|
+
| `api_key` | `str` | required | Raindrop API key |
|
|
46
|
+
| `user_id` | `str` | `None` | Associate all events with a user |
|
|
47
|
+
| `convo_id` | `str` | `None` | Group events into a conversation |
|
|
48
|
+
|
|
49
|
+
## Testing
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
cd packages/bedrock-python
|
|
53
|
+
pip install -e .
|
|
54
|
+
python -m pytest tests/ -v
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Known Limitations
|
|
58
|
+
|
|
59
|
+
- **InvokeModel body replacement**: After consuming the response body stream, it's replaced with a `BytesIO` object. Callers using `StreamingBody.read()` will get the same bytes, but the original `StreamingBody` API is not preserved.
|
|
60
|
+
- **No `events.*`, `users.*`, or `signals.*` APIs** — Python SDK limitation. Use `raindrop.analytics` directly for these features.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "raindrop-bedrock"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Raindrop integration for AWS Bedrock"
|
|
5
|
+
authors = ["Raindrop AI <sdk@raindrop.ai>"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
packages = [{ include = "raindrop_bedrock" }]
|
|
9
|
+
|
|
10
|
+
[tool.poetry.dependencies]
|
|
11
|
+
python = ">=3.10,<4.0"
|
|
12
|
+
raindrop-ai = ">=0.0.42"
|
|
13
|
+
boto3 = ">=1.34.0"
|
|
14
|
+
|
|
15
|
+
[tool.poetry.group.dev.dependencies]
|
|
16
|
+
pytest = "^8.0"
|
|
17
|
+
|
|
18
|
+
[tool.pytest.ini_options]
|
|
19
|
+
testpaths = ["tests"]
|
|
20
|
+
python_files = ["test_*.py"]
|
|
21
|
+
python_classes = ["Test*"]
|
|
22
|
+
python_functions = ["test_*"]
|
|
23
|
+
addopts = ["-v", "--tb=short"]
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["poetry-core"]
|
|
27
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AWS Bedrock client wrapper for Raindrop observability.
|
|
3
|
+
|
|
4
|
+
Wraps a boto3 bedrock-runtime client to automatically capture
|
|
5
|
+
converse() and invoke_model() calls and ship them to Raindrop.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from raindrop_bedrock import create_raindrop_bedrock
|
|
9
|
+
|
|
10
|
+
raindrop = create_raindrop_bedrock(api_key="rk_...")
|
|
11
|
+
bedrock = boto3.client("bedrock-runtime", region_name="us-east-1")
|
|
12
|
+
wrapped = raindrop["wrap"](bedrock)
|
|
13
|
+
|
|
14
|
+
response = wrapped.converse(modelId="anthropic.claude-3-5-sonnet-20241022-v2:0", ...)
|
|
15
|
+
raindrop["flush"]()
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import uuid
|
|
22
|
+
from typing import Any, Dict, Optional
|
|
23
|
+
|
|
24
|
+
import raindrop.analytics as raindrop
|
|
25
|
+
|
|
26
|
+
def create_raindrop_bedrock(
|
|
27
|
+
api_key: str,
|
|
28
|
+
user_id: Optional[str] = None,
|
|
29
|
+
convo_id: Optional[str] = None,
|
|
30
|
+
) -> dict:
|
|
31
|
+
"""
|
|
32
|
+
Create a Raindrop-instrumented Bedrock wrapper.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
api_key: Raindrop API key (rk_...).
|
|
36
|
+
user_id: Associate all events with this user.
|
|
37
|
+
convo_id: Conversation ID to group related events.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Dict with 'wrap', 'flush', and 'shutdown'.
|
|
41
|
+
|
|
42
|
+
Usage:
|
|
43
|
+
raindrop = create_raindrop_bedrock(api_key="rk_...")
|
|
44
|
+
bedrock = boto3.client("bedrock-runtime", region_name="us-east-1")
|
|
45
|
+
wrapped = raindrop["wrap"](bedrock)
|
|
46
|
+
response = wrapped.converse(modelId="...", messages=[...])
|
|
47
|
+
raindrop["flush"]()
|
|
48
|
+
"""
|
|
49
|
+
raindrop.init(api_key=api_key)
|
|
50
|
+
|
|
51
|
+
def wrap(client: Any) -> Any:
|
|
52
|
+
return wrap_bedrock_client(client, user_id=user_id, convo_id=convo_id)
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
"wrap": wrap,
|
|
56
|
+
"flush": raindrop.flush,
|
|
57
|
+
"shutdown": raindrop.shutdown,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def wrap_bedrock_client(
|
|
62
|
+
client: Any,
|
|
63
|
+
user_id: Optional[str] = None,
|
|
64
|
+
convo_id: Optional[str] = None,
|
|
65
|
+
) -> Any:
|
|
66
|
+
"""Wrap a boto3 bedrock-runtime client to capture LLM calls."""
|
|
67
|
+
|
|
68
|
+
original_converse = getattr(client, "converse", None)
|
|
69
|
+
original_invoke_model = getattr(client, "invoke_model", None)
|
|
70
|
+
|
|
71
|
+
if original_converse:
|
|
72
|
+
def wrapped_converse(**kwargs: Any) -> Any:
|
|
73
|
+
return _instrument_converse(original_converse, kwargs, user_id, convo_id)
|
|
74
|
+
client.converse = wrapped_converse
|
|
75
|
+
|
|
76
|
+
if original_invoke_model:
|
|
77
|
+
def wrapped_invoke_model(**kwargs: Any) -> Any:
|
|
78
|
+
return _instrument_invoke_model(original_invoke_model, kwargs, user_id, convo_id)
|
|
79
|
+
client.invoke_model = wrapped_invoke_model
|
|
80
|
+
|
|
81
|
+
return client
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _instrument_converse(
|
|
85
|
+
original_fn: Any,
|
|
86
|
+
kwargs: Dict[str, Any],
|
|
87
|
+
user_id: Optional[str],
|
|
88
|
+
convo_id: Optional[str],
|
|
89
|
+
) -> Any:
|
|
90
|
+
try:
|
|
91
|
+
model_id = kwargs.get("modelId")
|
|
92
|
+
input_text = _format_converse_input(kwargs.get("messages"))
|
|
93
|
+
event_id = str(uuid.uuid4())
|
|
94
|
+
except Exception:
|
|
95
|
+
return original_fn(**kwargs)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
response = original_fn(**kwargs)
|
|
99
|
+
except Exception:
|
|
100
|
+
try:
|
|
101
|
+
raindrop.track_ai(
|
|
102
|
+
user_id=user_id or "unknown",
|
|
103
|
+
event="ai_generation",
|
|
104
|
+
event_id=event_id,
|
|
105
|
+
input=input_text,
|
|
106
|
+
model=model_id,
|
|
107
|
+
convo_id=convo_id,
|
|
108
|
+
)
|
|
109
|
+
except Exception:
|
|
110
|
+
pass
|
|
111
|
+
raise
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
output_text = _extract_converse_output(response)
|
|
115
|
+
usage = response.get("usage", {})
|
|
116
|
+
prompt_tokens = usage.get("inputTokens")
|
|
117
|
+
completion_tokens = usage.get("outputTokens")
|
|
118
|
+
|
|
119
|
+
properties: Dict[str, Any] = {}
|
|
120
|
+
if prompt_tokens is not None:
|
|
121
|
+
properties["ai.usage.prompt_tokens"] = prompt_tokens
|
|
122
|
+
if completion_tokens is not None:
|
|
123
|
+
properties["ai.usage.completion_tokens"] = completion_tokens
|
|
124
|
+
|
|
125
|
+
raindrop.track_ai(
|
|
126
|
+
user_id=user_id or "unknown",
|
|
127
|
+
event="ai_generation",
|
|
128
|
+
event_id=event_id,
|
|
129
|
+
input=input_text,
|
|
130
|
+
output=output_text,
|
|
131
|
+
model=model_id,
|
|
132
|
+
properties=properties or None,
|
|
133
|
+
convo_id=convo_id,
|
|
134
|
+
)
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
return response
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _instrument_invoke_model(
|
|
142
|
+
original_fn: Any,
|
|
143
|
+
kwargs: Dict[str, Any],
|
|
144
|
+
user_id: Optional[str],
|
|
145
|
+
convo_id: Optional[str],
|
|
146
|
+
) -> Any:
|
|
147
|
+
try:
|
|
148
|
+
model_id = kwargs.get("modelId")
|
|
149
|
+
input_text = _parse_body(kwargs.get("body"))
|
|
150
|
+
event_id = str(uuid.uuid4())
|
|
151
|
+
except Exception:
|
|
152
|
+
return original_fn(**kwargs)
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
response = original_fn(**kwargs)
|
|
156
|
+
except Exception:
|
|
157
|
+
try:
|
|
158
|
+
raindrop.track_ai(
|
|
159
|
+
user_id=user_id or "unknown",
|
|
160
|
+
event="ai_generation",
|
|
161
|
+
event_id=event_id,
|
|
162
|
+
input=input_text,
|
|
163
|
+
model=model_id,
|
|
164
|
+
convo_id=convo_id,
|
|
165
|
+
)
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
168
|
+
raise
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
output_text, prompt_tokens, completion_tokens = _parse_invoke_response(response)
|
|
172
|
+
|
|
173
|
+
properties: Dict[str, Any] = {}
|
|
174
|
+
if prompt_tokens is not None:
|
|
175
|
+
properties["ai.usage.prompt_tokens"] = prompt_tokens
|
|
176
|
+
if completion_tokens is not None:
|
|
177
|
+
properties["ai.usage.completion_tokens"] = completion_tokens
|
|
178
|
+
|
|
179
|
+
raindrop.track_ai(
|
|
180
|
+
user_id=user_id or "unknown",
|
|
181
|
+
event="ai_generation",
|
|
182
|
+
event_id=event_id,
|
|
183
|
+
input=input_text,
|
|
184
|
+
output=output_text,
|
|
185
|
+
model=model_id,
|
|
186
|
+
properties=properties or None,
|
|
187
|
+
convo_id=convo_id,
|
|
188
|
+
)
|
|
189
|
+
except Exception:
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
return response
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _format_converse_input(messages: Any) -> Optional[str]:
|
|
196
|
+
if not messages:
|
|
197
|
+
return None
|
|
198
|
+
user_msgs = [m for m in messages if isinstance(m, dict) and m.get("role") == "user"]
|
|
199
|
+
to_extract = user_msgs if user_msgs else messages[-1:]
|
|
200
|
+
parts = []
|
|
201
|
+
for msg in to_extract:
|
|
202
|
+
content = msg.get("content", []) if isinstance(msg, dict) else []
|
|
203
|
+
text_parts = []
|
|
204
|
+
for block in (content if isinstance(content, list) else []):
|
|
205
|
+
if isinstance(block, dict) and block.get("text"):
|
|
206
|
+
text_parts.append(block["text"])
|
|
207
|
+
if text_parts:
|
|
208
|
+
parts.append(" ".join(text_parts))
|
|
209
|
+
return "\n".join(parts) if parts else None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _extract_converse_output(response: Dict[str, Any]) -> Optional[str]:
|
|
213
|
+
output = response.get("output", {})
|
|
214
|
+
message = output.get("message", {}) if isinstance(output, dict) else {}
|
|
215
|
+
content = message.get("content", []) if isinstance(message, dict) else []
|
|
216
|
+
texts = []
|
|
217
|
+
for block in (content if isinstance(content, list) else []):
|
|
218
|
+
if isinstance(block, dict) and "text" in block:
|
|
219
|
+
texts.append(block["text"])
|
|
220
|
+
return "\n".join(texts) if texts else None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _parse_body(body: Any) -> Optional[str]:
|
|
224
|
+
if body is None:
|
|
225
|
+
return None
|
|
226
|
+
try:
|
|
227
|
+
if isinstance(body, (bytes, bytearray)):
|
|
228
|
+
return body.decode("utf-8")
|
|
229
|
+
if isinstance(body, str):
|
|
230
|
+
return body
|
|
231
|
+
if hasattr(body, "read"):
|
|
232
|
+
data = body.read()
|
|
233
|
+
if hasattr(body, "seek"):
|
|
234
|
+
body.seek(0)
|
|
235
|
+
return data.decode("utf-8") if isinstance(data, bytes) else str(data)
|
|
236
|
+
return str(body)
|
|
237
|
+
except Exception:
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _parse_invoke_response(
|
|
242
|
+
response: Dict[str, Any],
|
|
243
|
+
) -> tuple:
|
|
244
|
+
"""Returns (output_text, prompt_tokens, completion_tokens).
|
|
245
|
+
|
|
246
|
+
If the body is a streaming object (like botocore.response.StreamingBody),
|
|
247
|
+
it is read and replaced with a BytesIO so the caller can still call .read().
|
|
248
|
+
"""
|
|
249
|
+
body = response.get("body")
|
|
250
|
+
if not body:
|
|
251
|
+
return None, None, None
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
if hasattr(body, "read"):
|
|
255
|
+
raw = body.read()
|
|
256
|
+
import io
|
|
257
|
+
response["body"] = io.BytesIO(raw)
|
|
258
|
+
elif isinstance(body, (bytes, bytearray)):
|
|
259
|
+
raw = body
|
|
260
|
+
else:
|
|
261
|
+
raw = str(body).encode("utf-8")
|
|
262
|
+
|
|
263
|
+
parsed = json.loads(raw)
|
|
264
|
+
|
|
265
|
+
# Anthropic Claude format
|
|
266
|
+
content = parsed.get("content")
|
|
267
|
+
if isinstance(content, list):
|
|
268
|
+
output = "\n".join(
|
|
269
|
+
c.get("text", "") for c in content if isinstance(c, dict) and c.get("type") == "text"
|
|
270
|
+
) or None
|
|
271
|
+
usage = parsed.get("usage", {})
|
|
272
|
+
return output, usage.get("input_tokens"), usage.get("output_tokens")
|
|
273
|
+
|
|
274
|
+
# Amazon Titan format
|
|
275
|
+
results = parsed.get("results")
|
|
276
|
+
if isinstance(results, list) and results:
|
|
277
|
+
output = "\n".join(
|
|
278
|
+
r.get("outputText", "") for r in results if isinstance(r, dict)
|
|
279
|
+
) or None
|
|
280
|
+
return output, parsed.get("inputTextTokenCount"), results[0].get("tokenCount") if isinstance(results[0], dict) else None
|
|
281
|
+
|
|
282
|
+
# Llama format
|
|
283
|
+
generation = parsed.get("generation")
|
|
284
|
+
if generation:
|
|
285
|
+
return generation, parsed.get("prompt_token_count"), parsed.get("generation_token_count")
|
|
286
|
+
|
|
287
|
+
# Fallback
|
|
288
|
+
output = parsed.get("output") or parsed.get("completion") or parsed.get("generated_text")
|
|
289
|
+
return output, None, None
|
|
290
|
+
|
|
291
|
+
except Exception:
|
|
292
|
+
return None, None, None
|