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 +30 -0
- llmslim/chunking.py +105 -0
- llmslim/cli.py +90 -0
- llmslim/core.py +354 -0
- llmslim/cost.py +117 -0
- llmslim/embeddings.py +121 -0
- llmslim/pipelines.py +104 -0
- llmslim/ranking.py +215 -0
- llmslim/tokenization.py +120 -0
- llmslim/tokens.py +58 -0
- llmslim-0.1.0.dist-info/METADATA +634 -0
- llmslim-0.1.0.dist-info/RECORD +16 -0
- llmslim-0.1.0.dist-info/WHEEL +5 -0
- llmslim-0.1.0.dist-info/entry_points.txt +2 -0
- llmslim-0.1.0.dist-info/licenses/LICENSE +21 -0
- llmslim-0.1.0.dist-info/top_level.txt +1 -0
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)
|