llm-optimizer 0.2.0__tar.gz → 0.3.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.
Files changed (24) hide show
  1. {llm_optimizer-0.2.0 → llm_optimizer-0.3.1}/PKG-INFO +5 -2
  2. {llm_optimizer-0.2.0 → llm_optimizer-0.3.1}/llm_optimizer/__init__.py +4 -1
  3. llm_optimizer-0.3.1/llm_optimizer/bedrock.py +324 -0
  4. {llm_optimizer-0.2.0 → llm_optimizer-0.3.1}/llm_optimizer/client.py +30 -2
  5. {llm_optimizer-0.2.0 → llm_optimizer-0.3.1}/llm_optimizer/models.py +21 -0
  6. {llm_optimizer-0.2.0 → llm_optimizer-0.3.1}/llm_optimizer/pricing.json +81 -13
  7. {llm_optimizer-0.2.0 → llm_optimizer-0.3.1}/llm_optimizer/streaming.py +26 -0
  8. {llm_optimizer-0.2.0 → llm_optimizer-0.3.1}/llm_optimizer.egg-info/PKG-INFO +5 -2
  9. {llm_optimizer-0.2.0 → llm_optimizer-0.3.1}/llm_optimizer.egg-info/SOURCES.txt +1 -2
  10. {llm_optimizer-0.2.0 → llm_optimizer-0.3.1}/llm_optimizer.egg-info/requires.txt +4 -0
  11. {llm_optimizer-0.2.0 → llm_optimizer-0.3.1}/pyproject.toml +4 -3
  12. {llm_optimizer-0.2.0 → llm_optimizer-0.3.1}/tests/test_llm_optimizer.py +165 -0
  13. llm_optimizer-0.2.0/llm_optimizer/cli.py +0 -79
  14. llm_optimizer-0.2.0/llm_optimizer/setup.py +0 -36
  15. {llm_optimizer-0.2.0 → llm_optimizer-0.3.1}/README.md +0 -0
  16. {llm_optimizer-0.2.0 → llm_optimizer-0.3.1}/llm_optimizer/batcher.py +0 -0
  17. {llm_optimizer-0.2.0 → llm_optimizer-0.3.1}/llm_optimizer/caching.py +0 -0
  18. {llm_optimizer-0.2.0 → llm_optimizer-0.3.1}/llm_optimizer/optimizer.py +0 -0
  19. {llm_optimizer-0.2.0 → llm_optimizer-0.3.1}/llm_optimizer/router.py +0 -0
  20. {llm_optimizer-0.2.0 → llm_optimizer-0.3.1}/llm_optimizer/tracker.py +0 -0
  21. {llm_optimizer-0.2.0 → llm_optimizer-0.3.1}/llm_optimizer.egg-info/dependency_links.txt +0 -0
  22. {llm_optimizer-0.2.0 → llm_optimizer-0.3.1}/llm_optimizer.egg-info/entry_points.txt +0 -0
  23. {llm_optimizer-0.2.0 → llm_optimizer-0.3.1}/llm_optimizer.egg-info/top_level.txt +0 -0
  24. {llm_optimizer-0.2.0 → llm_optimizer-0.3.1}/setup.cfg +0 -0
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: llm-optimizer
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Reduce LLM API costs by up to 90% — prompt caching, smart routing, batch processing, and more
5
5
  Author-email: Somnath Banerjee <banerjso30@gmail.com>
6
6
  License: MIT
7
- Project-URL: Homepage, https://github.com/banerjeeso/llm-optimizer
7
+ Project-URL: Homepage, https://github.com/banerjeesollm-optimizer
8
8
  Project-URL: Documentation, https://github.com/banerjeeso/llm-optimizer#readme
9
9
  Project-URL: Issues, https://github.com/banerjeeso/llm-optimizer/issues
10
10
  Keywords: llm,anthropic,openai,cost,optimization,token,caching,batch
@@ -29,6 +29,9 @@ Provides-Extra: all
29
29
  Requires-Dist: openai>=1.0.0; extra == "all"
30
30
  Requires-Dist: google-generativeai>=0.5.0; extra == "all"
31
31
  Requires-Dist: tiktoken>=0.7.0; extra == "all"
32
+ Requires-Dist: boto3>=1.34.0; extra == "all"
33
+ Provides-Extra: bedrock
34
+ Requires-Dist: boto3>=1.34.0; extra == "bedrock"
32
35
  Provides-Extra: dev
33
36
  Requires-Dist: pytest>=8.0; extra == "dev"
34
37
  Requires-Dist: build; extra == "dev"
@@ -31,7 +31,7 @@ from .caching import CacheManager, build_cached_system_prompt
31
31
  from .batcher import BatchProcessor, BatchRequest, BatchResult
32
32
  from .tracker import CostTracker, UsageRecord, estimate_tokens
33
33
 
34
- __version__ = "0.1.0"
34
+ __version__ = "0.3.0"
35
35
  __author__ = "Your Team"
36
36
 
37
37
  __all__ = [
@@ -56,6 +56,9 @@ __all__ = [
56
56
  "BatchResult",
57
57
  # Tracking
58
58
  "CostTracker",
59
+ "BedrockClient",
60
+ "BedrockResponse",
61
+ "make_bedrock_client",
59
62
  "UsageRecord",
60
63
  "estimate_tokens",
61
64
  ]
@@ -0,0 +1,324 @@
1
+ """
2
+ AWS Bedrock support for llm-optimizer.
3
+
4
+ Wraps boto3's bedrock-runtime client to match the same interface
5
+ as anthropic.Anthropic(), so all optimizations apply identically.
6
+
7
+ Supports two auth patterns:
8
+
9
+ Option 1 — AWS profile (most common for local dev):
10
+ from llm_optimizer import OptimizedClient, Provider
11
+ from llm_optimizer.bedrock import BedrockClient
12
+
13
+ client = OptimizedClient(
14
+ bedrock_client=BedrockClient(profile_name="ai-core", region="us-east-1"),
15
+ preferred_provider=Provider.BEDROCK,
16
+ )
17
+
18
+ Option 2 — Env vars (CI/CD pipelines, Lambda, EC2 with IAM role):
19
+ # Set: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION
20
+ # or attach an IAM role — boto3 picks it up automatically
21
+
22
+ client = OptimizedClient(
23
+ bedrock_client=BedrockClient(), # zero config — reads env/role
24
+ preferred_provider=Provider.BEDROCK,
25
+ )
26
+
27
+ Key differences from direct Anthropic API:
28
+ - Auth via AWS IAM, not API keys
29
+ - Prompt caching (cache_control) NOT supported — stripped automatically
30
+ - Model IDs use Bedrock format: anthropic.claude-haiku-4-5-20251001-v1:0
31
+ - Batch via Bedrock Batch Inference (not Anthropic Batch API)
32
+
33
+ Install:
34
+ pip install llm-optimizer[bedrock]
35
+ """
36
+
37
+ import json
38
+ import warnings
39
+ from dataclasses import dataclass
40
+ from typing import Any, Generator, Optional
41
+
42
+
43
+ @dataclass
44
+ class BedrockResponse:
45
+ """
46
+ Normalizes Bedrock response to match Anthropic SDK response shape.
47
+ Ensures tracker, streaming, and all other components work identically.
48
+ """
49
+ content: list
50
+ usage: "BedrockUsage"
51
+ model: str
52
+ stop_reason: str
53
+
54
+ def __str__(self):
55
+ return self.content[0].text if self.content else ""
56
+
57
+
58
+ @dataclass
59
+ class BedrockUsage:
60
+ input_tokens: int
61
+ output_tokens: int
62
+ cache_read_input_tokens: int = 0 # Always 0 — Bedrock does not support caching
63
+
64
+
65
+ @dataclass
66
+ class BedrockContentBlock:
67
+ text: str
68
+ type: str = "text"
69
+
70
+
71
+ class BedrockClient:
72
+ """
73
+ Drop-in Bedrock client for OptimizedClient.
74
+
75
+ Auth priority (boto3 standard credential chain):
76
+ 1. profile_name argument → ~/.aws/credentials [profile]
77
+ 2. session argument → pre-built boto3.Session
78
+ 3. Environment variables → AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
79
+ 4. IAM role → auto-detected on EC2 / Lambda / ECS
80
+
81
+ Args:
82
+ region: AWS region (default: us-east-1)
83
+ profile_name: AWS profile from ~/.aws/config (e.g. "ai-core")
84
+ session: Pre-configured boto3.Session — overrides profile_name
85
+ discount_pct: Your negotiated AWS discount (e.g. 16.0).
86
+ Applied to cost estimates only — does not affect API calls.
87
+ """
88
+
89
+ def __init__(
90
+ self,
91
+ region: str = "us-east-1",
92
+ profile_name: Optional[str] = None,
93
+ session=None,
94
+ discount_pct: float = 0.0,
95
+ ):
96
+ self.region = region
97
+ self.discount_pct = discount_pct
98
+ self._profile = profile_name
99
+ self._session = session
100
+ self._runtime = None
101
+ self._boto3 = None
102
+
103
+ # ── Credential chain ───────────────────────────────────────────────────────
104
+
105
+ def _get_runtime(self):
106
+ """Lazy-initialize boto3 bedrock-runtime client."""
107
+ if self._runtime:
108
+ return self._runtime
109
+
110
+ try:
111
+ import boto3
112
+ self._boto3 = boto3
113
+ except ImportError:
114
+ raise ImportError(
115
+ "boto3 is required for AWS Bedrock support.\n"
116
+ "Install with: pip install llm-optimizer[bedrock]"
117
+ )
118
+
119
+ # Credential chain: explicit session > profile > env/role
120
+ if self._session:
121
+ resolved_session = self._session
122
+ elif self._profile:
123
+ resolved_session = boto3.Session(profile_name=self._profile, region_name=self.region)
124
+ else:
125
+ # Falls through to env vars, then IAM role
126
+ resolved_session = boto3.Session(region_name=self.region)
127
+
128
+ self._runtime = resolved_session.client(
129
+ service_name="bedrock-runtime",
130
+ region_name=self.region,
131
+ )
132
+ return self._runtime
133
+
134
+ # ── messages interface (matches anthropic.Anthropic().messages) ────────────
135
+
136
+ class _Messages:
137
+ def __init__(self, bedrock_client: "BedrockClient"):
138
+ self._client = bedrock_client
139
+
140
+ def create(self, model, max_tokens, messages, system=None, **kwargs) -> BedrockResponse:
141
+ return self._client._invoke(model, max_tokens, messages, system, **kwargs)
142
+
143
+ def stream(self, model, max_tokens, messages, system=None, **kwargs):
144
+ return _BedrockStreamContext(self._client, model, max_tokens, messages, system, **kwargs)
145
+
146
+ @property
147
+ def messages(self):
148
+ return self._Messages(self)
149
+
150
+ # ── Effective cost (with discount) ────────────────────────────────────────
151
+
152
+ def effective_cost_multiplier(self) -> float:
153
+ """Returns 1.0 - (discount_pct / 100). Applied to pricing estimates."""
154
+ return 1.0 - (self.discount_pct / 100.0)
155
+
156
+ # ── Internal ──────────────────────────────────────────────────────────────
157
+
158
+ def _build_body(
159
+ self,
160
+ model: str,
161
+ max_tokens: int,
162
+ messages: list[dict],
163
+ system: Optional[Any],
164
+ temperature: float = 0.0,
165
+ **kwargs,
166
+ ) -> dict:
167
+ """
168
+ Build Bedrock request body.
169
+ Strips cache_control automatically — Bedrock rejects it.
170
+ """
171
+ clean_messages = self._strip_cache_control(messages)
172
+
173
+ # Resolve system prompt from str or Anthropic blocks list
174
+ system_text = ""
175
+ if system:
176
+ if isinstance(system, str):
177
+ system_text = system
178
+ elif isinstance(system, list):
179
+ system_text = " ".join(
180
+ b.get("text", "") for b in system
181
+ if isinstance(b, dict) and b.get("type") == "text"
182
+ )
183
+
184
+ body: dict[str, Any] = {
185
+ "anthropic_version": "bedrock-2023-05-31",
186
+ "max_tokens": max_tokens,
187
+ "messages": clean_messages,
188
+ }
189
+ if system_text:
190
+ body["system"] = system_text
191
+ if temperature > 0:
192
+ body["temperature"] = temperature
193
+
194
+ return body
195
+
196
+ def _invoke(self, model, max_tokens, messages, system=None, **kwargs) -> BedrockResponse:
197
+ runtime = self._get_runtime()
198
+ body = self._build_body(model, max_tokens, messages, system, **kwargs)
199
+
200
+ response = runtime.invoke_model(
201
+ modelId=model,
202
+ body=json.dumps(body),
203
+ contentType="application/json",
204
+ accept="application/json",
205
+ )
206
+
207
+ result = json.loads(response["body"].read())
208
+
209
+ content_blocks = [
210
+ BedrockContentBlock(text=block["text"])
211
+ for block in result.get("content", [])
212
+ if block.get("type") == "text"
213
+ ]
214
+
215
+ usage_data = result.get("usage", {})
216
+ return BedrockResponse(
217
+ content=content_blocks,
218
+ usage=BedrockUsage(
219
+ input_tokens=usage_data.get("input_tokens", 0),
220
+ output_tokens=usage_data.get("output_tokens", 0),
221
+ ),
222
+ model=model,
223
+ stop_reason=result.get("stop_reason", "end_turn"),
224
+ )
225
+
226
+ @staticmethod
227
+ def _strip_cache_control(messages: list[dict]) -> list[dict]:
228
+ """
229
+ Strip cache_control from all message content blocks.
230
+
231
+ Why: Bedrock's Claude API does not support prompt caching.
232
+ Sending cache_control causes a validation error.
233
+ The library strips it silently so callers don't need to branch
234
+ their code between Anthropic and Bedrock.
235
+ """
236
+ clean = []
237
+ for msg in messages:
238
+ content = msg.get("content", "")
239
+ if isinstance(content, list):
240
+ clean_blocks = [
241
+ {k: v for k, v in block.items() if k != "cache_control"}
242
+ if isinstance(block, dict) else block
243
+ for block in content
244
+ ]
245
+ clean.append({**msg, "content": clean_blocks})
246
+ else:
247
+ clean.append(msg)
248
+ return clean
249
+
250
+
251
+ class _BedrockStreamContext:
252
+ """Context manager for Bedrock streaming. Matches Anthropic stream interface."""
253
+
254
+ def __init__(self, bedrock_client, model, max_tokens, messages, system, **kwargs):
255
+ self._client = bedrock_client
256
+ self._model = model
257
+ self._max_tokens = max_tokens
258
+ self._messages = messages
259
+ self._system = system
260
+ self._kwargs = kwargs
261
+ self.usage = None
262
+
263
+ def __enter__(self):
264
+ return self
265
+
266
+ def __exit__(self, *args):
267
+ pass
268
+
269
+ def __iter__(self) -> Generator[str, None, None]:
270
+ runtime = self._client._get_runtime()
271
+ body = self._client._build_body(
272
+ self._model, self._max_tokens, self._messages, self._system, **self._kwargs
273
+ )
274
+
275
+ response = runtime.invoke_model_with_response_stream(
276
+ modelId=self._model,
277
+ body=json.dumps(body),
278
+ contentType="application/json",
279
+ accept="application/json",
280
+ )
281
+
282
+ input_tokens = output_tokens = 0
283
+
284
+ for event in response.get("body", []):
285
+ chunk = event.get("chunk", {})
286
+ if not chunk:
287
+ continue
288
+ data = json.loads(chunk.get("bytes", b"{}"))
289
+ etype = data.get("type", "")
290
+
291
+ if etype == "content_block_delta":
292
+ delta = data.get("delta", {})
293
+ if delta.get("type") == "text_delta":
294
+ yield delta.get("text", "")
295
+ elif etype == "message_start":
296
+ input_tokens = data.get("message", {}).get("usage", {}).get("input_tokens", 0)
297
+ elif etype == "message_delta":
298
+ output_tokens = data.get("usage", {}).get("output_tokens", 0)
299
+
300
+ self.usage = BedrockUsage(input_tokens=input_tokens, output_tokens=output_tokens)
301
+
302
+
303
+ # ── Convenience factory ────────────────────────────────────────────────────────
304
+
305
+ def make_bedrock_client(
306
+ profile_name: Optional[str] = None,
307
+ region: str = "us-east-1",
308
+ discount_pct: float = 0.0,
309
+ ) -> BedrockClient:
310
+ """
311
+ Convenience factory matching the proposed OptimizedClient shorthand.
312
+
313
+ Usage:
314
+ # Profile-based (most Saks devs)
315
+ bedrock = make_bedrock_client(profile_name="ai-core", discount_pct=16.0)
316
+
317
+ # Env-var / IAM role (CI/CD, Lambda)
318
+ bedrock = make_bedrock_client()
319
+ """
320
+ return BedrockClient(
321
+ region=region,
322
+ profile_name=profile_name,
323
+ discount_pct=discount_pct,
324
+ )
@@ -25,7 +25,7 @@ from .router import ModelRouter
25
25
  from .optimizer import PromptOptimizer, DocumentCompressor
26
26
  from .batcher import BatchProcessor
27
27
  from .tracker import CostTracker, estimate_tokens
28
- from .streaming import StreamResult, build_anthropic_stream, build_openai_stream
28
+ from .streaming import StreamResult, build_anthropic_stream, build_openai_stream, build_bedrock_stream
29
29
 
30
30
 
31
31
  class OptimizedClient:
@@ -55,6 +55,7 @@ class OptimizedClient:
55
55
  def __init__(
56
56
  self,
57
57
  anthropic_client=None,
58
+ bedrock_client=None,
58
59
  openai_client=None,
59
60
  google_client=None,
60
61
  # Feature flags
@@ -83,6 +84,7 @@ class OptimizedClient:
83
84
  batch_default_model: Optional[str] = None,
84
85
  ):
85
86
  self._anthropic = anthropic_client
87
+ self._bedrock = bedrock_client
86
88
  self._openai = openai_client
87
89
  self._google = google_client
88
90
  self._default_model = default_model
@@ -255,10 +257,18 @@ class OptimizedClient:
255
257
  self._openai, model_config.model_id, messages,
256
258
  system_blocks, max_tokens, temperature, **kwargs
257
259
  )
260
+ elif provider == Provider.BEDROCK:
261
+ if not self._bedrock:
262
+ raise RuntimeError("No Bedrock client. Pass bedrock_client=BedrockClient() to OptimizedClient.")
263
+ from .streaming import build_bedrock_stream
264
+ result = build_bedrock_stream(
265
+ self._bedrock, model_config.model_id, messages,
266
+ system_blocks, max_tokens, temperature, **kwargs
267
+ )
258
268
  else:
259
269
  raise NotImplementedError(
260
270
  f"Streaming is not yet supported for provider: {provider.value}. "
261
- "Supported: anthropic, openai."
271
+ "Supported: anthropic, openai, bedrock."
262
272
  )
263
273
 
264
274
  return result
@@ -453,6 +463,8 @@ class OptimizedClient:
453
463
  return self._call_openai(model_config, messages, system, max_tokens, temperature, **kwargs)
454
464
  elif provider == Provider.GOOGLE:
455
465
  return self._call_google(model_config, messages, system, max_tokens, temperature, **kwargs)
466
+ elif provider == Provider.BEDROCK:
467
+ return self._call_bedrock(model_config, messages, system, max_tokens, temperature, **kwargs)
456
468
  else:
457
469
  raise ValueError(f"Unsupported provider: {provider}")
458
470
 
@@ -499,6 +511,22 @@ class OptimizedClient:
499
511
  )
500
512
  return self._google.generate_content(prompt)
501
513
 
514
+
515
+ def _call_bedrock(self, model_config, messages, system, max_tokens, temperature, **kwargs):
516
+ if not self._bedrock:
517
+ raise RuntimeError(
518
+ "No Bedrock client. Pass bedrock_client=BedrockClient() to OptimizedClient.\n"
519
+ "from llm_optimizer.bedrock import BedrockClient\n"
520
+ "bedrock = BedrockClient(region='us-east-1', profile_name='ai-core')"
521
+ )
522
+ return self._bedrock.messages.create(
523
+ model=model_config.model_id,
524
+ max_tokens=max_tokens,
525
+ messages=messages,
526
+ system=system,
527
+ **kwargs,
528
+ )
529
+
502
530
  @staticmethod
503
531
  def _extract_usage(response) -> tuple[int, int, int]:
504
532
  if hasattr(response, "usage"):
@@ -30,6 +30,7 @@ class Provider(Enum):
30
30
  ANTHROPIC = "anthropic"
31
31
  OPENAI = "openai"
32
32
  GOOGLE = "google"
33
+ BEDROCK = "bedrock"
33
34
 
34
35
 
35
36
  @dataclass
@@ -144,3 +145,23 @@ TIER_DEFAULTS: dict[TaskComplexity, dict[Provider, str]] = {
144
145
  Provider.GOOGLE: "gemini-pro",
145
146
  },
146
147
  }
148
+
149
+ # Bedrock tier defaults (appended for v0.3.0)
150
+ TIER_DEFAULTS.update({
151
+ TaskComplexity.SIMPLE: {
152
+ **TIER_DEFAULTS.get(TaskComplexity.SIMPLE, {}),
153
+ Provider.BEDROCK: "bedrock-claude-haiku-4-5",
154
+ },
155
+ TaskComplexity.MEDIUM: {
156
+ **TIER_DEFAULTS.get(TaskComplexity.MEDIUM, {}),
157
+ Provider.BEDROCK: "bedrock-claude-haiku-4-5",
158
+ },
159
+ TaskComplexity.COMPLEX: {
160
+ **TIER_DEFAULTS.get(TaskComplexity.COMPLEX, {}),
161
+ Provider.BEDROCK: "bedrock-claude-sonnet-4-5",
162
+ },
163
+ TaskComplexity.EXPERT: {
164
+ **TIER_DEFAULTS.get(TaskComplexity.EXPERT, {}),
165
+ Provider.BEDROCK: "bedrock-claude-haiku-4-5",
166
+ },
167
+ })
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "_meta": {
3
- "version": "2026-04-03",
4
- "last_updated": "2026-04-03",
3
+ "version": "2026-07-14",
4
+ "last_updated": "2026-07-14",
5
5
  "warning": "Provider pricing changes without notice. Verify at provider pricing pages before relying on cost estimates.",
6
6
  "sources": {
7
7
  "anthropic": "https://www.anthropic.com/pricing",
8
8
  "openai": "https://openai.com/pricing",
9
- "google": "https://ai.google.dev/pricing"
10
- }
9
+ "google": "https://ai.google.dev/pricing",
10
+ "bedrock": "https://aws.amazon.com/bedrock/pricing/"
11
+ },
12
+ "bedrock_note": "Bedrock prices = AWS list price. If your account has a negotiated discount (e.g. 16%), set discount_pct in BedrockClient to apply it to cost estimates."
11
13
  },
12
14
  "models": {
13
15
  "claude-haiku-4-5": {
@@ -20,7 +22,10 @@
20
22
  "context_window": 200000,
21
23
  "supports_caching": true,
22
24
  "supports_batch": true,
23
- "complexity_tiers": ["simple", "medium"],
25
+ "complexity_tiers": [
26
+ "simple",
27
+ "medium"
28
+ ],
24
29
  "max_output_tokens": 8192
25
30
  },
26
31
  "claude-haiku-3-5": {
@@ -33,7 +38,10 @@
33
38
  "context_window": 200000,
34
39
  "supports_caching": true,
35
40
  "supports_batch": true,
36
- "complexity_tiers": ["simple", "medium"],
41
+ "complexity_tiers": [
42
+ "simple",
43
+ "medium"
44
+ ],
37
45
  "max_output_tokens": 8192
38
46
  },
39
47
  "claude-sonnet-4": {
@@ -46,7 +54,11 @@
46
54
  "context_window": 200000,
47
55
  "supports_caching": true,
48
56
  "supports_batch": true,
49
- "complexity_tiers": ["simple", "medium", "complex"],
57
+ "complexity_tiers": [
58
+ "simple",
59
+ "medium",
60
+ "complex"
61
+ ],
50
62
  "max_output_tokens": 8192
51
63
  },
52
64
  "claude-opus-4": {
@@ -59,7 +71,12 @@
59
71
  "context_window": 200000,
60
72
  "supports_caching": true,
61
73
  "supports_batch": true,
62
- "complexity_tiers": ["simple", "medium", "complex", "expert"],
74
+ "complexity_tiers": [
75
+ "simple",
76
+ "medium",
77
+ "complex",
78
+ "expert"
79
+ ],
63
80
  "max_output_tokens": 8192
64
81
  },
65
82
  "gpt-5-mini": {
@@ -72,7 +89,10 @@
72
89
  "context_window": 400000,
73
90
  "supports_caching": true,
74
91
  "supports_batch": true,
75
- "complexity_tiers": ["simple", "medium"],
92
+ "complexity_tiers": [
93
+ "simple",
94
+ "medium"
95
+ ],
76
96
  "max_output_tokens": 16384
77
97
  },
78
98
  "gpt-5": {
@@ -85,7 +105,12 @@
85
105
  "context_window": 1000000,
86
106
  "supports_caching": true,
87
107
  "supports_batch": true,
88
- "complexity_tiers": ["simple", "medium", "complex", "expert"],
108
+ "complexity_tiers": [
109
+ "simple",
110
+ "medium",
111
+ "complex",
112
+ "expert"
113
+ ],
89
114
  "max_output_tokens": 32768
90
115
  },
91
116
  "gemini-flash": {
@@ -98,7 +123,10 @@
98
123
  "context_window": 1000000,
99
124
  "supports_caching": true,
100
125
  "supports_batch": true,
101
- "complexity_tiers": ["simple", "medium"],
126
+ "complexity_tiers": [
127
+ "simple",
128
+ "medium"
129
+ ],
102
130
  "max_output_tokens": 8192
103
131
  },
104
132
  "gemini-pro": {
@@ -111,8 +139,48 @@
111
139
  "context_window": 2000000,
112
140
  "supports_caching": true,
113
141
  "supports_batch": true,
114
- "complexity_tiers": ["simple", "medium", "complex", "expert"],
142
+ "complexity_tiers": [
143
+ "simple",
144
+ "medium",
145
+ "complex",
146
+ "expert"
147
+ ],
115
148
  "max_output_tokens": 8192
149
+ },
150
+ "bedrock-claude-haiku-4-5": {
151
+ "model_id": "anthropic.claude-haiku-4-5-20251001-v1:0",
152
+ "provider": "bedrock",
153
+ "display_name": "Claude Haiku 4.5 (Bedrock)",
154
+ "input_cost_per_1m": 0.8,
155
+ "output_cost_per_1m": 4.0,
156
+ "cached_input_cost_per_1m": 0.08,
157
+ "context_window": 200000,
158
+ "supports_caching": false,
159
+ "supports_batch": true,
160
+ "complexity_tiers": [
161
+ "simple",
162
+ "medium"
163
+ ],
164
+ "max_output_tokens": 8192,
165
+ "notes": "AWS Bedrock list price. Prompt caching not supported on Bedrock. Your account discount applies on top."
166
+ },
167
+ "bedrock-claude-sonnet-4-5": {
168
+ "model_id": "anthropic.claude-sonnet-4-5-20250929-v1:0",
169
+ "provider": "bedrock",
170
+ "display_name": "Claude Sonnet 4.5 (Bedrock)",
171
+ "input_cost_per_1m": 3.0,
172
+ "output_cost_per_1m": 15.0,
173
+ "cached_input_cost_per_1m": 0.3,
174
+ "context_window": 200000,
175
+ "supports_caching": false,
176
+ "supports_batch": true,
177
+ "complexity_tiers": [
178
+ "simple",
179
+ "medium",
180
+ "complex"
181
+ ],
182
+ "max_output_tokens": 8192,
183
+ "notes": "AWS Bedrock list price. Prompt caching not supported on Bedrock. Your account discount applies on top."
116
184
  }
117
185
  }
118
- }
186
+ }
@@ -36,6 +36,8 @@ class StreamResult:
36
36
  yield from self._iter_anthropic()
37
37
  elif self._provider == "openai":
38
38
  yield from self._iter_openai()
39
+ elif self._provider == "bedrock":
40
+ yield from self._iter_bedrock()
39
41
  else:
40
42
  raise NotImplementedError(f"Streaming not implemented for provider: {self._provider}")
41
43
 
@@ -75,6 +77,17 @@ class StreamResult:
75
77
  return None
76
78
  return self._usage
77
79
 
80
+ def _iter_bedrock(self):
81
+ for chunk in self._raw:
82
+ yield chunk
83
+ if hasattr(self._raw, 'usage') and self._raw.usage:
84
+ self._usage = {
85
+ "input_tokens": self._raw.usage.input_tokens,
86
+ "output_tokens": self._raw.usage.output_tokens,
87
+ "cache_read_input_tokens": 0,
88
+ }
89
+ self._finished = True
90
+
78
91
  def __enter__(self):
79
92
  return self
80
93
 
@@ -118,3 +131,16 @@ def build_openai_stream(client, model_id: str, messages: list, system: Optional[
118
131
  **kwargs,
119
132
  )
120
133
  return StreamResult(raw, provider="openai")
134
+
135
+
136
+ def build_bedrock_stream(bedrock_client, model_id: str, messages: list,
137
+ system, max_tokens: int, temperature: float, **kwargs) -> "StreamResult":
138
+ """Create a Bedrock streaming request."""
139
+ raw = bedrock_client.messages.stream(
140
+ model=model_id,
141
+ max_tokens=max_tokens,
142
+ messages=messages,
143
+ system=system,
144
+ **kwargs,
145
+ )
146
+ return StreamResult(raw, provider="bedrock")
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: llm-optimizer
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Reduce LLM API costs by up to 90% — prompt caching, smart routing, batch processing, and more
5
5
  Author-email: Somnath Banerjee <banerjso30@gmail.com>
6
6
  License: MIT
7
- Project-URL: Homepage, https://github.com/banerjeeso/llm-optimizer
7
+ Project-URL: Homepage, https://github.com/banerjeesollm-optimizer
8
8
  Project-URL: Documentation, https://github.com/banerjeeso/llm-optimizer#readme
9
9
  Project-URL: Issues, https://github.com/banerjeeso/llm-optimizer/issues
10
10
  Keywords: llm,anthropic,openai,cost,optimization,token,caching,batch
@@ -29,6 +29,9 @@ Provides-Extra: all
29
29
  Requires-Dist: openai>=1.0.0; extra == "all"
30
30
  Requires-Dist: google-generativeai>=0.5.0; extra == "all"
31
31
  Requires-Dist: tiktoken>=0.7.0; extra == "all"
32
+ Requires-Dist: boto3>=1.34.0; extra == "all"
33
+ Provides-Extra: bedrock
34
+ Requires-Dist: boto3>=1.34.0; extra == "bedrock"
32
35
  Provides-Extra: dev
33
36
  Requires-Dist: pytest>=8.0; extra == "dev"
34
37
  Requires-Dist: build; extra == "dev"
@@ -2,14 +2,13 @@ README.md
2
2
  pyproject.toml
3
3
  llm_optimizer/__init__.py
4
4
  llm_optimizer/batcher.py
5
+ llm_optimizer/bedrock.py
5
6
  llm_optimizer/caching.py
6
- llm_optimizer/cli.py
7
7
  llm_optimizer/client.py
8
8
  llm_optimizer/models.py
9
9
  llm_optimizer/optimizer.py
10
10
  llm_optimizer/pricing.json
11
11
  llm_optimizer/router.py
12
- llm_optimizer/setup.py
13
12
  llm_optimizer/streaming.py
14
13
  llm_optimizer/tracker.py
15
14
  llm_optimizer.egg-info/PKG-INFO
@@ -4,6 +4,10 @@ anthropic>=0.40.0
4
4
  openai>=1.0.0
5
5
  google-generativeai>=0.5.0
6
6
  tiktoken>=0.7.0
7
+ boto3>=1.34.0
8
+
9
+ [bedrock]
10
+ boto3>=1.34.0
7
11
 
8
12
  [dev]
9
13
  pytest>=8.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "llm-optimizer"
7
- version = "0.2.0"
7
+ version = "0.3.1"
8
8
  description = "Reduce LLM API costs by up to 90% — prompt caching, smart routing, batch processing, and more"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -29,11 +29,12 @@ dependencies = [
29
29
  openai = ["openai>=1.0.0"]
30
30
  google = ["google-generativeai>=0.5.0"]
31
31
  tiktoken = ["tiktoken>=0.7.0"]
32
- all = ["openai>=1.0.0", "google-generativeai>=0.5.0", "tiktoken>=0.7.0"]
32
+ all = ["openai>=1.0.0", "google-generativeai>=0.5.0", "tiktoken>=0.7.0", "boto3>=1.34.0"]
33
+ bedrock = ["boto3>=1.34.0"]
33
34
  dev = ["pytest>=8.0", "build", "twine"]
34
35
 
35
36
  [project.urls]
36
- Homepage = "https://github.com/banerjeeso/llm-optimizer"
37
+ Homepage = "https://github.com/banerjeesollm-optimizer"
37
38
  Documentation = "https://github.com/banerjeeso/llm-optimizer#readme"
38
39
  Issues = "https://github.com/banerjeeso/llm-optimizer/issues"
39
40
 
@@ -548,3 +548,168 @@ class TestStreaming:
548
548
 
549
549
  if __name__ == "__main__":
550
550
  pytest.main([__file__, "-v", "-m", "not integration"])
551
+
552
+
553
+
554
+ # ─── Bedrock Client (unit, mocked) ────────────────────────────────────────────
555
+
556
+ class TestBedrockClient:
557
+ """
558
+ Tests BedrockClient with mocked boto3. No real AWS calls made.
559
+
560
+ Covers:
561
+ - Both auth patterns (profile_name and env/role)
562
+ - cache_control stripping
563
+ - Response shape normalization
564
+ - Discount multiplier
565
+ - OptimizedClient integration with Bedrock
566
+ - Correct model IDs for company Bedrock setup
567
+ """
568
+
569
+ def _mock_runtime(self, response_text="Test response"):
570
+ import json
571
+ from unittest.mock import MagicMock
572
+ mock = MagicMock()
573
+ body = {
574
+ "content": [{"type": "text", "text": response_text}],
575
+ "usage": {"input_tokens": 100, "output_tokens": 50},
576
+ "stop_reason": "end_turn",
577
+ }
578
+ mock.invoke_model.return_value = {
579
+ "body": MagicMock(read=lambda: json.dumps(body).encode())
580
+ }
581
+ return mock
582
+
583
+ def test_instantiates_with_profile(self):
584
+ from llm_optimizer.bedrock import BedrockClient
585
+ c = BedrockClient(region="us-east-1", profile_name="ai-core")
586
+ assert c.region == "us-east-1"
587
+ assert c._profile == "ai-core"
588
+
589
+ def test_instantiates_without_profile(self):
590
+ """Env vars / IAM role path — zero config."""
591
+ from llm_optimizer.bedrock import BedrockClient
592
+ c = BedrockClient()
593
+ assert c._profile is None
594
+ assert c.region == "us-east-1"
595
+
596
+ def test_discount_multiplier(self):
597
+ from llm_optimizer.bedrock import BedrockClient
598
+ c = BedrockClient(discount_pct=16.0)
599
+ assert abs(c.effective_cost_multiplier() - 0.84) < 0.001
600
+
601
+ def test_no_discount_multiplier_is_one(self):
602
+ from llm_optimizer.bedrock import BedrockClient
603
+ c = BedrockClient(discount_pct=0.0)
604
+ assert c.effective_cost_multiplier() == 1.0
605
+
606
+ def test_strip_cache_control_removes_key(self):
607
+ from llm_optimizer.bedrock import BedrockClient
608
+ msgs = [{"role": "user", "content": [
609
+ {"type": "text", "text": "Hello", "cache_control": {"type": "ephemeral"}}
610
+ ]}]
611
+ cleaned = BedrockClient._strip_cache_control(msgs)
612
+ assert "cache_control" not in cleaned[0]["content"][0]
613
+ assert cleaned[0]["content"][0]["text"] == "Hello"
614
+
615
+ def test_strip_cache_control_plain_string_unchanged(self):
616
+ from llm_optimizer.bedrock import BedrockClient
617
+ msgs = [{"role": "user", "content": "Hello"}]
618
+ cleaned = BedrockClient._strip_cache_control(msgs)
619
+ assert cleaned[0]["content"] == "Hello"
620
+
621
+ def test_strip_cache_control_preserves_other_keys(self):
622
+ from llm_optimizer.bedrock import BedrockClient
623
+ msgs = [{"role": "user", "content": [
624
+ {"type": "text", "text": "Hi", "cache_control": {"type": "ephemeral"}, "extra": "keep"}
625
+ ]}]
626
+ cleaned = BedrockClient._strip_cache_control(msgs)
627
+ assert cleaned[0]["content"][0]["extra"] == "keep"
628
+ assert "cache_control" not in cleaned[0]["content"][0]
629
+
630
+ def test_response_shape_normalized(self):
631
+ from llm_optimizer.bedrock import BedrockClient, BedrockResponse
632
+ c = BedrockClient(region="us-east-1")
633
+ c._runtime = self._mock_runtime("Hello from Bedrock")
634
+ resp = c.messages.create(
635
+ model="anthropic.claude-haiku-4-5-20251001-v1:0",
636
+ max_tokens=100,
637
+ messages=[{"role": "user", "content": "Hi"}],
638
+ )
639
+ assert isinstance(resp, BedrockResponse)
640
+ assert resp.content[0].text == "Hello from Bedrock"
641
+ assert resp.usage.input_tokens == 100
642
+ assert resp.usage.output_tokens == 50
643
+ assert resp.usage.cache_read_input_tokens == 0
644
+
645
+ def test_correct_haiku_model_id(self):
646
+ """Company uses this exact Bedrock model ID for Haiku."""
647
+ assert MODELS["bedrock-claude-haiku-4-5"].model_id == "anthropic.claude-haiku-4-5-20251001-v1:0"
648
+
649
+ def test_correct_sonnet_model_id(self):
650
+ """Company uses this exact Bedrock model ID for Sonnet."""
651
+ assert MODELS["bedrock-claude-sonnet-4-5"].model_id == "anthropic.claude-sonnet-4-5-20250929-v1:0"
652
+
653
+ def test_bedrock_haiku_pricing(self):
654
+ """Bedrock Haiku 4.5 AWS list price."""
655
+ m = MODELS["bedrock-claude-haiku-4-5"]
656
+ assert m.input_cost_per_1m == 0.80
657
+ assert m.output_cost_per_1m == 4.00
658
+
659
+ def test_bedrock_sonnet_pricing(self):
660
+ """Bedrock Sonnet 4.5 AWS list price."""
661
+ m = MODELS["bedrock-claude-sonnet-4-5"]
662
+ assert m.input_cost_per_1m == 3.00
663
+ assert m.output_cost_per_1m == 15.00
664
+
665
+ def test_bedrock_caching_not_supported(self):
666
+ """Bedrock models must have supports_caching=False."""
667
+ assert MODELS["bedrock-claude-haiku-4-5"].supports_caching is False
668
+ assert MODELS["bedrock-claude-sonnet-4-5"].supports_caching is False
669
+
670
+ def test_optimized_client_with_bedrock_profile(self):
671
+ """Full integration: OptimizedClient + BedrockClient with profile."""
672
+ from llm_optimizer.bedrock import BedrockClient
673
+ import json
674
+ from unittest.mock import MagicMock
675
+
676
+ bedrock = BedrockClient(region="us-east-1", profile_name="ai-core")
677
+ bedrock._runtime = self._mock_runtime("Classified as SPAM")
678
+
679
+ client = OptimizedClient(
680
+ bedrock_client=bedrock,
681
+ preferred_provider=Provider.BEDROCK,
682
+ )
683
+ resp = client.complete(
684
+ messages=[{"role": "user", "content": "Is this spam?"}],
685
+ system="You are a classifier.",
686
+ model="bedrock-claude-haiku-4-5",
687
+ )
688
+ assert resp.content[0].text == "Classified as SPAM"
689
+ bedrock._runtime.invoke_model.assert_called_once()
690
+
691
+ def test_optimized_client_no_bedrock_raises(self):
692
+ """Clear error when Bedrock client not provided."""
693
+ client = OptimizedClient(preferred_provider=Provider.BEDROCK)
694
+ with pytest.raises(RuntimeError, match="No Bedrock client"):
695
+ client.complete(
696
+ messages=[{"role": "user", "content": "Hello"}],
697
+ model="bedrock-claude-haiku-4-5",
698
+ )
699
+
700
+ def test_make_bedrock_client_factory(self):
701
+ from llm_optimizer.bedrock import make_bedrock_client
702
+ c = make_bedrock_client(profile_name="ai-core", discount_pct=16.0)
703
+ assert c._profile == "ai-core"
704
+ assert c.discount_pct == 16.0
705
+
706
+ def test_make_bedrock_client_no_args(self):
707
+ """Zero-config factory for env/role auth."""
708
+ from llm_optimizer.bedrock import make_bedrock_client
709
+ c = make_bedrock_client()
710
+ assert c._profile is None
711
+
712
+ def test_bedrock_provider_in_tier_defaults(self):
713
+ from llm_optimizer.models import TIER_DEFAULTS
714
+ assert Provider.BEDROCK in TIER_DEFAULTS[TaskComplexity.SIMPLE]
715
+ assert Provider.BEDROCK in TIER_DEFAULTS[TaskComplexity.COMPLEX]
@@ -1,79 +0,0 @@
1
- """
2
- CLI for llm-optimizer.
3
-
4
- Commands:
5
- llm-optimizer pricing-info Show current pricing data version and staleness
6
- llm-optimizer update-pricing Instructions to update pricing.json
7
- llm-optimizer check Verify install and show config
8
- """
9
-
10
- import sys
11
- import json
12
- from pathlib import Path
13
-
14
-
15
- def main():
16
- args = sys.argv[1:]
17
- cmd = args[0] if args else "check"
18
-
19
- if cmd == "pricing-info":
20
- _pricing_info()
21
- elif cmd == "update-pricing":
22
- _update_pricing_instructions()
23
- elif cmd == "check":
24
- _check()
25
- else:
26
- print(f"Unknown command: {cmd}")
27
- print("Usage: llm-optimizer [pricing-info | update-pricing | check]")
28
- sys.exit(1)
29
-
30
-
31
- def _pricing_info():
32
- pricing_path = Path(__file__).parent / "pricing.json"
33
- with open(pricing_path) as f:
34
- data = json.load(f)
35
- meta = data.get("_meta", {})
36
- print(f"\nllm-optimizer pricing data")
37
- print(f" Version : {meta.get('version', 'unknown')}")
38
- print(f" Last updated : {meta.get('last_updated', 'unknown')}")
39
- print(f" Models : {len(data.get('models', {}))}")
40
- print(f"\nSources to verify prices:")
41
- for provider, url in meta.get("sources", {}).items():
42
- print(f" {provider:12}: {url}")
43
- print(f"\nWarning: {meta.get('warning', '')}\n")
44
-
45
-
46
- def _update_pricing_instructions():
47
- pricing_path = Path(__file__).parent / "pricing.json"
48
- print(f"""
49
- To update pricing data:
50
-
51
- 1. Open {pricing_path}
52
- 2. Verify prices against provider pages:
53
- - Anthropic : https://www.anthropic.com/pricing
54
- - OpenAI : https://openai.com/pricing
55
- - Google : https://ai.google.dev/pricing
56
- 3. Update the relevant model prices
57
- 4. Bump "_meta.last_updated" to today's date (YYYY-MM-DD)
58
- 5. Bump "_meta.version" to match
59
-
60
- The library will stop warning about staleness once last_updated is within 30 days.
61
- """)
62
-
63
-
64
- def _check():
65
- try:
66
- from llm_optimizer import __version__
67
- from llm_optimizer.models import pricing_info, MODELS
68
- info = pricing_info()
69
- print(f"\nllm-optimizer v{__version__} installed ✓")
70
- print(f" Models loaded : {len(MODELS)}")
71
- print(f" Pricing version: {info['last_updated']}")
72
- print(f"\nRun 'llm-optimizer pricing-info' for full pricing details.\n")
73
- except Exception as e:
74
- print(f"Install check failed: {e}")
75
- sys.exit(1)
76
-
77
-
78
- if __name__ == "__main__":
79
- main()
@@ -1,36 +0,0 @@
1
- from setuptools import setup, find_packages
2
-
3
- with open("README.md", "r", encoding="utf-8") as fh:
4
- long_description = fh.read()
5
-
6
- setup(
7
- name="llm-optimizer",
8
- version="0.1.0",
9
- author="Your Team",
10
- description="Reduce LLM API costs by up to 90% with automatic optimizations",
11
- long_description=long_description,
12
- long_description_content_type="text/markdown",
13
- packages=find_packages(),
14
- python_requires=">=3.10",
15
- install_requires=[
16
- "anthropic>=0.40.0",
17
- ],
18
- extras_require={
19
- "openai": ["openai>=1.0.0"],
20
- "google": ["google-generativeai>=0.5.0"],
21
- "tiktoken": ["tiktoken>=0.7.0"], # for exact token counts
22
- "all": [
23
- "openai>=1.0.0",
24
- "google-generativeai>=0.5.0",
25
- "tiktoken>=0.7.0",
26
- ],
27
- },
28
- classifiers=[
29
- "Programming Language :: Python :: 3",
30
- "License :: OSI Approved :: MIT License",
31
- "Operating System :: OS Independent",
32
- "Topic :: Software Development :: Libraries :: Python Modules",
33
- "Intended Audience :: Developers",
34
- ],
35
- keywords="llm anthropic openai cost optimization token caching batch",
36
- )
File without changes
File without changes