contextpress 0.3.0__tar.gz → 0.4.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.
Files changed (50) hide show
  1. {contextpress-0.3.0 → contextpress-0.4.0}/CHANGELOG.md +8 -0
  2. {contextpress-0.3.0 → contextpress-0.4.0}/PKG-INFO +13 -2
  3. {contextpress-0.3.0 → contextpress-0.4.0}/README.md +12 -1
  4. {contextpress-0.3.0 → contextpress-0.4.0}/contextpress/__init__.py +1 -1
  5. {contextpress-0.3.0 → contextpress-0.4.0}/contextpress/core.py +53 -3
  6. contextpress-0.4.0/contextpress/llm/__init__.py +15 -0
  7. {contextpress-0.3.0 → contextpress-0.4.0}/contextpress/llm/adapters.py +41 -0
  8. {contextpress-0.3.0 → contextpress-0.4.0}/contextpress/pipeline.py +4 -1
  9. {contextpress-0.3.0 → contextpress-0.4.0}/contextpress/stats.py +2 -0
  10. contextpress-0.4.0/contextpress/warnings_capture.py +16 -0
  11. contextpress-0.4.0/examples/dry_run_preview.py +23 -0
  12. {contextpress-0.3.0 → contextpress-0.4.0}/pyproject.toml +1 -1
  13. contextpress-0.4.0/tests/test_v04.py +51 -0
  14. contextpress-0.3.0/contextpress/llm/__init__.py +0 -4
  15. {contextpress-0.3.0 → contextpress-0.4.0}/.gitignore +0 -0
  16. {contextpress-0.3.0 → contextpress-0.4.0}/CITATION.cff +0 -0
  17. {contextpress-0.3.0 → contextpress-0.4.0}/CONTRIBUTING.md +0 -0
  18. {contextpress-0.3.0 → contextpress-0.4.0}/LICENSE +0 -0
  19. {contextpress-0.3.0 → contextpress-0.4.0}/NOTICE +0 -0
  20. {contextpress-0.3.0 → contextpress-0.4.0}/contextpress/_bootstrap.py +0 -0
  21. {contextpress-0.3.0 → contextpress-0.4.0}/contextpress/compression.py +0 -0
  22. {contextpress-0.3.0 → contextpress-0.4.0}/contextpress/llm/_helpers.py +0 -0
  23. {contextpress-0.3.0 → contextpress-0.4.0}/contextpress/llm/base.py +0 -0
  24. {contextpress-0.3.0 → contextpress-0.4.0}/contextpress/models.py +0 -0
  25. {contextpress-0.3.0 → contextpress-0.4.0}/contextpress/normalizer.py +0 -0
  26. {contextpress-0.3.0 → contextpress-0.4.0}/contextpress/profiles.py +0 -0
  27. {contextpress-0.3.0 → contextpress-0.4.0}/contextpress/py.typed +0 -0
  28. {contextpress-0.3.0 → contextpress-0.4.0}/contextpress/registry.py +0 -0
  29. {contextpress-0.3.0 → contextpress-0.4.0}/contextpress/strategies/__init__.py +0 -0
  30. {contextpress-0.3.0 → contextpress-0.4.0}/contextpress/strategies/base.py +0 -0
  31. {contextpress-0.3.0 → contextpress-0.4.0}/contextpress/strategies/budget.py +0 -0
  32. {contextpress-0.3.0 → contextpress-0.4.0}/contextpress/strategies/filler.py +0 -0
  33. {contextpress-0.3.0 → contextpress-0.4.0}/contextpress/strategies/recency.py +0 -0
  34. {contextpress-0.3.0 → contextpress-0.4.0}/contextpress/strategies/repetition.py +0 -0
  35. {contextpress-0.3.0 → contextpress-0.4.0}/contextpress/strategies/resolution.py +0 -0
  36. {contextpress-0.3.0 → contextpress-0.4.0}/examples/estimate_and_stats.py +0 -0
  37. {contextpress-0.3.0 → contextpress-0.4.0}/examples/llm_tier_ollama.py +0 -0
  38. {contextpress-0.3.0 → contextpress-0.4.0}/examples/llm_tier_openai.py +0 -0
  39. {contextpress-0.3.0 → contextpress-0.4.0}/tests/__init__.py +0 -0
  40. {contextpress-0.3.0 → contextpress-0.4.0}/tests/test_budget.py +0 -0
  41. {contextpress-0.3.0 → contextpress-0.4.0}/tests/test_filler.py +0 -0
  42. {contextpress-0.3.0 → contextpress-0.4.0}/tests/test_llm_helpers.py +0 -0
  43. {contextpress-0.3.0 → contextpress-0.4.0}/tests/test_models.py +0 -0
  44. {contextpress-0.3.0 → contextpress-0.4.0}/tests/test_normalizer.py +0 -0
  45. {contextpress-0.3.0 → contextpress-0.4.0}/tests/test_pipeline.py +0 -0
  46. {contextpress-0.3.0 → contextpress-0.4.0}/tests/test_recency.py +0 -0
  47. {contextpress-0.3.0 → contextpress-0.4.0}/tests/test_repetition.py +0 -0
  48. {contextpress-0.3.0 → contextpress-0.4.0}/tests/test_resolution.py +0 -0
  49. {contextpress-0.3.0 → contextpress-0.4.0}/tests/test_stats.py +0 -0
  50. {contextpress-0.3.0 → contextpress-0.4.0}/tests/test_v03.py +0 -0
@@ -4,6 +4,14 @@ All notable changes to `contextpress` are recorded here.
4
4
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.4.0] - 2026-07-20
8
+
9
+ - **`preview()` / `dry_run=True`** — simulate compression and get stats without changing messages (Tier 1 only; no LLM calls).
10
+ - **`fits_budget()`** — check whether messages would fit a token budget after compression.
11
+ - **`CompressionStats.warnings_emitted`** — pipeline warnings collected during a run.
12
+ - **`OpenAICompatibleBackend`** — vLLM, LM Studio, LocalAI, and other OpenAI-compatible servers.
13
+ - Example: `examples/dry_run_preview.py`.
14
+
7
15
  ## [0.3.0] - 2026-07-14
8
16
 
9
17
  - **`estimate_tokens(messages)`** — count tokens before compressing (same tiktoken encoding as budget).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: contextpress
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Deterministic context compression for LLM chat, RAG, and agent pipelines
5
5
  Project-URL: Homepage, https://github.com/Taha-azizi/contextpress
6
6
  Project-URL: Documentation, https://github.com/Taha-azizi/contextpress#readme
@@ -296,6 +296,17 @@ Check token count **before** compressing:
296
296
  before = cm.estimate_tokens(messages)
297
297
  ```
298
298
 
299
+ **Preview without changing messages** (0.4+):
300
+
301
+ ```python
302
+ preview = cm.preview(messages, token_budget=500)
303
+ print(preview.stats.tokens_saved, preview.stats.warnings_emitted)
304
+ assert preview.messages == messages # unchanged
305
+
306
+ if cm.fits_budget(messages, 4000):
307
+ ...
308
+ ```
309
+
299
310
  ### Custom stages (0.3+)
300
311
 
301
312
  Register a `BaseStrategy` and include it in `stages=`:
@@ -491,7 +502,7 @@ Long chat histories inflate token usage, bury important facts (lost-in-the-middl
491
502
 
492
503
  ## Research and citing
493
504
 
494
- For academic use, cite this package in your paper’s software or methods section. A machine-readable citation file is provided as [`CITATION.cff`](CITATION.cff) (GitHub and Zenodo can ingest it). Replace the placeholder repository URL in that file with your fork’s URL when you publish.
505
+ For academic use, cite this package in your paper’s software or methods section. A machine-readable citation file is provided as [`CITATION.cff`](CITATION.cff).
495
506
 
496
507
  ## Extension and growth
497
508
 
@@ -56,6 +56,17 @@ Check token count **before** compressing:
56
56
  before = cm.estimate_tokens(messages)
57
57
  ```
58
58
 
59
+ **Preview without changing messages** (0.4+):
60
+
61
+ ```python
62
+ preview = cm.preview(messages, token_budget=500)
63
+ print(preview.stats.tokens_saved, preview.stats.warnings_emitted)
64
+ assert preview.messages == messages # unchanged
65
+
66
+ if cm.fits_budget(messages, 4000):
67
+ ...
68
+ ```
69
+
59
70
  ### Custom stages (0.3+)
60
71
 
61
72
  Register a `BaseStrategy` and include it in `stages=`:
@@ -251,7 +262,7 @@ Long chat histories inflate token usage, bury important facts (lost-in-the-middl
251
262
 
252
263
  ## Research and citing
253
264
 
254
- For academic use, cite this package in your paper’s software or methods section. A machine-readable citation file is provided as [`CITATION.cff`](CITATION.cff) (GitHub and Zenodo can ingest it). Replace the placeholder repository URL in that file with your fork’s URL when you publish.
265
+ For academic use, cite this package in your paper’s software or methods section. A machine-readable citation file is provided as [`CITATION.cff`](CITATION.cff).
255
266
 
256
267
  ## Extension and growth
257
268
 
@@ -16,7 +16,7 @@ __all__ = [
16
16
  "CompressionResult",
17
17
  "CompressionStats",
18
18
  ]
19
- __version__ = "0.3.0"
19
+ __version__ = "0.4.0"
20
20
 
21
21
 
22
22
  def __getattr__(name: str) -> Any:
@@ -6,10 +6,11 @@ from typing import TYPE_CHECKING, Any
6
6
 
7
7
  from contextpress.compression import apply_stage_selection, normalize_compression_level
8
8
  from contextpress.normalizer import denormalize_output, normalize_messages
9
- from contextpress.pipeline import VALID_LLM_MODES, Pipeline
9
+ from contextpress.pipeline import VALID_LLM_MODES, Pipeline, clone_conversation
10
10
  from contextpress.profiles import PROFILES, Profile, StageConfig
11
11
  from contextpress.registry import register_stage as _register_stage
12
12
  from contextpress.stats import CompressionResult, CompressionStats, count_conversation_tokens
13
+ from contextpress.warnings_capture import capture_warnings
13
14
 
14
15
  if TYPE_CHECKING:
15
16
  from collections.abc import Callable
@@ -66,6 +67,45 @@ class ContextManager:
66
67
  conv, _ = normalize_messages(messages, context_type=self._type)
67
68
  return count_conversation_tokens(conv, model if model is not None else self.model)
68
69
 
70
+ def fits_budget(
71
+ self,
72
+ messages: Any,
73
+ token_budget: int,
74
+ *,
75
+ compression: str | None = None,
76
+ stages: list[str] | None = None,
77
+ disable: list[str] | None = None,
78
+ ) -> bool:
79
+ """Return True if ``messages`` would fit ``token_budget`` after compression (dry run)."""
80
+ preview = self.preview(
81
+ messages,
82
+ token_budget=token_budget,
83
+ compression=compression,
84
+ stages=stages,
85
+ disable=disable,
86
+ )
87
+ return preview.stats.tokens_after <= token_budget
88
+
89
+ def preview(
90
+ self,
91
+ messages: Any,
92
+ token_budget: int | None = None,
93
+ *,
94
+ compression: str | None = None,
95
+ stages: list[str] | None = None,
96
+ disable: list[str] | None = None,
97
+ ) -> CompressionResult:
98
+ """Dry-run compression: stats only, original ``messages`` returned unchanged."""
99
+ return self.compress(
100
+ messages,
101
+ token_budget=token_budget,
102
+ compression=compression,
103
+ stages=stages,
104
+ disable=disable,
105
+ return_stats=True,
106
+ dry_run=True,
107
+ )
108
+
69
109
  def register_stage(
70
110
  self,
71
111
  name: str,
@@ -87,12 +127,16 @@ class ContextManager:
87
127
  stages: list[str] | None = None,
88
128
  disable: list[str] | None = None,
89
129
  return_stats: bool = False,
130
+ dry_run: bool = False,
90
131
  ) -> Any | CompressionResult:
91
132
  """Run the pipeline; return value matches input shape (dict list, tuples, strings, etc.).
92
133
 
93
134
  ``token_budget`` must be a positive int or None. Unknown keys in ``disable`` are ignored.
94
135
  With ``return_stats=True``, returns a ``CompressionResult`` with ``messages`` and ``stats``.
136
+ With ``dry_run=True``, runs Tier 1 only (no LLM calls) and returns the original messages.
95
137
  """
138
+ if dry_run:
139
+ return_stats = True
96
140
  _validate_token_budget(token_budget)
97
141
  level = compression if compression is not None else self._compression
98
142
  profile = copy.deepcopy(self._profile)
@@ -119,8 +163,14 @@ class ContextManager:
119
163
  llm_mode=self.llm_mode,
120
164
  custom_stages=custom_stages,
121
165
  )
122
- out = pipeline.run(conv, stats=stats)
123
- messages_out = denormalize_output(out, ctx)
166
+ with capture_warnings() as captured:
167
+ out = pipeline.run(conv, stats=stats, dry_run=dry_run)
168
+ if stats is not None:
169
+ stats.warnings_emitted = captured
170
+ if dry_run:
171
+ messages_out = denormalize_output(clone_conversation(conv), ctx)
172
+ else:
173
+ messages_out = denormalize_output(out, ctx)
124
174
  if return_stats:
125
175
  assert stats is not None
126
176
  return CompressionResult(messages=messages_out, stats=stats)
@@ -0,0 +1,15 @@
1
+ from contextpress.llm.adapters import (
2
+ AnthropicBackend,
3
+ OllamaBackend,
4
+ OpenAIBackend,
5
+ OpenAICompatibleBackend,
6
+ )
7
+ from contextpress.llm.base import LLMBackend
8
+
9
+ __all__ = [
10
+ "LLMBackend",
11
+ "OpenAIBackend",
12
+ "OpenAICompatibleBackend",
13
+ "AnthropicBackend",
14
+ "OllamaBackend",
15
+ ]
@@ -221,3 +221,44 @@ class OllamaBackend(LLMBackend):
221
221
  except Exception as exc:
222
222
  warnings.warn(f"contextpress OllamaBackend.deduplicate failed: {exc}", stacklevel=2)
223
223
  return list(range(len(turns)))
224
+
225
+
226
+ class OpenAICompatibleBackend(OpenAIBackend):
227
+ """
228
+ Adapter for **OpenAI-compatible HTTP APIs** (vLLM, LM Studio, LocalAI, etc.).
229
+
230
+ Requires: ``pip install openai``
231
+
232
+ Usage::
233
+
234
+ from contextpress.llm.adapters import OpenAICompatibleBackend
235
+
236
+ backend = OpenAICompatibleBackend(
237
+ base_url="http://localhost:8000/v1",
238
+ model="mistral",
239
+ )
240
+ cm = ContextManager(type="chat", llm_backend=backend)
241
+ """
242
+
243
+ def __init__(
244
+ self,
245
+ base_url: str,
246
+ model: str,
247
+ *,
248
+ api_key: str = "not-needed",
249
+ client: Any | None = None,
250
+ ):
251
+ if client is not None:
252
+ super().__init__(client=client, model=model)
253
+ return
254
+ try:
255
+ from openai import OpenAI
256
+ except ImportError as exc:
257
+ raise ImportError(
258
+ "OpenAICompatibleBackend requires the 'openai' package. "
259
+ "Install with: pip install openai"
260
+ ) from exc
261
+ super().__init__(
262
+ client=OpenAI(base_url=base_url, api_key=api_key),
263
+ model=model,
264
+ )
@@ -107,12 +107,15 @@ class Pipeline:
107
107
  self,
108
108
  conversation: Conversation,
109
109
  stats: CompressionStats | None = None,
110
+ *,
111
+ dry_run: bool = False,
110
112
  ) -> Conversation:
111
113
  if stats is not None:
112
114
  stats.turns_before = len(conversation.turns)
113
115
  stats.tokens_before = count_conversation_tokens(conversation, self.model)
114
116
  stats.context_type = conversation.type
115
117
  stats.token_budget = self.token_budget
118
+ stats.dry_run = dry_run
116
119
 
117
120
  result = clone_conversation(conversation)
118
121
  stage_order = effective_stage_order()
@@ -131,7 +134,7 @@ class Pipeline:
131
134
  if delta != 0:
132
135
  stats.turn_delta_by_stage[stage_name] = delta
133
136
 
134
- if self.llm_backend is not None:
137
+ if self.llm_backend is not None and not dry_run:
135
138
  result = self._run_llm_tier(result, stats=stats)
136
139
 
137
140
  if stats is not None:
@@ -49,6 +49,8 @@ class CompressionStats:
49
49
  compression_level: str | None = None
50
50
  context_type: str = "chat"
51
51
  token_budget: int | None = None
52
+ dry_run: bool = False
53
+ warnings_emitted: list[str] = field(default_factory=list)
52
54
 
53
55
  @property
54
56
  def turns_removed(self) -> int:
@@ -0,0 +1,16 @@
1
+ """Capture warnings emitted during compression into ``CompressionStats``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from collections.abc import Generator
7
+ from contextlib import contextmanager
8
+
9
+
10
+ @contextmanager
11
+ def capture_warnings() -> Generator[list[str], None, None]:
12
+ captured: list[str] = []
13
+ with warnings.catch_warnings(record=True) as caught:
14
+ warnings.simplefilter("always")
15
+ yield captured
16
+ captured.extend(str(item.message) for item in caught)
@@ -0,0 +1,23 @@
1
+ """Dry-run preview: see compression stats without changing messages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextpress import ContextManager
6
+
7
+ messages = [
8
+ {"role": "system", "content": "You are helpful."},
9
+ {"role": "user", "content": "Old topic: " + "blah " * 60},
10
+ {"role": "assistant", "content": "Basically, honestly, " + "yak " * 60},
11
+ {"role": "user", "content": "What is 2+2?"},
12
+ {"role": "assistant", "content": "Four."},
13
+ ]
14
+
15
+ cm = ContextManager(type="chat", compression="medium")
16
+ preview = cm.preview(messages, token_budget=150)
17
+
18
+ print("dry_run:", preview.stats.dry_run)
19
+ print("tokens:", preview.stats.tokens_before, "->", preview.stats.tokens_after)
20
+ print("would fit budget 150:", cm.fits_budget(messages, 150))
21
+ print("messages unchanged:", preview.messages == messages)
22
+ if preview.stats.warnings_emitted:
23
+ print("warnings:", preview.stats.warnings_emitted)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "contextpress"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Deterministic context compression for LLM chat, RAG, and agent pipelines"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -0,0 +1,51 @@
1
+ from unittest.mock import MagicMock
2
+
3
+ from contextpress import ContextManager
4
+ from contextpress.llm.adapters import OpenAICompatibleBackend
5
+
6
+
7
+ def test_preview_returns_original_messages():
8
+ cm = ContextManager(type="chat")
9
+ messages = [
10
+ {"role": "user", "content": "hello basically there"},
11
+ {"role": "assistant", "content": "hi"},
12
+ ]
13
+ preview = cm.preview(messages, token_budget=500)
14
+ assert preview.messages == messages
15
+ assert preview.stats.dry_run is True
16
+ assert preview.stats.tokens_before >= preview.stats.tokens_after
17
+
18
+
19
+ def test_dry_run_skips_llm_backend():
20
+ backend = MagicMock()
21
+ cm = ContextManager(type="chat", llm_backend=backend, llm_min_input_chars=0)
22
+ messages = [{"role": "user", "content": "hello " * 500}]
23
+ preview = cm.preview(messages, token_budget=100)
24
+ backend.deduplicate.assert_not_called()
25
+ backend.summarize.assert_not_called()
26
+ assert preview.stats.llm_tier_applied is False
27
+
28
+
29
+ def test_fits_budget():
30
+ cm = ContextManager(type="chat", compression="low")
31
+ messages = [{"role": "user", "content": "short"}]
32
+ assert cm.fits_budget(messages, 500) is True
33
+
34
+
35
+ def test_warnings_captured_on_budget_truncation():
36
+ cm = ContextManager(type="chat")
37
+ messages = [
38
+ {"role": "user", "content": "word " * 200},
39
+ {"role": "assistant", "content": "reply " * 200},
40
+ {"role": "user", "content": "recent"},
41
+ {"role": "assistant", "content": "ok"},
42
+ ]
43
+ preview = cm.preview(messages, token_budget=20, compression="low")
44
+ assert any("token budget" in w.lower() for w in preview.stats.warnings_emitted)
45
+
46
+
47
+ def test_openai_compatible_backend_init():
48
+ client = MagicMock()
49
+ backend = OpenAICompatibleBackend(base_url="http://localhost:8000/v1", model="x", client=client)
50
+ assert backend.model == "x"
51
+ assert backend.client is client
@@ -1,4 +0,0 @@
1
- from contextpress.llm.adapters import AnthropicBackend, OllamaBackend, OpenAIBackend
2
- from contextpress.llm.base import LLMBackend
3
-
4
- __all__ = ["LLMBackend", "OpenAIBackend", "AnthropicBackend", "OllamaBackend"]
File without changes
File without changes
File without changes
File without changes