compresr 2.7.9__tar.gz → 2.8.2__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.
Files changed (77) hide show
  1. {compresr-2.7.9 → compresr-2.8.2}/PKG-INFO +53 -7
  2. {compresr-2.7.9 → compresr-2.8.2}/README.md +47 -6
  3. {compresr-2.7.9 → compresr-2.8.2}/compresr/__init__.py +3 -0
  4. compresr-2.8.2/compresr/__main__.py +150 -0
  5. compresr-2.8.2/compresr/agents/tools/_agentcore.py +247 -0
  6. {compresr-2.7.9 → compresr-2.8.2}/compresr/agents/tools/web_search.py +168 -10
  7. compresr-2.8.2/compresr/auth.py +449 -0
  8. compresr-2.8.2/compresr/credentials.py +270 -0
  9. {compresr-2.7.9 → compresr-2.8.2}/compresr/integrations/langgraph/checkpoint.py +19 -0
  10. {compresr-2.7.9 → compresr-2.8.2}/compresr/services/compression.py +1 -1
  11. {compresr-2.7.9 → compresr-2.8.2}/compresr/services/proxy.py +23 -25
  12. {compresr-2.7.9 → compresr-2.8.2}/compresr.egg-info/PKG-INFO +53 -7
  13. {compresr-2.7.9 → compresr-2.8.2}/compresr.egg-info/SOURCES.txt +4 -0
  14. {compresr-2.7.9 → compresr-2.8.2}/compresr.egg-info/entry_points.txt +1 -0
  15. {compresr-2.7.9 → compresr-2.8.2}/compresr.egg-info/requires.txt +6 -0
  16. {compresr-2.7.9 → compresr-2.8.2}/pyproject.toml +10 -1
  17. {compresr-2.7.9 → compresr-2.8.2}/LICENSE +0 -0
  18. {compresr-2.7.9 → compresr-2.8.2}/compresr/agents/__init__.py +0 -0
  19. {compresr-2.7.9 → compresr-2.8.2}/compresr/agents/engine.py +0 -0
  20. {compresr-2.7.9 → compresr-2.8.2}/compresr/agents/facades/__init__.py +0 -0
  21. {compresr-2.7.9 → compresr-2.8.2}/compresr/agents/facades/anthropic.py +0 -0
  22. {compresr-2.7.9 → compresr-2.8.2}/compresr/agents/facades/native.py +0 -0
  23. {compresr-2.7.9 → compresr-2.8.2}/compresr/agents/facades/openai.py +0 -0
  24. {compresr-2.7.9 → compresr-2.8.2}/compresr/agents/normalized.py +0 -0
  25. {compresr-2.7.9 → compresr-2.8.2}/compresr/agents/research/__init__.py +0 -0
  26. {compresr-2.7.9 → compresr-2.8.2}/compresr/agents/research/agent.py +0 -0
  27. {compresr-2.7.9 → compresr-2.8.2}/compresr/agents/research/facade.py +0 -0
  28. {compresr-2.7.9 → compresr-2.8.2}/compresr/agents/research/parser.py +0 -0
  29. {compresr-2.7.9 → compresr-2.8.2}/compresr/agents/research/prompts.py +0 -0
  30. {compresr-2.7.9 → compresr-2.8.2}/compresr/agents/research/types.py +0 -0
  31. {compresr-2.7.9 → compresr-2.8.2}/compresr/agents/schemas/__init__.py +0 -0
  32. {compresr-2.7.9 → compresr-2.8.2}/compresr/agents/schemas/anthropic.py +0 -0
  33. {compresr-2.7.9 → compresr-2.8.2}/compresr/agents/schemas/openai.py +0 -0
  34. {compresr-2.7.9 → compresr-2.8.2}/compresr/agents/tools/__init__.py +0 -0
  35. {compresr-2.7.9 → compresr-2.8.2}/compresr/clients.py +0 -0
  36. {compresr-2.7.9 → compresr-2.8.2}/compresr/config.py +0 -0
  37. {compresr-2.7.9 → compresr-2.8.2}/compresr/exceptions/__init__.py +0 -0
  38. {compresr-2.7.9 → compresr-2.8.2}/compresr/exceptions/exceptions.py +0 -0
  39. {compresr-2.7.9 → compresr-2.8.2}/compresr/integrations/__init__.py +0 -0
  40. {compresr-2.7.9 → compresr-2.8.2}/compresr/integrations/_shared/__init__.py +0 -0
  41. {compresr-2.7.9 → compresr-2.8.2}/compresr/integrations/_shared/client.py +0 -0
  42. {compresr-2.7.9 → compresr-2.8.2}/compresr/integrations/_shared/compress.py +0 -0
  43. {compresr-2.7.9 → compresr-2.8.2}/compresr/integrations/_shared/errors.py +0 -0
  44. {compresr-2.7.9 → compresr-2.8.2}/compresr/integrations/_shared/filters.py +0 -0
  45. {compresr-2.7.9 → compresr-2.8.2}/compresr/integrations/_shared/kernel.py +0 -0
  46. {compresr-2.7.9 → compresr-2.8.2}/compresr/integrations/_shared/policy.py +0 -0
  47. {compresr-2.7.9 → compresr-2.8.2}/compresr/integrations/_shared/query.py +0 -0
  48. {compresr-2.7.9 → compresr-2.8.2}/compresr/integrations/_shared/tokens.py +0 -0
  49. {compresr-2.7.9 → compresr-2.8.2}/compresr/integrations/langchain/__init__.py +0 -0
  50. {compresr-2.7.9 → compresr-2.8.2}/compresr/integrations/langchain/middleware.py +0 -0
  51. {compresr-2.7.9 → compresr-2.8.2}/compresr/integrations/langchain/retriever.py +0 -0
  52. {compresr-2.7.9 → compresr-2.8.2}/compresr/integrations/langchain/wrappers.py +0 -0
  53. {compresr-2.7.9 → compresr-2.8.2}/compresr/integrations/langgraph/__init__.py +0 -0
  54. {compresr-2.7.9 → compresr-2.8.2}/compresr/integrations/langgraph/handoff.py +0 -0
  55. {compresr-2.7.9 → compresr-2.8.2}/compresr/integrations/langgraph/nodes.py +0 -0
  56. {compresr-2.7.9 → compresr-2.8.2}/compresr/integrations/langgraph/store.py +0 -0
  57. {compresr-2.7.9 → compresr-2.8.2}/compresr/integrations/litellm/__init__.py +0 -0
  58. {compresr-2.7.9 → compresr-2.8.2}/compresr/integrations/litellm/cli.py +0 -0
  59. {compresr-2.7.9 → compresr-2.8.2}/compresr/integrations/litellm/defaults.py +0 -0
  60. {compresr-2.7.9 → compresr-2.8.2}/compresr/integrations/litellm/guardrail.py +0 -0
  61. {compresr-2.7.9 → compresr-2.8.2}/compresr/integrations/litellm/initializer.py +0 -0
  62. {compresr-2.7.9 → compresr-2.8.2}/compresr/integrations/litellm/shim_installer.py +0 -0
  63. {compresr-2.7.9 → compresr-2.8.2}/compresr/integrations/litellm/types.py +0 -0
  64. {compresr-2.7.9 → compresr-2.8.2}/compresr/integrations/llamaindex/__init__.py +0 -0
  65. {compresr-2.7.9 → compresr-2.8.2}/compresr/integrations/llamaindex/memory.py +0 -0
  66. {compresr-2.7.9 → compresr-2.8.2}/compresr/integrations/llamaindex/postprocessor.py +0 -0
  67. {compresr-2.7.9 → compresr-2.8.2}/compresr/integrations/llamaindex/wrappers.py +0 -0
  68. {compresr-2.7.9 → compresr-2.8.2}/compresr/retry.py +0 -0
  69. {compresr-2.7.9 → compresr-2.8.2}/compresr/schemas/__init__.py +0 -0
  70. {compresr-2.7.9 → compresr-2.8.2}/compresr/schemas/base.py +0 -0
  71. {compresr-2.7.9 → compresr-2.8.2}/compresr/schemas/inference.py +0 -0
  72. {compresr-2.7.9 → compresr-2.8.2}/compresr/schemas/usage.py +0 -0
  73. {compresr-2.7.9 → compresr-2.8.2}/compresr/services/__init__.py +0 -0
  74. {compresr-2.7.9 → compresr-2.8.2}/compresr/services/base.py +0 -0
  75. {compresr-2.7.9 → compresr-2.8.2}/compresr.egg-info/dependency_links.txt +0 -0
  76. {compresr-2.7.9 → compresr-2.8.2}/compresr.egg-info/top_level.txt +0 -0
  77. {compresr-2.7.9 → compresr-2.8.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compresr
3
- Version: 2.7.9
3
+ Version: 2.8.2
4
4
  Summary: Python SDK for Compresr - Intelligent prompt compression service
5
5
  Author-email: Compresr Team <founders@compresr.ai>
6
6
  License-Expression: Apache-2.0
@@ -47,10 +47,15 @@ Provides-Extra: llamaindex
47
47
  Requires-Dist: llama-index-core>=0.11; extra == "llamaindex"
48
48
  Provides-Extra: litellm
49
49
  Requires-Dist: litellm[proxy]>=1.79; extra == "litellm"
50
+ Provides-Extra: agentcore
51
+ Requires-Dist: mcp>=1.0; extra == "agentcore"
52
+ Requires-Dist: nest-asyncio>=1.6; extra == "agentcore"
50
53
  Provides-Extra: all
51
54
  Requires-Dist: langgraph>=0.2; extra == "all"
52
55
  Requires-Dist: llama-index-core>=0.11; extra == "all"
53
56
  Requires-Dist: litellm[proxy]>=1.79; extra == "all"
57
+ Requires-Dist: mcp>=1.0; extra == "all"
58
+ Requires-Dist: nest-asyncio>=1.6; extra == "all"
54
59
  Provides-Extra: langchain
55
60
  Provides-Extra: agents
56
61
  Provides-Extra: agents-anthropic
@@ -92,7 +97,7 @@ print(f"Saved: {result.data.tokens_saved} tokens")
92
97
  print(result.data.compressed_context)
93
98
  ```
94
99
 
95
- The default model is `latte_v1` (query-aware). Pass any other model name your
100
+ The default model is `latte_v2` (query-aware). Pass any other model name your
96
101
  account has access to via `compression_model_name="..."` — the backend
97
102
  validates.
98
103
 
@@ -180,7 +185,7 @@ client.messages.create(model="claude-haiku-4-5", max_tokens=512,
180
185
  messages=[...], tools=[...])
181
186
 
182
187
  # OpenAI shape
183
- client.chat.completions.create(model="gpt-4o-mini", messages=[...], tools=[...])
188
+ client.chat.completions.create(model="gpt-5-mini", messages=[...], tools=[...])
184
189
 
185
190
  # Native — returns a NormalizedResult
186
191
  client.run(prompt="...", model="claude-haiku-4-5", tools=[...], max_tokens=512)
@@ -200,6 +205,46 @@ search = WebSearchTool.tavily(
200
205
  # Brave: WebSearchTool.brave(api_key=..., max_results=5)
201
206
  ```
202
207
 
208
+ #### Amazon Bedrock AgentCore
209
+
210
+ `WebSearchTool.agentcore(...)` reaches Amazon Bedrock AgentCore web search via a
211
+ Cognito OAuth handshake + an MCP streamable-HTTP session. Its runtime deps are
212
+ optional:
213
+
214
+ ```bash
215
+ pip install compresr[agentcore]
216
+ ```
217
+
218
+ Config resolves from explicit args first, then env vars (the
219
+ `AGENTCORE_`-prefixed name takes precedence, with the bare name as fallback):
220
+
221
+ | Field | Env var (fallback) |
222
+ |---|---|
223
+ | `gateway_url` | `AGENTCORE_GATEWAY_MCP_URL` (`GATEWAY_MCP_URL`) |
224
+ | `cognito_token_url` | `AGENTCORE_COGNITO_TOKEN_URL` (`COGNITO_TOKEN_URL`) |
225
+ | `client_id` | `AGENTCORE_COGNITO_CLIENT_ID` (`COGNITO_CLIENT_ID`) |
226
+ | `client_secret` | `AGENTCORE_COGNITO_CLIENT_SECRET` (`COGNITO_CLIENT_SECRET`) |
227
+ | `scope` | `AGENTCORE_COGNITO_SCOPE` (`COGNITO_SCOPE`) |
228
+
229
+ ```python
230
+ # All five values from env vars:
231
+ search = WebSearchTool.agentcore(max_results=5)
232
+
233
+ # Or pass them explicitly:
234
+ search = WebSearchTool.agentcore(
235
+ gateway_url="https://...gateway.../mcp",
236
+ cognito_token_url="https://...oauth2/token",
237
+ client_id="...",
238
+ client_secret="...", # never logged
239
+ scope="gateway/invoke",
240
+ max_results=5, # clamped to 1–25
241
+ )
242
+ ```
243
+
244
+ The tool emitted to the LLM is named `agentcore_web_search` and returns
245
+ plaintext. Domain filters (`allowed_domains` / `blocked_domains`) are not
246
+ supported by AgentCore — use Tavily for domain filtering.
247
+
203
248
  ### Bring your own tool
204
249
 
205
250
  Any `@tool`-decorated function works — its string output is compressed for you:
@@ -286,7 +331,7 @@ Tavily / Brave keys are read from `TAVILY_API_KEY` / `BRAVE_SEARCH_API_KEY`
286
331
 
287
332
  | Param | Purpose |
288
333
  |---|---|
289
- | `query` | Question the LLM is trying to answer — drives `latte_v1` compression |
334
+ | `query` | Question the LLM is trying to answer — drives `latte_v2` compression |
290
335
  | `target_compression_ratio` | `0-1` strength (e.g. `0.5` = remove 50%) or `>1` for Nx factor (`4` = 4x). Backend max: 200 |
291
336
  | `coarse` | `True` for paragraph-level (default, faster), `False` for token-level (fine-grained) |
292
337
  | `heuristic_chunking` | Structure-preserving chunking |
@@ -325,6 +370,7 @@ Genuinely optional integrations beyond the agents layer:
325
370
  | `compresr[langgraph]` | `langgraph` (LangGraph checkpoint serializer, store, handoff tool) |
326
371
  | `compresr[llamaindex]` | `llama-index-core` (node postprocessor, memory block, tool wrapper) |
327
372
  | `compresr[litellm]` | `litellm[proxy]` (LiteLLM proxy guardrail) |
373
+ | `compresr[agentcore]` | `mcp`, `nest-asyncio` (Amazon Bedrock AgentCore web search) |
328
374
  | `compresr[all]` | all three above |
329
375
 
330
376
  ```bash
@@ -407,9 +453,9 @@ Minimal `proxy_config.yaml`:
407
453
 
408
454
  ```yaml
409
455
  model_list:
410
- - model_name: gpt-4o-mini
456
+ - model_name: gpt-5-mini
411
457
  litellm_params:
412
- model: openai/gpt-4o-mini
458
+ model: openai/gpt-5-mini
413
459
  api_key: os.environ/OPENAI_API_KEY
414
460
 
415
461
  guardrails:
@@ -437,7 +483,7 @@ Per-request override (no restart, no YAML edit):
437
483
 
438
484
  ```json
439
485
  {
440
- "model": "gpt-4o-mini",
486
+ "model": "gpt-5-mini",
441
487
  "messages": [...],
442
488
  "metadata": {
443
489
  "guardrail_config": {
@@ -29,7 +29,7 @@ print(f"Saved: {result.data.tokens_saved} tokens")
29
29
  print(result.data.compressed_context)
30
30
  ```
31
31
 
32
- The default model is `latte_v1` (query-aware). Pass any other model name your
32
+ The default model is `latte_v2` (query-aware). Pass any other model name your
33
33
  account has access to via `compression_model_name="..."` — the backend
34
34
  validates.
35
35
 
@@ -117,7 +117,7 @@ client.messages.create(model="claude-haiku-4-5", max_tokens=512,
117
117
  messages=[...], tools=[...])
118
118
 
119
119
  # OpenAI shape
120
- client.chat.completions.create(model="gpt-4o-mini", messages=[...], tools=[...])
120
+ client.chat.completions.create(model="gpt-5-mini", messages=[...], tools=[...])
121
121
 
122
122
  # Native — returns a NormalizedResult
123
123
  client.run(prompt="...", model="claude-haiku-4-5", tools=[...], max_tokens=512)
@@ -137,6 +137,46 @@ search = WebSearchTool.tavily(
137
137
  # Brave: WebSearchTool.brave(api_key=..., max_results=5)
138
138
  ```
139
139
 
140
+ #### Amazon Bedrock AgentCore
141
+
142
+ `WebSearchTool.agentcore(...)` reaches Amazon Bedrock AgentCore web search via a
143
+ Cognito OAuth handshake + an MCP streamable-HTTP session. Its runtime deps are
144
+ optional:
145
+
146
+ ```bash
147
+ pip install compresr[agentcore]
148
+ ```
149
+
150
+ Config resolves from explicit args first, then env vars (the
151
+ `AGENTCORE_`-prefixed name takes precedence, with the bare name as fallback):
152
+
153
+ | Field | Env var (fallback) |
154
+ |---|---|
155
+ | `gateway_url` | `AGENTCORE_GATEWAY_MCP_URL` (`GATEWAY_MCP_URL`) |
156
+ | `cognito_token_url` | `AGENTCORE_COGNITO_TOKEN_URL` (`COGNITO_TOKEN_URL`) |
157
+ | `client_id` | `AGENTCORE_COGNITO_CLIENT_ID` (`COGNITO_CLIENT_ID`) |
158
+ | `client_secret` | `AGENTCORE_COGNITO_CLIENT_SECRET` (`COGNITO_CLIENT_SECRET`) |
159
+ | `scope` | `AGENTCORE_COGNITO_SCOPE` (`COGNITO_SCOPE`) |
160
+
161
+ ```python
162
+ # All five values from env vars:
163
+ search = WebSearchTool.agentcore(max_results=5)
164
+
165
+ # Or pass them explicitly:
166
+ search = WebSearchTool.agentcore(
167
+ gateway_url="https://...gateway.../mcp",
168
+ cognito_token_url="https://...oauth2/token",
169
+ client_id="...",
170
+ client_secret="...", # never logged
171
+ scope="gateway/invoke",
172
+ max_results=5, # clamped to 1–25
173
+ )
174
+ ```
175
+
176
+ The tool emitted to the LLM is named `agentcore_web_search` and returns
177
+ plaintext. Domain filters (`allowed_domains` / `blocked_domains`) are not
178
+ supported by AgentCore — use Tavily for domain filtering.
179
+
140
180
  ### Bring your own tool
141
181
 
142
182
  Any `@tool`-decorated function works — its string output is compressed for you:
@@ -223,7 +263,7 @@ Tavily / Brave keys are read from `TAVILY_API_KEY` / `BRAVE_SEARCH_API_KEY`
223
263
 
224
264
  | Param | Purpose |
225
265
  |---|---|
226
- | `query` | Question the LLM is trying to answer — drives `latte_v1` compression |
266
+ | `query` | Question the LLM is trying to answer — drives `latte_v2` compression |
227
267
  | `target_compression_ratio` | `0-1` strength (e.g. `0.5` = remove 50%) or `>1` for Nx factor (`4` = 4x). Backend max: 200 |
228
268
  | `coarse` | `True` for paragraph-level (default, faster), `False` for token-level (fine-grained) |
229
269
  | `heuristic_chunking` | Structure-preserving chunking |
@@ -262,6 +302,7 @@ Genuinely optional integrations beyond the agents layer:
262
302
  | `compresr[langgraph]` | `langgraph` (LangGraph checkpoint serializer, store, handoff tool) |
263
303
  | `compresr[llamaindex]` | `llama-index-core` (node postprocessor, memory block, tool wrapper) |
264
304
  | `compresr[litellm]` | `litellm[proxy]` (LiteLLM proxy guardrail) |
305
+ | `compresr[agentcore]` | `mcp`, `nest-asyncio` (Amazon Bedrock AgentCore web search) |
265
306
  | `compresr[all]` | all three above |
266
307
 
267
308
  ```bash
@@ -344,9 +385,9 @@ Minimal `proxy_config.yaml`:
344
385
 
345
386
  ```yaml
346
387
  model_list:
347
- - model_name: gpt-4o-mini
388
+ - model_name: gpt-5-mini
348
389
  litellm_params:
349
- model: openai/gpt-4o-mini
390
+ model: openai/gpt-5-mini
350
391
  api_key: os.environ/OPENAI_API_KEY
351
392
 
352
393
  guardrails:
@@ -374,7 +415,7 @@ Per-request override (no restart, no YAML edit):
374
415
 
375
416
  ```json
376
417
  {
377
- "model": "gpt-4o-mini",
418
+ "model": "gpt-5-mini",
378
419
  "messages": [...],
379
420
  "metadata": {
380
421
  "guardrail_config": {
@@ -42,6 +42,7 @@ Quick Start - Agents (opt-in provider-shape facades):
42
42
  from importlib.metadata import PackageNotFoundError, version
43
43
  from typing import Any
44
44
 
45
+ from .auth import login, logout
45
46
  from .clients import CompressionClient
46
47
  from .config import MODELS
47
48
  from .retry import RetryConfig
@@ -56,6 +57,8 @@ __all__ = [
56
57
  "CompressionClient",
57
58
  "MODELS",
58
59
  "RetryConfig",
60
+ "login",
61
+ "logout",
59
62
  "WebSearchTool",
60
63
  "CompressionPolicy",
61
64
  "ResearchResult",
@@ -0,0 +1,150 @@
1
+ """``compresr-sdk`` CLI: login, logout, whoami, status."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import configparser
7
+ import sys
8
+ from typing import Optional
9
+
10
+ from . import __version__
11
+ from .auth import login as _login
12
+ from .auth import logout as _logout
13
+ from .credentials import (
14
+ DEFAULT_PROFILE,
15
+ LockAcquisitionError,
16
+ credentials_path,
17
+ load,
18
+ )
19
+
20
+
21
+ def _err(msg: str) -> None:
22
+ print(msg, file=sys.stderr)
23
+
24
+
25
+ def _cmd_login(args: argparse.Namespace) -> int:
26
+ try:
27
+ _login(
28
+ app_url=args.app_url,
29
+ base_url=args.base_url,
30
+ profile=args.profile,
31
+ timeout=args.timeout,
32
+ open_browser=not args.no_browser,
33
+ )
34
+ return 0
35
+ except (RuntimeError, TimeoutError, ValueError) as e:
36
+ _err(f"error: {e}")
37
+ return 1
38
+ except PermissionError as e:
39
+ _err(f"error: {e}")
40
+ _err("hint: run `chmod 600 ~/.compresr/credentials` and retry.")
41
+ return 1
42
+
43
+
44
+ def _cmd_logout(args: argparse.Namespace) -> int:
45
+ try:
46
+ removed = _logout(
47
+ args.profile,
48
+ revoke_server_key=not args.no_server_revoke,
49
+ base_url=args.base_url,
50
+ )
51
+ except LockAcquisitionError as e:
52
+ _err(f"error: {e}")
53
+ return 1
54
+ except PermissionError as e:
55
+ _err(f"error: {e}")
56
+ _err("hint: run `chmod 600 ~/.compresr/credentials` and retry.")
57
+ return 1
58
+ if removed:
59
+ print(f"Removed credentials for profile '{args.profile}'.")
60
+ return 0
61
+ print(f"No credentials found for profile '{args.profile}'.")
62
+ return 0
63
+
64
+
65
+ def _cmd_whoami(args: argparse.Namespace) -> int:
66
+ try:
67
+ key = load(args.profile)
68
+ except PermissionError as e:
69
+ _err(f"error: {e}")
70
+ return 1
71
+ if not key:
72
+ _err(f"Not logged in (profile '{args.profile}'). Run `compresr-sdk login`.")
73
+ return 1
74
+ print(f"profile: {args.profile}")
75
+ print(f"file: {credentials_path()}")
76
+ print(f"api_key: {key[:8]}…{key[-4:]}")
77
+ return 0
78
+
79
+
80
+ def _cmd_status(_args: argparse.Namespace) -> int:
81
+ from .credentials import _read_file
82
+
83
+ path = credentials_path()
84
+ print(f"credentials file: {path}")
85
+ print(f"exists: {path.exists()}")
86
+ if not path.exists():
87
+ return 0
88
+ try:
89
+ parser = _read_file()
90
+ except PermissionError as e:
91
+ _err(f"error: {e}")
92
+ return 1
93
+ except configparser.Error as e:
94
+ _err(f"warning: file is corrupt ({e})")
95
+ return 0
96
+ profiles = parser.sections()
97
+ print(f"profiles: {', '.join(profiles) if profiles else '(none)'}")
98
+ return 0
99
+
100
+
101
+ def _build_parser() -> argparse.ArgumentParser:
102
+ p = argparse.ArgumentParser(prog="compresr-sdk", description="Compresr SDK CLI")
103
+ p.add_argument("--version", action="version", version=f"compresr-sdk {__version__}")
104
+ sub = p.add_subparsers(dest="command", required=True)
105
+
106
+ login_p = sub.add_parser("login", help="Log in via browser and store credentials")
107
+ login_p.add_argument("--profile", default=DEFAULT_PROFILE)
108
+ login_p.add_argument("--app-url", default=None, help="Override web app URL")
109
+ login_p.add_argument("--base-url", default=None, help="Override API base URL")
110
+ login_p.add_argument("--timeout", type=float, default=120.0)
111
+ login_p.add_argument(
112
+ "--no-browser", action="store_true", help="Print the URL instead of opening it"
113
+ )
114
+ login_p.set_defaults(func=_cmd_login)
115
+
116
+ logout_p = sub.add_parser(
117
+ "logout",
118
+ help="Revoke the server-side CLI key and remove stored credentials",
119
+ )
120
+ logout_p.add_argument("--profile", default=DEFAULT_PROFILE)
121
+ logout_p.add_argument("--base-url", default=None, help="Override API base URL")
122
+ logout_p.add_argument(
123
+ "--no-server-revoke",
124
+ action="store_true",
125
+ help="Skip the server-side key revoke and only delete local credentials.",
126
+ )
127
+ logout_p.set_defaults(func=_cmd_logout)
128
+
129
+ whoami_p = sub.add_parser("whoami", help="Show the stored API key preview")
130
+ whoami_p.add_argument("--profile", default=DEFAULT_PROFILE)
131
+ whoami_p.set_defaults(func=_cmd_whoami)
132
+
133
+ status_p = sub.add_parser("status", help="Show credentials file location and profiles")
134
+ status_p.set_defaults(func=_cmd_status)
135
+
136
+ return p
137
+
138
+
139
+ def main(argv: Optional[list] = None) -> int:
140
+ parser = _build_parser()
141
+ args = parser.parse_args(argv)
142
+ try:
143
+ return int(args.func(args))
144
+ except KeyboardInterrupt:
145
+ _err("\nAborted.")
146
+ return 130
147
+
148
+
149
+ if __name__ == "__main__":
150
+ raise SystemExit(main())
@@ -0,0 +1,247 @@
1
+ """AgentCore Web Search client (Amazon Bedrock AgentCore over MCP).
2
+
3
+ Ported from the standalone ``websearch_client.py`` reference. Wraps the
4
+ Cognito client-credentials OAuth handshake plus an MCP streamable-HTTP session
5
+ into a synchronous :meth:`AgentCoreClient.search` call that works in plain
6
+ scripts and in Jupyter (handles the already-running event-loop case).
7
+
8
+ The runtime dependencies (``mcp``, ``nest-asyncio``) are **optional** — they are
9
+ imported lazily inside the call so a bare ``pip install compresr`` still imports
10
+ fine. The error only surfaces when ``WebSearchTool.agentcore(...)`` is actually
11
+ invoked without the extra installed::
12
+
13
+ pip install compresr[agentcore]
14
+
15
+ Never logs the Cognito client secret or the bearer token.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import json
22
+ import threading
23
+ from dataclasses import dataclass
24
+ from typing import Any, Optional
25
+
26
+ import httpx
27
+
28
+ # Guard against an unbounded/misbehaving gateway response before buffering it
29
+ # in memory and parsing as JSON.
30
+ MAX_RESPONSE_BYTES = 1_000_000
31
+
32
+ # The gateway also exposes this built-in *tool*-search (semantic search over the
33
+ # gateway's own tools). It is NOT web search and must be ignored.
34
+ TOOL_SEARCH_META = "x_amz_bedrock_agentcore_search"
35
+
36
+ TOKEN_TIMEOUT_SECONDS = 30
37
+
38
+ # AgentCore web search accepts maxResults in the inclusive range 1..25.
39
+ MIN_RESULTS = 1
40
+ MAX_RESULTS = 25
41
+
42
+ _MISSING_DEP_MSG = (
43
+ "WebSearchTool.agentcore requires the 'agentcore' extra. "
44
+ "Install: pip install compresr[agentcore]"
45
+ )
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class AgentCoreConfig:
50
+ """Immutable connection config for the AgentCore web-search gateway."""
51
+
52
+ gateway_url: str
53
+ cognito_token_url: str
54
+ client_id: str
55
+ client_secret: str
56
+ scope: str
57
+ max_results: int = 5
58
+
59
+
60
+ def _clamp_results(value: int) -> int:
61
+ """Clamp a requested result count to AgentCore's inclusive 1..25 range."""
62
+ return max(MIN_RESULTS, min(MAX_RESULTS, value))
63
+
64
+
65
+ def _run(coro: Any) -> Any:
66
+ """Run a coroutine whether or not an event loop is already running (Jupyter)."""
67
+ try:
68
+ loop = asyncio.get_running_loop()
69
+ except RuntimeError:
70
+ return asyncio.run(coro)
71
+ # Inside a running loop (e.g. Jupyter): allow nested execution.
72
+ try:
73
+ import nest_asyncio
74
+ except ImportError as exc: # pragma: no cover - exercised via search()
75
+ raise ImportError(_MISSING_DEP_MSG) from exc
76
+
77
+ nest_asyncio.apply()
78
+ return loop.run_until_complete(coro)
79
+
80
+
81
+ class AgentCoreClient:
82
+ """Synchronous client for AgentCore web search over MCP.
83
+
84
+ Mints (and caches) a Cognito bearer token, then opens an MCP
85
+ streamable-HTTP session to call the gateway's web-search tool. A single
86
+ instance is shared by the built tool so the token is reused across calls.
87
+ """
88
+
89
+ def __init__(self, config: AgentCoreConfig) -> None:
90
+ self._config = config
91
+ self._token: Optional[str] = None
92
+ self._token_lock = threading.Lock()
93
+
94
+ # --- auth ----------------------------------------------------------------
95
+
96
+ def token(self, refresh: bool = False) -> str:
97
+ """Return a cached bearer token, minting a fresh one when needed.
98
+
99
+ Guarded by a lock (double-checked locking) so concurrent first-use
100
+ calls from multiple threads only mint one token. The client secret
101
+ and the resulting token are never logged.
102
+ """
103
+ if self._token and not refresh:
104
+ return self._token
105
+ with self._token_lock:
106
+ # Re-check after acquiring the lock: another thread may have
107
+ # already minted/refreshed the token while we were waiting.
108
+ if self._token and not refresh:
109
+ return self._token
110
+ try:
111
+ resp = httpx.post(
112
+ self._config.cognito_token_url,
113
+ data={
114
+ "grant_type": "client_credentials",
115
+ "client_id": self._config.client_id,
116
+ "client_secret": self._config.client_secret,
117
+ "scope": self._config.scope,
118
+ },
119
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
120
+ timeout=TOKEN_TIMEOUT_SECONDS,
121
+ )
122
+ resp.raise_for_status()
123
+ self._token = resp.json()["access_token"]
124
+ except httpx.HTTPError as exc:
125
+ # Re-raise sanitized: the original exception's .request (and
126
+ # this frame's locals) carry the client_secret, which we do
127
+ # not want chained into error-monitoring tools (e.g. Sentry).
128
+ status = getattr(getattr(exc, "response", None), "status_code", None)
129
+ detail = f" (HTTP {status})" if status is not None else ""
130
+ raise RuntimeError(f"AgentCore Cognito token request failed{detail}.") from None
131
+ return self._token
132
+
133
+ # --- tool selection ------------------------------------------------------
134
+
135
+ @staticmethod
136
+ def _pick_web_search_tool(names: list[str]) -> str:
137
+ """Pick the web-search tool, ignoring the semantic tool-search meta tool."""
138
+ candidates = [n for n in names if n != TOOL_SEARCH_META]
139
+ for name in candidates:
140
+ if "websearch" in name.lower().replace("-", "").replace("_", ""):
141
+ return name
142
+ if not candidates:
143
+ raise RuntimeError(f"No web-search tool found among {names}")
144
+ return candidates[0]
145
+
146
+ # --- search --------------------------------------------------------------
147
+
148
+ async def _search_async(self, query: str, max_results: int, *, refresh: bool) -> dict:
149
+ # Lazy import so a bare install imports fine; only fails on actual use.
150
+ try:
151
+ from mcp import ClientSession
152
+ from mcp.client.streamable_http import streamablehttp_client
153
+ except ImportError as exc:
154
+ raise ImportError(_MISSING_DEP_MSG) from exc
155
+
156
+ headers = {"Authorization": f"Bearer {self.token(refresh=refresh)}"}
157
+ async with streamablehttp_client(self._config.gateway_url, headers=headers) as (
158
+ read,
159
+ write,
160
+ _,
161
+ ):
162
+ async with ClientSession(read, write) as session:
163
+ await session.initialize()
164
+ names = [t.name for t in (await session.list_tools()).tools]
165
+ tool = self._pick_web_search_tool(names)
166
+ result = await session.call_tool(tool, {"query": query, "maxResults": max_results})
167
+ return _parse_tool_result(result)
168
+
169
+ def search(self, query: str, max_results: Optional[int] = None) -> list[dict]:
170
+ """Run a web search and return normalized result dicts.
171
+
172
+ Each result is ``{"title", "url", "content", "published_date"}`` — the
173
+ ``content`` key matches the SDK's ``_flatten_search_results`` flattener.
174
+ Re-mints the bearer token once on an MCP 401 and retries.
175
+ """
176
+ count = _clamp_results(self._config.max_results if max_results is None else max_results)
177
+ try:
178
+ payload = _run(self._search_async(query, count, refresh=False))
179
+ except Exception as exc: # noqa: BLE001 - inspect for a 401 to retry once
180
+ if _is_unauthorized(exc):
181
+ payload = _run(self._search_async(query, count, refresh=True))
182
+ else:
183
+ raise
184
+ results = payload.get("results", [])
185
+ if not isinstance(results, list):
186
+ raise RuntimeError(
187
+ "AgentCore web-search returned an unexpected 'results' shape "
188
+ f"(expected a list, got {type(results).__name__})."
189
+ )
190
+ out: list[dict] = []
191
+ for item in results:
192
+ if not isinstance(item, dict):
193
+ continue
194
+ out.append(
195
+ {
196
+ "title": item.get("title"),
197
+ "url": item.get("url"),
198
+ "content": item.get("text", "") or "",
199
+ "published_date": item.get("publishedDate"),
200
+ }
201
+ )
202
+ return out
203
+
204
+
205
+ def _parse_tool_result(result: Any) -> dict:
206
+ """Validate and parse an MCP ``CallToolResult`` into a JSON dict.
207
+
208
+ Defensive against everything a misbehaving/compromised gateway could
209
+ return: a tool-level error (``isError=True`` with plain-text content,
210
+ NOT JSON), an oversized body, malformed JSON, or a JSON value that isn't
211
+ an object. Raises ``RuntimeError`` with a clear, secret-free message in
212
+ every case instead of letting a bare ``JSONDecodeError``/``AttributeError``
213
+ propagate.
214
+ """
215
+ text = "".join(getattr(b, "text", "") for b in result.content)
216
+ if getattr(result, "isError", False):
217
+ # Gateway tool-level errors (bad query, throttling, auth/scope error)
218
+ # come back as plain text, NOT JSON. Surface them directly instead of
219
+ # failing on json.loads with a confusing JSONDecodeError. The text may
220
+ # itself contain "401" — keep it intact so the unauthorized-retry
221
+ # heuristic in _is_unauthorized still matches.
222
+ raise RuntimeError(f"AgentCore web-search tool error: {text}")
223
+ if len(text.encode("utf-8", errors="ignore")) > MAX_RESPONSE_BYTES:
224
+ raise RuntimeError(
225
+ f"AgentCore web-search response exceeded the {MAX_RESPONSE_BYTES} byte size guard."
226
+ )
227
+ try:
228
+ payload = json.loads(text)
229
+ except json.JSONDecodeError as exc:
230
+ raise RuntimeError(f"AgentCore web-search returned malformed JSON: {exc}") from None
231
+ if not isinstance(payload, dict):
232
+ raise RuntimeError(
233
+ "AgentCore web-search returned an unexpected JSON shape "
234
+ f"(expected an object, got {type(payload).__name__})."
235
+ )
236
+ return payload
237
+
238
+
239
+ def _is_unauthorized(exc: BaseException) -> bool:
240
+ """Best-effort detection of an MCP/HTTP 401 to trigger a single token re-mint."""
241
+ status = getattr(getattr(exc, "response", None), "status_code", None)
242
+ if status == 401:
243
+ return True
244
+ return "401" in str(exc)
245
+
246
+
247
+ __all__ = ["AgentCoreConfig", "AgentCoreClient"]