promptbudget 0.1.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.
- promptbudget-0.1.0/.github/workflows/ci.yml +93 -0
- promptbudget-0.1.0/.github/workflows/release.yml +104 -0
- promptbudget-0.1.0/.gitignore +17 -0
- promptbudget-0.1.0/CHANGELOG.md +48 -0
- promptbudget-0.1.0/CONTRIBUTING.md +91 -0
- promptbudget-0.1.0/LICENSE +21 -0
- promptbudget-0.1.0/PKG-INFO +238 -0
- promptbudget-0.1.0/README.md +204 -0
- promptbudget-0.1.0/RELEASING.md +91 -0
- promptbudget-0.1.0/pyproject.toml +79 -0
- promptbudget-0.1.0/src/promptbudget/__init__.py +94 -0
- promptbudget-0.1.0/src/promptbudget/errors.py +55 -0
- promptbudget-0.1.0/src/promptbudget/models.py +281 -0
- promptbudget-0.1.0/src/promptbudget/packer.py +251 -0
- promptbudget-0.1.0/src/promptbudget/py.typed +0 -0
- promptbudget-0.1.0/src/promptbudget/strategies.py +355 -0
- promptbudget-0.1.0/src/promptbudget/tokenizers.py +145 -0
- promptbudget-0.1.0/tests/test_api.py +203 -0
- promptbudget-0.1.0/tests/test_packer.py +441 -0
- promptbudget-0.1.0/tests/test_properties.py +183 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: ${{ matrix.python-version }}
|
|
20
|
+
- name: Install
|
|
21
|
+
run: |
|
|
22
|
+
python -m pip install --upgrade pip
|
|
23
|
+
pip install -e ".[dev,tiktoken]"
|
|
24
|
+
- name: Test
|
|
25
|
+
run: pytest -q --cov=promptbudget --cov-report=term-missing
|
|
26
|
+
|
|
27
|
+
package:
|
|
28
|
+
# Catches packaging regressions that unit tests cannot see, such as a
|
|
29
|
+
# missing py.typed marker silently disabling type hints for consumers.
|
|
30
|
+
runs-on: ubuntu-latest
|
|
31
|
+
steps:
|
|
32
|
+
- uses: actions/checkout@v4
|
|
33
|
+
- uses: actions/setup-python@v5
|
|
34
|
+
with:
|
|
35
|
+
python-version: "3.12"
|
|
36
|
+
|
|
37
|
+
- name: Build sdist and wheel
|
|
38
|
+
run: |
|
|
39
|
+
python -m pip install --upgrade pip
|
|
40
|
+
pip install build twine
|
|
41
|
+
python -m build
|
|
42
|
+
|
|
43
|
+
- name: Validate metadata renders on PyPI
|
|
44
|
+
run: twine check dist/*
|
|
45
|
+
|
|
46
|
+
- name: Assert py.typed ships in the wheel
|
|
47
|
+
run: |
|
|
48
|
+
python - <<'PY'
|
|
49
|
+
import glob, sys, zipfile
|
|
50
|
+
wheel = glob.glob("dist/*.whl")[0]
|
|
51
|
+
names = zipfile.ZipFile(wheel).namelist()
|
|
52
|
+
if not any(n.endswith("promptbudget/py.typed") for n in names):
|
|
53
|
+
sys.exit("py.typed missing from the wheel; PEP 561 consumers would lose all type hints")
|
|
54
|
+
print(f"py.typed present in {wheel}")
|
|
55
|
+
PY
|
|
56
|
+
|
|
57
|
+
- name: Install the built wheel and smoke-test it
|
|
58
|
+
run: |
|
|
59
|
+
python -m venv /tmp/smoke
|
|
60
|
+
/tmp/smoke/bin/pip install --quiet dist/*.whl
|
|
61
|
+
/tmp/smoke/bin/python - <<'PY'
|
|
62
|
+
from promptbudget import Item, Section, pack
|
|
63
|
+
|
|
64
|
+
result = pack(
|
|
65
|
+
[
|
|
66
|
+
Section("system", "You are helpful.", priority=100),
|
|
67
|
+
Section("history", [Item("x" * 50) for _ in range(10)], priority=10),
|
|
68
|
+
],
|
|
69
|
+
budget=100,
|
|
70
|
+
reserved_output=10,
|
|
71
|
+
)
|
|
72
|
+
assert result.used_tokens <= 90, result.used_tokens
|
|
73
|
+
assert result.section("system").text
|
|
74
|
+
print("smoke test passed:", result.used_tokens, "tokens")
|
|
75
|
+
PY
|
|
76
|
+
|
|
77
|
+
lint:
|
|
78
|
+
runs-on: ubuntu-latest
|
|
79
|
+
steps:
|
|
80
|
+
- uses: actions/checkout@v4
|
|
81
|
+
- uses: actions/setup-python@v5
|
|
82
|
+
with:
|
|
83
|
+
python-version: "3.12"
|
|
84
|
+
- name: Install
|
|
85
|
+
run: |
|
|
86
|
+
python -m pip install --upgrade pip
|
|
87
|
+
pip install -e ".[dev]"
|
|
88
|
+
- name: Ruff
|
|
89
|
+
run: ruff check .
|
|
90
|
+
- name: Ruff format
|
|
91
|
+
run: ruff format --check .
|
|
92
|
+
- name: Mypy
|
|
93
|
+
run: mypy
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
# Trigger by pushing a tag like v0.1.0. See RELEASING.md for the full
|
|
4
|
+
# checklist -- this workflow assumes the version bump and changelog
|
|
5
|
+
# entry are already committed before the tag is pushed.
|
|
6
|
+
on:
|
|
7
|
+
push:
|
|
8
|
+
tags:
|
|
9
|
+
- "v*.*.*"
|
|
10
|
+
|
|
11
|
+
permissions:
|
|
12
|
+
contents: write # to create the GitHub Release
|
|
13
|
+
id-token: write # for PyPI trusted publishing (no API token needed)
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
build:
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
|
|
21
|
+
- uses: actions/setup-python@v5
|
|
22
|
+
with:
|
|
23
|
+
python-version: "3.12"
|
|
24
|
+
|
|
25
|
+
- name: Verify the tag matches pyproject.toml
|
|
26
|
+
run: |
|
|
27
|
+
TAG_VERSION="${GITHUB_REF_NAME#v}"
|
|
28
|
+
PYPROJECT_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
|
|
29
|
+
if [ "$TAG_VERSION" != "$PYPROJECT_VERSION" ]; then
|
|
30
|
+
echo "::error::Tag is v$TAG_VERSION but pyproject.toml says $PYPROJECT_VERSION. Fix one of them before releasing."
|
|
31
|
+
exit 1
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
- name: Run the full test suite before shipping anything
|
|
35
|
+
run: |
|
|
36
|
+
python -m pip install --upgrade pip
|
|
37
|
+
pip install -e ".[dev,tiktoken]"
|
|
38
|
+
pytest -q
|
|
39
|
+
|
|
40
|
+
- name: Build sdist and wheel
|
|
41
|
+
run: |
|
|
42
|
+
pip install build
|
|
43
|
+
python -m build
|
|
44
|
+
|
|
45
|
+
- name: Upload build artifacts
|
|
46
|
+
uses: actions/upload-artifact@v4
|
|
47
|
+
with:
|
|
48
|
+
name: dist
|
|
49
|
+
path: dist/
|
|
50
|
+
|
|
51
|
+
publish-pypi:
|
|
52
|
+
needs: build
|
|
53
|
+
runs-on: ubuntu-latest
|
|
54
|
+
environment:
|
|
55
|
+
name: pypi
|
|
56
|
+
url: https://pypi.org/project/promptbudget/
|
|
57
|
+
permissions:
|
|
58
|
+
id-token: write
|
|
59
|
+
steps:
|
|
60
|
+
- uses: actions/download-artifact@v4
|
|
61
|
+
with:
|
|
62
|
+
name: dist
|
|
63
|
+
path: dist/
|
|
64
|
+
|
|
65
|
+
- name: Publish to PyPI
|
|
66
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
67
|
+
# Requires a trusted publisher configured on pypi.org for this
|
|
68
|
+
# repo + workflow first. See RELEASING.md -- this step will fail
|
|
69
|
+
# until that one-time manual setup is done.
|
|
70
|
+
|
|
71
|
+
github-release:
|
|
72
|
+
needs: publish-pypi
|
|
73
|
+
runs-on: ubuntu-latest
|
|
74
|
+
permissions:
|
|
75
|
+
contents: write
|
|
76
|
+
steps:
|
|
77
|
+
- uses: actions/checkout@v4
|
|
78
|
+
|
|
79
|
+
- uses: actions/download-artifact@v4
|
|
80
|
+
with:
|
|
81
|
+
name: dist
|
|
82
|
+
path: dist/
|
|
83
|
+
|
|
84
|
+
- name: Extract this version's changelog section
|
|
85
|
+
id: notes
|
|
86
|
+
run: |
|
|
87
|
+
VERSION="${GITHUB_REF_NAME#v}"
|
|
88
|
+
awk -v ver="$VERSION" '
|
|
89
|
+
$0 ~ "^## \\[" ver "\\]" { found=1; next }
|
|
90
|
+
found && /^## \[/ { exit }
|
|
91
|
+
found && /^\[.*\]: / { exit }
|
|
92
|
+
found { print }
|
|
93
|
+
' CHANGELOG.md > release_notes.md
|
|
94
|
+
if [ ! -s release_notes.md ]; then
|
|
95
|
+
echo "::error::No CHANGELOG.md section found for version $VERSION. Add one before tagging."
|
|
96
|
+
exit 1
|
|
97
|
+
fi
|
|
98
|
+
|
|
99
|
+
- name: Create GitHub Release
|
|
100
|
+
uses: softprops/action-gh-release@v2
|
|
101
|
+
with:
|
|
102
|
+
body_path: release_notes.md
|
|
103
|
+
files: dist/*
|
|
104
|
+
generate_release_notes: false
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
|
|
5
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
Nothing yet.
|
|
11
|
+
|
|
12
|
+
## [0.1.0] - 2026-09-09
|
|
13
|
+
|
|
14
|
+
Initial release.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- `pack()` / `ContextPacker`: allocate a fixed token budget across competing
|
|
19
|
+
`Section`s using declared priority, with ties broken deterministically by
|
|
20
|
+
section name.
|
|
21
|
+
- `min_tokens` / `max_tokens` guarantees per section. Infeasible minimums
|
|
22
|
+
raise `BudgetTooSmallError` instead of silently violating one.
|
|
23
|
+
- `reserved_output`: hold back tokens for the model's reply so it is never
|
|
24
|
+
offered to a section.
|
|
25
|
+
- Eviction strategies: `truncate` (default), `drop_oldest`, `drop_newest`,
|
|
26
|
+
`lowest_score_first`, `all_or_nothing`. Custom strategies plug in via
|
|
27
|
+
`Strategy` + `register_strategy()` with no change to the allocator.
|
|
28
|
+
- Pinned items (`Item(..., pinned=True)`) that are never dropped or cut,
|
|
29
|
+
and raise `SectionOverflowError` if they genuinely cannot fit.
|
|
30
|
+
- Tokenizer adapters: dependency-free `CharTokenizer` (default), any plain
|
|
31
|
+
`str -> int` callable, and an optional `TiktokenTokenizer` for exact
|
|
32
|
+
OpenAI-family counts.
|
|
33
|
+
- `PackResult` audit trail: `used_tokens`, `remaining_tokens`, `complete`,
|
|
34
|
+
per-section `SectionReport`s, and a full `ItemDecision` for every item
|
|
35
|
+
with a human-readable reason. Serializes via `to_dict()` / `to_json()`.
|
|
36
|
+
- Fully typed and PEP 561 compliant: a `py.typed` marker ships in the
|
|
37
|
+
wheel, so type checkers in consuming projects honour these annotations.
|
|
38
|
+
- Value semantics on `Item`: hashable (usable in sets and as dict keys),
|
|
39
|
+
with `metadata` copied on construction so packing stays a pure function
|
|
40
|
+
and no caller dict can be mutated out from under you.
|
|
41
|
+
- Eviction is linear in tokenizer calls, not quadratic -- token counts are
|
|
42
|
+
computed once and the running total maintained incrementally. Packing 400
|
|
43
|
+
messages costs 405 tokenizer calls rather than 80,603, and the gap widens
|
|
44
|
+
with size.
|
|
45
|
+
- Zero runtime dependencies in the core install.
|
|
46
|
+
|
|
47
|
+
[Unreleased]: https://github.com/mathewOracle/promptbudget/compare/v0.1.0...HEAD
|
|
48
|
+
[0.1.0]: https://github.com/mathewOracle/promptbudget/releases/tag/v0.1.0
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Contributing to promptbudget
|
|
2
|
+
|
|
3
|
+
Thanks for taking a look. This is a small library on purpose, so the bar for
|
|
4
|
+
what goes in is deliberately narrow.
|
|
5
|
+
|
|
6
|
+
## Ground rules
|
|
7
|
+
|
|
8
|
+
**The budget invariant is sacred.** No change may allow packed output to exceed
|
|
9
|
+
the declared budget. If you can produce an input that breaks it, that is a
|
|
10
|
+
high-priority bug — please open an issue with the reproducing input.
|
|
11
|
+
|
|
12
|
+
**Determinism is a feature.** Identical inputs must produce byte-identical
|
|
13
|
+
prompts. Anything relying on set iteration order, dict insertion luck, or
|
|
14
|
+
unstable sorts will be rejected. Tie-breaks must be explicit.
|
|
15
|
+
|
|
16
|
+
**Fail loudly, never silently.** When the request cannot be satisfied, raise a
|
|
17
|
+
descriptive error. Quietly returning a prompt that violates a declared guarantee
|
|
18
|
+
is the exact failure mode this library exists to prevent.
|
|
19
|
+
|
|
20
|
+
## Adding a strategy
|
|
21
|
+
|
|
22
|
+
This should not require touching the allocator. If it does, that is a design
|
|
23
|
+
smell worth raising in an issue first.
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from promptbudget import Strategy, StrategyResult, register_strategy
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class MyStrategy(Strategy):
|
|
30
|
+
name = "my_strategy"
|
|
31
|
+
|
|
32
|
+
def apply(self, section, items, allocated, tokenizer, separator):
|
|
33
|
+
# Must never return items whose rendered cost exceeds `allocated`.
|
|
34
|
+
# Must emit exactly one ItemDecision per input item.
|
|
35
|
+
return StrategyResult(kept_items, decisions)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
register_strategy("my_strategy", MyStrategy())
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Strategies that only differ in *eviction order* should subclass the shared
|
|
42
|
+
`_ItemwiseStrategy` helper and implement `eviction_order` — the cost accounting,
|
|
43
|
+
separator handling and pinned-item logic already live there exactly once. Please
|
|
44
|
+
don't copy it.
|
|
45
|
+
|
|
46
|
+
Every new strategy needs:
|
|
47
|
+
|
|
48
|
+
1. A unit test showing the ordering it promises.
|
|
49
|
+
2. Inclusion in `SAFE_STRATEGIES` in `tests/test_properties.py`, so the property
|
|
50
|
+
suite proves it respects the budget across generated inputs.
|
|
51
|
+
3. A row in the strategy table in the README.
|
|
52
|
+
|
|
53
|
+
## Adding a tokenizer
|
|
54
|
+
|
|
55
|
+
Subclass `Tokenizer` and implement `count`. You get a correct binary-search
|
|
56
|
+
`truncate` for free — only override it if the underlying tokenizer can slice
|
|
57
|
+
token ids directly, which is faster and exact.
|
|
58
|
+
|
|
59
|
+
New tokenizers must stay **optional dependencies**. The core installs with none,
|
|
60
|
+
and that is a promise to users, not an accident.
|
|
61
|
+
|
|
62
|
+
## Running things
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install -e ".[dev]"
|
|
66
|
+
pytest # unit + property tests + README doctests
|
|
67
|
+
ruff check . && ruff format --check .
|
|
68
|
+
mypy
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
CI runs on Python 3.9 through 3.13. Please make sure it is green before asking
|
|
72
|
+
for a review.
|
|
73
|
+
|
|
74
|
+
## Pull requests
|
|
75
|
+
|
|
76
|
+
- One logical change per PR.
|
|
77
|
+
- Explain *why*, and say what you ruled out. Reviewers care more about the
|
|
78
|
+
discarded alternatives than the chosen one.
|
|
79
|
+
- New behaviour needs a test that fails without your change. Verify that by
|
|
80
|
+
reverting the source and watching it go red.
|
|
81
|
+
- Add an entry under `## [Unreleased]` in [CHANGELOG.md](CHANGELOG.md) for
|
|
82
|
+
anything a user of the library would notice -- new behaviour, a fixed bug,
|
|
83
|
+
a changed default. Pure refactors and test-only changes don't need one.
|
|
84
|
+
- If you used an AI tool, that's fine -- but you must understand the code well
|
|
85
|
+
enough to defend every line in review. You are the author; the tool is not.
|
|
86
|
+
|
|
87
|
+
## Reporting bugs
|
|
88
|
+
|
|
89
|
+
The most useful bug report contains a `pack()` call, the inputs, what you
|
|
90
|
+
expected, and what you got. Since packing is a pure function of its arguments,
|
|
91
|
+
that is almost always enough to reproduce it exactly.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mathew Kadambatt
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: promptbudget
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Deterministic context-window budgeting for LLM prompts, with a full audit trail of every token decision.
|
|
5
|
+
Project-URL: Homepage, https://github.com/mathewOracle/promptbudget
|
|
6
|
+
Project-URL: Repository, https://github.com/mathewOracle/promptbudget
|
|
7
|
+
Project-URL: Issues, https://github.com/mathewOracle/promptbudget/issues
|
|
8
|
+
Author-email: Mathew Kadambatt <49642721+mathewOracle@users.noreply.github.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai,budget,context-window,llm,prompt,prompt-engineering,rag,tokenizer,tokens
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: hypothesis>=6.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
31
|
+
Provides-Extra: tiktoken
|
|
32
|
+
Requires-Dist: tiktoken>=0.5; extra == 'tiktoken'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# promptbudget
|
|
36
|
+
|
|
37
|
+
**Deterministic context-window budgeting for LLM prompts — with a full audit trail of every token decision.**
|
|
38
|
+
|
|
39
|
+
[](https://github.com/mathewOracle/promptbudget/actions/workflows/ci.yml)
|
|
40
|
+
[](https://pypi.org/project/promptbudget/)
|
|
41
|
+
[](https://pypi.org/project/promptbudget/)
|
|
42
|
+
[](LICENSE)
|
|
43
|
+
[](https://peps.python.org/pep-0561/)
|
|
44
|
+
|
|
45
|
+
Your system prompt, tool schemas, chat history and retrieved chunks are all competing
|
|
46
|
+
for the same context window. Most codebases resolve that fight with a `while
|
|
47
|
+
total > limit: messages.pop(0)` loop buried in a helper — which silently evicts the
|
|
48
|
+
one document the answer depended on, and leaves you no way to find out.
|
|
49
|
+
|
|
50
|
+
`promptbudget` makes that allocation an explicit, testable, auditable decision.
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from promptbudget import Section, Item, pack
|
|
54
|
+
|
|
55
|
+
result = pack(
|
|
56
|
+
[
|
|
57
|
+
Section("system", SYSTEM_PROMPT, priority=100, strategy="all_or_nothing"),
|
|
58
|
+
Section("tools", tool_schemas, priority=90, min_tokens=2_000),
|
|
59
|
+
Section("history", messages, priority=50, strategy="drop_oldest"),
|
|
60
|
+
Section("retrieval", chunks, priority=40, strategy="lowest_score_first"),
|
|
61
|
+
Section("scratchpad", notes, priority=10, max_tokens=1_000),
|
|
62
|
+
],
|
|
63
|
+
budget=128_000,
|
|
64
|
+
reserved_output=4_000,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
send(result.text)
|
|
68
|
+
|
|
69
|
+
for d in result.dropped():
|
|
70
|
+
log.info("%s[%d] %s: %s", d.section, d.index, d.action.value, d.reason)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
retrieval[7] dropped: lowest relevance score, evicted first to fit the section allocation
|
|
75
|
+
scratchpad[0] truncated: cut to the tokens remaining in the section allocation
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Why this exists
|
|
79
|
+
|
|
80
|
+
| The usual approach | What goes wrong |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `messages[-10:]` | Breaks the moment one message is huge |
|
|
83
|
+
| `while tokens > limit: pop(0)` | No priorities, no guarantees, drops pinned context |
|
|
84
|
+
| Framework-native trimming | Handles chat history only — not tools, RAG chunks and history *together* |
|
|
85
|
+
| Retry on `context_length_exceeded` | Pays for a failed call, and still doesn't decide *what* to shed |
|
|
86
|
+
| All of the above | Cannot answer "why is that chunk missing from my prompt?" |
|
|
87
|
+
|
|
88
|
+
Existing tools each cover a slice: LangChain's `trim_messages` trims message lists,
|
|
89
|
+
LLMLingua compresses text, `tiktoken` counts tokens. None of them arbitrate a fixed
|
|
90
|
+
budget across several sections with declared guarantees and tell you what they did.
|
|
91
|
+
|
|
92
|
+
## Install
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
pip install promptbudget # zero dependencies
|
|
96
|
+
pip install "promptbudget[tiktoken]" # exact OpenAI-family token counts
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Python 3.9+. The core has **no runtime dependencies**.
|
|
100
|
+
|
|
101
|
+
## Core ideas
|
|
102
|
+
|
|
103
|
+
### Sections compete; priority decides who wins
|
|
104
|
+
|
|
105
|
+
Sections are served highest priority first. Ties break on name, so identical inputs
|
|
106
|
+
always produce an identical prompt — the same reason you pin dependency versions.
|
|
107
|
+
|
|
108
|
+
Rendering follows *your* list order, not priority order. Priority decides who gets
|
|
109
|
+
tokens; you decide what the prompt reads like.
|
|
110
|
+
|
|
111
|
+
### Guarantees are kept or the call fails
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
Section("tools", tool_schemas, priority=90, min_tokens=2_000)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`min_tokens` is reserved before any lower-priority section is served — a greedy
|
|
118
|
+
high-priority section cannot eat tokens promised downstream. If the minimums
|
|
119
|
+
collectively don't fit, you get `BudgetTooSmallError` rather than a prompt that
|
|
120
|
+
quietly violates one:
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
BudgetTooSmallError: Guaranteed minimums require 6000 tokens but only 4000 are
|
|
124
|
+
available (budget=8000, reserved_output=4000). Lower a min_tokens value, raise
|
|
125
|
+
the budget, or reduce reserved_output.
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`max_tokens` is the mirror image: a cap, so a runaway history can't crowd out retrieval
|
|
129
|
+
even when the window is empty.
|
|
130
|
+
|
|
131
|
+
### Reserve room for the reply
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
pack(sections, budget=128_000, reserved_output=4_000)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Forgetting the model's own output is the most common way to get a context-length
|
|
138
|
+
error from a prompt that "obviously fits". Reserved tokens are never offered to a section.
|
|
139
|
+
|
|
140
|
+
### Strategies decide what gets shed
|
|
141
|
+
|
|
142
|
+
| Strategy | Behaviour | Use for |
|
|
143
|
+
|---|---|---|
|
|
144
|
+
| `"truncate"` *(default)* | Cuts the tail of the overflowing item | Prose, scratchpads |
|
|
145
|
+
| `"drop_oldest"` | Evicts from the front | Chat history |
|
|
146
|
+
| `"drop_newest"` | Evicts from the back | Append-only logs |
|
|
147
|
+
| `"lowest_score_first"` | Evicts least-relevant items | RAG chunks |
|
|
148
|
+
| `"all_or_nothing"` | Delivers intact or raises | System prompts, tool schemas |
|
|
149
|
+
|
|
150
|
+
Pin anything that must survive:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
Item("The user's actual question", pinned=True)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Pinned items are never dropped or cut. If a pinned item genuinely cannot fit, you get
|
|
157
|
+
`SectionOverflowError` — a loud failure beats a mangled prompt.
|
|
158
|
+
|
|
159
|
+
Bring your own by subclassing `Strategy`:
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
from promptbudget import Strategy, StrategyResult, register_strategy
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class DeduplicateThenTruncate(Strategy):
|
|
166
|
+
name = "dedupe"
|
|
167
|
+
|
|
168
|
+
def apply(
|
|
169
|
+
self, section, items, allocated, tokenizer, separator
|
|
170
|
+
): ... # return StrategyResult(items, decisions)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
register_strategy("dedupe", DeduplicateThenTruncate())
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Any tokenizer, or none
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
from promptbudget import TiktokenTokenizer, pack
|
|
180
|
+
|
|
181
|
+
pack(sections, budget=128_000, tokenizer=TiktokenTokenizer("gpt-4o"))
|
|
182
|
+
pack(sections, budget=8_000, tokenizer=lambda text: len(text) // 4) # any str -> int
|
|
183
|
+
pack(sections, budget=8_000) # built-in heuristic
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
The allocator never imports a tokenizer directly — it talks to the `Tokenizer`
|
|
187
|
+
interface, so HuggingFace, a provider SDK, or your own estimator all drop straight in.
|
|
188
|
+
|
|
189
|
+
### The result explains itself
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
result.text # the fitted prompt
|
|
193
|
+
result.used_tokens # what it actually costs
|
|
194
|
+
result.remaining_tokens # headroom left
|
|
195
|
+
result.complete # True when nothing was lost
|
|
196
|
+
result.dropped() # every item that lost content, and why
|
|
197
|
+
result.section("rag") # per-section accounting
|
|
198
|
+
result.to_json(indent=2) # structured logging, ready to ship
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Because packing is a pure function returning a plain value, you can assert on it:
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
def test_the_user_question_always_survives():
|
|
205
|
+
result = pack(sections, budget=4_000)
|
|
206
|
+
assert "user_question" not in {d.section for d in result.dropped()}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Guarantees
|
|
210
|
+
|
|
211
|
+
Verified by property-based tests across hundreds of generated inputs
|
|
212
|
+
([`tests/test_properties.py`](tests/test_properties.py)):
|
|
213
|
+
|
|
214
|
+
- The output **never** exceeds the budget.
|
|
215
|
+
- Reserved output tokens are **never** consumed.
|
|
216
|
+
- `max_tokens` caps are **never** breached.
|
|
217
|
+
- Identical inputs produce **byte-identical** prompts.
|
|
218
|
+
- Every item receives **exactly one** decision — nothing vanishes unrecorded.
|
|
219
|
+
- `complete` never lies about dropped content.
|
|
220
|
+
|
|
221
|
+
## Development
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
git clone https://github.com/mathewOracle/promptbudget && cd promptbudget
|
|
225
|
+
python -m venv .venv && source .venv/bin/activate
|
|
226
|
+
pip install -e ".[dev]"
|
|
227
|
+
pytest && ruff check . && mypy
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Contributions welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). New strategies are
|
|
231
|
+
especially welcome, and shouldn't require touching the allocator at all.
|
|
232
|
+
|
|
233
|
+
See [CHANGELOG.md](CHANGELOG.md) for release history and
|
|
234
|
+
[RELEASING.md](RELEASING.md) for how versions get published.
|
|
235
|
+
|
|
236
|
+
## License
|
|
237
|
+
|
|
238
|
+
MIT — see [LICENSE](LICENSE).
|