llmslim 0.1.0__py3-none-any.whl

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.
llmslim/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """llmslim: cut LLM prompt size by 40-70% with one function call.
2
+
3
+ Quickstart:
4
+ >>> from llmslim import compress
5
+ >>> result = compress(my_long_prompt, target_ratio=0.5)
6
+ >>> print(result.compressed_text)
7
+ >>> print(result.summary())
8
+ """
9
+
10
+ from .core import CompressionResult, ContextCompressor, compress
11
+ from .cost import CostEstimate, MODEL_PRICING, estimate_cost_savings, list_supported_models
12
+ from .pipelines import compress_chat_messages, compress_documents
13
+ from .tokens import count_tokens, count_tokens_batch
14
+
15
+ __version__ = "0.1.0"
16
+
17
+ __all__ = [
18
+ "compress",
19
+ "ContextCompressor",
20
+ "CompressionResult",
21
+ "compress_chat_messages",
22
+ "compress_documents",
23
+ "estimate_cost_savings",
24
+ "list_supported_models",
25
+ "CostEstimate",
26
+ "MODEL_PRICING",
27
+ "count_tokens",
28
+ "count_tokens_batch",
29
+ "__version__",
30
+ ]
llmslim/chunking.py ADDED
@@ -0,0 +1,105 @@
1
+ """Semantic chunking: group consecutive sentences into coherent chunks.
2
+
3
+ A new chunk is started whenever a sentence's embedding drifts too far
4
+ from the running topic centroid (a topic shift) or the chunk would
5
+ exceed a maximum token budget. This keeps each chunk focused on a single
6
+ idea, which makes per-chunk extractive ranking far more meaningful than
7
+ ranking sentences across an entire document at once.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import List, Sequence
13
+
14
+ import numpy as np
15
+
16
+ from .tokens import count_tokens
17
+
18
+
19
+ class Chunk:
20
+ """A semantically coherent group of consecutive sentences."""
21
+
22
+ __slots__ = ("sentence_indices", "sentences", "token_counts", "total_tokens")
23
+
24
+ def __init__(self, sentence_indices: List[int], sentences: List[str], token_counts: List[int]):
25
+ self.sentence_indices = sentence_indices
26
+ self.sentences = sentences
27
+ self.token_counts = token_counts
28
+ self.total_tokens = sum(token_counts)
29
+
30
+ def __len__(self) -> int:
31
+ return len(self.sentence_indices)
32
+
33
+ def __repr__(self) -> str:
34
+ preview = self.sentences[0][:40] + "..." if self.sentences else ""
35
+ return f"Chunk(n_sentences={len(self)}, tokens={self.total_tokens}, starts_with={preview!r})"
36
+
37
+
38
+ def _cosine(a: np.ndarray, b: np.ndarray) -> float:
39
+ na = float(np.linalg.norm(a))
40
+ nb = float(np.linalg.norm(b))
41
+ if na == 0.0 or nb == 0.0:
42
+ return 0.0
43
+ return float(np.dot(a, b) / (na * nb))
44
+
45
+
46
+ def semantic_chunk(
47
+ sentences: Sequence[str],
48
+ embeddings: np.ndarray,
49
+ max_chunk_tokens: int = 180,
50
+ similarity_threshold: float = 0.35,
51
+ ) -> List[Chunk]:
52
+ """Group ``sentences`` into semantically coherent :class:`Chunk` objects.
53
+
54
+ Args:
55
+ sentences: Sentences in original document order.
56
+ embeddings: Array of shape ``(len(sentences), dim)``.
57
+ max_chunk_tokens: Soft cap on the token size of a chunk.
58
+ similarity_threshold: Minimum cosine similarity to the running
59
+ chunk centroid required to add a sentence to the current
60
+ chunk. Lower values produce fewer, larger chunks.
61
+
62
+ Returns:
63
+ List of :class:`Chunk` objects covering every input sentence,
64
+ in original order.
65
+ """
66
+ if not sentences:
67
+ return []
68
+
69
+ token_counts = [count_tokens(s) for s in sentences]
70
+
71
+ chunks: List[Chunk] = []
72
+ current_indices: List[int] = [0]
73
+ current_embs: List[np.ndarray] = [embeddings[0]]
74
+ current_tokens = token_counts[0]
75
+
76
+ for i in range(1, len(sentences)):
77
+ sent_tokens = token_counts[i]
78
+ centroid = np.mean(current_embs, axis=0)
79
+ similarity = _cosine(embeddings[i], centroid)
80
+
81
+ starts_new_chunk = (
82
+ similarity < similarity_threshold
83
+ or current_tokens + sent_tokens > max_chunk_tokens
84
+ )
85
+
86
+ if starts_new_chunk and current_indices:
87
+ chunks.append(_build_chunk(current_indices, sentences, token_counts))
88
+ current_indices = [i]
89
+ current_embs = [embeddings[i]]
90
+ current_tokens = sent_tokens
91
+ else:
92
+ current_indices.append(i)
93
+ current_embs.append(embeddings[i])
94
+ current_tokens += sent_tokens
95
+
96
+ if current_indices:
97
+ chunks.append(_build_chunk(current_indices, sentences, token_counts))
98
+
99
+ return chunks
100
+
101
+
102
+ def _build_chunk(indices: List[int], sentences: Sequence[str], token_counts: List[int]) -> Chunk:
103
+ chunk_sentences = [sentences[i] for i in indices]
104
+ chunk_token_counts = [token_counts[i] for i in indices]
105
+ return Chunk(list(indices), chunk_sentences, chunk_token_counts)
llmslim/cli.py ADDED
@@ -0,0 +1,90 @@
1
+ """Command-line interface for llmslim.
2
+
3
+ Usage:
4
+ llmslim input.txt -r 0.5 -o compressed.txt --stats
5
+ cat prompt.txt | llmslim --ratio 0.4 --cost gpt-5
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+
13
+ from .core import compress
14
+ from .cost import estimate_cost_savings, list_supported_models
15
+
16
+
17
+ def build_parser() -> argparse.ArgumentParser:
18
+ parser = argparse.ArgumentParser(
19
+ prog="llmslim",
20
+ description="Compress text/prompts to reduce LLM token usage by 40-70%.",
21
+ )
22
+ parser.add_argument(
23
+ "input",
24
+ nargs="?",
25
+ help="Path to an input text file. Reads from stdin if omitted.",
26
+ )
27
+ parser.add_argument(
28
+ "-r", "--ratio", type=float, default=0.5,
29
+ help="Target fraction of tokens to keep, e.g. 0.5 = 50%% reduction (default: 0.5).",
30
+ )
31
+ parser.add_argument(
32
+ "-q", "--query", type=str, default=None,
33
+ help="Optional query for relevance-aware compression (RAG use case).",
34
+ )
35
+ parser.add_argument(
36
+ "-o", "--output", type=str, default=None,
37
+ help="Write compressed text to this file instead of stdout.",
38
+ )
39
+ parser.add_argument(
40
+ "--stats", action="store_true",
41
+ help="Print compression statistics to stderr.",
42
+ )
43
+ parser.add_argument(
44
+ "--cost", type=str, default=None, metavar="MODEL",
45
+ help=f"Print a cost-savings estimate for MODEL. Options: {', '.join(list_supported_models())}",
46
+ )
47
+ parser.add_argument(
48
+ "--requests-per-day", type=int, default=1000,
49
+ help="Request volume used for --cost estimates (default: 1000).",
50
+ )
51
+ return parser
52
+
53
+
54
+ def main(argv=None) -> int:
55
+ parser = build_parser()
56
+ args = parser.parse_args(argv)
57
+
58
+ if args.input:
59
+ with open(args.input, "r", encoding="utf-8") as f:
60
+ text = f.read()
61
+ else:
62
+ text = sys.stdin.read()
63
+
64
+ result = compress(text, target_ratio=args.ratio, query=args.query)
65
+
66
+ if args.output:
67
+ with open(args.output, "w", encoding="utf-8") as f:
68
+ f.write(result.compressed_text)
69
+ else:
70
+ print(result.compressed_text)
71
+
72
+ if args.stats:
73
+ print("\n--- Compression Stats ---", file=sys.stderr)
74
+ print(result.summary(), file=sys.stderr)
75
+
76
+ if args.cost:
77
+ print("\n--- Cost Savings Estimate ---", file=sys.stderr)
78
+ estimate = estimate_cost_savings(
79
+ result.original_tokens,
80
+ result.compressed_tokens,
81
+ model=args.cost,
82
+ requests_per_day=args.requests_per_day,
83
+ )
84
+ print(estimate.summary(), file=sys.stderr)
85
+
86
+ return 0
87
+
88
+
89
+ if __name__ == "__main__":
90
+ sys.exit(main())
llmslim/core.py ADDED
@@ -0,0 +1,354 @@
1
+ """Core compression engine.
2
+
3
+ This module ties together sentence splitting, semantic chunking,
4
+ extractive ranking, and budget-aware selection into the main
5
+ :func:`compress` function and :class:`ContextCompressor` class.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass, field
12
+ from typing import Dict, List, Optional, Sequence
13
+
14
+ import numpy as np
15
+
16
+ from .chunking import Chunk, semantic_chunk
17
+ from .embeddings import EmbeddingBackend, get_default_backend
18
+ from .ranking import score_chunk_sentences
19
+ from .tokenization import split_paragraphs, split_sentences
20
+ from .tokens import count_tokens
21
+
22
+ # Below this many tokens, compression overhead isn't worth it and the
23
+ # original text is returned unchanged.
24
+ DEFAULT_MIN_TOKENS_FOR_COMPRESSION = 40
25
+
26
+ _LIST_ITEM_RE = re.compile(r"^\s*(#{1,6}\s|[-*+]\s|\d+[.)]\s)")
27
+
28
+
29
+ @dataclass
30
+ class ChunkResult:
31
+ """Per-chunk compression detail, useful for debugging and analytics."""
32
+
33
+ text: str
34
+ original_tokens: int
35
+ compressed_tokens: int
36
+ sentences_total: int
37
+ sentences_kept: int
38
+
39
+
40
+ @dataclass
41
+ class CompressionResult:
42
+ """The result of compressing a piece of text.
43
+
44
+ The compressed text is available both as ``.compressed_text`` and via
45
+ ``str(result)``, so it can often be dropped directly into an existing
46
+ prompt-building pipeline.
47
+ """
48
+
49
+ original_text: str
50
+ compressed_text: str
51
+ original_tokens: int
52
+ compressed_tokens: int
53
+ target_ratio: float
54
+ sentences_total: int
55
+ sentences_kept: int
56
+ num_chunks: int
57
+ backend: str = ""
58
+ chunk_results: List[ChunkResult] = field(default_factory=list, repr=False)
59
+
60
+ @property
61
+ def actual_ratio(self) -> float:
62
+ """Fraction of original tokens retained (lower = more compression)."""
63
+ if self.original_tokens == 0:
64
+ return 1.0
65
+ return self.compressed_tokens / self.original_tokens
66
+
67
+ @property
68
+ def reduction_percent(self) -> float:
69
+ """Percentage reduction in token count."""
70
+ return round((1 - self.actual_ratio) * 100, 1)
71
+
72
+ @property
73
+ def tokens_saved(self) -> int:
74
+ return self.original_tokens - self.compressed_tokens
75
+
76
+ def summary(self) -> str:
77
+ """A human-readable summary of the compression result."""
78
+ target_reduction = round((1 - self.target_ratio) * 100, 1)
79
+ return (
80
+ f"Original tokens : {self.original_tokens}\n"
81
+ f"Compressed tokens: {self.compressed_tokens}\n"
82
+ f"Reduction : {self.reduction_percent}% (target ~{target_reduction}%)\n"
83
+ f"Sentences kept : {self.sentences_kept}/{self.sentences_total}\n"
84
+ f"Chunks : {self.num_chunks}\n"
85
+ f"Embedding backend: {self.backend}"
86
+ )
87
+
88
+ def __str__(self) -> str:
89
+ return self.compressed_text
90
+
91
+
92
+ class ContextCompressor:
93
+ """Compresses text via semantic chunking + extractive sentence ranking.
94
+
95
+ Example:
96
+ >>> compressor = ContextCompressor()
97
+ >>> result = compressor.compress(long_text, target_ratio=0.5)
98
+ >>> print(result.compressed_text)
99
+ >>> print(result.summary())
100
+
101
+ Args:
102
+ embedding_backend: Custom :class:`EmbeddingBackend`. Defaults to
103
+ :func:`get_default_backend` (sentence-transformers if
104
+ available, otherwise TF-IDF).
105
+ max_chunk_tokens: Soft token cap per semantic chunk.
106
+ similarity_threshold: Cosine similarity below which a new chunk
107
+ is started. Lower values create fewer, larger chunks.
108
+ min_tokens_for_compression: Texts at or below this token count
109
+ are returned unchanged.
110
+ weights: Optional overrides for ranking weights -- see
111
+ ``llmslim.ranking.DEFAULT_WEIGHTS``.
112
+ preserve_patterns: Optional list of regex strings; any sentence
113
+ matching one of these is always retained (e.g.
114
+ ``[r"API_KEY", r"^System:"]``).
115
+ """
116
+
117
+ def __init__(
118
+ self,
119
+ embedding_backend: Optional[EmbeddingBackend] = None,
120
+ max_chunk_tokens: int = 300,
121
+ similarity_threshold: float = 0.35,
122
+ min_tokens_for_compression: int = DEFAULT_MIN_TOKENS_FOR_COMPRESSION,
123
+ weights: Optional[Dict[str, float]] = None,
124
+ preserve_patterns: Optional[Sequence[str]] = None,
125
+ ):
126
+ self.backend = embedding_backend or get_default_backend()
127
+ self.max_chunk_tokens = max_chunk_tokens
128
+ self.similarity_threshold = similarity_threshold
129
+ self.min_tokens_for_compression = min_tokens_for_compression
130
+ self.weights = weights
131
+ self.preserve_patterns = list(preserve_patterns or [])
132
+
133
+ def compress(
134
+ self,
135
+ text: str,
136
+ target_ratio: float = 0.5,
137
+ query: Optional[str] = None,
138
+ ) -> CompressionResult:
139
+ """Compress ``text``, retaining approximately ``target_ratio`` of its tokens.
140
+
141
+ Args:
142
+ text: The prompt or document to compress.
143
+ target_ratio: Fraction of original tokens to retain
144
+ (e.g. ``0.5`` keeps ~50%, i.e. a ~50% reduction).
145
+ Must be in ``(0, 1]``.
146
+ query: Optional query string. When provided, sentences more
147
+ similar to the query are favored -- ideal for compressing
148
+ retrieved documents in a RAG pipeline.
149
+
150
+ Returns:
151
+ A :class:`CompressionResult`.
152
+ """
153
+ if not (0.0 < target_ratio <= 1.0):
154
+ raise ValueError("target_ratio must be in the range (0, 1]")
155
+
156
+ original_tokens = count_tokens(text)
157
+
158
+ if original_tokens <= self.min_tokens_for_compression:
159
+ return self._passthrough(text, original_tokens, target_ratio, sentences_total=0)
160
+
161
+ paragraphs = split_paragraphs(text)
162
+ all_sentences: List[str] = []
163
+ para_end_indices: List[int] = []
164
+ for paragraph in paragraphs:
165
+ sentences = split_sentences(paragraph)
166
+ if not sentences:
167
+ continue
168
+ all_sentences.extend(sentences)
169
+ para_end_indices.append(len(all_sentences))
170
+
171
+ if len(all_sentences) <= 1:
172
+ return self._passthrough(text, original_tokens, target_ratio, sentences_total=len(all_sentences))
173
+
174
+ embeddings, query_embedding = self._encode(all_sentences, query)
175
+
176
+ chunks = semantic_chunk(
177
+ all_sentences,
178
+ embeddings,
179
+ max_chunk_tokens=self.max_chunk_tokens,
180
+ similarity_threshold=self.similarity_threshold,
181
+ )
182
+
183
+ kept_mask = np.zeros(len(all_sentences), dtype=bool)
184
+ chunk_results: List[ChunkResult] = []
185
+
186
+ for chunk in chunks:
187
+ chunk_embeddings = embeddings[chunk.sentence_indices]
188
+ scored = score_chunk_sentences(
189
+ chunk,
190
+ chunk_embeddings,
191
+ query_embedding=query_embedding,
192
+ weights=self.weights,
193
+ preserve_patterns=self.preserve_patterns,
194
+ )
195
+ kept_local = self._select_for_chunk(scored, chunk.token_counts, target_ratio)
196
+
197
+ for local_idx in kept_local:
198
+ kept_mask[chunk.sentence_indices[local_idx]] = True
199
+
200
+ kept_tokens = sum(chunk.token_counts[i] for i in kept_local)
201
+ chunk_results.append(
202
+ ChunkResult(
203
+ text=" ".join(chunk.sentences[i] for i in sorted(kept_local)),
204
+ original_tokens=chunk.total_tokens,
205
+ compressed_tokens=kept_tokens,
206
+ sentences_total=len(chunk.sentences),
207
+ sentences_kept=len(kept_local),
208
+ )
209
+ )
210
+
211
+ compressed_text = self._reassemble(all_sentences, kept_mask, para_end_indices)
212
+ compressed_tokens = count_tokens(compressed_text)
213
+
214
+ return CompressionResult(
215
+ original_text=text,
216
+ compressed_text=compressed_text,
217
+ original_tokens=original_tokens,
218
+ compressed_tokens=compressed_tokens,
219
+ target_ratio=target_ratio,
220
+ sentences_total=len(all_sentences),
221
+ sentences_kept=int(kept_mask.sum()),
222
+ num_chunks=len(chunks),
223
+ backend=self.backend.name,
224
+ chunk_results=chunk_results,
225
+ )
226
+
227
+ def _passthrough(self, text: str, original_tokens: int, target_ratio: float, sentences_total: int) -> CompressionResult:
228
+ return CompressionResult(
229
+ original_text=text,
230
+ compressed_text=text,
231
+ original_tokens=original_tokens,
232
+ compressed_tokens=original_tokens,
233
+ target_ratio=target_ratio,
234
+ sentences_total=sentences_total,
235
+ sentences_kept=sentences_total,
236
+ num_chunks=0,
237
+ backend=self.backend.name,
238
+ )
239
+
240
+ def _encode(self, sentences: List[str], query: Optional[str]):
241
+ """Encode sentences (and optionally a query) with a single, consistent vector space."""
242
+ texts = list(sentences)
243
+ if query:
244
+ texts.append(query)
245
+
246
+ encoded = np.asarray(self.backend.encode(texts))
247
+
248
+ if query:
249
+ return encoded[:-1], encoded[-1]
250
+ return encoded, None
251
+
252
+ @staticmethod
253
+ def _select_for_chunk(scored: List[Dict], token_counts: List[int], target_ratio: float) -> set:
254
+ """Greedily select sentence indices for a chunk within its token budget."""
255
+ total_tokens = sum(token_counts)
256
+ target_tokens = max(1, round(total_tokens * target_ratio))
257
+
258
+ must_keep = [s for s in scored if s["must_keep"]]
259
+ optional = sorted(
260
+ (s for s in scored if not s["must_keep"]),
261
+ key=lambda s: s["score"],
262
+ reverse=True,
263
+ )
264
+
265
+ selected = {s["index"] for s in must_keep}
266
+ used_tokens = sum(token_counts[i] for i in selected)
267
+
268
+ if used_tokens > target_tokens:
269
+ # Too many must-keep sentences for the budget: keep the
270
+ # highest-scoring ones until we exceed the budget, but always
271
+ # keep at least one sentence.
272
+ must_sorted = sorted(must_keep, key=lambda s: s["score"], reverse=True)
273
+ selected = set()
274
+ used_tokens = 0
275
+ for s in must_sorted:
276
+ if not selected or used_tokens + token_counts[s["index"]] <= target_tokens:
277
+ selected.add(s["index"])
278
+ used_tokens += token_counts[s["index"]]
279
+ else:
280
+ for s in optional:
281
+ if used_tokens >= target_tokens:
282
+ break
283
+ if used_tokens + token_counts[s["index"]] <= target_tokens:
284
+ selected.add(s["index"])
285
+ used_tokens += token_counts[s["index"]]
286
+
287
+ if not selected:
288
+ best = max(scored, key=lambda s: s["score"])
289
+ selected.add(best["index"])
290
+
291
+ return selected
292
+
293
+ @staticmethod
294
+ def _reassemble(sentences: List[str], kept_mask: np.ndarray, para_end_indices: List[int]) -> str:
295
+ """Rebuild paragraphs from kept sentences, preserving list formatting."""
296
+ paragraphs_out: List[str] = []
297
+ start = 0
298
+ for end in para_end_indices:
299
+ kept_sentences = [s for s, keep in zip(sentences[start:end], kept_mask[start:end]) if keep]
300
+ start = end
301
+ if not kept_sentences:
302
+ continue
303
+ if len(kept_sentences) > 1 and all(_LIST_ITEM_RE.match(s) for s in kept_sentences):
304
+ paragraphs_out.append("\n".join(kept_sentences))
305
+ else:
306
+ paragraphs_out.append(" ".join(kept_sentences))
307
+ return "\n\n".join(paragraphs_out)
308
+
309
+
310
+ _default_compressor: Optional[ContextCompressor] = None
311
+
312
+
313
+ def _get_default_compressor() -> ContextCompressor:
314
+ global _default_compressor
315
+ if _default_compressor is None:
316
+ _default_compressor = ContextCompressor()
317
+ return _default_compressor
318
+
319
+
320
+ def compress(
321
+ text: str,
322
+ target_ratio: float = 0.5,
323
+ query: Optional[str] = None,
324
+ **kwargs,
325
+ ) -> CompressionResult:
326
+ """Compress ``text``, retaining approximately ``target_ratio`` of its tokens.
327
+
328
+ This is the main entry point for the library -- a single function
329
+ call that performs semantic chunking, extractive ranking, and
330
+ reassembly.
331
+
332
+ Args:
333
+ text: The prompt or document to compress.
334
+ target_ratio: Fraction of original tokens to retain (e.g. ``0.5``
335
+ for a ~50% reduction, ``0.3`` for a ~70% reduction).
336
+ query: Optional query string for relevance-aware compression
337
+ (useful for RAG contexts).
338
+ **kwargs: Passed to :class:`ContextCompressor` if provided (e.g.
339
+ ``max_chunk_tokens``, ``similarity_threshold``,
340
+ ``preserve_patterns``). If no kwargs are given, a shared
341
+ default compressor instance is reused for efficiency.
342
+
343
+ Returns:
344
+ A :class:`CompressionResult` with ``.compressed_text``,
345
+ ``.reduction_percent``, and other statistics.
346
+
347
+ Example:
348
+ >>> from llmslim import compress
349
+ >>> result = compress(my_long_prompt, target_ratio=0.5)
350
+ >>> send_to_llm(result.compressed_text)
351
+ >>> print(f"Saved {result.tokens_saved} tokens ({result.reduction_percent}%)")
352
+ """
353
+ compressor = ContextCompressor(**kwargs) if kwargs else _get_default_compressor()
354
+ return compressor.compress(text, target_ratio=target_ratio, query=query)
llmslim/cost.py ADDED
@@ -0,0 +1,117 @@
1
+ """Cost savings estimation for compressed prompts.
2
+
3
+ Provides rough, easily-updatable per-1K-token pricing for popular models
4
+ so users can quickly translate token reductions into dollar savings at
5
+ their own request volume.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Dict, List
12
+
13
+ # Approximate USD price per 1,000 *input* tokens. These are illustrative
14
+ # and change frequently -- always check current provider pricing pages
15
+ # for production cost calculations.
16
+ MODEL_PRICING: Dict[str, Dict[str, float]] = {
17
+ "gpt-5": {"input": 0.00125, "output": 0.0100},
18
+ "gpt-4o": {"input": 0.00250, "output": 0.0100},
19
+ "gpt-5.4": {"input": 0.00250, "output": 0.0150},
20
+ "gpt-5-mini": {"input": 0.00025, "output": 0.0020},
21
+ "claude-opus-4.8": {"input": 0.00500, "output": 0.0250},
22
+ "claude-sonnet-4.6": {"input": 0.00300, "output": 0.0150},
23
+ "claude-haiku-4.5": {"input": 0.00100, "output": 0.0050},
24
+ "gemini-2.5-pro": {"input": 0.00125, "output": 0.0050},
25
+ "gemini-1.5-pro": {"input": 0.00125, "output": 0.0050},
26
+ "gemini-2.5-flash": {"input": 0.000075, "output": 0.00030},
27
+ "gemini-2.5-flash-lite": {"input": 0.00010, "output": 0.0004},
28
+ "mistral-large-3": {"input": 0.00100, "output": 0.00300},
29
+ "mistral-small-4": {"input": 0.00010, "output": 0.0003},
30
+ "deepseek-v3": {"input": 0.00014, "output": 0.00028},
31
+ "deepseek-r1.5": {"input": 0.00055, "output": 0.00219},
32
+ }
33
+
34
+
35
+ @dataclass
36
+ class CostEstimate:
37
+ """Estimated cost savings from a compression result."""
38
+
39
+ model: str
40
+ original_tokens: int
41
+ compressed_tokens: int
42
+ tokens_saved: int
43
+ reduction_percent: float
44
+ requests_per_day: int
45
+ price_per_1k_input: float
46
+ daily_savings_usd: float
47
+ monthly_savings_usd: float
48
+ annual_savings_usd: float
49
+
50
+ def summary(self) -> str:
51
+ return (
52
+ f"Model: {self.model} (${self.price_per_1k_input}/1K input tokens)\n"
53
+ f"Tokens saved per request: {self.tokens_saved} ({self.reduction_percent}%)\n"
54
+ f"At {self.requests_per_day:,} requests/day:\n"
55
+ f" Daily savings: ${self.daily_savings_usd:,.2f}\n"
56
+ f" Monthly savings: ${self.monthly_savings_usd:,.2f}\n"
57
+ f" Annual savings: ${self.annual_savings_usd:,.2f}"
58
+ )
59
+
60
+ def __str__(self) -> str:
61
+ return self.summary()
62
+
63
+
64
+ def estimate_cost_savings(
65
+ original_tokens: int,
66
+ compressed_tokens: int,
67
+ model: str = "gpt-5",
68
+ requests_per_day: int = 1000,
69
+ ) -> CostEstimate:
70
+ """Estimate dollar savings from compressing prompts at scale.
71
+
72
+ Args:
73
+ original_tokens: Token count before compression.
74
+ compressed_tokens: Token count after compression.
75
+ model: One of the keys in :data:`MODEL_PRICING`.
76
+ requests_per_day: Expected daily request volume.
77
+
78
+ Returns:
79
+ A :class:`CostEstimate` with daily/monthly/annual savings.
80
+
81
+ Example:
82
+ >>> from llmslim import compress, estimate_cost_savings
83
+ >>> result = compress(prompt, target_ratio=0.5)
84
+ >>> cost = estimate_cost_savings(
85
+ ... result.original_tokens, result.compressed_tokens,
86
+ ... model="gpt-5", requests_per_day=50_000,
87
+ ... )
88
+ >>> print(cost.summary())
89
+ """
90
+ if model not in MODEL_PRICING:
91
+ available = ", ".join(sorted(MODEL_PRICING))
92
+ raise ValueError(f"Unknown model '{model}'. Available models: {available}")
93
+
94
+ price_per_1k_input = MODEL_PRICING[model]["input"]
95
+ tokens_saved = max(0, original_tokens - compressed_tokens)
96
+ reduction_percent = round((tokens_saved / original_tokens) * 100, 1) if original_tokens else 0.0
97
+
98
+ daily_saved_tokens = tokens_saved * requests_per_day
99
+ daily_savings = (daily_saved_tokens / 1000) * price_per_1k_input
100
+
101
+ return CostEstimate(
102
+ model=model,
103
+ original_tokens=original_tokens,
104
+ compressed_tokens=compressed_tokens,
105
+ tokens_saved=tokens_saved,
106
+ reduction_percent=reduction_percent,
107
+ requests_per_day=requests_per_day,
108
+ price_per_1k_input=price_per_1k_input,
109
+ daily_savings_usd=round(daily_savings, 4),
110
+ monthly_savings_usd=round(daily_savings * 30, 2),
111
+ annual_savings_usd=round(daily_savings * 365, 2),
112
+ )
113
+
114
+
115
+ def list_supported_models() -> List[str]:
116
+ """Return the list of model names recognized by :func:`estimate_cost_savings`."""
117
+ return sorted(MODEL_PRICING)