leancontext 2.0.4__tar.gz → 2.0.6__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.
- {leancontext-2.0.4 → leancontext-2.0.6}/.github/workflows/publish.yml +2 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/CHANGELOG.md +39 -1
- {leancontext-2.0.4 → leancontext-2.0.6}/PKG-INFO +20 -9
- {leancontext-2.0.4 → leancontext-2.0.6}/README.md +19 -8
- {leancontext-2.0.4 → leancontext-2.0.6}/leancontext/__init__.py +12 -1
- {leancontext-2.0.4 → leancontext-2.0.6}/leancontext/core.py +16 -8
- {leancontext-2.0.4 → leancontext-2.0.6}/leancontext/cost.py +10 -6
- {leancontext-2.0.4 → leancontext-2.0.6}/leancontext/fidelity.py +12 -2
- {leancontext-2.0.4 → leancontext-2.0.6}/leancontext/integrations/_common.py +20 -9
- {leancontext-2.0.4 → leancontext-2.0.6}/leancontext/integrations/clients.py +6 -1
- {leancontext-2.0.4 → leancontext-2.0.6}/leancontext/integrations/litellm.py +4 -3
- {leancontext-2.0.4 → leancontext-2.0.6}/leancontext/messages.py +106 -34
- {leancontext-2.0.4 → leancontext-2.0.6}/leancontext/paging.py +12 -3
- {leancontext-2.0.4 → leancontext-2.0.6}/leancontext/reducers/json_data.py +10 -8
- {leancontext-2.0.4 → leancontext-2.0.6}/pyproject.toml +1 -1
- leancontext-2.0.6/tests/test_concurrency.py +27 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/tests/test_core.py +10 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/tests/test_gateway.py +54 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/tests/test_messages.py +33 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/tests/test_paging.py +11 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/tests/test_reducers.py +0 -1
- {leancontext-2.0.4 → leancontext-2.0.6}/.editorconfig +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/.github/CODEOWNERS +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/.github/dependabot.yml +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/.github/workflows/ci.yml +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/.github/workflows/codeql.yml +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/.gitignore +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/.pre-commit-config.yaml +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/AGENTS.md +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/CITATION.cff +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/CODE_OF_CONDUCT.md +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/CONTRIBUTING.md +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/LICENSE +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/RELEASING.md +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/SECURITY.md +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/SUPPORT.md +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/assets/logo.png +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/assets/logo.svg +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/bench.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/demo.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/docs/ARCHITECTURE.md +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/examples/basic_usage.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/examples/validate_caching.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/leancontext/cli.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/leancontext/integrations/__init__.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/leancontext/integrations/anthropic_native.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/leancontext/integrations/decorator.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/leancontext/integrations/frameworks.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/leancontext/integrations/mcp_server.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/leancontext/integrations/otel.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/leancontext/integrations/proxy.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/leancontext/py.typed +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/leancontext/reducers/__init__.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/leancontext/reducers/base.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/leancontext/reducers/diff.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/leancontext/reducers/html.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/leancontext/reducers/logs.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/leancontext/reducers/stacktrace.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/leancontext/reducers/table.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/leancontext/tokens.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/tests/test_cache.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/tests/test_differentiators.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/tests/test_fidelity.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/tests/test_frameworks.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/tests/test_gemini.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/tests/test_limits.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/tests/test_mcp.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/tests/test_otel.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/tests/test_proxy.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/tests/test_table.py +0 -0
- {leancontext-2.0.4 → leancontext-2.0.6}/tests/test_tokens.py +0 -0
|
@@ -5,13 +5,43 @@ All notable changes to this project are documented here. The format is based on
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [2.0.6] - 2026-06-21
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- JSON reducer is now lossless on every value: rows are emitted as JSON arrays with
|
|
12
|
+
the field names factored into the header once, so values containing the column
|
|
13
|
+
delimiter, quotes, or newlines no longer corrupt the columnar layout. The JSON
|
|
14
|
+
fidelity check matches values in their encoded form, so it sees such corruption.
|
|
15
|
+
- Gateway paths (LiteLLM proxy + SDK patch) now reduce OpenAI Responses requests
|
|
16
|
+
(`input=`), not just chat (`messages=`).
|
|
17
|
+
- `reduce_messages` dispatches per item, so a list mixing message formats reduces
|
|
18
|
+
every tool output instead of only those matching the first format detected.
|
|
19
|
+
- OpenAI Responses tool outputs shaped as a list of content parts are now reduced.
|
|
20
|
+
- `__version__` is read from the installed package metadata (was a stale `0.0.1`).
|
|
21
|
+
- `CostTracker` running totals are guarded by a lock for multi-threaded agents.
|
|
22
|
+
|
|
23
|
+
### Docs
|
|
24
|
+
- README install commands use the published package (`pip install leancontext`),
|
|
25
|
+
document the `mcp` extra, note which tokenizer the benchmark uses, and state
|
|
26
|
+
which integrations are CI-verified vs best-effort.
|
|
27
|
+
|
|
28
|
+
## [2.0.5] - 2026-06-21
|
|
29
|
+
|
|
30
|
+
### Security
|
|
31
|
+
- Fix a path traversal in the disk-backed paging store: `expand()` and `ContentStore.get()`
|
|
32
|
+
now accept only content-hash ids, so a crafted reference can no longer read files outside
|
|
33
|
+
the store (reachable via the MCP `expand` tool). The default in-memory store was unaffected.
|
|
34
|
+
|
|
8
35
|
## [2.0.4] - 2026-06-21
|
|
9
36
|
|
|
10
37
|
### Fixed
|
|
11
38
|
- README uses absolute image and link URLs so the logo and links render on the PyPI
|
|
12
39
|
project page (relative paths only resolve on GitHub).
|
|
40
|
+
- The reduction cache is now thread-safe (guarded by a lock) for multi-threaded agents.
|
|
13
41
|
|
|
14
42
|
### Added
|
|
43
|
+
- OpenAI Responses API support: `reduce_messages` and `wrap_openai` handle `input`
|
|
44
|
+
with `function_call_output` items.
|
|
15
45
|
- PyPI downloads badge, `SUPPORT.md`, and a CodeQL security-scanning workflow.
|
|
16
46
|
|
|
17
47
|
## [2.0.2] - 2026-06-21
|
|
@@ -20,6 +50,11 @@ All notable changes to this project are documented here. The format is based on
|
|
|
20
50
|
- Lower the minimum Python from 3.14 to 3.10 so the package installs on current
|
|
21
51
|
interpreters (the code already supports 3.10+; CI runs 3.10 through 3.14).
|
|
22
52
|
|
|
53
|
+
## [2.0.1] - 2026-06-21
|
|
54
|
+
|
|
55
|
+
Intermediate release during the initial PyPI rollout (Python version metadata),
|
|
56
|
+
superseded by 2.0.2. Version 2.0.3 was never published.
|
|
57
|
+
|
|
23
58
|
## [2.0.0] - 2026-06-21
|
|
24
59
|
|
|
25
60
|
### Added
|
|
@@ -43,7 +78,10 @@ All notable changes to this project are documented here. The format is based on
|
|
|
43
78
|
- Targets Python 3.14; ruff, mypy, and coverage run in CI; examples, contributor, and
|
|
44
79
|
security docs included.
|
|
45
80
|
|
|
46
|
-
[Unreleased]: https://github.com/pankajniet/LeanContext/compare/v2.0.
|
|
81
|
+
[Unreleased]: https://github.com/pankajniet/LeanContext/compare/v2.0.6...HEAD
|
|
82
|
+
[2.0.6]: https://github.com/pankajniet/LeanContext/releases/tag/v2.0.6
|
|
83
|
+
[2.0.5]: https://github.com/pankajniet/LeanContext/releases/tag/v2.0.5
|
|
47
84
|
[2.0.4]: https://github.com/pankajniet/LeanContext/releases/tag/v2.0.4
|
|
48
85
|
[2.0.2]: https://github.com/pankajniet/LeanContext/releases/tag/v2.0.2
|
|
86
|
+
[2.0.1]: https://github.com/pankajniet/LeanContext/releases/tag/v2.0.1
|
|
49
87
|
[2.0.0]: https://github.com/pankajniet/LeanContext/releases/tag/v2.0.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: leancontext
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.6
|
|
4
4
|
Summary: Deterministic, type-aware reduction of agent tool outputs at the source. Cut LLM token cost without making the agent do less.
|
|
5
5
|
Project-URL: Homepage, https://github.com/pankajniet/LeanContext
|
|
6
6
|
Project-URL: Repository, https://github.com/pankajniet/LeanContext
|
|
@@ -84,14 +84,18 @@ $ python bench.py
|
|
|
84
84
|
sample kind before after saved fidelity
|
|
85
85
|
-----------------------------------------------------------------
|
|
86
86
|
log (incident) log 52642 100 100% 100%
|
|
87
|
-
json (RAG chunks) json 1862
|
|
87
|
+
json (RAG chunks) json 1862 1391 25% 100%
|
|
88
88
|
html (web fetch) html 1672 1093 35% 100%
|
|
89
89
|
diff (patch) diff 639 81 87% 100%
|
|
90
90
|
stacktrace stacktrace 896 94 90% 100%
|
|
91
91
|
-----------------------------------------------------------------
|
|
92
|
-
TOTAL 57711
|
|
92
|
+
TOTAL 57711 2759 95%
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
+
Counts above use the built-in heuristic tokenizer (≈4 chars/token). Install the
|
|
96
|
+
`tiktoken` extra for exact model token counts — the ratios are similar (~92% on
|
|
97
|
+
this sample). The reduced text is identical either way.
|
|
98
|
+
|
|
95
99
|
A real incident log, before and after:
|
|
96
100
|
|
|
97
101
|
```text
|
|
@@ -128,10 +132,11 @@ errors, anomalies, and identifiers, and collapses the rest.
|
|
|
128
132
|
## Install
|
|
129
133
|
|
|
130
134
|
```bash
|
|
131
|
-
pip install
|
|
132
|
-
pip install
|
|
133
|
-
pip install
|
|
134
|
-
pip install
|
|
135
|
+
pip install leancontext # core, standard library only
|
|
136
|
+
pip install "leancontext[integrations]" # openai, anthropic, litellm, fastapi adapters
|
|
137
|
+
pip install "leancontext[otel]" # OpenTelemetry metrics
|
|
138
|
+
pip install "leancontext[mcp]" # MCP server
|
|
139
|
+
pip install "leancontext[tiktoken]" # exact token counts (used automatically when present)
|
|
135
140
|
```
|
|
136
141
|
|
|
137
142
|
## Use it
|
|
@@ -176,6 +181,11 @@ r.fidelity # 0..1 signal preserved
|
|
|
176
181
|
| Frameworks | LangChain, LangGraph, Agno via `wrap(tools)`; any framework via `@reduce` on tool functions (sync or async) |
|
|
177
182
|
| MCP server | `python -m leancontext.integrations.mcp_server` — reduce / expand / stats over stdio |
|
|
178
183
|
|
|
184
|
+
CI exercises OpenAI (chat + Responses), Anthropic, LiteLLM, the standalone proxy, OpenTelemetry,
|
|
185
|
+
and the MCP server against the real packages. Message reduction for all formats (including Gemini)
|
|
186
|
+
is unit-tested directly. The framework adapters (LangChain / LangGraph / Agno) and the SDK-level
|
|
187
|
+
Gemini client wrapper are provided best-effort and are not yet covered in CI against the live SDKs.
|
|
188
|
+
|
|
179
189
|
## Reducers
|
|
180
190
|
|
|
181
191
|
| Kind | What it does |
|
|
@@ -185,6 +195,7 @@ r.fidelity # 0..1 signal preserved
|
|
|
185
195
|
| `diff` | Keep all change, hunk, and header lines, collapse unchanged context |
|
|
186
196
|
| `stacktrace` | Keep the exception and boundary frames, collapse the deep middle |
|
|
187
197
|
| `html` | Strip tags, scripts, and styles, keep visible text and links |
|
|
198
|
+
| `table` | Collapse whitespace-aligned command-line tables, keep header and data |
|
|
188
199
|
|
|
189
200
|
Anything else, or any payload below the size, saving, or fidelity thresholds, passes through unchanged.
|
|
190
201
|
|
|
@@ -216,8 +227,8 @@ leancontext.use_tiktoken("gpt-4o") # force a specific model's tokeniz
|
|
|
216
227
|
|
|
217
228
|
## Roadmap
|
|
218
229
|
|
|
219
|
-
|
|
220
|
-
|
|
230
|
+
CI-verified LangChain / LlamaIndex / CrewAI / Agno adapters, accurate provider tokenizers by
|
|
231
|
+
default, and broader Anthropic native interop.
|
|
221
232
|
|
|
222
233
|
## Contributing
|
|
223
234
|
|
|
@@ -38,14 +38,18 @@ $ python bench.py
|
|
|
38
38
|
sample kind before after saved fidelity
|
|
39
39
|
-----------------------------------------------------------------
|
|
40
40
|
log (incident) log 52642 100 100% 100%
|
|
41
|
-
json (RAG chunks) json 1862
|
|
41
|
+
json (RAG chunks) json 1862 1391 25% 100%
|
|
42
42
|
html (web fetch) html 1672 1093 35% 100%
|
|
43
43
|
diff (patch) diff 639 81 87% 100%
|
|
44
44
|
stacktrace stacktrace 896 94 90% 100%
|
|
45
45
|
-----------------------------------------------------------------
|
|
46
|
-
TOTAL 57711
|
|
46
|
+
TOTAL 57711 2759 95%
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
+
Counts above use the built-in heuristic tokenizer (≈4 chars/token). Install the
|
|
50
|
+
`tiktoken` extra for exact model token counts — the ratios are similar (~92% on
|
|
51
|
+
this sample). The reduced text is identical either way.
|
|
52
|
+
|
|
49
53
|
A real incident log, before and after:
|
|
50
54
|
|
|
51
55
|
```text
|
|
@@ -82,10 +86,11 @@ errors, anomalies, and identifiers, and collapses the rest.
|
|
|
82
86
|
## Install
|
|
83
87
|
|
|
84
88
|
```bash
|
|
85
|
-
pip install
|
|
86
|
-
pip install
|
|
87
|
-
pip install
|
|
88
|
-
pip install
|
|
89
|
+
pip install leancontext # core, standard library only
|
|
90
|
+
pip install "leancontext[integrations]" # openai, anthropic, litellm, fastapi adapters
|
|
91
|
+
pip install "leancontext[otel]" # OpenTelemetry metrics
|
|
92
|
+
pip install "leancontext[mcp]" # MCP server
|
|
93
|
+
pip install "leancontext[tiktoken]" # exact token counts (used automatically when present)
|
|
89
94
|
```
|
|
90
95
|
|
|
91
96
|
## Use it
|
|
@@ -130,6 +135,11 @@ r.fidelity # 0..1 signal preserved
|
|
|
130
135
|
| Frameworks | LangChain, LangGraph, Agno via `wrap(tools)`; any framework via `@reduce` on tool functions (sync or async) |
|
|
131
136
|
| MCP server | `python -m leancontext.integrations.mcp_server` — reduce / expand / stats over stdio |
|
|
132
137
|
|
|
138
|
+
CI exercises OpenAI (chat + Responses), Anthropic, LiteLLM, the standalone proxy, OpenTelemetry,
|
|
139
|
+
and the MCP server against the real packages. Message reduction for all formats (including Gemini)
|
|
140
|
+
is unit-tested directly. The framework adapters (LangChain / LangGraph / Agno) and the SDK-level
|
|
141
|
+
Gemini client wrapper are provided best-effort and are not yet covered in CI against the live SDKs.
|
|
142
|
+
|
|
133
143
|
## Reducers
|
|
134
144
|
|
|
135
145
|
| Kind | What it does |
|
|
@@ -139,6 +149,7 @@ r.fidelity # 0..1 signal preserved
|
|
|
139
149
|
| `diff` | Keep all change, hunk, and header lines, collapse unchanged context |
|
|
140
150
|
| `stacktrace` | Keep the exception and boundary frames, collapse the deep middle |
|
|
141
151
|
| `html` | Strip tags, scripts, and styles, keep visible text and links |
|
|
152
|
+
| `table` | Collapse whitespace-aligned command-line tables, keep header and data |
|
|
142
153
|
|
|
143
154
|
Anything else, or any payload below the size, saving, or fidelity thresholds, passes through unchanged.
|
|
144
155
|
|
|
@@ -170,8 +181,8 @@ leancontext.use_tiktoken("gpt-4o") # force a specific model's tokeniz
|
|
|
170
181
|
|
|
171
182
|
## Roadmap
|
|
172
183
|
|
|
173
|
-
|
|
174
|
-
|
|
184
|
+
CI-verified LangChain / LlamaIndex / CrewAI / Agno adapters, accurate provider tokenizers by
|
|
185
|
+
default, and broader Anthropic native interop.
|
|
175
186
|
|
|
176
187
|
## Contributing
|
|
177
188
|
|
|
@@ -49,7 +49,18 @@ from .integrations import (
|
|
|
49
49
|
from .messages import detect_format, reduce_messages
|
|
50
50
|
from .tokens import active_tokenizer, count_tokens, set_token_counter, use_tiktoken
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
# Single source of truth is the installed package metadata (pyproject version);
|
|
53
|
+
# the literal is only a fallback for running straight from a source tree.
|
|
54
|
+
try:
|
|
55
|
+
from importlib.metadata import PackageNotFoundError
|
|
56
|
+
from importlib.metadata import version as _pkg_version
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
__version__ = _pkg_version("leancontext")
|
|
60
|
+
except PackageNotFoundError:
|
|
61
|
+
__version__ = "2.0.6"
|
|
62
|
+
except ImportError: # pragma: no cover - importlib.metadata is stdlib on 3.10+
|
|
63
|
+
__version__ = "2.0.6"
|
|
53
64
|
|
|
54
65
|
__all__ = [
|
|
55
66
|
"reduce",
|
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import json
|
|
11
11
|
import os
|
|
12
|
+
import threading
|
|
12
13
|
from collections import OrderedDict
|
|
13
14
|
from collections.abc import Callable
|
|
14
15
|
from dataclasses import dataclass, field
|
|
@@ -36,11 +37,13 @@ CONFIG = _Config()
|
|
|
36
37
|
# A tool output is re-sent on every turn, so we reduce each unique payload once and
|
|
37
38
|
# reuse the result. Keyed by content hash + options; deterministic, so this is safe.
|
|
38
39
|
_CACHE: OrderedDict[tuple, Reduction] = OrderedDict()
|
|
40
|
+
_CACHE_LOCK = threading.Lock()
|
|
39
41
|
|
|
40
42
|
|
|
41
43
|
def clear_cache() -> None:
|
|
42
44
|
"""Drop all cached reductions."""
|
|
43
|
-
|
|
45
|
+
with _CACHE_LOCK:
|
|
46
|
+
_CACHE.clear()
|
|
44
47
|
|
|
45
48
|
|
|
46
49
|
def disable() -> None:
|
|
@@ -167,15 +170,20 @@ def reduce_text(
|
|
|
167
170
|
key = (ref, kind, min_saving, min_fidelity, CONFIG.min_tokens, CONFIG.max_input_chars)
|
|
168
171
|
use_cache = CONFIG.cache_size > 0
|
|
169
172
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
173
|
+
result = None
|
|
174
|
+
if use_cache:
|
|
175
|
+
with _CACHE_LOCK:
|
|
176
|
+
result = _CACHE.get(key)
|
|
177
|
+
if result is not None:
|
|
178
|
+
_CACHE.move_to_end(key)
|
|
179
|
+
|
|
180
|
+
if result is None:
|
|
174
181
|
result = _compute(original, before, ref, kind, min_saving, min_fidelity)
|
|
175
182
|
if use_cache:
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
_CACHE
|
|
183
|
+
with _CACHE_LOCK:
|
|
184
|
+
_CACHE[key] = result
|
|
185
|
+
if len(_CACHE) > CONFIG.cache_size:
|
|
186
|
+
_CACHE.popitem(last=False) # evict least-recently-used
|
|
179
187
|
|
|
180
188
|
if result.applied:
|
|
181
189
|
_emit(result)
|
|
@@ -13,6 +13,7 @@ no known price, token savings are still reported and ``usd_saved`` is ``None``.
|
|
|
13
13
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
|
+
import threading
|
|
16
17
|
from collections.abc import Callable
|
|
17
18
|
|
|
18
19
|
#: USD per 1M tokens (input, output). Indicative — override via set_price().
|
|
@@ -71,14 +72,17 @@ class CostTracker:
|
|
|
71
72
|
self.usd_saved = 0.0
|
|
72
73
|
self.has_price = _input_price(model, input_price_per_mtok) is not None
|
|
73
74
|
self._hook: Callable | None = None
|
|
75
|
+
self._lock = threading.Lock()
|
|
74
76
|
|
|
75
77
|
def _on(self, r) -> None:
|
|
76
|
-
|
|
77
|
-
self.
|
|
78
|
-
self.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
self.
|
|
78
|
+
# The hook fires from every reducing thread, so guard the running totals.
|
|
79
|
+
usd = estimate_savings(r, self.model, self.price)["usd_saved"] if self.has_price else 0.0
|
|
80
|
+
with self._lock:
|
|
81
|
+
self.reductions += 1
|
|
82
|
+
self.tokens_before += r.tokens_before
|
|
83
|
+
self.tokens_after += r.tokens_after
|
|
84
|
+
self.tokens_saved += r.tokens_saved
|
|
85
|
+
self.usd_saved += usd
|
|
82
86
|
|
|
83
87
|
def install(self) -> CostTracker:
|
|
84
88
|
from .core import on_reduction
|
|
@@ -60,12 +60,22 @@ def _iter_scalars(data: Any):
|
|
|
60
60
|
|
|
61
61
|
|
|
62
62
|
def _json_fidelity(original: str, reduced: str) -> float:
|
|
63
|
-
"""Fraction of JSON scalar values (strings and numbers) preserved in the output.
|
|
63
|
+
"""Fraction of JSON scalar values (strings and numbers) preserved in the output.
|
|
64
|
+
|
|
65
|
+
Values are matched in their JSON-encoded form (the reducer emits them that way),
|
|
66
|
+
so a value containing a delimiter, quote, or newline only counts as preserved if
|
|
67
|
+
its exact escaped bytes survive — the check sees structural corruption, not just
|
|
68
|
+
whether the characters appear somewhere.
|
|
69
|
+
"""
|
|
64
70
|
try:
|
|
65
71
|
data = json.loads(original)
|
|
66
72
|
except Exception:
|
|
67
73
|
return 1.0
|
|
68
|
-
values = [
|
|
74
|
+
values = [
|
|
75
|
+
json.dumps(v, ensure_ascii=False).strip('"')
|
|
76
|
+
for v in _iter_scalars(data)
|
|
77
|
+
]
|
|
78
|
+
values = [v for v in values if v]
|
|
69
79
|
if not values:
|
|
70
80
|
return 1.0
|
|
71
81
|
kept = sum(1 for v in values if v in reduced)
|
|
@@ -25,19 +25,30 @@ def mark(fn: Callable) -> Callable:
|
|
|
25
25
|
return fn
|
|
26
26
|
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
#: Request keys that can carry a message/tool-output list across providers:
|
|
29
|
+
#: ``messages`` (OpenAI chat / Anthropic), ``input`` (OpenAI Responses API).
|
|
30
|
+
_LIST_KEYS = ("messages", "input")
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
|
|
33
|
+
def reduce_messages_in(mapping: Any, fmt: str, opts: dict, key: str | None = "messages") -> None:
|
|
34
|
+
"""Fail-open, in-place reduction of the message list(s) in ``mapping`` (dict-like).
|
|
35
|
+
|
|
36
|
+
``key`` names the field to reduce (``messages`` for OpenAI/Anthropic). Pass
|
|
37
|
+
``key=None`` to reduce whichever known list keys are present — used on gateway
|
|
38
|
+
paths (LiteLLM) where a request may be chat (``messages``) or Responses (``input``).
|
|
32
39
|
"""
|
|
33
|
-
if isinstance(mapping, dict)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
40
|
+
if not isinstance(mapping, dict):
|
|
41
|
+
return
|
|
42
|
+
keys = _LIST_KEYS if key is None else (key,)
|
|
43
|
+
for k in keys:
|
|
44
|
+
if isinstance(mapping.get(k), list):
|
|
45
|
+
try:
|
|
46
|
+
mapping[k] = reduce_messages(mapping[k], fmt=fmt, **opts)
|
|
47
|
+
except Exception:
|
|
48
|
+
pass # fail open
|
|
38
49
|
|
|
39
50
|
|
|
40
|
-
def wrap_messages_create(create: Callable, *, fmt: str, opts: dict, key: str = "messages",
|
|
51
|
+
def wrap_messages_create(create: Callable, *, fmt: str, opts: dict, key: str | None = "messages",
|
|
41
52
|
reduce: bool = True,
|
|
42
53
|
before: Callable[[dict], None] | None = None) -> Callable:
|
|
43
54
|
"""Wrap a ``create(**kwargs)`` callable to reduce its messages before calling through.
|
|
@@ -15,12 +15,17 @@ from ._common import wrap_messages_create
|
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
def wrap_openai(client: Any, **opts) -> Any:
|
|
18
|
-
"""Reduce tool outputs on an OpenAI client's chat.completions.
|
|
18
|
+
"""Reduce tool outputs on an OpenAI client's chat.completions and responses APIs."""
|
|
19
19
|
try:
|
|
20
20
|
comp = client.chat.completions
|
|
21
21
|
comp.create = wrap_messages_create(comp.create, fmt="openai", opts=opts)
|
|
22
22
|
except Exception:
|
|
23
23
|
pass # fail open
|
|
24
|
+
try:
|
|
25
|
+
responses = client.responses
|
|
26
|
+
responses.create = wrap_messages_create(responses.create, fmt="responses", opts=opts, key="input")
|
|
27
|
+
except Exception:
|
|
28
|
+
pass # fail open
|
|
24
29
|
return client
|
|
25
30
|
|
|
26
31
|
|
|
@@ -35,7 +35,8 @@ def make_handler(**opts):
|
|
|
35
35
|
class LeanContextHandler(CustomLogger):
|
|
36
36
|
async def async_pre_call_hook(self, user_api_key_dict, cache, data, call_type):
|
|
37
37
|
if call_type in _REDUCIBLE_CALLS:
|
|
38
|
-
|
|
38
|
+
# key=None: reduce chat (messages) or Responses (input) payloads alike
|
|
39
|
+
reduce_messages_in(data, "auto", opts, key=None) # fail-open in-place
|
|
39
40
|
return data
|
|
40
41
|
|
|
41
42
|
return LeanContextHandler()
|
|
@@ -48,14 +49,14 @@ def patch(**opts) -> None:
|
|
|
48
49
|
if getattr(litellm, "_leancontext_patched", False):
|
|
49
50
|
return
|
|
50
51
|
|
|
51
|
-
litellm.completion = wrap_messages_create(litellm.completion, fmt="auto", opts=opts)
|
|
52
|
+
litellm.completion = wrap_messages_create(litellm.completion, fmt="auto", opts=opts, key=None)
|
|
52
53
|
|
|
53
54
|
if hasattr(litellm, "acompletion"):
|
|
54
55
|
_orig_acompletion = litellm.acompletion
|
|
55
56
|
|
|
56
57
|
@functools.wraps(_orig_acompletion)
|
|
57
58
|
async def acompletion(*args, **kwargs):
|
|
58
|
-
reduce_messages_in(kwargs, "auto", opts)
|
|
59
|
+
reduce_messages_in(kwargs, "auto", opts, key=None)
|
|
59
60
|
return await _orig_acompletion(*args, **kwargs)
|
|
60
61
|
|
|
61
62
|
litellm.acompletion = mark(acompletion)
|
|
@@ -1,39 +1,27 @@
|
|
|
1
1
|
"""Protocol-aware message reduction — the gateway/wire surface.
|
|
2
2
|
|
|
3
3
|
This is how LeanContext plugs into gateways (LiteLLM), SDK client wrappers, and proxies
|
|
4
|
-
*without* the structure-blindness that hurts wire-level compressors: the chat
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
else. We never touch system/user/assistant instruction text. Fail-open throughout.
|
|
4
|
+
*without* the structure-blindness that hurts wire-level compressors: the chat protocols
|
|
5
|
+
already tag tool outputs, so we find and reduce exactly those and nothing else. We never
|
|
6
|
+
touch system/user/assistant instruction text. Fail-open throughout.
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
Each provider format registers a detector and a per-item reducer in ``_FORMATS`` (like the
|
|
9
|
+
typed-reducer registry), so adding a format means adding one entry. Supported: OpenAI
|
|
10
|
+
chat-completions, Anthropic messages, Gemini contents, and the OpenAI Responses API.
|
|
11
|
+
|
|
12
|
+
Cache-safety: reductions are deterministic and content-addressed, so the same tool output
|
|
13
|
+
always serialises to the same bytes → the provider prompt-cache keeps hitting.
|
|
11
14
|
"""
|
|
12
15
|
|
|
13
16
|
from __future__ import annotations
|
|
14
17
|
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from dataclasses import dataclass
|
|
15
20
|
from typing import Any
|
|
16
21
|
|
|
17
22
|
from .core import reduce_text
|
|
18
23
|
|
|
19
24
|
|
|
20
|
-
def detect_format(messages: list) -> str:
|
|
21
|
-
"""Best-effort detection of the message protocol."""
|
|
22
|
-
for m in messages:
|
|
23
|
-
if not isinstance(m, dict):
|
|
24
|
-
continue
|
|
25
|
-
if isinstance(m.get("parts"), list):
|
|
26
|
-
return "gemini"
|
|
27
|
-
if m.get("role") in ("tool", "function"):
|
|
28
|
-
return "openai"
|
|
29
|
-
content = m.get("content")
|
|
30
|
-
if isinstance(content, list):
|
|
31
|
-
for block in content:
|
|
32
|
-
if isinstance(block, dict) and block.get("type") == "tool_result":
|
|
33
|
-
return "anthropic"
|
|
34
|
-
return "openai"
|
|
35
|
-
|
|
36
|
-
|
|
37
25
|
def _reduce_str(text: Any, opts: dict) -> Any:
|
|
38
26
|
if not isinstance(text, str):
|
|
39
27
|
return text
|
|
@@ -112,8 +100,7 @@ def _reduce_anthropic_textblock(x: Any, opts: dict) -> Any:
|
|
|
112
100
|
# --- Gemini format -----------------------------------------------------------
|
|
113
101
|
# Gemini uses `contents` -> `parts`, where a tool result is a `functionResponse`
|
|
114
102
|
# part whose `response` is a dict. We reduce the large string values inside that
|
|
115
|
-
# dict, keeping the dict shape Gemini requires. Typed SDK objects
|
|
116
|
-
# pass through untouched.
|
|
103
|
+
# dict, keeping the dict shape Gemini requires. Typed SDK objects pass through.
|
|
117
104
|
|
|
118
105
|
def _reduce_gemini_message(content: Any, opts: dict) -> Any:
|
|
119
106
|
if not isinstance(content, dict) or not isinstance(content.get("parts"), list):
|
|
@@ -133,20 +120,105 @@ def _reduce_gemini_message(content: Any, opts: dict) -> Any:
|
|
|
133
120
|
return {**content, "parts": new_parts}
|
|
134
121
|
|
|
135
122
|
|
|
123
|
+
# --- OpenAI Responses API format ---------------------------------------------
|
|
124
|
+
# The Responses API uses `input` (not `messages`); a tool result is an item with
|
|
125
|
+
# type "function_call_output" whose `output` is a string.
|
|
126
|
+
|
|
127
|
+
def _reduce_responses_message(item: Any, opts: dict) -> Any:
|
|
128
|
+
if not isinstance(item, dict) or item.get("type") != "function_call_output":
|
|
129
|
+
return item
|
|
130
|
+
output = item.get("output")
|
|
131
|
+
if isinstance(output, str):
|
|
132
|
+
new_item = dict(item)
|
|
133
|
+
new_item["output"] = _reduce_str(output, opts)
|
|
134
|
+
return new_item
|
|
135
|
+
# The Responses API also allows a list of content parts (e.g. output_text);
|
|
136
|
+
# reduce those the same way as chat parts. Anything else passes through.
|
|
137
|
+
if isinstance(output, list):
|
|
138
|
+
new_item = dict(item)
|
|
139
|
+
new_item["output"] = [_reduce_openai_part(p, opts) for p in output]
|
|
140
|
+
return new_item
|
|
141
|
+
return item
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# --- format registry ---------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def _is_responses(m: dict) -> bool:
|
|
147
|
+
return m.get("type") == "function_call_output"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _is_gemini(m: dict) -> bool:
|
|
151
|
+
return isinstance(m.get("parts"), list)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _is_openai(m: dict) -> bool:
|
|
155
|
+
return m.get("role") in ("tool", "function")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _is_anthropic(m: dict) -> bool:
|
|
159
|
+
content = m.get("content")
|
|
160
|
+
return isinstance(content, list) and any(
|
|
161
|
+
isinstance(b, dict) and b.get("type") == "tool_result" for b in content
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass(frozen=True)
|
|
166
|
+
class _Format:
|
|
167
|
+
name: str
|
|
168
|
+
detect: Callable[[dict], bool]
|
|
169
|
+
reduce: Callable[[Any, dict], Any]
|
|
170
|
+
priority: int
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# Detection runs in priority order; the first format any single message matches wins.
|
|
174
|
+
_FORMATS: list[_Format] = sorted(
|
|
175
|
+
[
|
|
176
|
+
_Format("responses", _is_responses, _reduce_responses_message, 10),
|
|
177
|
+
_Format("gemini", _is_gemini, _reduce_gemini_message, 20),
|
|
178
|
+
_Format("openai", _is_openai, _reduce_openai_message, 30),
|
|
179
|
+
_Format("anthropic", _is_anthropic, _reduce_anthropic_message, 40),
|
|
180
|
+
],
|
|
181
|
+
key=lambda f: f.priority,
|
|
182
|
+
)
|
|
183
|
+
_REDUCE_BY_NAME = {f.name: f.reduce for f in _FORMATS}
|
|
184
|
+
|
|
185
|
+
|
|
136
186
|
# --- public ------------------------------------------------------------------
|
|
137
187
|
|
|
188
|
+
def _format_for(m: Any) -> str:
|
|
189
|
+
"""The format a single message belongs to (priority order); defaults to ``openai``."""
|
|
190
|
+
if isinstance(m, dict):
|
|
191
|
+
for fmt in _FORMATS:
|
|
192
|
+
if fmt.detect(m):
|
|
193
|
+
return fmt.name
|
|
194
|
+
return "openai"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def detect_format(messages: list) -> str:
|
|
198
|
+
"""Best-effort detection of the message protocol; defaults to ``openai``."""
|
|
199
|
+
for m in messages:
|
|
200
|
+
if not isinstance(m, dict):
|
|
201
|
+
continue
|
|
202
|
+
for fmt in _FORMATS:
|
|
203
|
+
if fmt.detect(m):
|
|
204
|
+
return fmt.name
|
|
205
|
+
return "openai"
|
|
206
|
+
|
|
207
|
+
|
|
138
208
|
def reduce_messages(messages: Any, *, fmt: str = "auto", **opts) -> Any:
|
|
139
209
|
"""Return a new message list with tool outputs reduced. Input is not mutated.
|
|
140
210
|
|
|
141
|
-
Handles OpenAI (
|
|
142
|
-
|
|
143
|
-
|
|
211
|
+
Handles OpenAI (chat + Responses), Anthropic, and Gemini formats. Only tool-result
|
|
212
|
+
content is touched; instructions are never altered. Anything unrecognised passes
|
|
213
|
+
through unchanged (fail open).
|
|
214
|
+
|
|
215
|
+
With ``fmt="auto"`` each message is dispatched by its own format, so a list mixing
|
|
216
|
+
shapes (e.g. a chat tool message alongside a Responses ``function_call_output``)
|
|
217
|
+
reduces every item — not just the ones matching the first format seen.
|
|
144
218
|
"""
|
|
145
219
|
if not isinstance(messages, list):
|
|
146
220
|
return messages
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
return [_reduce_gemini_message(m, opts) for m in messages]
|
|
152
|
-
return [_reduce_openai_message(m, opts) for m in messages]
|
|
221
|
+
if fmt == "auto":
|
|
222
|
+
return [_REDUCE_BY_NAME.get(_format_for(m), _reduce_openai_message)(m, opts) for m in messages]
|
|
223
|
+
reducer = _REDUCE_BY_NAME.get(fmt, _reduce_openai_message)
|
|
224
|
+
return [reducer(m, opts) for m in messages]
|
|
@@ -18,6 +18,7 @@ from .tokens import content_ref, count_tokens
|
|
|
18
18
|
|
|
19
19
|
REF_SCHEME = "lc"
|
|
20
20
|
_REF_RE = re.compile(r"lc://([0-9a-f]{6,40})")
|
|
21
|
+
_HEX_REF = re.compile(r"[0-9a-f]{6,40}") # a valid content-hash id (no path chars)
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
class ContentStore:
|
|
@@ -42,6 +43,8 @@ class ContentStore:
|
|
|
42
43
|
return ref
|
|
43
44
|
|
|
44
45
|
def get(self, ref: str) -> str | None:
|
|
46
|
+
if not _HEX_REF.fullmatch(ref): # only content-hash ids; blocks path traversal
|
|
47
|
+
return None
|
|
45
48
|
if self.root:
|
|
46
49
|
try:
|
|
47
50
|
with open(self._path(ref), encoding="utf-8") as fh:
|
|
@@ -54,9 +57,12 @@ class ContentStore:
|
|
|
54
57
|
_DEFAULT_STORE = ContentStore()
|
|
55
58
|
|
|
56
59
|
|
|
57
|
-
def _normalize(ref: str) -> str:
|
|
60
|
+
def _normalize(ref: str) -> str | None:
|
|
58
61
|
m = _REF_RE.search(ref)
|
|
59
|
-
|
|
62
|
+
if m:
|
|
63
|
+
return m.group(1)
|
|
64
|
+
ref = ref.strip()
|
|
65
|
+
return ref if _HEX_REF.fullmatch(ref) else None
|
|
60
66
|
|
|
61
67
|
|
|
62
68
|
def store(content: str, using: ContentStore | None = None) -> str:
|
|
@@ -66,7 +72,10 @@ def store(content: str, using: ContentStore | None = None) -> str:
|
|
|
66
72
|
|
|
67
73
|
def expand(ref: str, using: ContentStore | None = None) -> str | None:
|
|
68
74
|
"""Return the original content for a ref (accepts 'lc://<id>' or a bare id)."""
|
|
69
|
-
|
|
75
|
+
norm = _normalize(ref)
|
|
76
|
+
if norm is None:
|
|
77
|
+
return None
|
|
78
|
+
return (using or _DEFAULT_STORE).get(norm)
|
|
70
79
|
|
|
71
80
|
|
|
72
81
|
def reference_line(content: str, summary: str | None = None,
|
|
@@ -27,20 +27,22 @@ def _find_records(data: Any) -> list[dict] | None:
|
|
|
27
27
|
return None
|
|
28
28
|
|
|
29
29
|
|
|
30
|
-
def _fmt(value: Any) -> str:
|
|
31
|
-
if isinstance(value, str):
|
|
32
|
-
return value
|
|
33
|
-
return json.dumps(value, separators=(",", ":"), ensure_ascii=False)
|
|
34
|
-
|
|
35
|
-
|
|
36
30
|
def reduce_json(text: str) -> tuple[str, list[str]]:
|
|
37
31
|
data = json.loads(text)
|
|
38
32
|
records = _find_records(data)
|
|
39
33
|
|
|
40
34
|
if records is not None and len(records) >= 3:
|
|
41
35
|
keys = list(dict.fromkeys(k for row in records for k in row.keys()))
|
|
42
|
-
|
|
43
|
-
|
|
36
|
+
# Each row is a JSON array of values in `keys` order, with the field names
|
|
37
|
+
# factored out into the header once (a missing field becomes null, keeping
|
|
38
|
+
# every row positional). JSON-encoding every cell keeps values that contain
|
|
39
|
+
# the delimiter, quotes, or newlines unambiguous and lossless — a plain
|
|
40
|
+
# " | " join would corrupt those, and the fidelity check wouldn't catch it.
|
|
41
|
+
header = "fields: " + json.dumps(keys, separators=(",", ":"), ensure_ascii=False)
|
|
42
|
+
rows = [
|
|
43
|
+
json.dumps([row.get(k) for k in keys], separators=(",", ":"), ensure_ascii=False)
|
|
44
|
+
for row in records
|
|
45
|
+
]
|
|
44
46
|
notes = [f"columnar: {len(records)} records × {len(keys)} fields, keys factored out once"]
|
|
45
47
|
return header + "\n" + "\n".join(rows), notes
|
|
46
48
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "leancontext"
|
|
3
|
-
version = "2.0.
|
|
3
|
+
version = "2.0.6"
|
|
4
4
|
description = "Deterministic, type-aware reduction of agent tool outputs at the source. Cut LLM token cost without making the agent do less."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import concurrent.futures
|
|
2
|
+
|
|
3
|
+
import leancontext
|
|
4
|
+
from leancontext.core import CONFIG, clear_cache
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _log(n=400):
|
|
8
|
+
lines = [f"2026-06-21T09:00:{i % 60:02d}Z INFO [worker] job={i} status=ok ms={i % 50}" for i in range(n)]
|
|
9
|
+
lines.insert(200, "2026-06-21T09:05:00Z FATAL [render] OOM killed worker=7 — root cause")
|
|
10
|
+
return "\n".join(lines)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_concurrent_reduce_with_eviction_is_safe():
|
|
14
|
+
# Small cache + many distinct payloads across threads exercises the cache's
|
|
15
|
+
# insert / move_to_end / evict paths concurrently. With the lock this is safe.
|
|
16
|
+
clear_cache()
|
|
17
|
+
old = CONFIG.cache_size
|
|
18
|
+
CONFIG.cache_size = 5
|
|
19
|
+
try:
|
|
20
|
+
payloads = [_log() + f"\nunique-{i} marker line " * 2 for i in range(60)] * 2
|
|
21
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
|
|
22
|
+
results = list(pool.map(lambda p: leancontext.reduce(p).text, payloads))
|
|
23
|
+
assert len(results) == len(payloads)
|
|
24
|
+
assert all("root cause" in r for r in results)
|
|
25
|
+
finally:
|
|
26
|
+
CONFIG.cache_size = old
|
|
27
|
+
clear_cache()
|
|
@@ -41,6 +41,16 @@ def test_json_columnar_is_lossless_on_values():
|
|
|
41
41
|
for i in range(20):
|
|
42
42
|
assert f"n{i}" in r.text # every value preserved
|
|
43
43
|
|
|
44
|
+
|
|
45
|
+
def test_json_columnar_handles_delimiter_and_newline_values():
|
|
46
|
+
# Values containing the column delimiter or a newline must not corrupt rows:
|
|
47
|
+
# each row must parse back to exactly its original fields (regression test).
|
|
48
|
+
records = [{"id": i, "text": f"row {i} | part A\nrow {i} part B", "n": i} for i in range(10)]
|
|
49
|
+
r = leancontext.reduce(json.dumps(records))
|
|
50
|
+
assert r.kind == "json" # reduction applied, not reverted
|
|
51
|
+
rows = [json.loads(line) for line in r.text.splitlines()[1:]] # skip the fields header
|
|
52
|
+
assert rows == [[i, f"row {i} | part A\nrow {i} part B", i] for i in range(10)]
|
|
53
|
+
|
|
44
54
|
def test_decorator_preserves_contract():
|
|
45
55
|
@leancontext.reduce
|
|
46
56
|
def tool(_: str) -> str:
|
|
@@ -36,6 +36,46 @@ def test_wrap_openai_client_reduces_messages():
|
|
|
36
36
|
assert len(sent) < len(_big_log()) and "root cause" in sent
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
def test_wrap_openai_responses_api():
|
|
40
|
+
openai = pytest.importorskip("openai")
|
|
41
|
+
client = openai.OpenAI(api_key="test-key")
|
|
42
|
+
if not hasattr(client, "responses"):
|
|
43
|
+
pytest.skip("Responses API not in this openai version")
|
|
44
|
+
|
|
45
|
+
captured = {}
|
|
46
|
+
client.responses.create = lambda **kw: captured.update(kw) or "OK"
|
|
47
|
+
|
|
48
|
+
from leancontext import wrap_openai
|
|
49
|
+
wrap_openai(client)
|
|
50
|
+
|
|
51
|
+
client.responses.create(
|
|
52
|
+
model="gpt-4o",
|
|
53
|
+
input=[{"type": "function_call_output", "call_id": "c", "output": _big_log()}],
|
|
54
|
+
)
|
|
55
|
+
sent = captured["input"][0]["output"]
|
|
56
|
+
assert len(sent) < len(_big_log()) and "root cause" in sent
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_wrap_async_openai_client_reduces_messages():
|
|
60
|
+
openai = pytest.importorskip("openai")
|
|
61
|
+
client = openai.AsyncOpenAI(api_key="test-key")
|
|
62
|
+
|
|
63
|
+
captured = {}
|
|
64
|
+
|
|
65
|
+
async def fake(**kw):
|
|
66
|
+
captured.update(kw)
|
|
67
|
+
return "OK"
|
|
68
|
+
|
|
69
|
+
client.chat.completions.create = fake
|
|
70
|
+
|
|
71
|
+
from leancontext import wrap_openai
|
|
72
|
+
wrap_openai(client)
|
|
73
|
+
|
|
74
|
+
asyncio.run(client.chat.completions.create(model="gpt-4o", messages=[_openai_tool_msg()]))
|
|
75
|
+
sent = captured["messages"][0]["content"]
|
|
76
|
+
assert len(sent) < len(_big_log()) and "root cause" in sent
|
|
77
|
+
|
|
78
|
+
|
|
39
79
|
def test_wrap_anthropic_client_reduces_tool_results():
|
|
40
80
|
anthropic = pytest.importorskip("anthropic")
|
|
41
81
|
client = anthropic.Anthropic(api_key="test-key")
|
|
@@ -87,6 +127,20 @@ def test_proxy_reduces_before_forwarding():
|
|
|
87
127
|
assert len(sent) < len(_big_log()) and "root cause" in sent
|
|
88
128
|
|
|
89
129
|
|
|
130
|
+
# --- gateway helper: chat (messages) vs Responses (input) --------------------
|
|
131
|
+
|
|
132
|
+
def test_reduce_messages_in_handles_responses_input_key():
|
|
133
|
+
# Gateway paths use key=None so a Responses request (input=) reduces too, not
|
|
134
|
+
# just chat (messages=). No third-party dependency needed for this logic.
|
|
135
|
+
from leancontext.integrations._common import reduce_messages_in
|
|
136
|
+
|
|
137
|
+
data = {"model": "gpt-4o",
|
|
138
|
+
"input": [{"type": "function_call_output", "call_id": "c", "output": _big_log()}]}
|
|
139
|
+
reduce_messages_in(data, "auto", {}, key=None)
|
|
140
|
+
sent = data["input"][0]["output"]
|
|
141
|
+
assert len(sent) < len(_big_log()) and "root cause" in sent
|
|
142
|
+
|
|
143
|
+
|
|
90
144
|
# --- LiteLLM (real CustomLogger) ---------------------------------------------
|
|
91
145
|
|
|
92
146
|
def test_litellm_pre_call_hook_reduces():
|
|
@@ -52,6 +52,39 @@ def test_input_not_mutated():
|
|
|
52
52
|
assert tool["content"] == before # original list/dicts untouched
|
|
53
53
|
|
|
54
54
|
|
|
55
|
+
def test_responses_format_reduced():
|
|
56
|
+
items = [
|
|
57
|
+
{"role": "user", "content": "why did it crash?"},
|
|
58
|
+
{"type": "function_call_output", "call_id": "c1", "output": _log()},
|
|
59
|
+
]
|
|
60
|
+
out = reduce_messages(items) # auto-detect -> responses
|
|
61
|
+
assert out[0] == items[0] # the user message is untouched
|
|
62
|
+
reduced = out[1]["output"]
|
|
63
|
+
assert len(reduced) < len(_log()) and "root cause" in reduced
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_mixed_format_list_reduces_every_item():
|
|
67
|
+
# A chat tool message AND a Responses function_call_output in one list: auto
|
|
68
|
+
# dispatch must reduce both, not just the format of the first message seen.
|
|
69
|
+
items = [
|
|
70
|
+
{"role": "tool", "tool_call_id": "c1", "content": _log()},
|
|
71
|
+
{"type": "function_call_output", "call_id": "c2", "output": _log()},
|
|
72
|
+
]
|
|
73
|
+
out = reduce_messages(items)
|
|
74
|
+
assert len(out[0]["content"]) < len(_log()) and "root cause" in out[0]["content"]
|
|
75
|
+
assert len(out[1]["output"]) < len(_log()) and "root cause" in out[1]["output"]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_responses_list_shaped_output_reduced():
|
|
79
|
+
items = [
|
|
80
|
+
{"type": "function_call_output", "call_id": "c1",
|
|
81
|
+
"output": [{"type": "output_text", "text": _log()}]},
|
|
82
|
+
]
|
|
83
|
+
out = reduce_messages(items)
|
|
84
|
+
reduced = out[0]["output"][0]["text"]
|
|
85
|
+
assert len(reduced) < len(_log()) and "root cause" in reduced
|
|
86
|
+
|
|
87
|
+
|
|
55
88
|
def test_non_list_passthrough():
|
|
56
89
|
assert reduce_messages("not a list") == "not a list"
|
|
57
90
|
|
|
@@ -44,3 +44,14 @@ def test_expand_tool_spec_shape():
|
|
|
44
44
|
spec = paging.EXPAND_TOOL_SPEC
|
|
45
45
|
assert spec["name"] == "leancontext_expand"
|
|
46
46
|
assert spec["input_schema"]["required"] == ["ref"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_expand_rejects_path_traversal(tmp_path):
|
|
50
|
+
store = paging.ContentStore(root=str(tmp_path))
|
|
51
|
+
secret = tmp_path.parent / "leak.txt"
|
|
52
|
+
secret.write_text("TOPSECRET")
|
|
53
|
+
# refs that aren't content hashes must never resolve to a filesystem path
|
|
54
|
+
for evil in ("../leak", "../../etc/hosts", "/etc/hosts", "..%2Fleak"):
|
|
55
|
+
assert paging.expand(evil, using=store) is None
|
|
56
|
+
assert store.get(evil) is None
|
|
57
|
+
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|