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,3 @@
1
+ from .wrapper import create_raindrop_bedrock
2
+
3
+ __all__ = ["create_raindrop_bedrock"]
@@ -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