llm-optimizer 0.2.0__tar.gz → 0.3.0__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.
- {llm_optimizer-0.2.0 → llm_optimizer-0.3.0}/PKG-INFO +8 -5
- {llm_optimizer-0.2.0 → llm_optimizer-0.3.0}/llm_optimizer/__init__.py +4 -1
- llm_optimizer-0.3.0/llm_optimizer/bedrock.py +324 -0
- {llm_optimizer-0.2.0 → llm_optimizer-0.3.0}/llm_optimizer/client.py +30 -2
- {llm_optimizer-0.2.0 → llm_optimizer-0.3.0}/llm_optimizer/models.py +21 -0
- {llm_optimizer-0.2.0 → llm_optimizer-0.3.0}/llm_optimizer/pricing.json +81 -13
- {llm_optimizer-0.2.0 → llm_optimizer-0.3.0}/llm_optimizer/streaming.py +26 -0
- {llm_optimizer-0.2.0 → llm_optimizer-0.3.0}/llm_optimizer.egg-info/PKG-INFO +8 -5
- {llm_optimizer-0.2.0 → llm_optimizer-0.3.0}/llm_optimizer.egg-info/SOURCES.txt +1 -1
- {llm_optimizer-0.2.0 → llm_optimizer-0.3.0}/llm_optimizer.egg-info/requires.txt +4 -0
- {llm_optimizer-0.2.0 → llm_optimizer-0.3.0}/pyproject.toml +7 -6
- {llm_optimizer-0.2.0 → llm_optimizer-0.3.0}/tests/test_llm_optimizer.py +165 -0
- llm_optimizer-0.2.0/llm_optimizer/cli.py +0 -79
- {llm_optimizer-0.2.0 → llm_optimizer-0.3.0}/README.md +0 -0
- {llm_optimizer-0.2.0 → llm_optimizer-0.3.0}/llm_optimizer/batcher.py +0 -0
- {llm_optimizer-0.2.0 → llm_optimizer-0.3.0}/llm_optimizer/caching.py +0 -0
- {llm_optimizer-0.2.0 → llm_optimizer-0.3.0}/llm_optimizer/optimizer.py +0 -0
- {llm_optimizer-0.2.0 → llm_optimizer-0.3.0}/llm_optimizer/router.py +0 -0
- {llm_optimizer-0.2.0 → llm_optimizer-0.3.0}/llm_optimizer/setup.py +0 -0
- {llm_optimizer-0.2.0 → llm_optimizer-0.3.0}/llm_optimizer/tracker.py +0 -0
- {llm_optimizer-0.2.0 → llm_optimizer-0.3.0}/llm_optimizer.egg-info/dependency_links.txt +0 -0
- {llm_optimizer-0.2.0 → llm_optimizer-0.3.0}/llm_optimizer.egg-info/entry_points.txt +0 -0
- {llm_optimizer-0.2.0 → llm_optimizer-0.3.0}/llm_optimizer.egg-info/top_level.txt +0 -0
- {llm_optimizer-0.2.0 → llm_optimizer-0.3.0}/setup.cfg +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: llm-optimizer
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Reduce LLM API costs by up to 90% — prompt caching, smart routing, batch processing, and more
|
|
5
|
-
Author-email:
|
|
5
|
+
Author-email: Your Name <you@example.com>
|
|
6
6
|
License: MIT
|
|
7
|
-
Project-URL: Homepage, https://github.com/
|
|
8
|
-
Project-URL: Documentation, https://github.com/
|
|
9
|
-
Project-URL: Issues, https://github.com/
|
|
7
|
+
Project-URL: Homepage, https://github.com/your-org/llm-optimizer
|
|
8
|
+
Project-URL: Documentation, https://github.com/your-org/llm-optimizer#readme
|
|
9
|
+
Project-URL: Issues, https://github.com/your-org/llm-optimizer/issues
|
|
10
10
|
Keywords: llm,anthropic,openai,cost,optimization,token,caching,batch
|
|
11
11
|
Classifier: Programming Language :: Python :: 3
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.10
|
|
@@ -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.
|
|
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-
|
|
4
|
-
"last_updated": "2026-
|
|
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": [
|
|
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": [
|
|
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": [
|
|
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": [
|
|
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": [
|
|
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": [
|
|
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": [
|
|
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": [
|
|
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,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: llm-optimizer
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Reduce LLM API costs by up to 90% — prompt caching, smart routing, batch processing, and more
|
|
5
|
-
Author-email:
|
|
5
|
+
Author-email: Your Name <you@example.com>
|
|
6
6
|
License: MIT
|
|
7
|
-
Project-URL: Homepage, https://github.com/
|
|
8
|
-
Project-URL: Documentation, https://github.com/
|
|
9
|
-
Project-URL: Issues, https://github.com/
|
|
7
|
+
Project-URL: Homepage, https://github.com/your-org/llm-optimizer
|
|
8
|
+
Project-URL: Documentation, https://github.com/your-org/llm-optimizer#readme
|
|
9
|
+
Project-URL: Issues, https://github.com/your-org/llm-optimizer/issues
|
|
10
10
|
Keywords: llm,anthropic,openai,cost,optimization,token,caching,batch
|
|
11
11
|
Classifier: Programming Language :: Python :: 3
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.10
|
|
@@ -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"
|
|
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "llm-optimizer"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
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" }
|
|
11
11
|
requires-python = ">=3.10"
|
|
12
|
-
authors = [{ name = "
|
|
12
|
+
authors = [{ name = "Your Name", email = "you@example.com" }]
|
|
13
13
|
keywords = ["llm", "anthropic", "openai", "cost", "optimization", "token", "caching", "batch"]
|
|
14
14
|
classifiers = [
|
|
15
15
|
"Programming Language :: Python :: 3",
|
|
@@ -29,13 +29,14 @@ 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/
|
|
37
|
-
Documentation = "https://github.com/
|
|
38
|
-
Issues = "https://github.com/
|
|
37
|
+
Homepage = "https://github.com/your-org/llm-optimizer"
|
|
38
|
+
Documentation = "https://github.com/your-org/llm-optimizer#readme"
|
|
39
|
+
Issues = "https://github.com/your-org/llm-optimizer/issues"
|
|
39
40
|
|
|
40
41
|
[project.scripts]
|
|
41
42
|
llm-optimizer = "llm_optimizer.cli:main"
|
|
@@ -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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|