contextpress 0.2.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 (51) hide show
  1. {contextpress-0.2.0 → contextpress-0.4.0}/.gitignore +3 -0
  2. contextpress-0.4.0/CHANGELOG.md +32 -0
  3. {contextpress-0.2.0 → contextpress-0.4.0}/PKG-INFO +37 -3
  4. {contextpress-0.2.0 → contextpress-0.4.0}/README.md +36 -2
  5. {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/__init__.py +1 -1
  6. {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/compression.py +19 -3
  7. {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/core.py +91 -6
  8. contextpress-0.4.0/contextpress/llm/__init__.py +15 -0
  9. {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/llm/adapters.py +41 -0
  10. {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/pipeline.py +82 -22
  11. contextpress-0.4.0/contextpress/registry.py +85 -0
  12. {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/stats.py +2 -0
  13. contextpress-0.4.0/contextpress/warnings_capture.py +16 -0
  14. contextpress-0.4.0/examples/dry_run_preview.py +23 -0
  15. contextpress-0.4.0/examples/estimate_and_stats.py +21 -0
  16. {contextpress-0.2.0 → contextpress-0.4.0}/pyproject.toml +1 -1
  17. contextpress-0.4.0/tests/test_v03.py +128 -0
  18. contextpress-0.4.0/tests/test_v04.py +51 -0
  19. contextpress-0.2.0/CHANGELOG.md +0 -17
  20. contextpress-0.2.0/contextpress/llm/__init__.py +0 -4
  21. {contextpress-0.2.0 → contextpress-0.4.0}/CITATION.cff +0 -0
  22. {contextpress-0.2.0 → contextpress-0.4.0}/CONTRIBUTING.md +0 -0
  23. {contextpress-0.2.0 → contextpress-0.4.0}/LICENSE +0 -0
  24. {contextpress-0.2.0 → contextpress-0.4.0}/NOTICE +0 -0
  25. {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/_bootstrap.py +0 -0
  26. {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/llm/_helpers.py +0 -0
  27. {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/llm/base.py +0 -0
  28. {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/models.py +0 -0
  29. {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/normalizer.py +0 -0
  30. {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/profiles.py +0 -0
  31. {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/py.typed +0 -0
  32. {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/strategies/__init__.py +0 -0
  33. {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/strategies/base.py +0 -0
  34. {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/strategies/budget.py +0 -0
  35. {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/strategies/filler.py +0 -0
  36. {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/strategies/recency.py +0 -0
  37. {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/strategies/repetition.py +0 -0
  38. {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/strategies/resolution.py +0 -0
  39. {contextpress-0.2.0 → contextpress-0.4.0}/examples/llm_tier_ollama.py +0 -0
  40. {contextpress-0.2.0 → contextpress-0.4.0}/examples/llm_tier_openai.py +0 -0
  41. {contextpress-0.2.0 → contextpress-0.4.0}/tests/__init__.py +0 -0
  42. {contextpress-0.2.0 → contextpress-0.4.0}/tests/test_budget.py +0 -0
  43. {contextpress-0.2.0 → contextpress-0.4.0}/tests/test_filler.py +0 -0
  44. {contextpress-0.2.0 → contextpress-0.4.0}/tests/test_llm_helpers.py +0 -0
  45. {contextpress-0.2.0 → contextpress-0.4.0}/tests/test_models.py +0 -0
  46. {contextpress-0.2.0 → contextpress-0.4.0}/tests/test_normalizer.py +0 -0
  47. {contextpress-0.2.0 → contextpress-0.4.0}/tests/test_pipeline.py +0 -0
  48. {contextpress-0.2.0 → contextpress-0.4.0}/tests/test_recency.py +0 -0
  49. {contextpress-0.2.0 → contextpress-0.4.0}/tests/test_repetition.py +0 -0
  50. {contextpress-0.2.0 → contextpress-0.4.0}/tests/test_resolution.py +0 -0
  51. {contextpress-0.2.0 → contextpress-0.4.0}/tests/test_stats.py +0 -0
@@ -29,3 +29,6 @@ htmlcov/
29
29
  *.swp
30
30
  .DS_Store
31
31
  Thumbs.db
32
+
33
+ # Secrets (never commit)
34
+ .env
@@ -0,0 +1,32 @@
1
+ # Changelog
2
+
3
+ All notable changes to `contextpress` are recorded here.
4
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
5
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
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
+
15
+ ## [0.3.0] - 2026-07-14
16
+
17
+ - **`estimate_tokens(messages)`** — count tokens before compressing (same tiktoken encoding as budget).
18
+ - **Stage registry** — `ContextManager.register_stage()` for custom `BaseStrategy` stages in `stages=[...]`.
19
+ - **Tier 2 `llm_mode`** — `replace_all` (default), `dedupe_only`, or `summarize_only`.
20
+ - Example: `examples/estimate_and_stats.py`.
21
+
22
+ ## [0.2.0] - 2026-05-24
23
+
24
+ - **`return_stats=True`** on `ContextManager.compress()` returns a `CompressionResult` with token/turn counts, stages run, and per-stage turn deltas.
25
+ - **Tier 2 deduplication** implemented in OpenAI, Anthropic, and Ollama adapters (LLM selects indices to keep).
26
+ - **GitHub Actions CI** runs `pytest` on Python 3.10–3.13.
27
+ - Added **`CONTRIBUTING.md`** with contribution and review expectations.
28
+
29
+ ## [0.1.0] - 2026-04-19
30
+
31
+ - Initial release by Taha Azizi.
32
+ - Tier 1 pipeline (filler, repetition, resolution, recency, budget) and optional Tier 2 `LLMBackend` (OpenAI, Anthropic, Ollama adapters).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: contextpress
3
- Version: 0.2.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
@@ -290,6 +290,38 @@ print(result.stats.tokens_saved, result.stats.stages_run)
290
290
  compressed = result.messages
291
291
  ```
292
292
 
293
+ Check token count **before** compressing:
294
+
295
+ ```python
296
+ before = cm.estimate_tokens(messages)
297
+ ```
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
+
310
+ ### Custom stages (0.3+)
311
+
312
+ Register a `BaseStrategy` and include it in `stages=`:
313
+
314
+ ```python
315
+ from contextpress.strategies.base import BaseStrategy
316
+
317
+ class MyStage(BaseStrategy):
318
+ def process(self, conversation):
319
+ ...
320
+
321
+ cm.register_stage("my_stage", MyStage)
322
+ out = cm.compress(messages, stages=["filler", "my_stage", "budget"], token_budget=500)
323
+ ```
324
+
293
325
  ### Minimal examples
294
326
 
295
327
  ```python
@@ -405,7 +437,9 @@ After **Tier 1** finishes, you can attach an **`LLMBackend`** for semantic compr
405
437
  2. If the combined transcript is long enough (default **1500** characters; set **`llm_min_input_chars=0`** to always run), calls **`summarize(transcript, max_tokens)`**.
406
438
  3. **System turns are unchanged** in order and content. **All other turns are replaced** by a **single assistant** message whose content is the LLM summary (metadata includes `source: contextpress_llm_tier`). If the LLM call fails, the Tier 1 conversation is returned and a **warning** is emitted.
407
439
 
408
- Optional constructor knobs: **`llm_min_input_chars`**, **`llm_max_summary_tokens`**.
440
+ Optional constructor knobs: **`llm_min_input_chars`**, **`llm_max_summary_tokens`**, **`llm_mode`**.
441
+
442
+ **`llm_mode`** (0.3+): `replace_all` (default — dedupe then one summary turn), `dedupe_only` (dedupe, keep turns), `summarize_only` (append summary, keep turns).
409
443
 
410
444
  **Install SDKs** (not bundled): `pip install openai`, `anthropic`, and/or **`ollama`** (for local Ollama), or `pip install "contextpress[llm]"` from this repo’s `pyproject.toml` to pull all optional LLM clients.
411
445
 
@@ -468,7 +502,7 @@ Long chat histories inflate token usage, bury important facts (lost-in-the-middl
468
502
 
469
503
  ## Research and citing
470
504
 
471
- 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).
472
506
 
473
507
  ## Extension and growth
474
508
 
@@ -50,6 +50,38 @@ print(result.stats.tokens_saved, result.stats.stages_run)
50
50
  compressed = result.messages
51
51
  ```
52
52
 
53
+ Check token count **before** compressing:
54
+
55
+ ```python
56
+ before = cm.estimate_tokens(messages)
57
+ ```
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
+
70
+ ### Custom stages (0.3+)
71
+
72
+ Register a `BaseStrategy` and include it in `stages=`:
73
+
74
+ ```python
75
+ from contextpress.strategies.base import BaseStrategy
76
+
77
+ class MyStage(BaseStrategy):
78
+ def process(self, conversation):
79
+ ...
80
+
81
+ cm.register_stage("my_stage", MyStage)
82
+ out = cm.compress(messages, stages=["filler", "my_stage", "budget"], token_budget=500)
83
+ ```
84
+
53
85
  ### Minimal examples
54
86
 
55
87
  ```python
@@ -165,7 +197,9 @@ After **Tier 1** finishes, you can attach an **`LLMBackend`** for semantic compr
165
197
  2. If the combined transcript is long enough (default **1500** characters; set **`llm_min_input_chars=0`** to always run), calls **`summarize(transcript, max_tokens)`**.
166
198
  3. **System turns are unchanged** in order and content. **All other turns are replaced** by a **single assistant** message whose content is the LLM summary (metadata includes `source: contextpress_llm_tier`). If the LLM call fails, the Tier 1 conversation is returned and a **warning** is emitted.
167
199
 
168
- Optional constructor knobs: **`llm_min_input_chars`**, **`llm_max_summary_tokens`**.
200
+ Optional constructor knobs: **`llm_min_input_chars`**, **`llm_max_summary_tokens`**, **`llm_mode`**.
201
+
202
+ **`llm_mode`** (0.3+): `replace_all` (default — dedupe then one summary turn), `dedupe_only` (dedupe, keep turns), `summarize_only` (append summary, keep turns).
169
203
 
170
204
  **Install SDKs** (not bundled): `pip install openai`, `anthropic`, and/or **`ollama`** (for local Ollama), or `pip install "contextpress[llm]"` from this repo’s `pyproject.toml` to pull all optional LLM clients.
171
205
 
@@ -228,7 +262,7 @@ Long chat histories inflate token usage, bury important facts (lost-in-the-middl
228
262
 
229
263
  ## Research and citing
230
264
 
231
- 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).
232
266
 
233
267
  ## Extension and growth
234
268
 
@@ -16,7 +16,7 @@ __all__ = [
16
16
  "CompressionResult",
17
17
  "CompressionStats",
18
18
  ]
19
- __version__ = "0.2.0"
19
+ __version__ = "0.4.0"
20
20
 
21
21
 
22
22
  def __getattr__(name: str) -> Any:
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from contextpress.profiles import Profile
5
+ from contextpress.profiles import Profile, StageConfig
6
6
 
7
7
  STAGE_ORDER: tuple[str, ...] = (
8
8
  "filler",
@@ -14,6 +14,14 @@ STAGE_ORDER: tuple[str, ...] = (
14
14
 
15
15
  VALID_STAGES = frozenset(STAGE_ORDER)
16
16
 
17
+
18
+ def known_stages() -> frozenset[str]:
19
+ """Built-in plus registered custom stage names."""
20
+ from contextpress.registry import registered_stage_names
21
+
22
+ return VALID_STAGES | registered_stage_names()
23
+
24
+
17
25
  _NON_BUDGET_ORDER: tuple[str, ...] = tuple(s for s in STAGE_ORDER if s != "budget")
18
26
 
19
27
  # NLP stages only; budget is toggled from token_budget (see apply_stage_selection)
@@ -38,6 +46,7 @@ __all__ = [
38
46
  "STAGE_ORDER",
39
47
  "VALID_STAGES",
40
48
  "apply_stage_selection",
49
+ "known_stages",
41
50
  "normalize_compression_level",
42
51
  ]
43
52
 
@@ -60,6 +69,7 @@ def apply_stage_selection(
60
69
  stages: list[str] | None,
61
70
  disable: list[str] | None,
62
71
  token_budget: int | None,
72
+ custom_stages: dict[str, StageConfig] | None = None,
63
73
  ) -> None:
64
74
  """
65
75
  Mutates ``profile`` in place: sets each stage's ``enabled`` from explicit
@@ -70,12 +80,15 @@ def apply_stage_selection(
70
80
  if stages is not None:
71
81
  if not stages:
72
82
  raise ValueError("stages= must list at least one stage name when provided")
73
- unknown = [s for s in stages if s not in VALID_STAGES]
83
+ unknown = [s for s in stages if s not in known_stages()]
74
84
  if unknown:
75
- raise ValueError(f"unknown stage name(s): {unknown}; valid: {sorted(VALID_STAGES)}")
85
+ raise ValueError(f"unknown stage name(s): {unknown}; valid: {sorted(known_stages())}")
76
86
  want = frozenset(stages)
77
87
  for name in _NON_BUDGET_ORDER:
78
88
  getattr(profile, name).enabled = name in want
89
+ if custom_stages:
90
+ for name, cfg in custom_stages.items():
91
+ cfg.enabled = name in want
79
92
  else:
80
93
  level = normalize_compression_level(compression)
81
94
  preset = _COMPRESSION_PRESETS[level]
@@ -85,6 +98,9 @@ def apply_stage_selection(
85
98
 
86
99
  if disable:
87
100
  for name in disable:
101
+ if custom_stages and name in custom_stages:
102
+ custom_stages[name].enabled = False
103
+ continue
88
104
  if not hasattr(profile, name):
89
105
  continue
90
106
  getattr(profile, name).enabled = False
@@ -6,12 +6,17 @@ 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 Pipeline
9
+ from contextpress.pipeline import VALID_LLM_MODES, Pipeline, clone_conversation
10
10
  from contextpress.profiles import PROFILES, Profile, StageConfig
11
- from contextpress.stats import CompressionResult, CompressionStats
11
+ from contextpress.registry import register_stage as _register_stage
12
+ from contextpress.stats import CompressionResult, CompressionStats, count_conversation_tokens
13
+ from contextpress.warnings_capture import capture_warnings
12
14
 
13
15
  if TYPE_CHECKING:
16
+ from collections.abc import Callable
17
+
14
18
  from contextpress.llm.base import LLMBackend
19
+ from contextpress.strategies.base import BaseStrategy
15
20
 
16
21
 
17
22
  def _validate_token_budget(token_budget: int | None) -> None:
@@ -39,9 +44,14 @@ class ContextManager:
39
44
  compression: str = "medium",
40
45
  llm_min_input_chars: int = 1500,
41
46
  llm_max_summary_tokens: int = 2048,
47
+ llm_mode: str = "replace_all",
42
48
  ):
43
49
  if type not in PROFILES:
44
50
  raise ValueError(f"unknown context type {type!r}")
51
+ if llm_mode not in VALID_LLM_MODES:
52
+ raise ValueError(
53
+ f"unknown llm_mode {llm_mode!r}; use one of: {sorted(VALID_LLM_MODES)}"
54
+ )
45
55
  self._type = type
46
56
  self._profile: Profile = copy.deepcopy(PROFILES[type])
47
57
  self._compression: str = normalize_compression_level(compression)
@@ -49,6 +59,64 @@ class ContextManager:
49
59
  self.llm_backend = llm_backend
50
60
  self.llm_min_input_chars = int(llm_min_input_chars)
51
61
  self.llm_max_summary_tokens = int(llm_max_summary_tokens)
62
+ self.llm_mode = llm_mode
63
+ self._custom_stages: dict[str, StageConfig] = {}
64
+
65
+ def estimate_tokens(self, messages: Any, *, model: str | None = None) -> int:
66
+ """Count tokens for ``messages`` using the same encoding as the budget stage."""
67
+ conv, _ = normalize_messages(messages, context_type=self._type)
68
+ return count_conversation_tokens(conv, model if model is not None else self.model)
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
+
109
+ def register_stage(
110
+ self,
111
+ name: str,
112
+ factory: Callable[..., BaseStrategy],
113
+ *,
114
+ before: str = "budget",
115
+ aggressiveness: float = 0.5,
116
+ ) -> None:
117
+ """Register a custom pipeline stage (see ``contextpress.registry``)."""
118
+ _register_stage(name, factory, before=before)
119
+ self._custom_stages[name] = StageConfig(enabled=False, aggressiveness=aggressiveness)
52
120
 
53
121
  def compress(
54
122
  self,
@@ -59,15 +127,20 @@ class ContextManager:
59
127
  stages: list[str] | None = None,
60
128
  disable: list[str] | None = None,
61
129
  return_stats: bool = False,
130
+ dry_run: bool = False,
62
131
  ) -> Any | CompressionResult:
63
132
  """Run the pipeline; return value matches input shape (dict list, tuples, strings, etc.).
64
133
 
65
134
  ``token_budget`` must be a positive int or None. Unknown keys in ``disable`` are ignored.
66
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.
67
137
  """
138
+ if dry_run:
139
+ return_stats = True
68
140
  _validate_token_budget(token_budget)
69
141
  level = compression if compression is not None else self._compression
70
142
  profile = copy.deepcopy(self._profile)
143
+ custom_stages = copy.deepcopy(self._custom_stages)
71
144
  apply_stage_selection(
72
145
  profile,
73
146
  base_profile=self._profile,
@@ -75,6 +148,7 @@ class ContextManager:
75
148
  stages=stages,
76
149
  disable=disable,
77
150
  token_budget=token_budget,
151
+ custom_stages=custom_stages,
78
152
  )
79
153
 
80
154
  conv, ctx = normalize_messages(messages, context_type=self._type)
@@ -86,9 +160,17 @@ class ContextManager:
86
160
  llm_backend=self.llm_backend,
87
161
  llm_min_input_chars=self.llm_min_input_chars,
88
162
  llm_max_summary_tokens=self.llm_max_summary_tokens,
163
+ llm_mode=self.llm_mode,
164
+ custom_stages=custom_stages,
89
165
  )
90
- out = pipeline.run(conv, stats=stats)
91
- 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)
92
174
  if return_stats:
93
175
  assert stats is not None
94
176
  return CompressionResult(messages=messages_out, stats=stats)
@@ -100,9 +182,12 @@ class ContextManager:
100
182
 
101
183
  def configure(self, stage: str, **kwargs: Any) -> None:
102
184
  """Patch ``StageConfig`` fields on the live profile (e.g. aggressiveness, enabled)."""
103
- if not hasattr(self._profile, stage):
185
+ if stage in self._custom_stages:
186
+ sc = self._custom_stages[stage]
187
+ elif hasattr(self._profile, stage):
188
+ sc = getattr(self._profile, stage)
189
+ else:
104
190
  raise ValueError(f"unknown stage {stage!r}")
105
- sc: StageConfig = getattr(self._profile, stage)
106
191
  for k, v in kwargs.items():
107
192
  if hasattr(sc, k):
108
193
  setattr(sc, k, v)
@@ -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
+ )
@@ -27,6 +27,11 @@ from contextpress.compression import STAGE_ORDER
27
27
  from contextpress.models import Conversation, Turn
28
28
  from contextpress.normalizer import extract_text_for_processing
29
29
  from contextpress.profiles import Profile, StageConfig
30
+ from contextpress.registry import (
31
+ build_custom_strategy,
32
+ effective_stage_order,
33
+ registered_stage_names,
34
+ )
30
35
  from contextpress.stats import CompressionStats, count_conversation_tokens
31
36
  from contextpress.strategies.base import BaseStrategy
32
37
  from contextpress.strategies.budget import BudgetStrategy
@@ -38,6 +43,8 @@ from contextpress.strategies.resolution import ResolutionStrategy
38
43
  if TYPE_CHECKING:
39
44
  from contextpress.llm.base import LLMBackend
40
45
 
46
+ VALID_LLM_MODES = frozenset({"replace_all", "dedupe_only", "summarize_only"})
47
+
41
48
 
42
49
  def clone_turn(t: Turn) -> Turn:
43
50
  if isinstance(t.content, str):
@@ -80,6 +87,8 @@ class Pipeline:
80
87
  *,
81
88
  llm_min_input_chars: int = 1500,
82
89
  llm_max_summary_tokens: int = 2048,
90
+ llm_mode: str = "replace_all",
91
+ custom_stages: dict[str, StageConfig] | None = None,
83
92
  ):
84
93
  self.profile = profile
85
94
  self.token_budget = token_budget
@@ -87,24 +96,34 @@ class Pipeline:
87
96
  self.llm_backend = llm_backend # None = Tier 1 only
88
97
  self.llm_min_input_chars = max(0, int(llm_min_input_chars))
89
98
  self.llm_max_summary_tokens = max(64, int(llm_max_summary_tokens))
99
+ if llm_mode not in VALID_LLM_MODES:
100
+ raise ValueError(
101
+ f"unknown llm_mode {llm_mode!r}; use one of: {sorted(VALID_LLM_MODES)}"
102
+ )
103
+ self.llm_mode = llm_mode
104
+ self.custom_stages = custom_stages or {}
90
105
 
91
106
  def run(
92
107
  self,
93
108
  conversation: Conversation,
94
109
  stats: CompressionStats | None = None,
110
+ *,
111
+ dry_run: bool = False,
95
112
  ) -> Conversation:
96
113
  if stats is not None:
97
114
  stats.turns_before = len(conversation.turns)
98
115
  stats.tokens_before = count_conversation_tokens(conversation, self.model)
99
116
  stats.context_type = conversation.type
100
117
  stats.token_budget = self.token_budget
118
+ stats.dry_run = dry_run
101
119
 
102
120
  result = clone_conversation(conversation)
103
- for stage_name in self.STAGE_ORDER:
121
+ stage_order = effective_stage_order()
122
+ for stage_name in stage_order:
104
123
  if stage_name == "budget" and self.token_budget is None:
105
124
  continue
106
- stage_config = getattr(self.profile, stage_name)
107
- if not stage_config.enabled:
125
+ stage_config = self._stage_config(stage_name)
126
+ if stage_config is None or not stage_config.enabled:
108
127
  continue
109
128
  before_turns = len(result.turns)
110
129
  strategy = self._build_strategy(stage_name, stage_config)
@@ -115,7 +134,7 @@ class Pipeline:
115
134
  if delta != 0:
116
135
  stats.turn_delta_by_stage[stage_name] = delta
117
136
 
118
- if self.llm_backend is not None:
137
+ if self.llm_backend is not None and not dry_run:
119
138
  result = self._run_llm_tier(result, stats=stats)
120
139
 
121
140
  if stats is not None:
@@ -124,12 +143,26 @@ class Pipeline:
124
143
 
125
144
  return result
126
145
 
146
+ def _stage_config(self, name: str) -> StageConfig | None:
147
+ if name in self.custom_stages:
148
+ return self.custom_stages[name]
149
+ if hasattr(self.profile, name):
150
+ return getattr(self.profile, name)
151
+ return None
152
+
127
153
  def _build_strategy(self, name: str, config: StageConfig) -> BaseStrategy:
128
154
  kwargs: dict[str, Any] = {
129
155
  "aggressiveness": config.aggressiveness,
130
156
  "conv_type": self.profile.name,
131
157
  "role_aware": self.profile.role_aware,
132
158
  }
159
+ if name in registered_stage_names():
160
+ return build_custom_strategy(
161
+ name,
162
+ config,
163
+ conv_type=self.profile.name,
164
+ role_aware=self.profile.role_aware,
165
+ )
133
166
  if name == "filler":
134
167
  return FillerStrategy(**kwargs)
135
168
  if name == "repetition":
@@ -166,25 +199,36 @@ class Pipeline:
166
199
  stats.llm_dedup_turns_before = len(ns_turns)
167
200
 
168
201
  texts = [extract_text_for_processing(t) for t in ns_turns]
169
- try:
170
- keep_idx = self.llm_backend.deduplicate(texts)
171
- except Exception:
172
- keep_idx = list(range(len(ns_turns)))
173
- valid = sorted(
174
- {
175
- i
176
- for i in keep_idx
177
- if type(i) is int and not isinstance(i, bool) and 0 <= i < len(ns_turns)
178
- }
179
- )
180
- if not valid:
181
- valid = list(range(len(ns_turns)))
182
- if len(valid) < len(ns_turns):
183
- ns_turns = [ns_turns[i] for i in valid]
184
- texts = [texts[i] for i in valid]
202
+ if self.llm_mode in ("replace_all", "dedupe_only"):
203
+ try:
204
+ keep_idx = self.llm_backend.deduplicate(texts)
205
+ except Exception:
206
+ keep_idx = list(range(len(ns_turns)))
207
+ valid = sorted(
208
+ {
209
+ i
210
+ for i in keep_idx
211
+ if type(i) is int and not isinstance(i, bool) and 0 <= i < len(ns_turns)
212
+ }
213
+ )
214
+ if not valid:
215
+ valid = list(range(len(ns_turns)))
216
+ if len(valid) < len(ns_turns):
217
+ ns_turns = [ns_turns[i] for i in valid]
218
+ texts = [texts[i] for i in valid]
185
219
 
186
- if stats is not None:
187
- stats.llm_dedup_turns_after = len(ns_turns)
220
+ if stats is not None:
221
+ stats.llm_dedup_turns_after = len(ns_turns)
222
+
223
+ if self.llm_mode == "dedupe_only":
224
+ new_turns = list(system_turns) + [clone_turn(t) for t in ns_turns]
225
+ if stats is not None:
226
+ stats.llm_tier_applied = True
227
+ return Conversation(
228
+ turns=new_turns,
229
+ type=conversation.type,
230
+ metadata=copy.deepcopy(conversation.metadata),
231
+ )
188
232
 
189
233
  lines = [f"{t.role}: {txt}" for t, txt in zip(ns_turns, texts, strict=True)]
190
234
  combined = "\n\n".join(lines)
@@ -207,6 +251,22 @@ class Pipeline:
207
251
  if stats is not None:
208
252
  stats.llm_tier_applied = True
209
253
 
254
+ if self.llm_mode == "summarize_only":
255
+ new_turns = list(system_turns) + [clone_turn(t) for t in ns_turns]
256
+ new_turns.append(
257
+ Turn(
258
+ role="assistant",
259
+ content=summary,
260
+ metadata={"source": "contextpress_llm_tier", "mode": "summarize_only"},
261
+ compressed=True,
262
+ )
263
+ )
264
+ return Conversation(
265
+ turns=new_turns,
266
+ type=conversation.type,
267
+ metadata=copy.deepcopy(conversation.metadata),
268
+ )
269
+
210
270
  new_turns: list[Turn] = list(system_turns)
211
271
  new_turns.append(
212
272
  Turn(
@@ -0,0 +1,85 @@
1
+ """Register custom pipeline stages without subclassing ``Pipeline``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ from contextpress.profiles import StageConfig
9
+ from contextpress.strategies.base import BaseStrategy
10
+
11
+ # (name, factory, insert_before)
12
+ _CustomEntry = tuple[str, Callable[..., BaseStrategy], str]
13
+ _REGISTRY: list[_CustomEntry] = []
14
+
15
+
16
+ def _builtin_stages() -> tuple[str, ...]:
17
+ from contextpress.compression import STAGE_ORDER
18
+
19
+ return STAGE_ORDER
20
+
21
+
22
+ def register_stage(
23
+ name: str,
24
+ factory: Callable[..., BaseStrategy],
25
+ *,
26
+ before: str = "budget",
27
+ ) -> None:
28
+ """Register a custom stage factory.
29
+
30
+ ``factory`` receives ``aggressiveness``, ``conv_type``, ``role_aware``, and
31
+ optional ``**kwargs`` — same as built-in strategies. Custom stages run when
32
+ named in ``stages=`` on ``compress()``.
33
+
34
+ Parameters
35
+ ----------
36
+ name:
37
+ Unique stage name (must not collide with a built-in stage).
38
+ before:
39
+ Built-in or custom stage name to insert before (default: ``budget``).
40
+ """
41
+ if not name or name in _builtin_stages():
42
+ raise ValueError(f"cannot register stage {name!r}: name invalid or reserved")
43
+ if any(e[0] == name for e in _REGISTRY):
44
+ raise ValueError(f"stage {name!r} is already registered")
45
+ _REGISTRY.append((name, factory, before))
46
+
47
+
48
+ def registered_stage_names() -> frozenset[str]:
49
+ return frozenset(name for name, _, _ in _REGISTRY)
50
+
51
+
52
+ def clear_registry() -> None:
53
+ """Remove all custom stages (for tests)."""
54
+ _REGISTRY.clear()
55
+
56
+
57
+ def effective_stage_order() -> list[str]:
58
+ """Built-in ``STAGE_ORDER`` with registered custom stages inserted."""
59
+ order = list(_builtin_stages())
60
+ for name, _, before in _REGISTRY:
61
+ if before not in order:
62
+ raise RuntimeError(f"custom stage {name!r}: anchor {before!r} not in stage order")
63
+ order.insert(order.index(before), name)
64
+ return order
65
+
66
+
67
+ def build_custom_strategy(
68
+ name: str,
69
+ config: StageConfig,
70
+ *,
71
+ conv_type: str,
72
+ role_aware: bool,
73
+ extra_kwargs: dict[str, Any] | None = None,
74
+ ) -> BaseStrategy:
75
+ for reg_name, factory, _ in _REGISTRY:
76
+ if reg_name == name:
77
+ kwargs: dict[str, Any] = {
78
+ "aggressiveness": config.aggressiveness,
79
+ "conv_type": conv_type,
80
+ "role_aware": role_aware,
81
+ }
82
+ if extra_kwargs:
83
+ kwargs.update(extra_kwargs)
84
+ return factory(**kwargs)
85
+ raise ValueError(f"no registered factory for custom stage {name!r}")
@@ -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)
@@ -0,0 +1,21 @@
1
+ """Estimate tokens before compressing, then inspect stats after."""
2
+
3
+ from contextpress import ContextManager
4
+
5
+ messages = [
6
+ {"role": "system", "content": "You are helpful."},
7
+ {"role": "user", "content": "Old topic: " + "blah " * 50},
8
+ {"role": "assistant", "content": "Basically, honestly, " + "yak " * 50},
9
+ {"role": "user", "content": "What is 2+2?"},
10
+ {"role": "assistant", "content": "Four."},
11
+ ]
12
+
13
+ cm = ContextManager(type="chat", compression="medium")
14
+ before = cm.estimate_tokens(messages)
15
+ print("tokens before:", before)
16
+
17
+ result = cm.compress(messages, token_budget=200, return_stats=True)
18
+ print("tokens after:", result.stats.tokens_after)
19
+ print("saved:", result.stats.tokens_saved)
20
+ print("stages:", result.stats.stages_run)
21
+ print("turns:", result.stats.turns_before, "->", result.stats.turns_after)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "contextpress"
7
- version = "0.2.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,128 @@
1
+ import copy
2
+
3
+ import pytest
4
+
5
+ from contextpress import ContextManager
6
+ from contextpress.compression import known_stages
7
+ from contextpress.models import Conversation, Turn
8
+ from contextpress.pipeline import Pipeline
9
+ from contextpress.profiles import PROFILES
10
+ from contextpress.registry import clear_registry, register_stage
11
+ from contextpress.strategies.base import BaseStrategy
12
+
13
+
14
+ class _DropMarkedStrategy(BaseStrategy):
15
+ def process(self, conversation: Conversation) -> Conversation:
16
+ kept = [
17
+ copy.deepcopy(t)
18
+ for t in conversation.turns
19
+ if t.role == "system"
20
+ or "DROP" not in (t.content if isinstance(t.content, str) else str(t.content))
21
+ ]
22
+ return Conversation(
23
+ turns=kept,
24
+ type=conversation.type,
25
+ metadata=copy.deepcopy(conversation.metadata),
26
+ )
27
+
28
+
29
+ @pytest.fixture(autouse=True)
30
+ def _reset_registry():
31
+ clear_registry()
32
+ yield
33
+ clear_registry()
34
+
35
+
36
+ def test_estimate_tokens_dict_list():
37
+ cm = ContextManager(type="chat")
38
+ messages = [
39
+ {"role": "user", "content": "Hello world"},
40
+ {"role": "assistant", "content": "Hi there"},
41
+ ]
42
+ n = cm.estimate_tokens(messages)
43
+ assert isinstance(n, int) and n > 0
44
+
45
+
46
+ def test_register_stage_and_run():
47
+ cm = ContextManager(type="chat")
48
+ cm.register_stage("drop_marked", _DropMarkedStrategy)
49
+ assert "drop_marked" in known_stages()
50
+ messages = [
51
+ {"role": "user", "content": "keep me"},
52
+ {"role": "assistant", "content": "DROP this turn"},
53
+ ]
54
+ out = cm.compress(messages, stages=["drop_marked"])
55
+ assert len(out) == 1
56
+ assert out[0]["content"] == "keep me"
57
+
58
+
59
+ def test_register_stage_reserved_name_raises():
60
+ with pytest.raises(ValueError):
61
+ register_stage("filler", _DropMarkedStrategy)
62
+
63
+
64
+ def test_configure_custom_stage():
65
+ cm = ContextManager(type="chat")
66
+ cm.register_stage("drop_marked", _DropMarkedStrategy, aggressiveness=0.2)
67
+ cm.configure("drop_marked", aggressiveness=0.9)
68
+ assert cm._custom_stages["drop_marked"].aggressiveness == 0.9
69
+
70
+
71
+ class _DedupOnlyBackend:
72
+ def summarize(self, text: str, max_tokens: int) -> str:
73
+ raise AssertionError("summarize should not run in dedupe_only mode")
74
+
75
+ def deduplicate(self, turns: list[str]) -> list[int]:
76
+ return [0]
77
+
78
+
79
+ class _SummarizeOnlyBackend:
80
+ def summarize(self, text: str, max_tokens: int) -> str:
81
+ return "summary appended"
82
+
83
+ def deduplicate(self, turns: list[str]) -> list[int]:
84
+ raise AssertionError("deduplicate should not run in summarize_only mode")
85
+
86
+
87
+ def test_llm_mode_dedupe_only():
88
+ conv = Conversation(
89
+ turns=[
90
+ Turn(role="user", content="hello"),
91
+ Turn(role="user", content="hello again"),
92
+ ],
93
+ type="chat",
94
+ )
95
+ p = Pipeline(
96
+ PROFILES["chat"],
97
+ token_budget=None,
98
+ llm_backend=_DedupOnlyBackend(),
99
+ llm_mode="dedupe_only",
100
+ llm_min_input_chars=0,
101
+ )
102
+ out = p.run(conv)
103
+ assert len(out.turns) == 1
104
+ assert out.turns[0].content == "hello"
105
+
106
+
107
+ def test_llm_mode_summarize_only():
108
+ conv = Conversation(
109
+ turns=[Turn(role="user", content="hello " * 200)],
110
+ type="chat",
111
+ )
112
+ p = Pipeline(
113
+ PROFILES["chat"],
114
+ token_budget=None,
115
+ llm_backend=_SummarizeOnlyBackend(),
116
+ llm_mode="summarize_only",
117
+ llm_min_input_chars=0,
118
+ )
119
+ out = p.run(conv)
120
+ assert len(out.turns) == 2
121
+ assert out.turns[0].role == "user"
122
+ assert out.turns[1].content == "summary appended"
123
+ assert out.turns[1].metadata.get("mode") == "summarize_only"
124
+
125
+
126
+ def test_invalid_llm_mode_raises():
127
+ with pytest.raises(ValueError):
128
+ ContextManager(llm_mode="invalid")
@@ -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,17 +0,0 @@
1
- # Changelog
2
-
3
- All notable changes to `contextpress` are recorded here.
4
- The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
5
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
-
7
- ## [0.2.0] - 2026-05-24
8
-
9
- - **`return_stats=True`** on `ContextManager.compress()` returns a `CompressionResult` with token/turn counts, stages run, and per-stage turn deltas.
10
- - **Tier 2 deduplication** implemented in OpenAI, Anthropic, and Ollama adapters (LLM selects indices to keep).
11
- - **GitHub Actions CI** runs `pytest` on Python 3.10–3.13.
12
- - Added **`CONTRIBUTING.md`** with contribution and review expectations.
13
-
14
- ## [0.1.0] - 2026-04-19
15
-
16
- - Initial release by Taha Azizi.
17
- - Tier 1 pipeline (filler, repetition, resolution, recency, budget) and optional Tier 2 `LLMBackend` (OpenAI, Anthropic, Ollama adapters).
@@ -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