Spaces:
Build error
Prepare for OSS release v0.2.0
Browse filesThis commit prepares Headroom for public open source release with
comprehensive documentation, licensing, and community infrastructure.
License & Legal:
- Add Apache 2.0 LICENSE file
- Add NOTICE file with third-party attributions
- Add SECURITY.md for vulnerability reporting
Community:
- Add CONTRIBUTING.md with contribution guidelines
- Add CODE_OF_CONDUCT.md (Contributor Covenant)
- Add GitHub issue templates (bug report, feature request)
- Add pull request template
Documentation:
- Update README.md with compelling value proposition
- Add docs/getting-started.md
- Add docs/proxy.md for proxy server documentation
- Add docs/transforms.md for transform reference
- Add docs/api.md for API reference
- Add examples/README.md
Package Infrastructure:
- Add headroom/py.typed for PEP 561 compliance
- Add headroom/cli.py for CLI entry point
- Add .github/workflows/ci.yml for CI pipeline
- Add .github/workflows/publish.yml for PyPI publishing
- Update pyproject.toml with proper metadata
New Features:
- Add multi-provider support (Google, Cohere, LiteLLM, OpenAI-compatible)
- Add universal tokenizer registry with multiple backends
- Add model registry with pricing and context limits
- Add production proxy server with caching and rate limiting
Code Quality:
- Fix 83 lint issues via ruff auto-fix
- Fix version consistency (benchmarks 0.1.0 β 0.2.0)
- Add skip decorators for optional dependency tests
- .github/FUNDING.yml +7 -0
- .github/ISSUE_TEMPLATE/bug_report.md +53 -0
- .github/ISSUE_TEMPLATE/config.yml +8 -0
- .github/ISSUE_TEMPLATE/feature_request.md +44 -0
- .github/PULL_REQUEST_TEMPLATE.md +56 -0
- .github/workflows/ci.yml +108 -0
- .github/workflows/publish.yml +31 -0
- .gitignore +62 -6
- CHANGELOG.md +120 -0
- CODE_OF_CONDUCT.md +133 -0
- CONTRIBUTING.md +209 -0
- LICENSE +190 -0
- NOTICE +43 -0
- README.md +176 -198
- SECURITY.md +65 -0
- benchmarks/__init__.py +1 -1
- EXPLANATION.md β docs/ARCHITECTURE.md +0 -0
- docs/README.md +28 -0
- docs/api.md +346 -0
- docs/getting-started.md +109 -0
- docs/proxy.md +173 -0
- docs/transforms.md +198 -0
- examples/README.md +133 -0
- headroom/__init__.py +9 -9
- headroom/cli.py +185 -0
- headroom/client.py +2 -1
- headroom/config.py +2 -2
- headroom/integrations/__init__.py +4 -5
- headroom/integrations/langchain.py +3 -2
- headroom/integrations/mcp.py +3 -2
- headroom/models/__init__.py +39 -0
- headroom/models/registry.py +749 -0
- headroom/parser.py +1 -1
- headroom/pricing/__init__.py +10 -7
- headroom/pricing/anthropic_prices.py +0 -1
- headroom/pricing/openai_prices.py +0 -1
- headroom/pricing/registry.py +11 -12
- headroom/providers/__init__.py +45 -0
- headroom/providers/cohere.py +313 -0
- headroom/providers/google.py +372 -0
- headroom/providers/litellm.py +293 -0
- headroom/providers/openai.py +0 -1
- headroom/providers/openai_compatible.py +521 -0
- headroom/proxy/__init__.py +19 -0
- headroom/proxy/server.py +1399 -0
- headroom/py.typed +0 -0
- headroom/relevance/bm25.py +1 -2
- headroom/relevance/embedding.py +4 -4
- headroom/reporting/generator.py +0 -1
- headroom/storage/base.py +2 -1
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# These are supported funding model platforms
|
| 2 |
+
|
| 3 |
+
github: [headroom-sdk]
|
| 4 |
+
# patreon: headroom
|
| 5 |
+
# open_collective: headroom
|
| 6 |
+
# ko_fi: headroom
|
| 7 |
+
# custom: ["https://headroom.dev/sponsor"]
|
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: Bug Report
|
| 3 |
+
about: Report a bug to help us improve Headroom
|
| 4 |
+
title: '[BUG] '
|
| 5 |
+
labels: bug
|
| 6 |
+
assignees: ''
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Description
|
| 10 |
+
|
| 11 |
+
A clear and concise description of what the bug is.
|
| 12 |
+
|
| 13 |
+
## To Reproduce
|
| 14 |
+
|
| 15 |
+
Steps to reproduce the behavior:
|
| 16 |
+
|
| 17 |
+
1. Install headroom with '...'
|
| 18 |
+
2. Run this code '...'
|
| 19 |
+
3. See error
|
| 20 |
+
|
| 21 |
+
## Expected Behavior
|
| 22 |
+
|
| 23 |
+
What you expected to happen.
|
| 24 |
+
|
| 25 |
+
## Actual Behavior
|
| 26 |
+
|
| 27 |
+
What actually happened.
|
| 28 |
+
|
| 29 |
+
## Code Sample
|
| 30 |
+
|
| 31 |
+
```python
|
| 32 |
+
# Minimal code to reproduce the issue
|
| 33 |
+
from headroom import HeadroomClient
|
| 34 |
+
|
| 35 |
+
# Your code here
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
## Error Output
|
| 39 |
+
|
| 40 |
+
```
|
| 41 |
+
Paste any error messages or stack traces here
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
## Environment
|
| 45 |
+
|
| 46 |
+
- **Headroom version**: (run `python -c "import headroom; print(headroom.__version__)"`)
|
| 47 |
+
- **Python version**: (run `python --version`)
|
| 48 |
+
- **OS**: (e.g., macOS 14.0, Ubuntu 22.04, Windows 11)
|
| 49 |
+
- **LLM Provider**: (e.g., OpenAI, Anthropic)
|
| 50 |
+
|
| 51 |
+
## Additional Context
|
| 52 |
+
|
| 53 |
+
Add any other context about the problem here (logs, screenshots, etc.)
|
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
blank_issues_enabled: true
|
| 2 |
+
contact_links:
|
| 3 |
+
- name: Questions & Discussions
|
| 4 |
+
url: https://github.com/headroom-sdk/headroom/discussions
|
| 5 |
+
about: Ask questions and discuss ideas in GitHub Discussions
|
| 6 |
+
- name: Documentation
|
| 7 |
+
url: https://headroom.dev/docs
|
| 8 |
+
about: Check out the documentation for guides and API reference
|
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: Feature Request
|
| 3 |
+
about: Suggest a new feature for Headroom
|
| 4 |
+
title: '[FEATURE] '
|
| 5 |
+
labels: enhancement
|
| 6 |
+
assignees: ''
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Problem Statement
|
| 10 |
+
|
| 11 |
+
A clear description of the problem you're trying to solve.
|
| 12 |
+
Ex: "I'm always frustrated when..."
|
| 13 |
+
|
| 14 |
+
## Proposed Solution
|
| 15 |
+
|
| 16 |
+
Describe the solution you'd like. Be as specific as possible.
|
| 17 |
+
|
| 18 |
+
## Use Case
|
| 19 |
+
|
| 20 |
+
Explain your use case and why this feature would be valuable:
|
| 21 |
+
|
| 22 |
+
- What type of application are you building?
|
| 23 |
+
- How would this feature help you?
|
| 24 |
+
- How many tokens/cost would this save?
|
| 25 |
+
|
| 26 |
+
## Alternatives Considered
|
| 27 |
+
|
| 28 |
+
Describe any alternative solutions or features you've considered.
|
| 29 |
+
|
| 30 |
+
## Example API (Optional)
|
| 31 |
+
|
| 32 |
+
If you have ideas about how the API should look:
|
| 33 |
+
|
| 34 |
+
```python
|
| 35 |
+
# How you'd like to use this feature
|
| 36 |
+
from headroom import SomeNewFeature
|
| 37 |
+
|
| 38 |
+
# Example usage
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
## Additional Context
|
| 42 |
+
|
| 43 |
+
- Are you willing to contribute this feature?
|
| 44 |
+
- Any relevant links, papers, or prior art?
|
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Description
|
| 2 |
+
|
| 3 |
+
Brief description of changes and motivation.
|
| 4 |
+
|
| 5 |
+
Fixes #(issue number)
|
| 6 |
+
|
| 7 |
+
## Type of Change
|
| 8 |
+
|
| 9 |
+
- [ ] Bug fix (non-breaking change that fixes an issue)
|
| 10 |
+
- [ ] New feature (non-breaking change that adds functionality)
|
| 11 |
+
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
|
| 12 |
+
- [ ] Documentation update
|
| 13 |
+
- [ ] Performance improvement
|
| 14 |
+
- [ ] Code refactoring (no functional changes)
|
| 15 |
+
|
| 16 |
+
## Changes Made
|
| 17 |
+
|
| 18 |
+
- Change 1
|
| 19 |
+
- Change 2
|
| 20 |
+
- Change 3
|
| 21 |
+
|
| 22 |
+
## Testing
|
| 23 |
+
|
| 24 |
+
Describe the tests you ran to verify your changes:
|
| 25 |
+
|
| 26 |
+
- [ ] Unit tests pass (`pytest`)
|
| 27 |
+
- [ ] Linting passes (`ruff check .`)
|
| 28 |
+
- [ ] Type checking passes (`mypy headroom`)
|
| 29 |
+
- [ ] New tests added for new functionality
|
| 30 |
+
- [ ] Manual testing performed
|
| 31 |
+
|
| 32 |
+
## Test Output
|
| 33 |
+
|
| 34 |
+
```
|
| 35 |
+
# Paste relevant test output here
|
| 36 |
+
pytest -v tests/test_your_feature.py
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
## Checklist
|
| 40 |
+
|
| 41 |
+
- [ ] My code follows the project's style guidelines
|
| 42 |
+
- [ ] I have performed a self-review of my code
|
| 43 |
+
- [ ] I have commented my code, particularly in hard-to-understand areas
|
| 44 |
+
- [ ] I have made corresponding changes to the documentation
|
| 45 |
+
- [ ] My changes generate no new warnings
|
| 46 |
+
- [ ] I have added tests that prove my fix is effective or that my feature works
|
| 47 |
+
- [ ] New and existing unit tests pass locally with my changes
|
| 48 |
+
- [ ] I have updated the CHANGELOG.md if applicable
|
| 49 |
+
|
| 50 |
+
## Screenshots (if applicable)
|
| 51 |
+
|
| 52 |
+
Add screenshots to help explain your changes.
|
| 53 |
+
|
| 54 |
+
## Additional Notes
|
| 55 |
+
|
| 56 |
+
Any additional information that reviewers should know.
|
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: CI
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [main]
|
| 6 |
+
pull_request:
|
| 7 |
+
branches: [main]
|
| 8 |
+
|
| 9 |
+
jobs:
|
| 10 |
+
test:
|
| 11 |
+
runs-on: ubuntu-latest
|
| 12 |
+
strategy:
|
| 13 |
+
fail-fast: false
|
| 14 |
+
matrix:
|
| 15 |
+
python-version: ["3.10", "3.11", "3.12"]
|
| 16 |
+
|
| 17 |
+
steps:
|
| 18 |
+
- uses: actions/checkout@v4
|
| 19 |
+
|
| 20 |
+
- name: Set up Python ${{ matrix.python-version }}
|
| 21 |
+
uses: actions/setup-python@v5
|
| 22 |
+
with:
|
| 23 |
+
python-version: ${{ matrix.python-version }}
|
| 24 |
+
|
| 25 |
+
- name: Cache pip packages
|
| 26 |
+
uses: actions/cache@v4
|
| 27 |
+
with:
|
| 28 |
+
path: ~/.cache/pip
|
| 29 |
+
key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('pyproject.toml') }}
|
| 30 |
+
restore-keys: |
|
| 31 |
+
${{ runner.os }}-pip-${{ matrix.python-version }}-
|
| 32 |
+
|
| 33 |
+
- name: Install dependencies
|
| 34 |
+
run: |
|
| 35 |
+
python -m pip install --upgrade pip
|
| 36 |
+
pip install -e ".[dev]"
|
| 37 |
+
|
| 38 |
+
- name: Run linting
|
| 39 |
+
run: |
|
| 40 |
+
ruff check .
|
| 41 |
+
ruff format --check .
|
| 42 |
+
|
| 43 |
+
- name: Run type checking
|
| 44 |
+
run: |
|
| 45 |
+
mypy headroom --ignore-missing-imports
|
| 46 |
+
|
| 47 |
+
- name: Run tests
|
| 48 |
+
run: |
|
| 49 |
+
pytest -v --tb=short
|
| 50 |
+
|
| 51 |
+
- name: Run tests with coverage
|
| 52 |
+
if: matrix.python-version == '3.11'
|
| 53 |
+
run: |
|
| 54 |
+
pytest --cov=headroom --cov-report=xml --cov-report=term-missing
|
| 55 |
+
|
| 56 |
+
- name: Upload coverage to Codecov
|
| 57 |
+
if: matrix.python-version == '3.11'
|
| 58 |
+
uses: codecov/codecov-action@v4
|
| 59 |
+
with:
|
| 60 |
+
file: ./coverage.xml
|
| 61 |
+
fail_ci_if_error: false
|
| 62 |
+
|
| 63 |
+
test-extras:
|
| 64 |
+
runs-on: ubuntu-latest
|
| 65 |
+
steps:
|
| 66 |
+
- uses: actions/checkout@v4
|
| 67 |
+
|
| 68 |
+
- name: Set up Python
|
| 69 |
+
uses: actions/setup-python@v5
|
| 70 |
+
with:
|
| 71 |
+
python-version: "3.11"
|
| 72 |
+
|
| 73 |
+
- name: Install with relevance extras
|
| 74 |
+
run: |
|
| 75 |
+
python -m pip install --upgrade pip
|
| 76 |
+
pip install -e ".[dev,relevance]"
|
| 77 |
+
|
| 78 |
+
- name: Run relevance tests
|
| 79 |
+
run: |
|
| 80 |
+
pytest tests/test_relevance.py -v
|
| 81 |
+
|
| 82 |
+
build:
|
| 83 |
+
runs-on: ubuntu-latest
|
| 84 |
+
steps:
|
| 85 |
+
- uses: actions/checkout@v4
|
| 86 |
+
|
| 87 |
+
- name: Set up Python
|
| 88 |
+
uses: actions/setup-python@v5
|
| 89 |
+
with:
|
| 90 |
+
python-version: "3.11"
|
| 91 |
+
|
| 92 |
+
- name: Install build tools
|
| 93 |
+
run: |
|
| 94 |
+
python -m pip install --upgrade pip build twine
|
| 95 |
+
|
| 96 |
+
- name: Build package
|
| 97 |
+
run: |
|
| 98 |
+
python -m build
|
| 99 |
+
|
| 100 |
+
- name: Check package
|
| 101 |
+
run: |
|
| 102 |
+
twine check dist/*
|
| 103 |
+
|
| 104 |
+
- name: Upload artifacts
|
| 105 |
+
uses: actions/upload-artifact@v4
|
| 106 |
+
with:
|
| 107 |
+
name: dist
|
| 108 |
+
path: dist/
|
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Publish to PyPI
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
release:
|
| 5 |
+
types: [published]
|
| 6 |
+
|
| 7 |
+
jobs:
|
| 8 |
+
publish:
|
| 9 |
+
runs-on: ubuntu-latest
|
| 10 |
+
environment: pypi
|
| 11 |
+
permissions:
|
| 12 |
+
id-token: write # For trusted publishing
|
| 13 |
+
|
| 14 |
+
steps:
|
| 15 |
+
- uses: actions/checkout@v4
|
| 16 |
+
|
| 17 |
+
- name: Set up Python
|
| 18 |
+
uses: actions/setup-python@v5
|
| 19 |
+
with:
|
| 20 |
+
python-version: "3.11"
|
| 21 |
+
|
| 22 |
+
- name: Install build tools
|
| 23 |
+
run: |
|
| 24 |
+
python -m pip install --upgrade pip build
|
| 25 |
+
|
| 26 |
+
- name: Build package
|
| 27 |
+
run: |
|
| 28 |
+
python -m build
|
| 29 |
+
|
| 30 |
+
- name: Publish to PyPI
|
| 31 |
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -20,9 +20,11 @@ parts/
|
|
| 20 |
sdist/
|
| 21 |
var/
|
| 22 |
wheels/
|
|
|
|
| 23 |
*.egg-info/
|
| 24 |
.installed.cfg
|
| 25 |
*.egg
|
|
|
|
| 26 |
|
| 27 |
# PyInstaller
|
| 28 |
*.manifest
|
|
@@ -45,6 +47,7 @@ coverage.xml
|
|
| 45 |
*.py,cover
|
| 46 |
.hypothesis/
|
| 47 |
.pytest_cache/
|
|
|
|
| 48 |
|
| 49 |
# Translations
|
| 50 |
*.mo
|
|
@@ -59,16 +62,19 @@ venv/
|
|
| 59 |
ENV/
|
| 60 |
env.bak/
|
| 61 |
venv.bak/
|
|
|
|
| 62 |
|
| 63 |
-
# Secrets and API keys
|
| 64 |
*.pem
|
| 65 |
*.key
|
| 66 |
secrets.json
|
| 67 |
credentials.json
|
| 68 |
.secrets
|
| 69 |
api_keys.txt
|
|
|
|
|
|
|
| 70 |
|
| 71 |
-
# IDE
|
| 72 |
.idea/
|
| 73 |
.vscode/
|
| 74 |
*.swp
|
|
@@ -77,36 +83,86 @@ api_keys.txt
|
|
| 77 |
.project
|
| 78 |
.pydevproject
|
| 79 |
.settings/
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
# Jupyter Notebook
|
| 82 |
.ipynb_checkpoints
|
|
|
|
| 83 |
|
| 84 |
# macOS
|
| 85 |
.DS_Store
|
| 86 |
.AppleDouble
|
| 87 |
.LSOverride
|
|
|
|
| 88 |
|
| 89 |
# Thumbnails
|
|
|
|
| 90 |
._*
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
# Local configuration
|
| 93 |
local_settings.py
|
| 94 |
*.local.py
|
|
|
|
|
|
|
| 95 |
|
| 96 |
-
# Database
|
| 97 |
*.db
|
|
|
|
| 98 |
*.sqlite3
|
| 99 |
|
| 100 |
-
#
|
| 101 |
*.log
|
| 102 |
logs/
|
|
|
|
| 103 |
|
| 104 |
# Temporary files
|
| 105 |
tmp/
|
| 106 |
temp/
|
| 107 |
*.tmp
|
| 108 |
*.bak
|
|
|
|
| 109 |
|
| 110 |
-
# Benchmark results (keep
|
| 111 |
-
/
|
| 112 |
benchmark_results.json
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
sdist/
|
| 21 |
var/
|
| 22 |
wheels/
|
| 23 |
+
share/python-wheels/
|
| 24 |
*.egg-info/
|
| 25 |
.installed.cfg
|
| 26 |
*.egg
|
| 27 |
+
MANIFEST
|
| 28 |
|
| 29 |
# PyInstaller
|
| 30 |
*.manifest
|
|
|
|
| 47 |
*.py,cover
|
| 48 |
.hypothesis/
|
| 49 |
.pytest_cache/
|
| 50 |
+
pytest_cache/
|
| 51 |
|
| 52 |
# Translations
|
| 53 |
*.mo
|
|
|
|
| 62 |
ENV/
|
| 63 |
env.bak/
|
| 64 |
venv.bak/
|
| 65 |
+
.python-version
|
| 66 |
|
| 67 |
+
# Secrets and API keys - NEVER commit these
|
| 68 |
*.pem
|
| 69 |
*.key
|
| 70 |
secrets.json
|
| 71 |
credentials.json
|
| 72 |
.secrets
|
| 73 |
api_keys.txt
|
| 74 |
+
.anthropic
|
| 75 |
+
.openai
|
| 76 |
|
| 77 |
+
# IDE and editors
|
| 78 |
.idea/
|
| 79 |
.vscode/
|
| 80 |
*.swp
|
|
|
|
| 83 |
.project
|
| 84 |
.pydevproject
|
| 85 |
.settings/
|
| 86 |
+
*.sublime-project
|
| 87 |
+
*.sublime-workspace
|
| 88 |
+
.spyproject
|
| 89 |
+
.spyderproject
|
| 90 |
|
| 91 |
# Jupyter Notebook
|
| 92 |
.ipynb_checkpoints
|
| 93 |
+
*.ipynb
|
| 94 |
|
| 95 |
# macOS
|
| 96 |
.DS_Store
|
| 97 |
.AppleDouble
|
| 98 |
.LSOverride
|
| 99 |
+
._*
|
| 100 |
|
| 101 |
# Thumbnails
|
| 102 |
+
Icon?
|
| 103 |
._*
|
| 104 |
|
| 105 |
+
# Windows
|
| 106 |
+
Thumbs.db
|
| 107 |
+
ehthumbs.db
|
| 108 |
+
Desktop.ini
|
| 109 |
+
|
| 110 |
+
# Linux
|
| 111 |
+
*~
|
| 112 |
+
|
| 113 |
# Local configuration
|
| 114 |
local_settings.py
|
| 115 |
*.local.py
|
| 116 |
+
*.local.json
|
| 117 |
+
*.local.yaml
|
| 118 |
|
| 119 |
+
# Database files
|
| 120 |
*.db
|
| 121 |
+
*.sqlite
|
| 122 |
*.sqlite3
|
| 123 |
|
| 124 |
+
# Log files
|
| 125 |
*.log
|
| 126 |
logs/
|
| 127 |
+
log/
|
| 128 |
|
| 129 |
# Temporary files
|
| 130 |
tmp/
|
| 131 |
temp/
|
| 132 |
*.tmp
|
| 133 |
*.bak
|
| 134 |
+
*.swp
|
| 135 |
|
| 136 |
+
# Benchmark results (keep framework, not results)
|
| 137 |
+
.benchmarks/
|
| 138 |
benchmark_results.json
|
| 139 |
+
benchmark_results/
|
| 140 |
+
|
| 141 |
+
# DeepEval cache
|
| 142 |
+
.deepeval/
|
| 143 |
+
|
| 144 |
+
# Headroom specific
|
| 145 |
+
headroom.db
|
| 146 |
+
headroom_*.db
|
| 147 |
+
*.jsonl
|
| 148 |
+
!tests/fixtures/*.jsonl
|
| 149 |
+
|
| 150 |
+
# Documentation build
|
| 151 |
+
docs/_build/
|
| 152 |
+
site/
|
| 153 |
+
|
| 154 |
+
# mypy
|
| 155 |
+
.mypy_cache/
|
| 156 |
+
.dmypy.json
|
| 157 |
+
dmypy.json
|
| 158 |
+
|
| 159 |
+
# Ruff
|
| 160 |
+
.ruff_cache/
|
| 161 |
+
|
| 162 |
+
# pyright
|
| 163 |
+
pyrightconfig.json
|
| 164 |
+
|
| 165 |
+
# Editor backup files
|
| 166 |
+
*~
|
| 167 |
+
\#*\#
|
| 168 |
+
.\#*
|
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Changelog
|
| 2 |
+
|
| 3 |
+
All notable changes to Headroom will be documented in this file.
|
| 4 |
+
|
| 5 |
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
| 6 |
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
| 7 |
+
|
| 8 |
+
## [Unreleased]
|
| 9 |
+
|
| 10 |
+
### Added
|
| 11 |
+
- Production-ready proxy server with caching, rate limiting, and metrics
|
| 12 |
+
- CLI command `headroom proxy` to start the proxy server
|
| 13 |
+
|
| 14 |
+
## [0.2.0] - 2025-01-07
|
| 15 |
+
|
| 16 |
+
### Added
|
| 17 |
+
- **SmartCrusher**: Statistical compression for tool outputs
|
| 18 |
+
- Keeps first/last K items, errors, anomalies, and relevance matches
|
| 19 |
+
- Variance-based change point detection
|
| 20 |
+
- Pattern detection (time series, logs, search results)
|
| 21 |
+
- **Relevance Scoring Engine**: ML-powered item relevance
|
| 22 |
+
- `BM25Scorer`: Fast keyword matching (zero dependencies)
|
| 23 |
+
- `EmbeddingScorer`: Semantic similarity with sentence-transformers
|
| 24 |
+
- `HybridScorer`: Adaptive combination of both methods
|
| 25 |
+
- **CacheAligner**: Prefix stabilization for better cache hits
|
| 26 |
+
- Dynamic date extraction
|
| 27 |
+
- Whitespace normalization
|
| 28 |
+
- Stable prefix hashing
|
| 29 |
+
- **RollingWindow**: Context management within token limits
|
| 30 |
+
- Drops oldest tool units first
|
| 31 |
+
- Never orphans tool results
|
| 32 |
+
- Preserves recent turns
|
| 33 |
+
- **Multi-Provider Support**:
|
| 34 |
+
- Anthropic with official `count_tokens` API
|
| 35 |
+
- Google with official `countTokens` API
|
| 36 |
+
- Cohere with official `tokenize` API
|
| 37 |
+
- Mistral with official tokenizer
|
| 38 |
+
- LiteLLM for unified interface
|
| 39 |
+
- **Integrations**:
|
| 40 |
+
- LangChain callback handler (`HeadroomOptimizer`)
|
| 41 |
+
- MCP (Model Context Protocol) utilities
|
| 42 |
+
- **Proxy Server** (`headroom.proxy`):
|
| 43 |
+
- Semantic caching with LRU eviction
|
| 44 |
+
- Token bucket rate limiting
|
| 45 |
+
- Retry with exponential backoff
|
| 46 |
+
- Cost tracking with budget enforcement
|
| 47 |
+
- Prometheus metrics endpoint
|
| 48 |
+
- Request logging (JSONL)
|
| 49 |
+
- **Pricing Registry**: Centralized model pricing with staleness tracking
|
| 50 |
+
- **Benchmarks**: Performance benchmarks for transforms and relevance scoring
|
| 51 |
+
|
| 52 |
+
### Changed
|
| 53 |
+
- Improved token counting accuracy across all providers
|
| 54 |
+
- Enhanced tool output compression with relevance-aware selection
|
| 55 |
+
|
| 56 |
+
### Fixed
|
| 57 |
+
- Mistral tokenizer API compatibility
|
| 58 |
+
- Google token counting for multi-turn conversations
|
| 59 |
+
|
| 60 |
+
## [0.1.0] - 2025-01-05
|
| 61 |
+
|
| 62 |
+
### Added
|
| 63 |
+
- Initial release
|
| 64 |
+
- `HeadroomClient`: OpenAI-compatible client wrapper
|
| 65 |
+
- `ToolCrusher`: Basic tool output compression
|
| 66 |
+
- Audit mode for observation without modification
|
| 67 |
+
- Optimize mode for applying transforms
|
| 68 |
+
- Simulate mode for previewing changes
|
| 69 |
+
- SQLite and JSONL storage backends
|
| 70 |
+
- HTML report generation
|
| 71 |
+
- Streaming support
|
| 72 |
+
|
| 73 |
+
### Safety Guarantees
|
| 74 |
+
- Never removes human content
|
| 75 |
+
- Never breaks tool ordering
|
| 76 |
+
- Parse failures are no-ops
|
| 77 |
+
- Preserves recency (last N turns)
|
| 78 |
+
|
| 79 |
+
---
|
| 80 |
+
|
| 81 |
+
## Migration Guide
|
| 82 |
+
|
| 83 |
+
### From 0.1.x to 0.2.x
|
| 84 |
+
|
| 85 |
+
The 0.2.0 release is backward compatible. New features are opt-in:
|
| 86 |
+
|
| 87 |
+
```python
|
| 88 |
+
# Old code still works
|
| 89 |
+
from headroom import HeadroomClient, OpenAIProvider
|
| 90 |
+
|
| 91 |
+
# New SmartCrusher (replaces ToolCrusher for better compression)
|
| 92 |
+
from headroom import SmartCrusher, SmartCrusherConfig
|
| 93 |
+
|
| 94 |
+
config = SmartCrusherConfig(
|
| 95 |
+
min_tokens_to_crush=200,
|
| 96 |
+
max_items_after_crush=50,
|
| 97 |
+
)
|
| 98 |
+
crusher = SmartCrusher(config)
|
| 99 |
+
|
| 100 |
+
# New relevance scoring
|
| 101 |
+
from headroom import create_scorer
|
| 102 |
+
|
| 103 |
+
scorer = create_scorer("hybrid") # or "bm25" for zero deps
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
+
### Using the Proxy
|
| 107 |
+
|
| 108 |
+
New in 0.2.0 - run Headroom as a proxy server:
|
| 109 |
+
|
| 110 |
+
```bash
|
| 111 |
+
# Start the proxy
|
| 112 |
+
python -m headroom.proxy.server --port 8787
|
| 113 |
+
|
| 114 |
+
# Use with Claude Code
|
| 115 |
+
ANTHROPIC_BASE_URL=http://localhost:8787 claude
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
[Unreleased]: https://github.com/headroom-sdk/headroom/compare/v0.2.0...HEAD
|
| 119 |
+
[0.2.0]: https://github.com/headroom-sdk/headroom/compare/v0.1.0...v0.2.0
|
| 120 |
+
[0.1.0]: https://github.com/headroom-sdk/headroom/releases/tag/v0.1.0
|
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Contributor Covenant Code of Conduct
|
| 2 |
+
|
| 3 |
+
## Our Pledge
|
| 4 |
+
|
| 5 |
+
We as members, contributors, and leaders pledge to make participation in our
|
| 6 |
+
community a harassment-free experience for everyone, regardless of age, body
|
| 7 |
+
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
| 8 |
+
identity and expression, level of experience, education, socio-economic status,
|
| 9 |
+
nationality, personal appearance, race, caste, color, religion, or sexual
|
| 10 |
+
identity and orientation.
|
| 11 |
+
|
| 12 |
+
We pledge to act and interact in ways that contribute to an open, welcoming,
|
| 13 |
+
diverse, inclusive, and healthy community.
|
| 14 |
+
|
| 15 |
+
## Our Standards
|
| 16 |
+
|
| 17 |
+
Examples of behavior that contributes to a positive environment for our
|
| 18 |
+
community include:
|
| 19 |
+
|
| 20 |
+
* Demonstrating empathy and kindness toward other people
|
| 21 |
+
* Being respectful of differing opinions, viewpoints, and experiences
|
| 22 |
+
* Giving and gracefully accepting constructive feedback
|
| 23 |
+
* Accepting responsibility and apologizing to those affected by our mistakes,
|
| 24 |
+
and learning from the experience
|
| 25 |
+
* Focusing on what is best not just for us as individuals, but for the overall
|
| 26 |
+
community
|
| 27 |
+
|
| 28 |
+
Examples of unacceptable behavior include:
|
| 29 |
+
|
| 30 |
+
* The use of sexualized language or imagery, and sexual attention or advances of
|
| 31 |
+
any kind
|
| 32 |
+
* Trolling, insulting or derogatory comments, and personal or political attacks
|
| 33 |
+
* Public or private harassment
|
| 34 |
+
* Publishing others' private information, such as a physical or email address,
|
| 35 |
+
without their explicit permission
|
| 36 |
+
* Other conduct which could reasonably be considered inappropriate in a
|
| 37 |
+
professional setting
|
| 38 |
+
|
| 39 |
+
## Enforcement Responsibilities
|
| 40 |
+
|
| 41 |
+
Community leaders are responsible for clarifying and enforcing our standards of
|
| 42 |
+
acceptable behavior and will take appropriate and fair corrective action in
|
| 43 |
+
response to any behavior that they deem inappropriate, threatening, offensive,
|
| 44 |
+
or harmful.
|
| 45 |
+
|
| 46 |
+
Community leaders have the right and responsibility to remove, edit, or reject
|
| 47 |
+
comments, commits, code, wiki edits, issues, and other contributions that are
|
| 48 |
+
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
| 49 |
+
decisions when appropriate.
|
| 50 |
+
|
| 51 |
+
## Scope
|
| 52 |
+
|
| 53 |
+
This Code of Conduct applies within all community spaces, and also applies when
|
| 54 |
+
an individual is officially representing the community in public spaces.
|
| 55 |
+
Examples of representing our community include using an official email address,
|
| 56 |
+
posting via an official social media account, or acting as an appointed
|
| 57 |
+
representative at an online or offline event.
|
| 58 |
+
|
| 59 |
+
## Enforcement
|
| 60 |
+
|
| 61 |
+
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
| 62 |
+
reported to the community leaders responsible for enforcement at
|
| 63 |
+
**conduct@headroom.dev**.
|
| 64 |
+
|
| 65 |
+
All complaints will be reviewed and investigated promptly and fairly.
|
| 66 |
+
|
| 67 |
+
All community leaders are obligated to respect the privacy and security of the
|
| 68 |
+
reporter of any incident.
|
| 69 |
+
|
| 70 |
+
## Enforcement Guidelines
|
| 71 |
+
|
| 72 |
+
Community leaders will follow these Community Impact Guidelines in determining
|
| 73 |
+
the consequences for any action they deem in violation of this Code of Conduct:
|
| 74 |
+
|
| 75 |
+
### 1. Correction
|
| 76 |
+
|
| 77 |
+
**Community Impact**: Use of inappropriate language or other behavior deemed
|
| 78 |
+
unprofessional or unwelcome in the community.
|
| 79 |
+
|
| 80 |
+
**Consequence**: A private, written warning from community leaders, providing
|
| 81 |
+
clarity around the nature of the violation and an explanation of why the
|
| 82 |
+
behavior was inappropriate. A public apology may be requested.
|
| 83 |
+
|
| 84 |
+
### 2. Warning
|
| 85 |
+
|
| 86 |
+
**Community Impact**: A violation through a single incident or series of
|
| 87 |
+
actions.
|
| 88 |
+
|
| 89 |
+
**Consequence**: A warning with consequences for continued behavior. No
|
| 90 |
+
interaction with the people involved, including unsolicited interaction with
|
| 91 |
+
those enforcing the Code of Conduct, for a specified period of time. This
|
| 92 |
+
includes avoiding interactions in community spaces as well as external channels
|
| 93 |
+
like social media. Violating these terms may lead to a temporary or permanent
|
| 94 |
+
ban.
|
| 95 |
+
|
| 96 |
+
### 3. Temporary Ban
|
| 97 |
+
|
| 98 |
+
**Community Impact**: A serious violation of community standards, including
|
| 99 |
+
sustained inappropriate behavior.
|
| 100 |
+
|
| 101 |
+
**Consequence**: A temporary ban from any sort of interaction or public
|
| 102 |
+
communication with the community for a specified period of time. No public or
|
| 103 |
+
private interaction with the people involved, including unsolicited interaction
|
| 104 |
+
with those enforcing the Code of Conduct, is allowed during this period.
|
| 105 |
+
Violating these terms may lead to a permanent ban.
|
| 106 |
+
|
| 107 |
+
### 4. Permanent Ban
|
| 108 |
+
|
| 109 |
+
**Community Impact**: Demonstrating a pattern of violation of community
|
| 110 |
+
standards, including sustained inappropriate behavior, harassment of an
|
| 111 |
+
individual, or aggression toward or disparagement of classes of individuals.
|
| 112 |
+
|
| 113 |
+
**Consequence**: A permanent ban from any sort of public interaction within the
|
| 114 |
+
community.
|
| 115 |
+
|
| 116 |
+
## Attribution
|
| 117 |
+
|
| 118 |
+
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
| 119 |
+
version 2.1, available at
|
| 120 |
+
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
| 121 |
+
|
| 122 |
+
Community Impact Guidelines were inspired by
|
| 123 |
+
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
| 124 |
+
|
| 125 |
+
For answers to common questions about this code of conduct, see the FAQ at
|
| 126 |
+
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
| 127 |
+
[https://www.contributor-covenant.org/translations][translations].
|
| 128 |
+
|
| 129 |
+
[homepage]: https://www.contributor-covenant.org
|
| 130 |
+
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
| 131 |
+
[Mozilla CoC]: https://github.com/mozilla/diversity
|
| 132 |
+
[FAQ]: https://www.contributor-covenant.org/faq
|
| 133 |
+
[translations]: https://www.contributor-covenant.org/translations
|
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Contributing to Headroom
|
| 2 |
+
|
| 3 |
+
Thank you for your interest in contributing to Headroom! This document provides guidelines and instructions for contributing.
|
| 4 |
+
|
| 5 |
+
## Code of Conduct
|
| 6 |
+
|
| 7 |
+
By participating in this project, you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md).
|
| 8 |
+
|
| 9 |
+
## How to Contribute
|
| 10 |
+
|
| 11 |
+
### Reporting Bugs
|
| 12 |
+
|
| 13 |
+
Before creating a bug report, please check existing issues to avoid duplicates. When creating a bug report, include:
|
| 14 |
+
|
| 15 |
+
- **Clear title** describing the issue
|
| 16 |
+
- **Steps to reproduce** the behavior
|
| 17 |
+
- **Expected behavior** vs what actually happened
|
| 18 |
+
- **Environment details** (Python version, OS, Headroom version)
|
| 19 |
+
- **Code samples** or minimal reproduction if possible
|
| 20 |
+
|
| 21 |
+
### Suggesting Features
|
| 22 |
+
|
| 23 |
+
Feature requests are welcome! Please:
|
| 24 |
+
|
| 25 |
+
- Check existing issues/discussions first
|
| 26 |
+
- Clearly describe the use case and motivation
|
| 27 |
+
- Explain how it fits with Headroom's goals (context optimization, safety, determinism)
|
| 28 |
+
|
| 29 |
+
### Pull Requests
|
| 30 |
+
|
| 31 |
+
1. **Fork the repository** and create your branch from `main`
|
| 32 |
+
2. **Install development dependencies**:
|
| 33 |
+
```bash
|
| 34 |
+
pip install -e ".[dev]"
|
| 35 |
+
```
|
| 36 |
+
3. **Make your changes** following our coding standards
|
| 37 |
+
4. **Add tests** for new functionality
|
| 38 |
+
5. **Run the test suite**:
|
| 39 |
+
```bash
|
| 40 |
+
pytest
|
| 41 |
+
```
|
| 42 |
+
6. **Run linting**:
|
| 43 |
+
```bash
|
| 44 |
+
ruff check .
|
| 45 |
+
ruff format .
|
| 46 |
+
```
|
| 47 |
+
7. **Update documentation** if needed
|
| 48 |
+
8. **Submit your PR** with a clear description
|
| 49 |
+
|
| 50 |
+
## Development Setup
|
| 51 |
+
|
| 52 |
+
```bash
|
| 53 |
+
# Clone the repository
|
| 54 |
+
git clone https://github.com/headroom-sdk/headroom.git
|
| 55 |
+
cd headroom
|
| 56 |
+
|
| 57 |
+
# Create a virtual environment
|
| 58 |
+
python -m venv .venv
|
| 59 |
+
source .venv/bin/activate # or `.venv\Scripts\activate` on Windows
|
| 60 |
+
|
| 61 |
+
# Install in development mode with all dependencies
|
| 62 |
+
pip install -e ".[dev,relevance,proxy]"
|
| 63 |
+
|
| 64 |
+
# Run tests
|
| 65 |
+
pytest
|
| 66 |
+
|
| 67 |
+
# Run tests with coverage
|
| 68 |
+
pytest --cov=headroom --cov-report=html
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
## Coding Standards
|
| 72 |
+
|
| 73 |
+
### Style
|
| 74 |
+
|
| 75 |
+
- We use [Ruff](https://github.com/astral-sh/ruff) for linting and formatting
|
| 76 |
+
- Line length: 100 characters
|
| 77 |
+
- Use type hints for all public functions
|
| 78 |
+
- Follow PEP 8 naming conventions
|
| 79 |
+
|
| 80 |
+
### Code Organization
|
| 81 |
+
|
| 82 |
+
```
|
| 83 |
+
headroom/
|
| 84 |
+
βββ __init__.py # Public API exports
|
| 85 |
+
βββ client.py # HeadroomClient wrapper
|
| 86 |
+
βββ config.py # Configuration dataclasses
|
| 87 |
+
βββ transforms/ # Context transforms
|
| 88 |
+
β βββ smart_crusher.py # Statistical compression
|
| 89 |
+
β βββ cache_aligner.py # Cache optimization
|
| 90 |
+
β βββ rolling_window.py# Context windowing
|
| 91 |
+
βββ relevance/ # Relevance scoring
|
| 92 |
+
βββ providers/ # LLM provider adapters
|
| 93 |
+
βββ proxy/ # Proxy server
|
| 94 |
+
βββ storage/ # Metrics storage
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
### Testing
|
| 98 |
+
|
| 99 |
+
- Write tests for all new functionality
|
| 100 |
+
- Use pytest fixtures for common setup
|
| 101 |
+
- Test edge cases and error conditions
|
| 102 |
+
- Aim for >80% coverage on new code
|
| 103 |
+
|
| 104 |
+
Example test structure:
|
| 105 |
+
```python
|
| 106 |
+
class TestSmartCrusher:
|
| 107 |
+
"""Tests for SmartCrusher transform."""
|
| 108 |
+
|
| 109 |
+
def test_compresses_large_arrays(self):
|
| 110 |
+
"""Should compress arrays above token threshold."""
|
| 111 |
+
...
|
| 112 |
+
|
| 113 |
+
def test_preserves_errors(self):
|
| 114 |
+
"""Should never drop items containing errors."""
|
| 115 |
+
...
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
### Documentation
|
| 119 |
+
|
| 120 |
+
- Add docstrings to all public classes and functions
|
| 121 |
+
- Use Google-style docstrings
|
| 122 |
+
- Update README.md for user-facing changes
|
| 123 |
+
- Add examples for new features
|
| 124 |
+
|
| 125 |
+
```python
|
| 126 |
+
def compress_tool_output(
|
| 127 |
+
content: str,
|
| 128 |
+
max_items: int = 50,
|
| 129 |
+
) -> str:
|
| 130 |
+
"""Compress tool output while preserving important items.
|
| 131 |
+
|
| 132 |
+
Args:
|
| 133 |
+
content: The tool output content (usually JSON).
|
| 134 |
+
max_items: Maximum items to keep in arrays.
|
| 135 |
+
|
| 136 |
+
Returns:
|
| 137 |
+
Compressed content string.
|
| 138 |
+
|
| 139 |
+
Raises:
|
| 140 |
+
ValueError: If content is not valid JSON.
|
| 141 |
+
|
| 142 |
+
Example:
|
| 143 |
+
>>> compress_tool_output('[{"id": 1}, {"id": 2}]', max_items=1)
|
| 144 |
+
'[{"id": 1}]'
|
| 145 |
+
"""
|
| 146 |
+
```
|
| 147 |
+
|
| 148 |
+
## Pull Request Guidelines
|
| 149 |
+
|
| 150 |
+
### PR Title Format
|
| 151 |
+
|
| 152 |
+
Use conventional commit style:
|
| 153 |
+
- `feat: Add semantic caching to proxy`
|
| 154 |
+
- `fix: Handle empty tool outputs correctly`
|
| 155 |
+
- `docs: Update proxy documentation`
|
| 156 |
+
- `test: Add tests for CacheAligner`
|
| 157 |
+
- `refactor: Simplify rolling window logic`
|
| 158 |
+
|
| 159 |
+
### PR Description
|
| 160 |
+
|
| 161 |
+
Include:
|
| 162 |
+
- **What** changes were made
|
| 163 |
+
- **Why** the changes were needed
|
| 164 |
+
- **How** to test the changes
|
| 165 |
+
- **Breaking changes** if any
|
| 166 |
+
|
| 167 |
+
### Review Process
|
| 168 |
+
|
| 169 |
+
1. All PRs require at least one review
|
| 170 |
+
2. CI must pass (tests, linting, type checking)
|
| 171 |
+
3. Maintain or improve test coverage
|
| 172 |
+
4. Update CHANGELOG.md for notable changes
|
| 173 |
+
|
| 174 |
+
## Architecture Decisions
|
| 175 |
+
|
| 176 |
+
### Safety First
|
| 177 |
+
|
| 178 |
+
Headroom's core principle is **safety**. When in doubt:
|
| 179 |
+
- Never drop user/assistant content
|
| 180 |
+
- Never break tool call/response pairing
|
| 181 |
+
- Malformed content passes through unchanged
|
| 182 |
+
- Prefer false negatives over false positives
|
| 183 |
+
|
| 184 |
+
### Performance
|
| 185 |
+
|
| 186 |
+
- Transforms should add <50ms latency at P99
|
| 187 |
+
- Use lazy loading for optional dependencies
|
| 188 |
+
- Profile before optimizing
|
| 189 |
+
|
| 190 |
+
### Compatibility
|
| 191 |
+
|
| 192 |
+
- Support Python 3.10+
|
| 193 |
+
- Core functionality has minimal dependencies
|
| 194 |
+
- Optional features use extras (e.g., `pip install headroom[relevance]`)
|
| 195 |
+
|
| 196 |
+
## Getting Help
|
| 197 |
+
|
| 198 |
+
- **Questions**: Open a [Discussion](https://github.com/headroom-sdk/headroom/discussions)
|
| 199 |
+
- **Bugs**: Open an [Issue](https://github.com/headroom-sdk/headroom/issues)
|
| 200 |
+
- **Security**: Email security@headroom.dev (do not open public issues)
|
| 201 |
+
|
| 202 |
+
## Recognition
|
| 203 |
+
|
| 204 |
+
Contributors are recognized in:
|
| 205 |
+
- The CHANGELOG for their contributions
|
| 206 |
+
- The GitHub contributors page
|
| 207 |
+
- Release notes for significant features
|
| 208 |
+
|
| 209 |
+
Thank you for contributing to Headroom!
|
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Apache License
|
| 2 |
+
Version 2.0, January 2004
|
| 3 |
+
http://www.apache.org/licenses/
|
| 4 |
+
|
| 5 |
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
| 6 |
+
|
| 7 |
+
1. Definitions.
|
| 8 |
+
|
| 9 |
+
"License" shall mean the terms and conditions for use, reproduction,
|
| 10 |
+
and distribution as defined by Sections 1 through 9 of this document.
|
| 11 |
+
|
| 12 |
+
"Licensor" shall mean the copyright owner or entity authorized by
|
| 13 |
+
the copyright owner that is granting the License.
|
| 14 |
+
|
| 15 |
+
"Legal Entity" shall mean the union of the acting entity and all
|
| 16 |
+
other entities that control, are controlled by, or are under common
|
| 17 |
+
control with that entity. For the purposes of this definition,
|
| 18 |
+
"control" means (i) the power, direct or indirect, to cause the
|
| 19 |
+
direction or management of such entity, whether by contract or
|
| 20 |
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
| 21 |
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
| 22 |
+
|
| 23 |
+
"You" (or "Your") shall mean an individual or Legal Entity
|
| 24 |
+
exercising permissions granted by this License.
|
| 25 |
+
|
| 26 |
+
"Source" form shall mean the preferred form for making modifications,
|
| 27 |
+
including but not limited to software source code, documentation
|
| 28 |
+
source, and configuration files.
|
| 29 |
+
|
| 30 |
+
"Object" form shall mean any form resulting from mechanical
|
| 31 |
+
transformation or translation of a Source form, including but
|
| 32 |
+
not limited to compiled object code, generated documentation,
|
| 33 |
+
and conversions to other media types.
|
| 34 |
+
|
| 35 |
+
"Work" shall mean the work of authorship, whether in Source or
|
| 36 |
+
Object form, made available under the License, as indicated by a
|
| 37 |
+
copyright notice that is included in or attached to the work
|
| 38 |
+
(an example is provided in the Appendix below).
|
| 39 |
+
|
| 40 |
+
"Derivative Works" shall mean any work, whether in Source or Object
|
| 41 |
+
form, that is based on (or derived from) the Work and for which the
|
| 42 |
+
editorial revisions, annotations, elaborations, or other modifications
|
| 43 |
+
represent, as a whole, an original work of authorship. For the purposes
|
| 44 |
+
of this License, Derivative Works shall not include works that remain
|
| 45 |
+
separable from, or merely link (or bind by name) to the interfaces of,
|
| 46 |
+
the Work and Derivative Works thereof.
|
| 47 |
+
|
| 48 |
+
"Contribution" shall mean any work of authorship, including
|
| 49 |
+
the original version of the Work and any modifications or additions
|
| 50 |
+
to that Work or Derivative Works thereof, that is intentionally
|
| 51 |
+
submitted to the Licensor for inclusion in the Work by the copyright owner
|
| 52 |
+
or by an individual or Legal Entity authorized to submit on behalf of
|
| 53 |
+
the copyright owner. For the purposes of this definition, "submitted"
|
| 54 |
+
means any form of electronic, verbal, or written communication sent
|
| 55 |
+
to the Licensor or its representatives, including but not limited to
|
| 56 |
+
communication on electronic mailing lists, source code control systems,
|
| 57 |
+
and issue tracking systems that are managed by, or on behalf of, the
|
| 58 |
+
Licensor for the purpose of discussing and improving the Work, but
|
| 59 |
+
excluding communication that is conspicuously marked or otherwise
|
| 60 |
+
designated in writing by the copyright owner as "Not a Contribution."
|
| 61 |
+
|
| 62 |
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
| 63 |
+
on behalf of whom a Contribution has been received by Licensor and
|
| 64 |
+
subsequently incorporated within the Work.
|
| 65 |
+
|
| 66 |
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
| 67 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 68 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 69 |
+
copyright license to reproduce, prepare Derivative Works of,
|
| 70 |
+
publicly display, publicly perform, sublicense, and distribute the
|
| 71 |
+
Work and such Derivative Works in Source or Object form.
|
| 72 |
+
|
| 73 |
+
3. Grant of Patent License. Subject to the terms and conditions of
|
| 74 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 75 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 76 |
+
(except as stated in this section) patent license to make, have made,
|
| 77 |
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
| 78 |
+
where such license applies only to those patent claims licensable
|
| 79 |
+
by such Contributor that are necessarily infringed by their
|
| 80 |
+
Contribution(s) alone or by combination of their Contribution(s)
|
| 81 |
+
with the Work to which such Contribution(s) was submitted. If You
|
| 82 |
+
institute patent litigation against any entity (including a
|
| 83 |
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
| 84 |
+
or a Contribution incorporated within the Work constitutes direct
|
| 85 |
+
or contributory patent infringement, then any patent licenses
|
| 86 |
+
granted to You under this License for that Work shall terminate
|
| 87 |
+
as of the date such litigation is filed.
|
| 88 |
+
|
| 89 |
+
4. Redistribution. You may reproduce and distribute copies of the
|
| 90 |
+
Work or Derivative Works thereof in any medium, with or without
|
| 91 |
+
modifications, and in Source or Object form, provided that You
|
| 92 |
+
meet the following conditions:
|
| 93 |
+
|
| 94 |
+
(a) You must give any other recipients of the Work or
|
| 95 |
+
Derivative Works a copy of this License; and
|
| 96 |
+
|
| 97 |
+
(b) You must cause any modified files to carry prominent notices
|
| 98 |
+
stating that You changed the files; and
|
| 99 |
+
|
| 100 |
+
(c) You must retain, in the Source form of any Derivative Works
|
| 101 |
+
that You distribute, all copyright, patent, trademark, and
|
| 102 |
+
attribution notices from the Source form of the Work,
|
| 103 |
+
excluding those notices that do not pertain to any part of
|
| 104 |
+
the Derivative Works; and
|
| 105 |
+
|
| 106 |
+
(d) If the Work includes a "NOTICE" text file as part of its
|
| 107 |
+
distribution, then any Derivative Works that You distribute must
|
| 108 |
+
include a readable copy of the attribution notices contained
|
| 109 |
+
within such NOTICE file, excluding those notices that do not
|
| 110 |
+
pertain to any part of the Derivative Works, in at least one
|
| 111 |
+
of the following places: within a NOTICE text file distributed
|
| 112 |
+
as part of the Derivative Works; within the Source form or
|
| 113 |
+
documentation, if provided along with the Derivative Works; or,
|
| 114 |
+
within a display generated by the Derivative Works, if and
|
| 115 |
+
wherever such third-party notices normally appear. The contents
|
| 116 |
+
of the NOTICE file are for informational purposes only and
|
| 117 |
+
do not modify the License. You may add Your own attribution
|
| 118 |
+
notices within Derivative Works that You distribute, alongside
|
| 119 |
+
or as an addendum to the NOTICE text from the Work, provided
|
| 120 |
+
that such additional attribution notices cannot be construed
|
| 121 |
+
as modifying the License.
|
| 122 |
+
|
| 123 |
+
You may add Your own copyright statement to Your modifications and
|
| 124 |
+
may provide additional or different license terms and conditions
|
| 125 |
+
for use, reproduction, or distribution of Your modifications, or
|
| 126 |
+
for any such Derivative Works as a whole, provided Your use,
|
| 127 |
+
reproduction, and distribution of the Work otherwise complies with
|
| 128 |
+
the conditions stated in this License.
|
| 129 |
+
|
| 130 |
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
| 131 |
+
any Contribution intentionally submitted for inclusion in the Work
|
| 132 |
+
by You to the Licensor shall be under the terms and conditions of
|
| 133 |
+
this License, without any additional terms or conditions.
|
| 134 |
+
Notwithstanding the above, nothing herein shall supersede or modify
|
| 135 |
+
the terms of any separate license agreement you may have executed
|
| 136 |
+
with Licensor regarding such Contributions.
|
| 137 |
+
|
| 138 |
+
6. Trademarks. This License does not grant permission to use the trade
|
| 139 |
+
names, trademarks, service marks, or product names of the Licensor,
|
| 140 |
+
except as required for reasonable and customary use in describing the
|
| 141 |
+
origin of the Work and reproducing the content of the NOTICE file.
|
| 142 |
+
|
| 143 |
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
| 144 |
+
agreed to in writing, Licensor provides the Work (and each
|
| 145 |
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
| 146 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
| 147 |
+
implied, including, without limitation, any warranties or conditions
|
| 148 |
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
| 149 |
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
| 150 |
+
appropriateness of using or redistributing the Work and assume any
|
| 151 |
+
risks associated with Your exercise of permissions under this License.
|
| 152 |
+
|
| 153 |
+
8. Limitation of Liability. In no event and under no legal theory,
|
| 154 |
+
whether in tort (including negligence), contract, or otherwise,
|
| 155 |
+
unless required by applicable law (such as deliberate and grossly
|
| 156 |
+
negligent acts) or agreed to in writing, shall any Contributor be
|
| 157 |
+
liable to You for damages, including any direct, indirect, special,
|
| 158 |
+
incidental, or consequential damages of any character arising as a
|
| 159 |
+
result of this License or out of the use or inability to use the
|
| 160 |
+
Work (including but not limited to damages for loss of goodwill,
|
| 161 |
+
work stoppage, computer failure or malfunction, or any and all
|
| 162 |
+
other commercial damages or losses), even if such Contributor
|
| 163 |
+
has been advised of the possibility of such damages.
|
| 164 |
+
|
| 165 |
+
9. Accepting Warranty or Additional Liability. While redistributing
|
| 166 |
+
the Work or Derivative Works thereof, You may choose to offer,
|
| 167 |
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
| 168 |
+
or other liability obligations and/or rights consistent with this
|
| 169 |
+
License. However, in accepting such obligations, You may act only
|
| 170 |
+
on Your own behalf and on Your sole responsibility, not on behalf
|
| 171 |
+
of any other Contributor, and only if You agree to indemnify,
|
| 172 |
+
defend, and hold each Contributor harmless for any liability
|
| 173 |
+
incurred by, or claims asserted against, such Contributor by reason
|
| 174 |
+
of your accepting any such warranty or additional liability.
|
| 175 |
+
|
| 176 |
+
END OF TERMS AND CONDITIONS
|
| 177 |
+
|
| 178 |
+
Copyright 2025 Headroom Contributors
|
| 179 |
+
|
| 180 |
+
Licensed under the Apache License, Version 2.0 (the "License");
|
| 181 |
+
you may not use this file except in compliance with the License.
|
| 182 |
+
You may obtain a copy of the License at
|
| 183 |
+
|
| 184 |
+
http://www.apache.org/licenses/LICENSE-2.0
|
| 185 |
+
|
| 186 |
+
Unless required by applicable law or agreed to in writing, software
|
| 187 |
+
distributed under the License is distributed on an "AS IS" BASIS,
|
| 188 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 189 |
+
See the License for the specific language governing permissions and
|
| 190 |
+
limitations under the License.
|
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Headroom
|
| 2 |
+
Copyright 2025 Headroom Contributors
|
| 3 |
+
|
| 4 |
+
This product includes software developed by the Headroom Contributors.
|
| 5 |
+
|
| 6 |
+
Third-Party Licenses
|
| 7 |
+
====================
|
| 8 |
+
|
| 9 |
+
This software uses the following third-party libraries:
|
| 10 |
+
|
| 11 |
+
tiktoken
|
| 12 |
+
--------
|
| 13 |
+
Copyright (c) 2022 OpenAI, Shantanu Jain
|
| 14 |
+
Licensed under the MIT License
|
| 15 |
+
https://github.com/openai/tiktoken
|
| 16 |
+
|
| 17 |
+
Pydantic
|
| 18 |
+
--------
|
| 19 |
+
Copyright (c) 2017 to present Pydantic Services Inc. and individual contributors
|
| 20 |
+
Licensed under the MIT License
|
| 21 |
+
https://github.com/pydantic/pydantic
|
| 22 |
+
|
| 23 |
+
sentence-transformers (optional dependency)
|
| 24 |
+
-------------------------------------------
|
| 25 |
+
Copyright 2019 Nils Reimers
|
| 26 |
+
Licensed under the Apache License 2.0
|
| 27 |
+
https://github.com/UKPLab/sentence-transformers
|
| 28 |
+
|
| 29 |
+
Note: Some pretrained sentence-transformer models may have additional licensing
|
| 30 |
+
restrictions based on their training data. Please verify model-specific licenses
|
| 31 |
+
before commercial use.
|
| 32 |
+
|
| 33 |
+
FastAPI (optional dependency)
|
| 34 |
+
-----------------------------
|
| 35 |
+
Copyright (c) 2018 SebastiΓ‘n RamΓrez
|
| 36 |
+
Licensed under the MIT License
|
| 37 |
+
https://github.com/tiangolo/fastapi
|
| 38 |
+
|
| 39 |
+
NumPy (optional dependency)
|
| 40 |
+
---------------------------
|
| 41 |
+
Copyright (c) 2005-2024, NumPy Developers
|
| 42 |
+
Licensed under the BSD 3-Clause License
|
| 43 |
+
https://github.com/numpy/numpy
|
|
@@ -1,285 +1,263 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
**Increase effective TPM headroom. Reduce latency. Never break correctness.**
|
| 6 |
-
|
| 7 |
-
## Features
|
| 8 |
|
| 9 |
-
|
| 10 |
-
- **Tool Output Compression**: Safely compress large tool outputs
|
| 11 |
-
- **Cache-Aligned Prefixes**: Optimize for provider caching (OpenAI, etc.)
|
| 12 |
-
- **Rolling Window Management**: Keep context within token limits
|
| 13 |
-
- **Streaming Support**: Full pass-through streaming with metrics
|
| 14 |
-
- **Simulate Mode**: Preview optimizations before applying
|
| 15 |
|
| 16 |
-
|
| 17 |
|
| 18 |
```bash
|
| 19 |
pip install headroom
|
| 20 |
-
```
|
| 21 |
|
| 22 |
-
|
|
|
|
| 23 |
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
| 28 |
```
|
| 29 |
|
| 30 |
-
##
|
|
|
|
|
|
|
| 31 |
|
| 32 |
```python
|
| 33 |
from headroom import HeadroomClient
|
| 34 |
from openai import OpenAI
|
| 35 |
|
| 36 |
-
# Wrap any OpenAI-compatible client
|
| 37 |
-
base = OpenAI(api_key="...")
|
| 38 |
client = HeadroomClient(
|
| 39 |
-
original_client=
|
| 40 |
-
|
| 41 |
-
default_mode="audit", # Start in observation mode
|
| 42 |
)
|
| 43 |
|
| 44 |
# Use exactly like the original client
|
| 45 |
response = client.chat.completions.create(
|
| 46 |
model="gpt-4o",
|
| 47 |
-
messages=[
|
| 48 |
-
{"role": "system", "content": "You are a helpful assistant."},
|
| 49 |
-
{"role": "user", "content": "Hello!"},
|
| 50 |
-
],
|
| 51 |
)
|
| 52 |
-
print(response.choices[0].message.content)
|
| 53 |
```
|
| 54 |
|
| 55 |
-
##
|
| 56 |
-
|
| 57 |
-
### Audit Mode (Default)
|
| 58 |
-
|
| 59 |
-
Observe and log without making changes:
|
| 60 |
|
| 61 |
```python
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
default_mode="audit",
|
| 65 |
-
)
|
| 66 |
-
|
| 67 |
-
# Logs metrics to SQLite but doesn't modify requests
|
| 68 |
-
response = client.chat.completions.create(...)
|
| 69 |
-
```
|
| 70 |
-
|
| 71 |
-
### Optimize Mode
|
| 72 |
-
|
| 73 |
-
Apply safe, deterministic transforms:
|
| 74 |
|
| 75 |
-
|
| 76 |
-
response = client.chat.completions.create(
|
| 77 |
-
model="gpt-4o",
|
| 78 |
-
messages=[...],
|
| 79 |
-
headroom_mode="optimize", # Enable optimization
|
| 80 |
-
)
|
| 81 |
```
|
| 82 |
|
| 83 |
-
##
|
| 84 |
|
| 85 |
-
|
| 86 |
|
| 87 |
```python
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
messages=[...],
|
| 91 |
-
)
|
| 92 |
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
|
|
|
| 98 |
```
|
| 99 |
|
| 100 |
-
##
|
| 101 |
-
|
| 102 |
-
### Headroom Parameters
|
| 103 |
-
|
| 104 |
-
All headroom parameters are optional:
|
| 105 |
|
| 106 |
```python
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
messages=[...],
|
| 110 |
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
headroom_keep_turns=2, # Never drop last N turns
|
| 115 |
-
headroom_tool_profiles={ # Per-tool compression
|
| 116 |
-
"search": {"max_array_items": 5},
|
| 117 |
-
},
|
| 118 |
-
|
| 119 |
-
# All other OpenAI parameters work normally
|
| 120 |
-
temperature=0.7,
|
| 121 |
-
max_tokens=1000,
|
| 122 |
-
)
|
| 123 |
```
|
| 124 |
|
| 125 |
-
###
|
| 126 |
-
|
| 127 |
-
Override default context limits:
|
| 128 |
|
| 129 |
```python
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
"my-custom-model": 32000,
|
| 135 |
-
},
|
| 136 |
-
)
|
| 137 |
```
|
| 138 |
|
| 139 |
-
##
|
| 140 |
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
- Limits nesting depth
|
| 148 |
-
- **Safe**: Malformed JSON is never modified
|
| 149 |
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
{"results": [{"id": 1, ...}, {"id": 2, ...}, ... 1000 items ...]}
|
| 153 |
|
| 154 |
-
#
|
| 155 |
-
|
| 156 |
-
<headroom:tool_digest sha256="abc123">
|
| 157 |
```
|
| 158 |
|
| 159 |
-
##
|
| 160 |
-
|
| 161 |
-
Stabilizes prefixes for better cache hit rates:
|
| 162 |
|
| 163 |
-
|
| 164 |
-
- Normalizes whitespace
|
| 165 |
-
- Computes stable prefix hash
|
| 166 |
|
| 167 |
```python
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
# After: Stable prefix, date moved to context
|
| 172 |
-
"You are helpful.
|
| 173 |
-
|
| 174 |
-
[Context: Current Date: 2024-01-15]"
|
| 175 |
```
|
| 176 |
|
| 177 |
-
###
|
| 178 |
-
|
| 179 |
-
Keeps context within token limits:
|
| 180 |
-
|
| 181 |
-
- Drops oldest tool call units first
|
| 182 |
-
- Never orphans tool responses
|
| 183 |
-
- Preserves system prompt and recent turns
|
| 184 |
-
- Inserts dropped context markers
|
| 185 |
-
|
| 186 |
-
## Reporting
|
| 187 |
-
|
| 188 |
-
Generate HTML reports of context waste:
|
| 189 |
|
| 190 |
```python
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
generate_report(
|
| 194 |
-
store_url="sqlite:///headroom.db",
|
| 195 |
-
output_path="report.html",
|
| 196 |
-
)
|
| 197 |
```
|
| 198 |
|
| 199 |
-
|
| 200 |
-
- Waste histogram by category
|
| 201 |
-
- Top high-waste requests
|
| 202 |
-
- Cache alignment analysis
|
| 203 |
-
- Actionable recommendations
|
| 204 |
-
|
| 205 |
-
## Safety Guarantees
|
| 206 |
-
|
| 207 |
-
Headroom follows strict safety rules:
|
| 208 |
-
|
| 209 |
-
1. **Never removes human content**: User/assistant text is sacred
|
| 210 |
-
2. **Never breaks tool ordering**: Tool calls and responses stay paired
|
| 211 |
-
3. **Parse failures are no-ops**: Malformed content passes through unchanged
|
| 212 |
-
4. **Preserves recency**: Last N turns are always kept
|
| 213 |
-
|
| 214 |
-
## Streaming
|
| 215 |
-
|
| 216 |
-
Full streaming support:
|
| 217 |
|
| 218 |
```python
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
messages=[...],
|
| 222 |
-
stream=True,
|
| 223 |
-
headroom_mode="optimize",
|
| 224 |
-
)
|
| 225 |
-
|
| 226 |
-
for chunk in stream:
|
| 227 |
-
print(chunk.choices[0].delta.content, end="")
|
| 228 |
```
|
| 229 |
|
| 230 |
-
##
|
| 231 |
-
|
| 232 |
-
### SQLite (Default)
|
| 233 |
|
| 234 |
```python
|
|
|
|
|
|
|
| 235 |
client = HeadroomClient(
|
| 236 |
original_client=base,
|
| 237 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
)
|
| 239 |
```
|
| 240 |
|
| 241 |
-
##
|
| 242 |
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
|
|
|
|
|
|
| 249 |
|
| 250 |
-
##
|
| 251 |
|
| 252 |
-
|
| 253 |
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
|
|
|
| 257 |
|
| 258 |
-
#
|
| 259 |
-
summary = client.get_summary()
|
| 260 |
-
print(f"Total tokens saved: {summary['total_tokens_saved']}")
|
| 261 |
-
```
|
| 262 |
|
| 263 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
|
| 265 |
-
|
| 266 |
-
# Install dev dependencies
|
| 267 |
-
pip install -e ".[dev]"
|
| 268 |
|
| 269 |
-
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
| 271 |
|
| 272 |
-
#
|
| 273 |
-
|
|
|
|
| 274 |
|
| 275 |
-
|
| 276 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
```
|
| 278 |
|
| 279 |
## License
|
| 280 |
|
| 281 |
-
|
| 282 |
|
| 283 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
|
| 285 |
-
|
|
|
|
|
|
|
|
|
| 1 |
+
<p align="center">
|
| 2 |
+
<h1 align="center">Headroom</h1>
|
| 3 |
+
<p align="center">
|
| 4 |
+
<strong>The Context Optimization Layer for LLM Applications</strong>
|
| 5 |
+
</p>
|
| 6 |
+
<p align="center">
|
| 7 |
+
Cut your LLM costs by 50-90% without losing accuracy
|
| 8 |
+
</p>
|
| 9 |
+
</p>
|
| 10 |
+
|
| 11 |
+
<p align="center">
|
| 12 |
+
<a href="https://github.com/headroom-sdk/headroom/actions/workflows/ci.yml">
|
| 13 |
+
<img src="https://github.com/headroom-sdk/headroom/actions/workflows/ci.yml/badge.svg" alt="CI">
|
| 14 |
+
</a>
|
| 15 |
+
<a href="https://pypi.org/project/headroom/">
|
| 16 |
+
<img src="https://img.shields.io/pypi/v/headroom.svg" alt="PyPI">
|
| 17 |
+
</a>
|
| 18 |
+
<a href="https://pypi.org/project/headroom/">
|
| 19 |
+
<img src="https://img.shields.io/pypi/pyversions/headroom.svg" alt="Python">
|
| 20 |
+
</a>
|
| 21 |
+
<a href="https://github.com/headroom-sdk/headroom/blob/main/LICENSE">
|
| 22 |
+
<img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License">
|
| 23 |
+
</a>
|
| 24 |
+
</p>
|
| 25 |
+
|
| 26 |
+
---
|
| 27 |
+
|
| 28 |
+
## The Problem
|
| 29 |
+
|
| 30 |
+
AI coding agents and tool-using applications generate **massive contexts**:
|
| 31 |
+
|
| 32 |
+
- Tool outputs with 1000s of search results, log entries, API responses
|
| 33 |
+
- Long conversation histories that hit token limits
|
| 34 |
+
- System prompts with dynamic dates that break provider caching
|
| 35 |
+
|
| 36 |
+
**Result**: You pay for tokens you don't need, and cache hits are rare.
|
| 37 |
+
|
| 38 |
+
## The Solution
|
| 39 |
+
|
| 40 |
+
Headroom is a **smart compression layer** that sits between your app and LLM providers. It applies three transforms:
|
| 41 |
+
|
| 42 |
+
| Transform | What It Does | Savings |
|
| 43 |
+
|-----------|--------------|---------|
|
| 44 |
+
| **SmartCrusher** | Compresses tool outputs statistically (keeps errors, anomalies, relevant items) | 70-90% |
|
| 45 |
+
| **CacheAligner** | Stabilizes prefixes so provider caching works | Up to 10x |
|
| 46 |
+
| **RollingWindow** | Manages context within limits without breaking tool calls | Prevents failures |
|
| 47 |
+
|
| 48 |
+
**Zero accuracy loss** - we keep what matters: errors, anomalies, relevant items.
|
| 49 |
|
| 50 |
+
## Quick Start
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
+
### Option 1: Proxy (Recommended)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
+
Run Headroom as a proxy server - works with any client:
|
| 55 |
|
| 56 |
```bash
|
| 57 |
pip install headroom
|
|
|
|
| 58 |
|
| 59 |
+
# Start the proxy
|
| 60 |
+
headroom proxy --port 8787
|
| 61 |
|
| 62 |
+
# Use with Claude Code
|
| 63 |
+
ANTHROPIC_BASE_URL=http://localhost:8787 claude
|
| 64 |
+
|
| 65 |
+
# Use with any OpenAI-compatible client
|
| 66 |
+
OPENAI_BASE_URL=http://localhost:8787/v1 your-app
|
| 67 |
```
|
| 68 |
|
| 69 |
+
### Option 2: Python SDK
|
| 70 |
+
|
| 71 |
+
Wrap your existing client:
|
| 72 |
|
| 73 |
```python
|
| 74 |
from headroom import HeadroomClient
|
| 75 |
from openai import OpenAI
|
| 76 |
|
|
|
|
|
|
|
| 77 |
client = HeadroomClient(
|
| 78 |
+
original_client=OpenAI(),
|
| 79 |
+
default_mode="optimize",
|
|
|
|
| 80 |
)
|
| 81 |
|
| 82 |
# Use exactly like the original client
|
| 83 |
response = client.chat.completions.create(
|
| 84 |
model="gpt-4o",
|
| 85 |
+
messages=[...],
|
|
|
|
|
|
|
|
|
|
| 86 |
)
|
|
|
|
| 87 |
```
|
| 88 |
|
| 89 |
+
### Option 3: LangChain Integration
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
```python
|
| 92 |
+
from langchain_openai import ChatOpenAI
|
| 93 |
+
from headroom.integrations import HeadroomOptimizer
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
+
llm = ChatOpenAI(model="gpt-4o", callbacks=[HeadroomOptimizer()])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
```
|
| 97 |
|
| 98 |
+
## Features
|
| 99 |
|
| 100 |
+
### Smart Tool Output Compression
|
| 101 |
|
| 102 |
```python
|
| 103 |
+
# Before: 50KB tool response with 1000 items
|
| 104 |
+
{"results": [{"id": 1, ...}, {"id": 2, ...}, ... 1000 items ...]}
|
|
|
|
|
|
|
| 105 |
|
| 106 |
+
# After: ~2KB with important items preserved
|
| 107 |
+
# - First 3 items (context)
|
| 108 |
+
# - Last 2 items (recency)
|
| 109 |
+
# - All error items
|
| 110 |
+
# - Anomalous values (> 2 std dev)
|
| 111 |
+
# - Items matching user's query
|
| 112 |
```
|
| 113 |
|
| 114 |
+
### Cache-Aligned Prefixes
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
```python
|
| 117 |
+
# Before: Cache miss every day due to changing date
|
| 118 |
+
"You are helpful. Today is January 7, 2025."
|
|
|
|
| 119 |
|
| 120 |
+
# After: Stable prefix (cache hit!) + dynamic context
|
| 121 |
+
"You are helpful."
|
| 122 |
+
# [Dynamic context moved to end]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
```
|
| 124 |
|
| 125 |
+
### Rolling Window
|
|
|
|
|
|
|
| 126 |
|
| 127 |
```python
|
| 128 |
+
# Automatically manages context within token limits
|
| 129 |
+
# - Drops oldest tool outputs first
|
| 130 |
+
# - Never orphans tool call/response pairs
|
| 131 |
+
# - Always preserves system prompt and recent turns
|
|
|
|
|
|
|
|
|
|
| 132 |
```
|
| 133 |
|
| 134 |
+
### Production Proxy Features
|
| 135 |
|
| 136 |
+
- **Semantic Caching**: LRU cache with TTL for repeated queries
|
| 137 |
+
- **Rate Limiting**: Token bucket (requests + tokens per minute)
|
| 138 |
+
- **Cost Tracking**: Budget enforcement (hourly/daily/monthly)
|
| 139 |
+
- **Prometheus Metrics**: `/metrics` endpoint for monitoring
|
| 140 |
+
- **Request Logging**: JSONL logs for debugging
|
| 141 |
|
| 142 |
+
## Installation
|
| 143 |
+
|
| 144 |
+
```bash
|
| 145 |
+
# Core (minimal dependencies)
|
| 146 |
+
pip install headroom
|
| 147 |
|
| 148 |
+
# With semantic relevance scoring
|
| 149 |
+
pip install headroom[relevance]
|
|
|
|
|
|
|
| 150 |
|
| 151 |
+
# With proxy server
|
| 152 |
+
pip install headroom[proxy]
|
|
|
|
| 153 |
|
| 154 |
+
# Everything
|
| 155 |
+
pip install headroom[all]
|
|
|
|
| 156 |
```
|
| 157 |
|
| 158 |
+
## Modes
|
|
|
|
|
|
|
| 159 |
|
| 160 |
+
### Audit Mode (Observe Only)
|
|
|
|
|
|
|
| 161 |
|
| 162 |
```python
|
| 163 |
+
client = HeadroomClient(original_client=base, default_mode="audit")
|
| 164 |
+
# Logs metrics but doesn't modify requests
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
```
|
| 166 |
|
| 167 |
+
### Optimize Mode (Apply Transforms)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
|
| 169 |
```python
|
| 170 |
+
client = HeadroomClient(original_client=base, default_mode="optimize")
|
| 171 |
+
# Applies safe, deterministic transforms
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
```
|
| 173 |
|
| 174 |
+
### Simulate Mode (Preview)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
|
| 176 |
```python
|
| 177 |
+
plan = client.chat.completions.simulate(model="gpt-4o", messages=[...])
|
| 178 |
+
print(f"Would save {plan.tokens_saved} tokens ({plan.savings_percent:.1f}%)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
```
|
| 180 |
|
| 181 |
+
## Configuration
|
|
|
|
|
|
|
| 182 |
|
| 183 |
```python
|
| 184 |
+
from headroom import HeadroomClient, SmartCrusherConfig
|
| 185 |
+
|
| 186 |
client = HeadroomClient(
|
| 187 |
original_client=base,
|
| 188 |
+
default_mode="optimize",
|
| 189 |
+
smart_crusher_config=SmartCrusherConfig(
|
| 190 |
+
min_tokens_to_crush=200, # Only compress if > 200 tokens
|
| 191 |
+
max_items_after_crush=50, # Keep at most 50 items
|
| 192 |
+
keep_first=3, # Always keep first 3
|
| 193 |
+
keep_last=2, # Always keep last 2
|
| 194 |
+
relevance_threshold=0.3, # Keep items with relevance > 0.3
|
| 195 |
+
),
|
| 196 |
)
|
| 197 |
```
|
| 198 |
|
| 199 |
+
## Supported Providers
|
| 200 |
|
| 201 |
+
| Provider | Token Counting | Status |
|
| 202 |
+
|----------|----------------|--------|
|
| 203 |
+
| OpenAI | tiktoken | Full support |
|
| 204 |
+
| Anthropic | Official API | Full support |
|
| 205 |
+
| Google | Official API | Full support |
|
| 206 |
+
| Cohere | Official API | Full support |
|
| 207 |
+
| Mistral | Official tokenizer | Full support |
|
| 208 |
+
| LiteLLM | Via provider | Full support |
|
| 209 |
|
| 210 |
+
## Safety Guarantees
|
| 211 |
|
| 212 |
+
Headroom follows strict safety rules:
|
| 213 |
|
| 214 |
+
1. **Never removes human content** - User/assistant text is sacred
|
| 215 |
+
2. **Never breaks tool ordering** - Tool calls and responses stay paired
|
| 216 |
+
3. **Parse failures are no-ops** - Malformed content passes through unchanged
|
| 217 |
+
4. **Preserves recency** - Last N turns are always kept
|
| 218 |
|
| 219 |
+
## Benchmarks
|
|
|
|
|
|
|
|
|
|
| 220 |
|
| 221 |
+
| Scenario | Before | After | Savings |
|
| 222 |
+
|----------|--------|-------|---------|
|
| 223 |
+
| Search results (1000 items) | 45,000 tokens | 4,500 tokens | 90% |
|
| 224 |
+
| Log analysis (500 entries) | 22,000 tokens | 3,300 tokens | 85% |
|
| 225 |
+
| API response (nested JSON) | 15,000 tokens | 2,250 tokens | 85% |
|
| 226 |
+
| Long conversation (50 turns) | 80,000 tokens | 32,000 tokens | 60% |
|
| 227 |
|
| 228 |
+
## Documentation
|
|
|
|
|
|
|
| 229 |
|
| 230 |
+
- [Getting Started Guide](docs/getting-started.md)
|
| 231 |
+
- [Proxy Server Documentation](docs/proxy.md)
|
| 232 |
+
- [Transform Reference](docs/transforms.md)
|
| 233 |
+
- [API Reference](docs/api.md)
|
| 234 |
+
- [Examples](examples/)
|
| 235 |
|
| 236 |
+
## Contributing
|
| 237 |
+
|
| 238 |
+
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details.
|
| 239 |
|
| 240 |
+
```bash
|
| 241 |
+
# Development setup
|
| 242 |
+
git clone https://github.com/headroom-sdk/headroom.git
|
| 243 |
+
cd headroom
|
| 244 |
+
pip install -e ".[dev]"
|
| 245 |
+
pytest
|
| 246 |
```
|
| 247 |
|
| 248 |
## License
|
| 249 |
|
| 250 |
+
Apache License 2.0 - see [LICENSE](LICENSE) for details.
|
| 251 |
|
| 252 |
+
## Links
|
| 253 |
+
|
| 254 |
+
- [GitHub](https://github.com/headroom-sdk/headroom)
|
| 255 |
+
- [PyPI](https://pypi.org/project/headroom/)
|
| 256 |
+
- [Documentation](https://headroom.dev/docs)
|
| 257 |
+
- [Discord](https://discord.gg/headroom)
|
| 258 |
+
|
| 259 |
+
---
|
| 260 |
|
| 261 |
+
<p align="center">
|
| 262 |
+
<sub>Built with care for the AI developer community</sub>
|
| 263 |
+
</p>
|
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Security Policy
|
| 2 |
+
|
| 3 |
+
## Supported Versions
|
| 4 |
+
|
| 5 |
+
| Version | Supported |
|
| 6 |
+
| ------- | ------------------ |
|
| 7 |
+
| 0.2.x | :white_check_mark: |
|
| 8 |
+
| 0.1.x | :x: |
|
| 9 |
+
|
| 10 |
+
## Reporting a Vulnerability
|
| 11 |
+
|
| 12 |
+
We take security vulnerabilities seriously. If you discover a security issue, please report it responsibly.
|
| 13 |
+
|
| 14 |
+
### How to Report
|
| 15 |
+
|
| 16 |
+
**Please DO NOT open a public GitHub issue for security vulnerabilities.**
|
| 17 |
+
|
| 18 |
+
Instead, please email us at: **security@headroom.dev**
|
| 19 |
+
|
| 20 |
+
Include the following information:
|
| 21 |
+
- Type of vulnerability (e.g., injection, data exposure, authentication bypass)
|
| 22 |
+
- Full path of the affected source file(s)
|
| 23 |
+
- Step-by-step instructions to reproduce the issue
|
| 24 |
+
- Proof-of-concept or exploit code (if possible)
|
| 25 |
+
- Impact assessment
|
| 26 |
+
|
| 27 |
+
### What to Expect
|
| 28 |
+
|
| 29 |
+
1. **Acknowledgment**: We will acknowledge receipt within 48 hours
|
| 30 |
+
2. **Assessment**: We will assess the vulnerability and determine its severity
|
| 31 |
+
3. **Updates**: We will keep you informed of our progress
|
| 32 |
+
4. **Resolution**: We aim to resolve critical issues within 7 days
|
| 33 |
+
5. **Credit**: With your permission, we will credit you in the security advisory
|
| 34 |
+
|
| 35 |
+
### Security Best Practices for Users
|
| 36 |
+
|
| 37 |
+
When using Headroom:
|
| 38 |
+
|
| 39 |
+
1. **API Keys**: Never commit API keys. Use environment variables.
|
| 40 |
+
2. **Proxy Exposure**: Don't expose the proxy server to the public internet without authentication
|
| 41 |
+
3. **Log Files**: Be aware that request logs may contain sensitive information
|
| 42 |
+
4. **Budget Limits**: Set budget limits to prevent unexpected costs
|
| 43 |
+
|
| 44 |
+
### Scope
|
| 45 |
+
|
| 46 |
+
The following are in scope for security reports:
|
| 47 |
+
- Headroom Python package (`pip install headroom`)
|
| 48 |
+
- Headroom proxy server
|
| 49 |
+
- Official integrations (LangChain, MCP)
|
| 50 |
+
|
| 51 |
+
The following are out of scope:
|
| 52 |
+
- Third-party integrations not maintained by us
|
| 53 |
+
- Issues in dependencies (report these to the upstream project)
|
| 54 |
+
- Social engineering attacks
|
| 55 |
+
|
| 56 |
+
## Security Features
|
| 57 |
+
|
| 58 |
+
Headroom includes several security features:
|
| 59 |
+
|
| 60 |
+
- **No credential storage**: We never store or log API keys
|
| 61 |
+
- **Passthrough mode**: Sensitive content passes through unchanged by default
|
| 62 |
+
- **Input validation**: All inputs are validated before processing
|
| 63 |
+
- **Safe defaults**: Security-conscious defaults out of the box
|
| 64 |
+
|
| 65 |
+
Thank you for helping keep Headroom and its users safe!
|
|
@@ -21,7 +21,7 @@ Performance Targets:
|
|
| 21 |
- HybridScorer: < 50ms for 100 items (with embeddings)
|
| 22 |
"""
|
| 23 |
|
| 24 |
-
__version__ = "0.
|
| 25 |
|
| 26 |
from .scenarios.tool_outputs import (
|
| 27 |
generate_api_responses,
|
|
|
|
| 21 |
- HybridScorer: < 50ms for 100 items (with embeddings)
|
| 22 |
"""
|
| 23 |
|
| 24 |
+
__version__ = "0.2.0"
|
| 25 |
|
| 26 |
from .scenarios.tool_outputs import (
|
| 27 |
generate_api_responses,
|
|
File without changes
|
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Headroom Documentation
|
| 2 |
+
|
| 3 |
+
Welcome to the Headroom documentation.
|
| 4 |
+
|
| 5 |
+
## Quick Links
|
| 6 |
+
|
| 7 |
+
- [Getting Started](getting-started.md)
|
| 8 |
+
- [Proxy Server](proxy.md)
|
| 9 |
+
- [Transforms](transforms.md)
|
| 10 |
+
- [API Reference](api.md)
|
| 11 |
+
- [Architecture](ARCHITECTURE.md)
|
| 12 |
+
|
| 13 |
+
## Overview
|
| 14 |
+
|
| 15 |
+
Headroom is the Context Optimization Layer for LLM applications. It reduces your LLM costs by 50-90% through intelligent context compression.
|
| 16 |
+
|
| 17 |
+
### Core Concepts
|
| 18 |
+
|
| 19 |
+
1. **Transforms**: Stateless functions that modify message arrays to reduce tokens
|
| 20 |
+
2. **Providers**: Adapters for different LLM providers (OpenAI, Anthropic, etc.)
|
| 21 |
+
3. **Pipeline**: Chains multiple transforms together
|
| 22 |
+
4. **Proxy**: HTTP server that applies transforms transparently
|
| 23 |
+
|
| 24 |
+
### Getting Help
|
| 25 |
+
|
| 26 |
+
- [GitHub Issues](https://github.com/headroom-sdk/headroom/issues) - Bug reports
|
| 27 |
+
- [GitHub Discussions](https://github.com/headroom-sdk/headroom/discussions) - Questions
|
| 28 |
+
- [Discord](https://discord.gg/headroom) - Community chat
|
|
@@ -0,0 +1,346 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# API Reference
|
| 2 |
+
|
| 3 |
+
## HeadroomClient
|
| 4 |
+
|
| 5 |
+
The main entry point for Headroom SDK.
|
| 6 |
+
|
| 7 |
+
```python
|
| 8 |
+
from headroom import HeadroomClient
|
| 9 |
+
from openai import OpenAI
|
| 10 |
+
|
| 11 |
+
client = HeadroomClient(
|
| 12 |
+
original_client=OpenAI(),
|
| 13 |
+
default_mode="optimize",
|
| 14 |
+
)
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
### Constructor Parameters
|
| 18 |
+
|
| 19 |
+
| Parameter | Type | Default | Description |
|
| 20 |
+
|-----------|------|---------|-------------|
|
| 21 |
+
| `original_client` | `OpenAI \| Anthropic` | Required | The underlying LLM client |
|
| 22 |
+
| `provider` | `Provider` | Auto-detected | Token counting provider |
|
| 23 |
+
| `default_mode` | `str` | `"audit"` | Default mode: "audit", "optimize", "off" |
|
| 24 |
+
| `store_url` | `str` | `None` | Storage URL for metrics |
|
| 25 |
+
| `smart_crusher_config` | `SmartCrusherConfig` | Default | Compression settings |
|
| 26 |
+
| `cache_aligner_config` | `CacheAlignerConfig` | Default | Cache alignment settings |
|
| 27 |
+
| `rolling_window_config` | `RollingWindowConfig` | Default | Context window settings |
|
| 28 |
+
|
| 29 |
+
### Methods
|
| 30 |
+
|
| 31 |
+
#### `chat.completions.create(**kwargs)`
|
| 32 |
+
|
| 33 |
+
Create a chat completion with optional optimization.
|
| 34 |
+
|
| 35 |
+
```python
|
| 36 |
+
response = client.chat.completions.create(
|
| 37 |
+
model="gpt-4o",
|
| 38 |
+
messages=[...],
|
| 39 |
+
headroom_mode="optimize", # Override default mode
|
| 40 |
+
)
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
**Additional Parameters:**
|
| 44 |
+
|
| 45 |
+
| Parameter | Type | Description |
|
| 46 |
+
|-----------|------|-------------|
|
| 47 |
+
| `headroom_mode` | `str` | Override mode for this request |
|
| 48 |
+
| `headroom_query` | `str` | Query for relevance scoring |
|
| 49 |
+
|
| 50 |
+
#### `chat.completions.simulate(**kwargs)`
|
| 51 |
+
|
| 52 |
+
Preview optimization without making an API call.
|
| 53 |
+
|
| 54 |
+
```python
|
| 55 |
+
plan = client.chat.completions.simulate(
|
| 56 |
+
model="gpt-4o",
|
| 57 |
+
messages=[...],
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
print(f"Tokens before: {plan.tokens_before}")
|
| 61 |
+
print(f"Tokens after: {plan.tokens_after}")
|
| 62 |
+
print(f"Savings: {plan.savings_percent:.1f}%")
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
**Returns:** `SimulationResult`
|
| 66 |
+
|
| 67 |
+
---
|
| 68 |
+
|
| 69 |
+
## Configuration Classes
|
| 70 |
+
|
| 71 |
+
### SmartCrusherConfig
|
| 72 |
+
|
| 73 |
+
```python
|
| 74 |
+
from headroom import SmartCrusherConfig
|
| 75 |
+
|
| 76 |
+
config = SmartCrusherConfig(
|
| 77 |
+
min_tokens_to_crush=200,
|
| 78 |
+
max_items_after_crush=50,
|
| 79 |
+
keep_first=3,
|
| 80 |
+
keep_last=2,
|
| 81 |
+
relevance_threshold=0.3,
|
| 82 |
+
anomaly_std_threshold=2.0,
|
| 83 |
+
preserve_errors=True,
|
| 84 |
+
)
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
### CacheAlignerConfig
|
| 88 |
+
|
| 89 |
+
```python
|
| 90 |
+
from headroom import CacheAlignerConfig
|
| 91 |
+
|
| 92 |
+
config = CacheAlignerConfig(
|
| 93 |
+
extract_dates=True,
|
| 94 |
+
normalize_whitespace=True,
|
| 95 |
+
stable_prefix_min_tokens=100,
|
| 96 |
+
)
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
### RollingWindowConfig
|
| 100 |
+
|
| 101 |
+
```python
|
| 102 |
+
from headroom import RollingWindowConfig
|
| 103 |
+
|
| 104 |
+
config = RollingWindowConfig(
|
| 105 |
+
max_tokens=100000,
|
| 106 |
+
preserve_system=True,
|
| 107 |
+
preserve_recent_turns=5,
|
| 108 |
+
drop_oldest_first=True,
|
| 109 |
+
)
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
### RelevanceScorerConfig
|
| 113 |
+
|
| 114 |
+
```python
|
| 115 |
+
from headroom import RelevanceScorerConfig
|
| 116 |
+
|
| 117 |
+
config = RelevanceScorerConfig(
|
| 118 |
+
scorer_type="bm25", # "bm25", "embedding", or "hybrid"
|
| 119 |
+
embedding_model=None, # Model name for embedding scorer
|
| 120 |
+
hybrid_alpha=0.5, # Weight for hybrid scoring
|
| 121 |
+
)
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
---
|
| 125 |
+
|
| 126 |
+
## Data Models
|
| 127 |
+
|
| 128 |
+
### SimulationResult
|
| 129 |
+
|
| 130 |
+
Returned by `simulate()`.
|
| 131 |
+
|
| 132 |
+
```python
|
| 133 |
+
@dataclass
|
| 134 |
+
class SimulationResult:
|
| 135 |
+
tokens_before: int
|
| 136 |
+
tokens_after: int
|
| 137 |
+
tokens_saved: int
|
| 138 |
+
savings_percent: float
|
| 139 |
+
transforms_applied: list[str]
|
| 140 |
+
waste_signals: WasteSignals
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
### RequestMetrics
|
| 144 |
+
|
| 145 |
+
Metrics for a single request.
|
| 146 |
+
|
| 147 |
+
```python
|
| 148 |
+
@dataclass
|
| 149 |
+
class RequestMetrics:
|
| 150 |
+
request_id: str
|
| 151 |
+
timestamp: datetime
|
| 152 |
+
model: str
|
| 153 |
+
tokens_input_before: int
|
| 154 |
+
tokens_input_after: int
|
| 155 |
+
tokens_output: int
|
| 156 |
+
cost_before: float
|
| 157 |
+
cost_after: float
|
| 158 |
+
transforms_applied: list[str]
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
### WasteSignals
|
| 162 |
+
|
| 163 |
+
Detected waste in the request.
|
| 164 |
+
|
| 165 |
+
```python
|
| 166 |
+
@dataclass
|
| 167 |
+
class WasteSignals:
|
| 168 |
+
json_bloat_tokens: int
|
| 169 |
+
html_noise_tokens: int
|
| 170 |
+
whitespace_tokens: int
|
| 171 |
+
dynamic_date_tokens: int
|
| 172 |
+
repetition_tokens: int
|
| 173 |
+
```
|
| 174 |
+
|
| 175 |
+
---
|
| 176 |
+
|
| 177 |
+
## Providers
|
| 178 |
+
|
| 179 |
+
### OpenAIProvider
|
| 180 |
+
|
| 181 |
+
```python
|
| 182 |
+
from headroom import OpenAIProvider
|
| 183 |
+
|
| 184 |
+
provider = OpenAIProvider()
|
| 185 |
+
|
| 186 |
+
# Get token counter
|
| 187 |
+
counter = provider.get_token_counter("gpt-4o")
|
| 188 |
+
tokens = counter.count_text("Hello, world!")
|
| 189 |
+
|
| 190 |
+
# Get context limit
|
| 191 |
+
limit = provider.get_context_limit("gpt-4o") # 128000
|
| 192 |
+
|
| 193 |
+
# Estimate cost
|
| 194 |
+
cost = provider.estimate_cost(
|
| 195 |
+
input_tokens=1000,
|
| 196 |
+
output_tokens=500,
|
| 197 |
+
model="gpt-4o",
|
| 198 |
+
)
|
| 199 |
+
```
|
| 200 |
+
|
| 201 |
+
### AnthropicProvider
|
| 202 |
+
|
| 203 |
+
```python
|
| 204 |
+
from headroom import AnthropicProvider
|
| 205 |
+
from anthropic import Anthropic
|
| 206 |
+
|
| 207 |
+
provider = AnthropicProvider(client=Anthropic())
|
| 208 |
+
|
| 209 |
+
counter = provider.get_token_counter("claude-3-5-sonnet-latest")
|
| 210 |
+
tokens = counter.count_messages(messages) # Accurate count via API
|
| 211 |
+
```
|
| 212 |
+
|
| 213 |
+
---
|
| 214 |
+
|
| 215 |
+
## Relevance Scoring
|
| 216 |
+
|
| 217 |
+
### BM25Scorer
|
| 218 |
+
|
| 219 |
+
Fast keyword-based scoring (zero dependencies).
|
| 220 |
+
|
| 221 |
+
```python
|
| 222 |
+
from headroom import BM25Scorer
|
| 223 |
+
|
| 224 |
+
scorer = BM25Scorer()
|
| 225 |
+
scores = scorer.score_items(
|
| 226 |
+
items=["item 1", "item 2", ...],
|
| 227 |
+
query="search query",
|
| 228 |
+
)
|
| 229 |
+
```
|
| 230 |
+
|
| 231 |
+
### EmbeddingScorer
|
| 232 |
+
|
| 233 |
+
Semantic similarity scoring (requires `sentence-transformers`).
|
| 234 |
+
|
| 235 |
+
```python
|
| 236 |
+
from headroom import EmbeddingScorer, embedding_available
|
| 237 |
+
|
| 238 |
+
if embedding_available():
|
| 239 |
+
scorer = EmbeddingScorer(model="all-MiniLM-L6-v2")
|
| 240 |
+
scores = scorer.score_items(items, query)
|
| 241 |
+
```
|
| 242 |
+
|
| 243 |
+
### HybridScorer
|
| 244 |
+
|
| 245 |
+
Combines BM25 and embeddings.
|
| 246 |
+
|
| 247 |
+
```python
|
| 248 |
+
from headroom import HybridScorer
|
| 249 |
+
|
| 250 |
+
scorer = HybridScorer(alpha=0.5) # 50% BM25, 50% embedding
|
| 251 |
+
scores = scorer.score_items(items, query)
|
| 252 |
+
```
|
| 253 |
+
|
| 254 |
+
### create_scorer()
|
| 255 |
+
|
| 256 |
+
Factory function to create scorers.
|
| 257 |
+
|
| 258 |
+
```python
|
| 259 |
+
from headroom import create_scorer
|
| 260 |
+
|
| 261 |
+
# Auto-select best available scorer
|
| 262 |
+
scorer = create_scorer()
|
| 263 |
+
|
| 264 |
+
# Explicitly choose type
|
| 265 |
+
scorer = create_scorer(scorer_type="hybrid", alpha=0.7)
|
| 266 |
+
```
|
| 267 |
+
|
| 268 |
+
---
|
| 269 |
+
|
| 270 |
+
## Transforms (Direct Use)
|
| 271 |
+
|
| 272 |
+
### SmartCrusher
|
| 273 |
+
|
| 274 |
+
```python
|
| 275 |
+
from headroom import SmartCrusher
|
| 276 |
+
|
| 277 |
+
crusher = SmartCrusher()
|
| 278 |
+
result = crusher.crush(
|
| 279 |
+
data={"results": [...]},
|
| 280 |
+
query="user query",
|
| 281 |
+
)
|
| 282 |
+
```
|
| 283 |
+
|
| 284 |
+
### CacheAligner
|
| 285 |
+
|
| 286 |
+
```python
|
| 287 |
+
from headroom import CacheAligner
|
| 288 |
+
|
| 289 |
+
aligner = CacheAligner()
|
| 290 |
+
result = aligner.align(messages)
|
| 291 |
+
```
|
| 292 |
+
|
| 293 |
+
### RollingWindow
|
| 294 |
+
|
| 295 |
+
```python
|
| 296 |
+
from headroom import RollingWindow
|
| 297 |
+
|
| 298 |
+
window = RollingWindow(config)
|
| 299 |
+
result = window.apply(messages, max_tokens=100000)
|
| 300 |
+
```
|
| 301 |
+
|
| 302 |
+
### TransformPipeline
|
| 303 |
+
|
| 304 |
+
```python
|
| 305 |
+
from headroom import TransformPipeline
|
| 306 |
+
|
| 307 |
+
pipeline = TransformPipeline([
|
| 308 |
+
SmartCrusher(),
|
| 309 |
+
CacheAligner(),
|
| 310 |
+
RollingWindow(),
|
| 311 |
+
])
|
| 312 |
+
|
| 313 |
+
result = pipeline.transform(messages)
|
| 314 |
+
```
|
| 315 |
+
|
| 316 |
+
---
|
| 317 |
+
|
| 318 |
+
## Utilities
|
| 319 |
+
|
| 320 |
+
### Tokenizer
|
| 321 |
+
|
| 322 |
+
```python
|
| 323 |
+
from headroom import Tokenizer, count_tokens_text, count_tokens_messages
|
| 324 |
+
|
| 325 |
+
# Quick counting
|
| 326 |
+
tokens = count_tokens_text("Hello, world!", model="gpt-4o")
|
| 327 |
+
|
| 328 |
+
# With tokenizer instance
|
| 329 |
+
tokenizer = Tokenizer(model="gpt-4o")
|
| 330 |
+
tokens = tokenizer.count_text("Hello")
|
| 331 |
+
tokens = tokenizer.count_messages(messages)
|
| 332 |
+
```
|
| 333 |
+
|
| 334 |
+
### generate_report()
|
| 335 |
+
|
| 336 |
+
Generate HTML/Markdown reports from stored metrics.
|
| 337 |
+
|
| 338 |
+
```python
|
| 339 |
+
from headroom import generate_report
|
| 340 |
+
|
| 341 |
+
report = generate_report(
|
| 342 |
+
store_url="sqlite:///headroom.db",
|
| 343 |
+
format="html",
|
| 344 |
+
period="day",
|
| 345 |
+
)
|
| 346 |
+
```
|
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Getting Started with Headroom
|
| 2 |
+
|
| 3 |
+
This guide will help you get up and running with Headroom in under 5 minutes.
|
| 4 |
+
|
| 5 |
+
## Installation
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
# Core package (minimal dependencies)
|
| 9 |
+
pip install headroom
|
| 10 |
+
|
| 11 |
+
# With proxy server
|
| 12 |
+
pip install headroom[proxy]
|
| 13 |
+
|
| 14 |
+
# With semantic relevance (for smarter compression)
|
| 15 |
+
pip install headroom[relevance]
|
| 16 |
+
|
| 17 |
+
# Everything
|
| 18 |
+
pip install headroom[all]
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
## Quick Start: Proxy Mode (Recommended)
|
| 22 |
+
|
| 23 |
+
The easiest way to use Headroom is as a proxy server:
|
| 24 |
+
|
| 25 |
+
```bash
|
| 26 |
+
# Start the proxy
|
| 27 |
+
headroom proxy --port 8787
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
Then point your LLM client at it:
|
| 31 |
+
|
| 32 |
+
```bash
|
| 33 |
+
# Claude Code
|
| 34 |
+
ANTHROPIC_BASE_URL=http://localhost:8787 claude
|
| 35 |
+
|
| 36 |
+
# OpenAI-compatible clients
|
| 37 |
+
OPENAI_BASE_URL=http://localhost:8787/v1 your-app
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
That's it! All your requests now go through Headroom and get optimized automatically.
|
| 41 |
+
|
| 42 |
+
## Quick Start: Python SDK
|
| 43 |
+
|
| 44 |
+
If you want programmatic control:
|
| 45 |
+
|
| 46 |
+
```python
|
| 47 |
+
from headroom import HeadroomClient
|
| 48 |
+
from openai import OpenAI
|
| 49 |
+
|
| 50 |
+
# Create a wrapped client
|
| 51 |
+
client = HeadroomClient(
|
| 52 |
+
original_client=OpenAI(),
|
| 53 |
+
default_mode="optimize",
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
# Use exactly like the original
|
| 57 |
+
response = client.chat.completions.create(
|
| 58 |
+
model="gpt-4o",
|
| 59 |
+
messages=[
|
| 60 |
+
{"role": "system", "content": "You are a helpful assistant."},
|
| 61 |
+
{"role": "user", "content": "Hello!"},
|
| 62 |
+
],
|
| 63 |
+
)
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
## Modes
|
| 67 |
+
|
| 68 |
+
### Audit Mode
|
| 69 |
+
|
| 70 |
+
Observe without modifying:
|
| 71 |
+
|
| 72 |
+
```python
|
| 73 |
+
client = HeadroomClient(
|
| 74 |
+
original_client=OpenAI(),
|
| 75 |
+
default_mode="audit",
|
| 76 |
+
)
|
| 77 |
+
# Logs metrics but doesn't change requests
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
### Optimize Mode
|
| 81 |
+
|
| 82 |
+
Apply transforms to reduce tokens:
|
| 83 |
+
|
| 84 |
+
```python
|
| 85 |
+
client = HeadroomClient(
|
| 86 |
+
original_client=OpenAI(),
|
| 87 |
+
default_mode="optimize",
|
| 88 |
+
)
|
| 89 |
+
# Compresses tool outputs, aligns cache prefixes, etc.
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
### Simulate Mode
|
| 93 |
+
|
| 94 |
+
Preview what optimizations would do:
|
| 95 |
+
|
| 96 |
+
```python
|
| 97 |
+
plan = client.chat.completions.simulate(
|
| 98 |
+
model="gpt-4o",
|
| 99 |
+
messages=[...],
|
| 100 |
+
)
|
| 101 |
+
print(f"Would save {plan.tokens_saved} tokens")
|
| 102 |
+
print(f"Transforms: {plan.transforms_applied}")
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
## Next Steps
|
| 106 |
+
|
| 107 |
+
- [Proxy Server Documentation](proxy.md) - Configure the proxy
|
| 108 |
+
- [Transforms Reference](transforms.md) - Understand each transform
|
| 109 |
+
- [API Reference](api.md) - Full API documentation
|
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Proxy Server Documentation
|
| 2 |
+
|
| 3 |
+
The Headroom proxy server is a production-ready HTTP server that applies context optimization to all requests passing through it.
|
| 4 |
+
|
| 5 |
+
## Starting the Proxy
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
# Basic usage
|
| 9 |
+
headroom proxy
|
| 10 |
+
|
| 11 |
+
# Custom port
|
| 12 |
+
headroom proxy --port 8080
|
| 13 |
+
|
| 14 |
+
# With all options
|
| 15 |
+
headroom proxy \
|
| 16 |
+
--host 0.0.0.0 \
|
| 17 |
+
--port 8787 \
|
| 18 |
+
--log-file /var/log/headroom.jsonl \
|
| 19 |
+
--budget 100.0
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
## Command Line Options
|
| 23 |
+
|
| 24 |
+
| Option | Default | Description |
|
| 25 |
+
|--------|---------|-------------|
|
| 26 |
+
| `--host` | `127.0.0.1` | Host to bind to |
|
| 27 |
+
| `--port` | `8787` | Port to bind to |
|
| 28 |
+
| `--no-optimize` | `false` | Disable optimization (passthrough mode) |
|
| 29 |
+
| `--no-cache` | `false` | Disable semantic caching |
|
| 30 |
+
| `--no-rate-limit` | `false` | Disable rate limiting |
|
| 31 |
+
| `--log-file` | None | Path to JSONL log file |
|
| 32 |
+
| `--budget` | None | Daily budget limit in USD |
|
| 33 |
+
|
| 34 |
+
## API Endpoints
|
| 35 |
+
|
| 36 |
+
### Health Check
|
| 37 |
+
|
| 38 |
+
```bash
|
| 39 |
+
curl http://localhost:8787/health
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
Response:
|
| 43 |
+
```json
|
| 44 |
+
{
|
| 45 |
+
"status": "healthy",
|
| 46 |
+
"optimize": true,
|
| 47 |
+
"stats": {
|
| 48 |
+
"total_requests": 42,
|
| 49 |
+
"tokens_saved": 15000,
|
| 50 |
+
"savings_percent": 45.2
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
### Detailed Statistics
|
| 56 |
+
|
| 57 |
+
```bash
|
| 58 |
+
curl http://localhost:8787/stats
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
### Prometheus Metrics
|
| 62 |
+
|
| 63 |
+
```bash
|
| 64 |
+
curl http://localhost:8787/metrics
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
### LLM APIs
|
| 68 |
+
|
| 69 |
+
The proxy supports both Anthropic and OpenAI API formats:
|
| 70 |
+
|
| 71 |
+
```bash
|
| 72 |
+
# Anthropic format
|
| 73 |
+
POST /v1/messages
|
| 74 |
+
|
| 75 |
+
# OpenAI format
|
| 76 |
+
POST /v1/chat/completions
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
## Using with Claude Code
|
| 80 |
+
|
| 81 |
+
```bash
|
| 82 |
+
# Start proxy
|
| 83 |
+
headroom proxy --port 8787
|
| 84 |
+
|
| 85 |
+
# In another terminal
|
| 86 |
+
ANTHROPIC_BASE_URL=http://localhost:8787 claude
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
## Using with Cursor
|
| 90 |
+
|
| 91 |
+
1. Start the proxy: `headroom proxy`
|
| 92 |
+
2. In Cursor settings, set the base URL to `http://localhost:8787`
|
| 93 |
+
|
| 94 |
+
## Using with OpenAI SDK
|
| 95 |
+
|
| 96 |
+
```python
|
| 97 |
+
from openai import OpenAI
|
| 98 |
+
|
| 99 |
+
client = OpenAI(
|
| 100 |
+
base_url="http://localhost:8787/v1",
|
| 101 |
+
api_key="your-api-key", # Still needed for upstream
|
| 102 |
+
)
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
## Features
|
| 106 |
+
|
| 107 |
+
### Semantic Caching
|
| 108 |
+
|
| 109 |
+
The proxy caches responses for repeated queries:
|
| 110 |
+
|
| 111 |
+
- LRU eviction with configurable max entries
|
| 112 |
+
- TTL-based expiration
|
| 113 |
+
- Cache key based on message content hash
|
| 114 |
+
|
| 115 |
+
### Rate Limiting
|
| 116 |
+
|
| 117 |
+
Token bucket rate limiting protects against runaway costs:
|
| 118 |
+
|
| 119 |
+
- Configurable requests per minute
|
| 120 |
+
- Configurable tokens per minute
|
| 121 |
+
- Per-API-key tracking
|
| 122 |
+
|
| 123 |
+
### Cost Tracking
|
| 124 |
+
|
| 125 |
+
Track spending and enforce budgets:
|
| 126 |
+
|
| 127 |
+
- Real-time cost estimation
|
| 128 |
+
- Budget periods: hourly, daily, monthly
|
| 129 |
+
- Automatic request rejection when over budget
|
| 130 |
+
|
| 131 |
+
### Prometheus Metrics
|
| 132 |
+
|
| 133 |
+
Export metrics for monitoring:
|
| 134 |
+
|
| 135 |
+
```
|
| 136 |
+
headroom_requests_total
|
| 137 |
+
headroom_tokens_saved_total
|
| 138 |
+
headroom_cost_usd_total
|
| 139 |
+
headroom_latency_ms_sum
|
| 140 |
+
```
|
| 141 |
+
|
| 142 |
+
## Configuration via Environment
|
| 143 |
+
|
| 144 |
+
```bash
|
| 145 |
+
export HEADROOM_HOST=0.0.0.0
|
| 146 |
+
export HEADROOM_PORT=8787
|
| 147 |
+
export HEADROOM_BUDGET=100.0
|
| 148 |
+
headroom proxy
|
| 149 |
+
```
|
| 150 |
+
|
| 151 |
+
## Running in Production
|
| 152 |
+
|
| 153 |
+
For production deployments:
|
| 154 |
+
|
| 155 |
+
```bash
|
| 156 |
+
# Use a process manager
|
| 157 |
+
pip install gunicorn
|
| 158 |
+
|
| 159 |
+
# Run with gunicorn
|
| 160 |
+
gunicorn headroom.proxy.server:app \
|
| 161 |
+
--workers 4 \
|
| 162 |
+
--bind 0.0.0.0:8787 \
|
| 163 |
+
--worker-class uvicorn.workers.UvicornWorker
|
| 164 |
+
```
|
| 165 |
+
|
| 166 |
+
Or with Docker:
|
| 167 |
+
|
| 168 |
+
```dockerfile
|
| 169 |
+
FROM python:3.11-slim
|
| 170 |
+
RUN pip install headroom[proxy]
|
| 171 |
+
EXPOSE 8787
|
| 172 |
+
CMD ["headroom", "proxy", "--host", "0.0.0.0"]
|
| 173 |
+
```
|
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Transform Reference
|
| 2 |
+
|
| 3 |
+
Headroom provides three core transforms that work together to optimize LLM context.
|
| 4 |
+
|
| 5 |
+
## SmartCrusher
|
| 6 |
+
|
| 7 |
+
Statistical compression for JSON tool outputs.
|
| 8 |
+
|
| 9 |
+
### How It Works
|
| 10 |
+
|
| 11 |
+
SmartCrusher analyzes JSON arrays and selectively keeps important items:
|
| 12 |
+
|
| 13 |
+
1. **First/Last items** - Context for pagination and recency
|
| 14 |
+
2. **Error items** - 100% preservation of error states
|
| 15 |
+
3. **Anomalies** - Statistical outliers (> 2 std dev from mean)
|
| 16 |
+
4. **Relevant items** - Matches to user's query via BM25/embeddings
|
| 17 |
+
5. **Change points** - Significant transitions in data
|
| 18 |
+
|
| 19 |
+
### Configuration
|
| 20 |
+
|
| 21 |
+
```python
|
| 22 |
+
from headroom import SmartCrusherConfig
|
| 23 |
+
|
| 24 |
+
config = SmartCrusherConfig(
|
| 25 |
+
min_tokens_to_crush=200, # Only compress if > 200 tokens
|
| 26 |
+
max_items_after_crush=50, # Keep at most 50 items
|
| 27 |
+
keep_first=3, # Always keep first 3 items
|
| 28 |
+
keep_last=2, # Always keep last 2 items
|
| 29 |
+
relevance_threshold=0.3, # Keep items with relevance > 0.3
|
| 30 |
+
anomaly_std_threshold=2.0, # Keep items > 2 std dev from mean
|
| 31 |
+
preserve_errors=True, # Always keep error items
|
| 32 |
+
)
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
### Example
|
| 36 |
+
|
| 37 |
+
```python
|
| 38 |
+
from headroom import SmartCrusher
|
| 39 |
+
|
| 40 |
+
crusher = SmartCrusher(config)
|
| 41 |
+
|
| 42 |
+
# Before: 1000 search results (45,000 tokens)
|
| 43 |
+
tool_output = {"results": [...1000 items...]}
|
| 44 |
+
|
| 45 |
+
# After: ~50 important items (4,500 tokens) - 90% reduction
|
| 46 |
+
compressed = crusher.crush(tool_output, query="user's question")
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
### What Gets Preserved
|
| 50 |
+
|
| 51 |
+
| Category | Preserved | Why |
|
| 52 |
+
|----------|-----------|-----|
|
| 53 |
+
| Errors | 100% | Critical for debugging |
|
| 54 |
+
| First N | 100% | Context/pagination |
|
| 55 |
+
| Last N | 100% | Recency |
|
| 56 |
+
| Anomalies | All | Unusual values matter |
|
| 57 |
+
| Relevant | Top K | Match user's query |
|
| 58 |
+
| Others | Sampled | Statistical representation |
|
| 59 |
+
|
| 60 |
+
---
|
| 61 |
+
|
| 62 |
+
## CacheAligner
|
| 63 |
+
|
| 64 |
+
Prefix stabilization for improved cache hit rates.
|
| 65 |
+
|
| 66 |
+
### The Problem
|
| 67 |
+
|
| 68 |
+
LLM providers cache request prefixes. But dynamic content breaks caching:
|
| 69 |
+
|
| 70 |
+
```
|
| 71 |
+
"You are helpful. Today is January 7, 2025." # Changes daily = no cache
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
### The Solution
|
| 75 |
+
|
| 76 |
+
CacheAligner extracts dynamic content to stabilize the prefix:
|
| 77 |
+
|
| 78 |
+
```python
|
| 79 |
+
from headroom import CacheAligner
|
| 80 |
+
|
| 81 |
+
aligner = CacheAligner()
|
| 82 |
+
result = aligner.align(messages)
|
| 83 |
+
|
| 84 |
+
# Static prefix (cacheable):
|
| 85 |
+
# "You are helpful."
|
| 86 |
+
|
| 87 |
+
# Dynamic content moved to end:
|
| 88 |
+
# [Current date context]
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
### Configuration
|
| 92 |
+
|
| 93 |
+
```python
|
| 94 |
+
from headroom import CacheAlignerConfig
|
| 95 |
+
|
| 96 |
+
config = CacheAlignerConfig(
|
| 97 |
+
extract_dates=True, # Move dates to dynamic section
|
| 98 |
+
normalize_whitespace=True, # Consistent spacing
|
| 99 |
+
stable_prefix_min_tokens=100, # Min prefix size for alignment
|
| 100 |
+
)
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
### Cache Hit Improvement
|
| 104 |
+
|
| 105 |
+
| Scenario | Before | After |
|
| 106 |
+
|----------|--------|-------|
|
| 107 |
+
| Daily date in prompt | 0% hits | ~95% hits |
|
| 108 |
+
| Dynamic user context | ~10% hits | ~80% hits |
|
| 109 |
+
| Consistent prompts | ~90% hits | ~95% hits |
|
| 110 |
+
|
| 111 |
+
---
|
| 112 |
+
|
| 113 |
+
## RollingWindow
|
| 114 |
+
|
| 115 |
+
Context management within token limits.
|
| 116 |
+
|
| 117 |
+
### The Problem
|
| 118 |
+
|
| 119 |
+
Long conversations exceed context limits. Naive truncation breaks tool calls:
|
| 120 |
+
|
| 121 |
+
```
|
| 122 |
+
[tool_call: search] # Kept
|
| 123 |
+
[tool_result: ...] # Dropped = orphaned call!
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
### The Solution
|
| 127 |
+
|
| 128 |
+
RollingWindow drops complete tool units, preserving pairs:
|
| 129 |
+
|
| 130 |
+
```python
|
| 131 |
+
from headroom import RollingWindow
|
| 132 |
+
|
| 133 |
+
window = RollingWindow(config)
|
| 134 |
+
result = window.apply(messages, max_tokens=100000)
|
| 135 |
+
|
| 136 |
+
# Guarantees:
|
| 137 |
+
# 1. Tool calls paired with results
|
| 138 |
+
# 2. System prompt preserved
|
| 139 |
+
# 3. Recent turns kept
|
| 140 |
+
# 4. Oldest tool outputs dropped first
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
### Configuration
|
| 144 |
+
|
| 145 |
+
```python
|
| 146 |
+
from headroom import RollingWindowConfig
|
| 147 |
+
|
| 148 |
+
config = RollingWindowConfig(
|
| 149 |
+
max_tokens=100000, # Target token limit
|
| 150 |
+
preserve_system=True, # Always keep system prompt
|
| 151 |
+
preserve_recent_turns=5, # Keep last 5 user/assistant turns
|
| 152 |
+
drop_oldest_first=True, # Remove oldest tool outputs
|
| 153 |
+
)
|
| 154 |
+
```
|
| 155 |
+
|
| 156 |
+
### Drop Priority
|
| 157 |
+
|
| 158 |
+
1. **Oldest tool outputs** - First to go
|
| 159 |
+
2. **Old assistant messages** - Summary preserved
|
| 160 |
+
3. **Old user messages** - Only if necessary
|
| 161 |
+
4. **Never dropped**: System prompt, recent turns, active tool pairs
|
| 162 |
+
|
| 163 |
+
---
|
| 164 |
+
|
| 165 |
+
## TransformPipeline
|
| 166 |
+
|
| 167 |
+
Combine transforms for optimal results.
|
| 168 |
+
|
| 169 |
+
```python
|
| 170 |
+
from headroom import TransformPipeline, SmartCrusher, CacheAligner, RollingWindow
|
| 171 |
+
|
| 172 |
+
pipeline = TransformPipeline([
|
| 173 |
+
SmartCrusher(), # First: compress tool outputs
|
| 174 |
+
CacheAligner(), # Then: stabilize prefix
|
| 175 |
+
RollingWindow(), # Finally: fit in context
|
| 176 |
+
])
|
| 177 |
+
|
| 178 |
+
result = pipeline.transform(messages)
|
| 179 |
+
print(f"Saved {result.tokens_saved} tokens")
|
| 180 |
+
```
|
| 181 |
+
|
| 182 |
+
### Recommended Order
|
| 183 |
+
|
| 184 |
+
1. **SmartCrusher** - Reduce individual messages
|
| 185 |
+
2. **CacheAligner** - Optimize for caching
|
| 186 |
+
3. **RollingWindow** - Final size constraint
|
| 187 |
+
|
| 188 |
+
---
|
| 189 |
+
|
| 190 |
+
## Safety Guarantees
|
| 191 |
+
|
| 192 |
+
All transforms follow strict safety rules:
|
| 193 |
+
|
| 194 |
+
1. **Never remove human content** - User/assistant text is sacred
|
| 195 |
+
2. **Never break tool ordering** - Calls and results stay paired
|
| 196 |
+
3. **Parse failures are no-ops** - Malformed content passes through
|
| 197 |
+
4. **Preserves recency** - Last N turns always kept
|
| 198 |
+
5. **100% error preservation** - Error items never dropped
|
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Headroom Examples
|
| 2 |
+
|
| 3 |
+
This directory contains examples demonstrating Headroom's capabilities.
|
| 4 |
+
|
| 5 |
+
## Quick Start Examples
|
| 6 |
+
|
| 7 |
+
### basic_usage.py
|
| 8 |
+
|
| 9 |
+
Basic integration with OpenAI client:
|
| 10 |
+
|
| 11 |
+
```bash
|
| 12 |
+
export OPENAI_API_KEY='your-key'
|
| 13 |
+
python examples/basic_usage.py
|
| 14 |
+
```
|
| 15 |
+
|
| 16 |
+
### anthropic_example.py
|
| 17 |
+
|
| 18 |
+
Integration with Anthropic Claude:
|
| 19 |
+
|
| 20 |
+
```bash
|
| 21 |
+
export ANTHROPIC_API_KEY='your-key'
|
| 22 |
+
python examples/anthropic_example.py
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
### streaming_example.py
|
| 26 |
+
|
| 27 |
+
Streaming responses with optimization:
|
| 28 |
+
|
| 29 |
+
```bash
|
| 30 |
+
export OPENAI_API_KEY='your-key'
|
| 31 |
+
python examples/streaming_example.py
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
## Evaluation Examples
|
| 35 |
+
|
| 36 |
+
### smart_vs_naive_eval.py
|
| 37 |
+
|
| 38 |
+
Compare SmartCrusher against naive truncation:
|
| 39 |
+
|
| 40 |
+
```bash
|
| 41 |
+
export OPENAI_API_KEY='your-key'
|
| 42 |
+
python examples/smart_vs_naive_eval.py
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
### real_world_eval.py
|
| 46 |
+
|
| 47 |
+
Comprehensive evaluation with Anthropic models:
|
| 48 |
+
|
| 49 |
+
```bash
|
| 50 |
+
export ANTHROPIC_API_KEY='your-key'
|
| 51 |
+
python examples/real_world_eval.py
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
### real_world_openai_eval.py
|
| 55 |
+
|
| 56 |
+
Comprehensive evaluation with OpenAI models:
|
| 57 |
+
|
| 58 |
+
```bash
|
| 59 |
+
export OPENAI_API_KEY='your-key'
|
| 60 |
+
python examples/real_world_openai_eval.py
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
## Demo Directories
|
| 64 |
+
|
| 65 |
+
### langchain_demo/
|
| 66 |
+
|
| 67 |
+
Full LangChain agent integration demo:
|
| 68 |
+
|
| 69 |
+
```bash
|
| 70 |
+
# No API key needed for compression demo
|
| 71 |
+
PYTHONPATH=. python -m examples.langchain_demo.show_compression
|
| 72 |
+
|
| 73 |
+
# Full comparison (requires API key)
|
| 74 |
+
export OPENAI_API_KEY='your-key'
|
| 75 |
+
PYTHONPATH=. python -m examples.langchain_demo.run_comparison
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
See [langchain_demo/README.md](langchain_demo/README.md) for details.
|
| 79 |
+
|
| 80 |
+
### mcp_demo/
|
| 81 |
+
|
| 82 |
+
MCP (Model Context Protocol) integration demo:
|
| 83 |
+
|
| 84 |
+
```bash
|
| 85 |
+
export OPENAI_API_KEY='your-key'
|
| 86 |
+
PYTHONPATH=. python -m examples.mcp_demo.run_agent_eval
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
## Running Examples
|
| 90 |
+
|
| 91 |
+
All examples can be run from the repository root:
|
| 92 |
+
|
| 93 |
+
```bash
|
| 94 |
+
# Install dependencies
|
| 95 |
+
pip install -e ".[dev]"
|
| 96 |
+
|
| 97 |
+
# Run any example
|
| 98 |
+
python examples/<example_name>.py
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
## Expected Results
|
| 102 |
+
|
| 103 |
+
| Example | Token Savings | Notes |
|
| 104 |
+
|---------|---------------|-------|
|
| 105 |
+
| basic_usage | 50-70% | Simple tool output compression |
|
| 106 |
+
| langchain_demo | 70-85% | Real agent with multiple tools |
|
| 107 |
+
| mcp_demo | 60-80% | MCP tool outputs |
|
| 108 |
+
| real_world_eval | 50-90% | Varies by scenario |
|
| 109 |
+
|
| 110 |
+
## Troubleshooting
|
| 111 |
+
|
| 112 |
+
**ModuleNotFoundError: No module named 'headroom'**
|
| 113 |
+
|
| 114 |
+
Run from the repository root with PYTHONPATH:
|
| 115 |
+
|
| 116 |
+
```bash
|
| 117 |
+
PYTHONPATH=. python examples/basic_usage.py
|
| 118 |
+
```
|
| 119 |
+
|
| 120 |
+
Or install in development mode:
|
| 121 |
+
|
| 122 |
+
```bash
|
| 123 |
+
pip install -e .
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
**API Key Errors**
|
| 127 |
+
|
| 128 |
+
Ensure your API keys are set:
|
| 129 |
+
|
| 130 |
+
```bash
|
| 131 |
+
export OPENAI_API_KEY='sk-...'
|
| 132 |
+
export ANTHROPIC_API_KEY='sk-ant-...'
|
| 133 |
+
```
|
|
@@ -57,15 +57,6 @@ from .config import (
|
|
| 57 |
WasteSignals,
|
| 58 |
)
|
| 59 |
from .providers import AnthropicProvider, OpenAIProvider, Provider, TokenCounter
|
| 60 |
-
from .reporting import generate_report
|
| 61 |
-
from .tokenizer import Tokenizer, count_tokens_messages, count_tokens_text
|
| 62 |
-
from .transforms import (
|
| 63 |
-
CacheAligner,
|
| 64 |
-
RollingWindow,
|
| 65 |
-
SmartCrusher,
|
| 66 |
-
ToolCrusher,
|
| 67 |
-
TransformPipeline,
|
| 68 |
-
)
|
| 69 |
from .relevance import (
|
| 70 |
BM25Scorer,
|
| 71 |
EmbeddingScorer,
|
|
@@ -75,6 +66,15 @@ from .relevance import (
|
|
| 75 |
create_scorer,
|
| 76 |
embedding_available,
|
| 77 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
__version__ = "0.2.0"
|
| 80 |
|
|
|
|
| 57 |
WasteSignals,
|
| 58 |
)
|
| 59 |
from .providers import AnthropicProvider, OpenAIProvider, Provider, TokenCounter
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
from .relevance import (
|
| 61 |
BM25Scorer,
|
| 62 |
EmbeddingScorer,
|
|
|
|
| 66 |
create_scorer,
|
| 67 |
embedding_available,
|
| 68 |
)
|
| 69 |
+
from .reporting import generate_report
|
| 70 |
+
from .tokenizer import Tokenizer, count_tokens_messages, count_tokens_text
|
| 71 |
+
from .transforms import (
|
| 72 |
+
CacheAligner,
|
| 73 |
+
RollingWindow,
|
| 74 |
+
SmartCrusher,
|
| 75 |
+
ToolCrusher,
|
| 76 |
+
TransformPipeline,
|
| 77 |
+
)
|
| 78 |
|
| 79 |
__version__ = "0.2.0"
|
| 80 |
|
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Headroom CLI - The Context Optimization Layer for LLM Applications.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
headroom proxy [OPTIONS] Start the optimization proxy server
|
| 6 |
+
headroom --version Show version
|
| 7 |
+
headroom --help Show this help message
|
| 8 |
+
|
| 9 |
+
Examples:
|
| 10 |
+
# Start proxy on default port (8787)
|
| 11 |
+
headroom proxy
|
| 12 |
+
|
| 13 |
+
# Start proxy on custom port
|
| 14 |
+
headroom proxy --port 8080
|
| 15 |
+
|
| 16 |
+
# Start with optimization disabled (passthrough mode)
|
| 17 |
+
headroom proxy --no-optimize
|
| 18 |
+
|
| 19 |
+
# Use with Claude Code
|
| 20 |
+
ANTHROPIC_BASE_URL=http://localhost:8787 claude
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import argparse
|
| 26 |
+
import sys
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def get_version() -> str:
|
| 30 |
+
"""Get the current version."""
|
| 31 |
+
try:
|
| 32 |
+
from headroom import __version__
|
| 33 |
+
return __version__
|
| 34 |
+
except ImportError:
|
| 35 |
+
return "unknown"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def cmd_proxy(args: argparse.Namespace) -> int:
|
| 39 |
+
"""Start the proxy server."""
|
| 40 |
+
try:
|
| 41 |
+
from headroom.proxy.server import ProxyConfig, run_server
|
| 42 |
+
except ImportError as e:
|
| 43 |
+
print("Error: Proxy dependencies not installed. Run: pip install headroom[proxy]")
|
| 44 |
+
print(f"Details: {e}")
|
| 45 |
+
return 1
|
| 46 |
+
|
| 47 |
+
config = ProxyConfig(
|
| 48 |
+
host=args.host,
|
| 49 |
+
port=args.port,
|
| 50 |
+
optimize=not args.no_optimize,
|
| 51 |
+
cache_enabled=not args.no_cache,
|
| 52 |
+
rate_limit_enabled=not args.no_rate_limit,
|
| 53 |
+
log_file=args.log_file,
|
| 54 |
+
budget_limit_usd=args.budget,
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
print(f"""
|
| 58 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 59 |
+
β HEADROOM PROXY β
|
| 60 |
+
β The Context Optimization Layer for LLM Applications β
|
| 61 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 62 |
+
|
| 63 |
+
Starting proxy server...
|
| 64 |
+
|
| 65 |
+
URL: http://{config.host}:{config.port}
|
| 66 |
+
Optimization: {'ENABLED' if config.optimize else 'DISABLED'}
|
| 67 |
+
Caching: {'ENABLED' if config.cache_enabled else 'DISABLED'}
|
| 68 |
+
Rate Limit: {'ENABLED' if config.rate_limit_enabled else 'DISABLED'}
|
| 69 |
+
|
| 70 |
+
Usage with Claude Code:
|
| 71 |
+
ANTHROPIC_BASE_URL=http://{config.host}:{config.port} claude
|
| 72 |
+
|
| 73 |
+
Usage with OpenAI-compatible clients:
|
| 74 |
+
OPENAI_BASE_URL=http://{config.host}:{config.port}/v1 your-app
|
| 75 |
+
|
| 76 |
+
Endpoints:
|
| 77 |
+
GET /health Health check
|
| 78 |
+
GET /stats Detailed statistics
|
| 79 |
+
GET /metrics Prometheus metrics
|
| 80 |
+
POST /v1/messages Anthropic API
|
| 81 |
+
POST /v1/chat/completions OpenAI API
|
| 82 |
+
|
| 83 |
+
Press Ctrl+C to stop.
|
| 84 |
+
""")
|
| 85 |
+
|
| 86 |
+
try:
|
| 87 |
+
run_server(config)
|
| 88 |
+
except KeyboardInterrupt:
|
| 89 |
+
print("\nShutting down...")
|
| 90 |
+
return 0
|
| 91 |
+
|
| 92 |
+
return 0
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def cmd_version(args: argparse.Namespace) -> int:
|
| 96 |
+
"""Print version information."""
|
| 97 |
+
print(f"headroom {get_version()}")
|
| 98 |
+
return 0
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def main(argv: list[str] | None = None) -> int:
|
| 102 |
+
"""Main CLI entry point."""
|
| 103 |
+
parser = argparse.ArgumentParser(
|
| 104 |
+
prog="headroom",
|
| 105 |
+
description="The Context Optimization Layer for LLM Applications",
|
| 106 |
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
| 107 |
+
epilog="""
|
| 108 |
+
Examples:
|
| 109 |
+
headroom proxy Start proxy on port 8787
|
| 110 |
+
headroom proxy --port 8080 Start proxy on port 8080
|
| 111 |
+
headroom proxy --no-optimize Passthrough mode (no optimization)
|
| 112 |
+
|
| 113 |
+
Environment Variables:
|
| 114 |
+
ANTHROPIC_API_KEY Your Anthropic API key (for proxying)
|
| 115 |
+
OPENAI_API_KEY Your OpenAI API key (for proxying)
|
| 116 |
+
|
| 117 |
+
Documentation: https://github.com/headroom-sdk/headroom
|
| 118 |
+
""",
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
parser.add_argument(
|
| 122 |
+
"--version", "-V",
|
| 123 |
+
action="store_true",
|
| 124 |
+
help="Show version and exit",
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
subparsers = parser.add_subparsers(dest="command", help="Commands")
|
| 128 |
+
|
| 129 |
+
# Proxy command
|
| 130 |
+
proxy_parser = subparsers.add_parser(
|
| 131 |
+
"proxy",
|
| 132 |
+
help="Start the optimization proxy server",
|
| 133 |
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
| 134 |
+
)
|
| 135 |
+
proxy_parser.add_argument(
|
| 136 |
+
"--host",
|
| 137 |
+
default="127.0.0.1",
|
| 138 |
+
help="Host to bind to (default: 127.0.0.1)",
|
| 139 |
+
)
|
| 140 |
+
proxy_parser.add_argument(
|
| 141 |
+
"--port", "-p",
|
| 142 |
+
type=int,
|
| 143 |
+
default=8787,
|
| 144 |
+
help="Port to bind to (default: 8787)",
|
| 145 |
+
)
|
| 146 |
+
proxy_parser.add_argument(
|
| 147 |
+
"--no-optimize",
|
| 148 |
+
action="store_true",
|
| 149 |
+
help="Disable optimization (passthrough mode)",
|
| 150 |
+
)
|
| 151 |
+
proxy_parser.add_argument(
|
| 152 |
+
"--no-cache",
|
| 153 |
+
action="store_true",
|
| 154 |
+
help="Disable semantic caching",
|
| 155 |
+
)
|
| 156 |
+
proxy_parser.add_argument(
|
| 157 |
+
"--no-rate-limit",
|
| 158 |
+
action="store_true",
|
| 159 |
+
help="Disable rate limiting",
|
| 160 |
+
)
|
| 161 |
+
proxy_parser.add_argument(
|
| 162 |
+
"--log-file",
|
| 163 |
+
help="Path to JSONL log file",
|
| 164 |
+
)
|
| 165 |
+
proxy_parser.add_argument(
|
| 166 |
+
"--budget",
|
| 167 |
+
type=float,
|
| 168 |
+
help="Daily budget limit in USD",
|
| 169 |
+
)
|
| 170 |
+
proxy_parser.set_defaults(func=cmd_proxy)
|
| 171 |
+
|
| 172 |
+
args = parser.parse_args(argv)
|
| 173 |
+
|
| 174 |
+
if args.version:
|
| 175 |
+
return cmd_version(args)
|
| 176 |
+
|
| 177 |
+
if args.command is None:
|
| 178 |
+
parser.print_help()
|
| 179 |
+
return 0
|
| 180 |
+
|
| 181 |
+
return args.func(args)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
if __name__ == "__main__":
|
| 185 |
+
sys.exit(main())
|
|
@@ -2,8 +2,9 @@
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 5 |
from datetime import datetime
|
| 6 |
-
from typing import Any
|
| 7 |
|
| 8 |
from .config import (
|
| 9 |
HeadroomConfig,
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
from collections.abc import Iterator
|
| 6 |
from datetime import datetime
|
| 7 |
+
from typing import Any
|
| 8 |
|
| 9 |
from .config import (
|
| 10 |
HeadroomConfig,
|
|
@@ -301,8 +301,8 @@ class TransformResult:
|
|
| 301 |
transforms_applied: list[str]
|
| 302 |
markers_inserted: list[str] = field(default_factory=list)
|
| 303 |
warnings: list[str] = field(default_factory=list)
|
| 304 |
-
diff_artifact:
|
| 305 |
-
cache_metrics:
|
| 306 |
|
| 307 |
|
| 308 |
@dataclass
|
|
|
|
| 301 |
transforms_applied: list[str]
|
| 302 |
markers_inserted: list[str] = field(default_factory=list)
|
| 303 |
warnings: list[str] = field(default_factory=list)
|
| 304 |
+
diff_artifact: DiffArtifact | None = None # Populated if generate_diff_artifact=True
|
| 305 |
+
cache_metrics: CachePrefixMetrics | None = None # Populated by CacheAligner
|
| 306 |
|
| 307 |
|
| 308 |
@dataclass
|
|
@@ -8,21 +8,20 @@ Install LangChain support: pip install headroom[langchain]
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
from .langchain import (
|
| 11 |
-
HeadroomChatModel,
|
| 12 |
HeadroomCallbackHandler,
|
| 13 |
-
|
| 14 |
HeadroomRunnable,
|
|
|
|
| 15 |
)
|
| 16 |
-
|
| 17 |
from .mcp import (
|
| 18 |
-
|
| 19 |
HeadroomMCPClientWrapper,
|
|
|
|
| 20 |
MCPCompressionResult,
|
| 21 |
MCPToolProfile,
|
| 22 |
compress_tool_result,
|
| 23 |
compress_tool_result_with_metrics,
|
| 24 |
create_headroom_mcp_proxy,
|
| 25 |
-
DEFAULT_MCP_PROFILES,
|
| 26 |
)
|
| 27 |
|
| 28 |
__all__ = [
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
from .langchain import (
|
|
|
|
| 11 |
HeadroomCallbackHandler,
|
| 12 |
+
HeadroomChatModel,
|
| 13 |
HeadroomRunnable,
|
| 14 |
+
optimize_messages,
|
| 15 |
)
|
|
|
|
| 16 |
from .mcp import (
|
| 17 |
+
DEFAULT_MCP_PROFILES,
|
| 18 |
HeadroomMCPClientWrapper,
|
| 19 |
+
HeadroomMCPCompressor,
|
| 20 |
MCPCompressionResult,
|
| 21 |
MCPToolProfile,
|
| 22 |
compress_tool_result,
|
| 23 |
compress_tool_result_with_metrics,
|
| 24 |
create_headroom_mcp_proxy,
|
|
|
|
| 25 |
)
|
| 26 |
|
| 27 |
__all__ = [
|
|
@@ -29,9 +29,10 @@ from __future__ import annotations
|
|
| 29 |
|
| 30 |
import json
|
| 31 |
import logging
|
|
|
|
| 32 |
from dataclasses import dataclass
|
| 33 |
from datetime import datetime
|
| 34 |
-
from typing import Any
|
| 35 |
from uuid import uuid4
|
| 36 |
|
| 37 |
# LangChain imports - these are optional dependencies
|
|
@@ -378,7 +379,7 @@ class HeadroomChatModel(BaseChatModel):
|
|
| 378 |
**kwargs,
|
| 379 |
)
|
| 380 |
|
| 381 |
-
def bind_tools(self, tools: Sequence[Any], **kwargs) ->
|
| 382 |
"""Bind tools to the wrapped model."""
|
| 383 |
new_wrapped = self.wrapped_model.bind_tools(tools, **kwargs)
|
| 384 |
return HeadroomChatModel(
|
|
|
|
| 29 |
|
| 30 |
import json
|
| 31 |
import logging
|
| 32 |
+
from collections.abc import Iterator, Sequence
|
| 33 |
from dataclasses import dataclass
|
| 34 |
from datetime import datetime
|
| 35 |
+
from typing import Any
|
| 36 |
from uuid import uuid4
|
| 37 |
|
| 38 |
# LangChain imports - these are optional dependencies
|
|
|
|
| 379 |
**kwargs,
|
| 380 |
)
|
| 381 |
|
| 382 |
+
def bind_tools(self, tools: Sequence[Any], **kwargs) -> HeadroomChatModel:
|
| 383 |
"""Bind tools to the wrapped model."""
|
| 384 |
new_wrapped = self.wrapped_model.bind_tools(tools, **kwargs)
|
| 385 |
return HeadroomChatModel(
|
|
@@ -48,12 +48,13 @@ from __future__ import annotations
|
|
| 48 |
|
| 49 |
import json
|
| 50 |
import re
|
|
|
|
| 51 |
from dataclasses import dataclass, field
|
| 52 |
-
from typing import Any
|
| 53 |
|
| 54 |
from headroom.config import HeadroomConfig, SmartCrusherConfig
|
| 55 |
-
from headroom.transforms import SmartCrusher
|
| 56 |
from headroom.providers import OpenAIProvider
|
|
|
|
| 57 |
|
| 58 |
|
| 59 |
@dataclass
|
|
|
|
| 48 |
|
| 49 |
import json
|
| 50 |
import re
|
| 51 |
+
from collections.abc import Callable
|
| 52 |
from dataclasses import dataclass, field
|
| 53 |
+
from typing import Any
|
| 54 |
|
| 55 |
from headroom.config import HeadroomConfig, SmartCrusherConfig
|
|
|
|
| 56 |
from headroom.providers import OpenAIProvider
|
| 57 |
+
from headroom.transforms import SmartCrusher
|
| 58 |
|
| 59 |
|
| 60 |
@dataclass
|
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Model registry and capabilities database.
|
| 2 |
+
|
| 3 |
+
Provides a centralized registry of LLM models with their capabilities,
|
| 4 |
+
context limits, pricing, and provider information.
|
| 5 |
+
|
| 6 |
+
Usage:
|
| 7 |
+
from headroom.models import ModelRegistry, get_model_info
|
| 8 |
+
|
| 9 |
+
# Get info about a model
|
| 10 |
+
info = get_model_info("gpt-4o")
|
| 11 |
+
print(f"Context: {info.context_window}")
|
| 12 |
+
print(f"Provider: {info.provider}")
|
| 13 |
+
|
| 14 |
+
# List all models from a provider
|
| 15 |
+
models = ModelRegistry.list_models(provider="openai")
|
| 16 |
+
|
| 17 |
+
# Register a custom model
|
| 18 |
+
ModelRegistry.register(
|
| 19 |
+
"my-custom-model",
|
| 20 |
+
provider="custom",
|
| 21 |
+
context_window=32000,
|
| 22 |
+
)
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from .registry import (
|
| 26 |
+
ModelInfo,
|
| 27 |
+
ModelRegistry,
|
| 28 |
+
get_model_info,
|
| 29 |
+
list_models,
|
| 30 |
+
register_model,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
__all__ = [
|
| 34 |
+
"ModelRegistry",
|
| 35 |
+
"ModelInfo",
|
| 36 |
+
"get_model_info",
|
| 37 |
+
"list_models",
|
| 38 |
+
"register_model",
|
| 39 |
+
]
|
|
@@ -0,0 +1,749 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Model registry with capabilities database.
|
| 2 |
+
|
| 3 |
+
Centralized database of LLM models with their capabilities, context limits,
|
| 4 |
+
pricing, and provider information. Supports dynamic registration of custom
|
| 5 |
+
models and automatic provider detection.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from dataclasses import dataclass
|
| 11 |
+
from datetime import date
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass(frozen=True)
|
| 16 |
+
class ModelInfo:
|
| 17 |
+
"""Information about an LLM model.
|
| 18 |
+
|
| 19 |
+
Attributes:
|
| 20 |
+
name: Model identifier.
|
| 21 |
+
provider: Provider name (openai, anthropic, etc.).
|
| 22 |
+
context_window: Maximum context window in tokens.
|
| 23 |
+
max_output_tokens: Maximum output tokens.
|
| 24 |
+
supports_tools: Whether model supports tool/function calling.
|
| 25 |
+
supports_vision: Whether model supports image inputs.
|
| 26 |
+
supports_streaming: Whether model supports streaming responses.
|
| 27 |
+
supports_json_mode: Whether model supports JSON output mode.
|
| 28 |
+
tokenizer_backend: Tokenizer backend to use.
|
| 29 |
+
input_cost_per_1m: Cost per 1M input tokens in USD.
|
| 30 |
+
output_cost_per_1m: Cost per 1M output tokens in USD.
|
| 31 |
+
cached_input_cost_per_1m: Cost per 1M cached input tokens.
|
| 32 |
+
pricing_date: Date pricing was last updated.
|
| 33 |
+
aliases: Alternative names for the model.
|
| 34 |
+
notes: Additional notes about the model.
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
name: str
|
| 38 |
+
provider: str
|
| 39 |
+
context_window: int = 128000
|
| 40 |
+
max_output_tokens: int = 4096
|
| 41 |
+
supports_tools: bool = True
|
| 42 |
+
supports_vision: bool = False
|
| 43 |
+
supports_streaming: bool = True
|
| 44 |
+
supports_json_mode: bool = True
|
| 45 |
+
tokenizer_backend: str | None = None
|
| 46 |
+
input_cost_per_1m: float | None = None
|
| 47 |
+
output_cost_per_1m: float | None = None
|
| 48 |
+
cached_input_cost_per_1m: float | None = None
|
| 49 |
+
pricing_date: date | None = None
|
| 50 |
+
aliases: tuple[str, ...] = ()
|
| 51 |
+
notes: str = ""
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# Built-in model database
|
| 55 |
+
# Pricing as of January 2025 - verify current rates
|
| 56 |
+
_MODELS: dict[str, ModelInfo] = {}
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _register_builtin_models() -> None:
|
| 60 |
+
"""Register built-in models."""
|
| 61 |
+
|
| 62 |
+
# ============================================================
|
| 63 |
+
# OpenAI Models
|
| 64 |
+
# ============================================================
|
| 65 |
+
|
| 66 |
+
# GPT-4o family
|
| 67 |
+
_MODELS["gpt-4o"] = ModelInfo(
|
| 68 |
+
name="gpt-4o",
|
| 69 |
+
provider="openai",
|
| 70 |
+
context_window=128000,
|
| 71 |
+
max_output_tokens=16384,
|
| 72 |
+
supports_tools=True,
|
| 73 |
+
supports_vision=True,
|
| 74 |
+
supports_streaming=True,
|
| 75 |
+
tokenizer_backend="tiktoken",
|
| 76 |
+
input_cost_per_1m=2.50,
|
| 77 |
+
output_cost_per_1m=10.00,
|
| 78 |
+
cached_input_cost_per_1m=1.25,
|
| 79 |
+
pricing_date=date(2025, 1, 6),
|
| 80 |
+
aliases=("gpt-4o-2024-11-20", "gpt-4o-2024-08-06", "gpt-4o-2024-05-13"),
|
| 81 |
+
notes="Latest GPT-4o with vision and tools",
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
_MODELS["gpt-4o-mini"] = ModelInfo(
|
| 85 |
+
name="gpt-4o-mini",
|
| 86 |
+
provider="openai",
|
| 87 |
+
context_window=128000,
|
| 88 |
+
max_output_tokens=16384,
|
| 89 |
+
supports_tools=True,
|
| 90 |
+
supports_vision=True,
|
| 91 |
+
supports_streaming=True,
|
| 92 |
+
tokenizer_backend="tiktoken",
|
| 93 |
+
input_cost_per_1m=0.15,
|
| 94 |
+
output_cost_per_1m=0.60,
|
| 95 |
+
cached_input_cost_per_1m=0.075,
|
| 96 |
+
pricing_date=date(2025, 1, 6),
|
| 97 |
+
aliases=("gpt-4o-mini-2024-07-18",),
|
| 98 |
+
notes="Cost-effective GPT-4o variant",
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
# o1 reasoning models
|
| 102 |
+
_MODELS["o1"] = ModelInfo(
|
| 103 |
+
name="o1",
|
| 104 |
+
provider="openai",
|
| 105 |
+
context_window=200000,
|
| 106 |
+
max_output_tokens=100000,
|
| 107 |
+
supports_tools=True,
|
| 108 |
+
supports_vision=True,
|
| 109 |
+
supports_streaming=True,
|
| 110 |
+
tokenizer_backend="tiktoken",
|
| 111 |
+
input_cost_per_1m=15.00,
|
| 112 |
+
output_cost_per_1m=60.00,
|
| 113 |
+
cached_input_cost_per_1m=7.50,
|
| 114 |
+
pricing_date=date(2025, 1, 6),
|
| 115 |
+
notes="Full reasoning model with extended thinking",
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
_MODELS["o1-mini"] = ModelInfo(
|
| 119 |
+
name="o1-mini",
|
| 120 |
+
provider="openai",
|
| 121 |
+
context_window=128000,
|
| 122 |
+
max_output_tokens=65536,
|
| 123 |
+
supports_tools=True,
|
| 124 |
+
supports_vision=False,
|
| 125 |
+
supports_streaming=True,
|
| 126 |
+
tokenizer_backend="tiktoken",
|
| 127 |
+
input_cost_per_1m=1.10,
|
| 128 |
+
output_cost_per_1m=4.40,
|
| 129 |
+
cached_input_cost_per_1m=0.55,
|
| 130 |
+
pricing_date=date(2025, 1, 6),
|
| 131 |
+
notes="Fast reasoning model",
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
_MODELS["o3-mini"] = ModelInfo(
|
| 135 |
+
name="o3-mini",
|
| 136 |
+
provider="openai",
|
| 137 |
+
context_window=200000,
|
| 138 |
+
max_output_tokens=100000,
|
| 139 |
+
supports_tools=True,
|
| 140 |
+
supports_vision=True,
|
| 141 |
+
supports_streaming=True,
|
| 142 |
+
tokenizer_backend="tiktoken",
|
| 143 |
+
input_cost_per_1m=1.10,
|
| 144 |
+
output_cost_per_1m=4.40,
|
| 145 |
+
cached_input_cost_per_1m=0.55,
|
| 146 |
+
pricing_date=date(2025, 1, 6),
|
| 147 |
+
notes="Latest reasoning model",
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
# GPT-4 Turbo
|
| 151 |
+
_MODELS["gpt-4-turbo"] = ModelInfo(
|
| 152 |
+
name="gpt-4-turbo",
|
| 153 |
+
provider="openai",
|
| 154 |
+
context_window=128000,
|
| 155 |
+
max_output_tokens=4096,
|
| 156 |
+
supports_tools=True,
|
| 157 |
+
supports_vision=True,
|
| 158 |
+
supports_streaming=True,
|
| 159 |
+
tokenizer_backend="tiktoken",
|
| 160 |
+
input_cost_per_1m=10.00,
|
| 161 |
+
output_cost_per_1m=30.00,
|
| 162 |
+
cached_input_cost_per_1m=5.00,
|
| 163 |
+
pricing_date=date(2025, 1, 6),
|
| 164 |
+
aliases=("gpt-4-turbo-preview", "gpt-4-turbo-2024-04-09"),
|
| 165 |
+
notes="GPT-4 Turbo with vision",
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
# GPT-4
|
| 169 |
+
_MODELS["gpt-4"] = ModelInfo(
|
| 170 |
+
name="gpt-4",
|
| 171 |
+
provider="openai",
|
| 172 |
+
context_window=8192,
|
| 173 |
+
max_output_tokens=4096,
|
| 174 |
+
supports_tools=True,
|
| 175 |
+
supports_vision=False,
|
| 176 |
+
supports_streaming=True,
|
| 177 |
+
tokenizer_backend="tiktoken",
|
| 178 |
+
input_cost_per_1m=30.00,
|
| 179 |
+
output_cost_per_1m=60.00,
|
| 180 |
+
pricing_date=date(2025, 1, 6),
|
| 181 |
+
aliases=("gpt-4-0613",),
|
| 182 |
+
notes="Original GPT-4",
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
_MODELS["gpt-4-32k"] = ModelInfo(
|
| 186 |
+
name="gpt-4-32k",
|
| 187 |
+
provider="openai",
|
| 188 |
+
context_window=32768,
|
| 189 |
+
max_output_tokens=4096,
|
| 190 |
+
supports_tools=True,
|
| 191 |
+
supports_vision=False,
|
| 192 |
+
supports_streaming=True,
|
| 193 |
+
tokenizer_backend="tiktoken",
|
| 194 |
+
input_cost_per_1m=60.00,
|
| 195 |
+
output_cost_per_1m=120.00,
|
| 196 |
+
pricing_date=date(2025, 1, 6),
|
| 197 |
+
notes="Extended context GPT-4",
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
# GPT-3.5
|
| 201 |
+
_MODELS["gpt-3.5-turbo"] = ModelInfo(
|
| 202 |
+
name="gpt-3.5-turbo",
|
| 203 |
+
provider="openai",
|
| 204 |
+
context_window=16385,
|
| 205 |
+
max_output_tokens=4096,
|
| 206 |
+
supports_tools=True,
|
| 207 |
+
supports_vision=False,
|
| 208 |
+
supports_streaming=True,
|
| 209 |
+
tokenizer_backend="tiktoken",
|
| 210 |
+
input_cost_per_1m=0.50,
|
| 211 |
+
output_cost_per_1m=1.50,
|
| 212 |
+
cached_input_cost_per_1m=0.25,
|
| 213 |
+
pricing_date=date(2025, 1, 6),
|
| 214 |
+
aliases=("gpt-3.5-turbo-0125", "gpt-3.5-turbo-1106"),
|
| 215 |
+
notes="Fast and cost-effective",
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
# ============================================================
|
| 219 |
+
# Anthropic Models
|
| 220 |
+
# ============================================================
|
| 221 |
+
|
| 222 |
+
_MODELS["claude-3-5-sonnet-20241022"] = ModelInfo(
|
| 223 |
+
name="claude-3-5-sonnet-20241022",
|
| 224 |
+
provider="anthropic",
|
| 225 |
+
context_window=200000,
|
| 226 |
+
max_output_tokens=8192,
|
| 227 |
+
supports_tools=True,
|
| 228 |
+
supports_vision=True,
|
| 229 |
+
supports_streaming=True,
|
| 230 |
+
tokenizer_backend="anthropic",
|
| 231 |
+
input_cost_per_1m=3.00,
|
| 232 |
+
output_cost_per_1m=15.00,
|
| 233 |
+
cached_input_cost_per_1m=0.30,
|
| 234 |
+
pricing_date=date(2025, 1, 6),
|
| 235 |
+
aliases=("claude-3-5-sonnet-latest", "claude-sonnet-4-20250514"),
|
| 236 |
+
notes="Claude 3.5 Sonnet - Best balance of speed and capability",
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
_MODELS["claude-3-5-haiku-20241022"] = ModelInfo(
|
| 240 |
+
name="claude-3-5-haiku-20241022",
|
| 241 |
+
provider="anthropic",
|
| 242 |
+
context_window=200000,
|
| 243 |
+
max_output_tokens=8192,
|
| 244 |
+
supports_tools=True,
|
| 245 |
+
supports_vision=True,
|
| 246 |
+
supports_streaming=True,
|
| 247 |
+
tokenizer_backend="anthropic",
|
| 248 |
+
input_cost_per_1m=0.80,
|
| 249 |
+
output_cost_per_1m=4.00,
|
| 250 |
+
cached_input_cost_per_1m=0.08,
|
| 251 |
+
pricing_date=date(2025, 1, 6),
|
| 252 |
+
aliases=("claude-3-5-haiku-latest",),
|
| 253 |
+
notes="Claude 3.5 Haiku - Fast and cost-effective",
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
_MODELS["claude-3-opus-20240229"] = ModelInfo(
|
| 257 |
+
name="claude-3-opus-20240229",
|
| 258 |
+
provider="anthropic",
|
| 259 |
+
context_window=200000,
|
| 260 |
+
max_output_tokens=4096,
|
| 261 |
+
supports_tools=True,
|
| 262 |
+
supports_vision=True,
|
| 263 |
+
supports_streaming=True,
|
| 264 |
+
tokenizer_backend="anthropic",
|
| 265 |
+
input_cost_per_1m=15.00,
|
| 266 |
+
output_cost_per_1m=75.00,
|
| 267 |
+
cached_input_cost_per_1m=1.50,
|
| 268 |
+
pricing_date=date(2025, 1, 6),
|
| 269 |
+
aliases=("claude-3-opus-latest",),
|
| 270 |
+
notes="Claude 3 Opus - Most capable",
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
_MODELS["claude-3-haiku-20240307"] = ModelInfo(
|
| 274 |
+
name="claude-3-haiku-20240307",
|
| 275 |
+
provider="anthropic",
|
| 276 |
+
context_window=200000,
|
| 277 |
+
max_output_tokens=4096,
|
| 278 |
+
supports_tools=True,
|
| 279 |
+
supports_vision=True,
|
| 280 |
+
supports_streaming=True,
|
| 281 |
+
tokenizer_backend="anthropic",
|
| 282 |
+
input_cost_per_1m=0.25,
|
| 283 |
+
output_cost_per_1m=1.25,
|
| 284 |
+
cached_input_cost_per_1m=0.03,
|
| 285 |
+
pricing_date=date(2025, 1, 6),
|
| 286 |
+
notes="Claude 3 Haiku - Legacy fast model",
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
# ============================================================
|
| 290 |
+
# Google Models
|
| 291 |
+
# ============================================================
|
| 292 |
+
|
| 293 |
+
_MODELS["gemini-2.0-flash"] = ModelInfo(
|
| 294 |
+
name="gemini-2.0-flash",
|
| 295 |
+
provider="google",
|
| 296 |
+
context_window=1000000,
|
| 297 |
+
max_output_tokens=8192,
|
| 298 |
+
supports_tools=True,
|
| 299 |
+
supports_vision=True,
|
| 300 |
+
supports_streaming=True,
|
| 301 |
+
tokenizer_backend="google",
|
| 302 |
+
input_cost_per_1m=0.10,
|
| 303 |
+
output_cost_per_1m=0.40,
|
| 304 |
+
pricing_date=date(2025, 1, 6),
|
| 305 |
+
aliases=("gemini-2.0-flash-exp",),
|
| 306 |
+
notes="Gemini 2.0 Flash - Fast multimodal",
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
_MODELS["gemini-1.5-pro"] = ModelInfo(
|
| 310 |
+
name="gemini-1.5-pro",
|
| 311 |
+
provider="google",
|
| 312 |
+
context_window=2000000,
|
| 313 |
+
max_output_tokens=8192,
|
| 314 |
+
supports_tools=True,
|
| 315 |
+
supports_vision=True,
|
| 316 |
+
supports_streaming=True,
|
| 317 |
+
tokenizer_backend="google",
|
| 318 |
+
input_cost_per_1m=1.25,
|
| 319 |
+
output_cost_per_1m=5.00,
|
| 320 |
+
pricing_date=date(2025, 1, 6),
|
| 321 |
+
aliases=("gemini-1.5-pro-latest",),
|
| 322 |
+
notes="Gemini 1.5 Pro - 2M context window",
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
_MODELS["gemini-1.5-flash"] = ModelInfo(
|
| 326 |
+
name="gemini-1.5-flash",
|
| 327 |
+
provider="google",
|
| 328 |
+
context_window=1000000,
|
| 329 |
+
max_output_tokens=8192,
|
| 330 |
+
supports_tools=True,
|
| 331 |
+
supports_vision=True,
|
| 332 |
+
supports_streaming=True,
|
| 333 |
+
tokenizer_backend="google",
|
| 334 |
+
input_cost_per_1m=0.075,
|
| 335 |
+
output_cost_per_1m=0.30,
|
| 336 |
+
pricing_date=date(2025, 1, 6),
|
| 337 |
+
aliases=("gemini-1.5-flash-latest",),
|
| 338 |
+
notes="Gemini 1.5 Flash - Cost-effective",
|
| 339 |
+
)
|
| 340 |
+
|
| 341 |
+
# ============================================================
|
| 342 |
+
# Meta Llama Models (open source)
|
| 343 |
+
# ============================================================
|
| 344 |
+
|
| 345 |
+
_MODELS["llama-3.3-70b"] = ModelInfo(
|
| 346 |
+
name="llama-3.3-70b",
|
| 347 |
+
provider="meta",
|
| 348 |
+
context_window=128000,
|
| 349 |
+
max_output_tokens=4096,
|
| 350 |
+
supports_tools=True,
|
| 351 |
+
supports_vision=False,
|
| 352 |
+
supports_streaming=True,
|
| 353 |
+
tokenizer_backend="huggingface",
|
| 354 |
+
aliases=("llama-3.3-70b-instruct", "meta-llama/Llama-3.3-70B-Instruct"),
|
| 355 |
+
notes="Llama 3.3 70B - Open source",
|
| 356 |
+
)
|
| 357 |
+
|
| 358 |
+
_MODELS["llama-3.1-405b"] = ModelInfo(
|
| 359 |
+
name="llama-3.1-405b",
|
| 360 |
+
provider="meta",
|
| 361 |
+
context_window=128000,
|
| 362 |
+
max_output_tokens=4096,
|
| 363 |
+
supports_tools=True,
|
| 364 |
+
supports_vision=False,
|
| 365 |
+
supports_streaming=True,
|
| 366 |
+
tokenizer_backend="huggingface",
|
| 367 |
+
aliases=("llama-3.1-405b-instruct", "meta-llama/Llama-3.1-405B-Instruct"),
|
| 368 |
+
notes="Llama 3.1 405B - Largest open source",
|
| 369 |
+
)
|
| 370 |
+
|
| 371 |
+
_MODELS["llama-3.1-70b"] = ModelInfo(
|
| 372 |
+
name="llama-3.1-70b",
|
| 373 |
+
provider="meta",
|
| 374 |
+
context_window=128000,
|
| 375 |
+
max_output_tokens=4096,
|
| 376 |
+
supports_tools=True,
|
| 377 |
+
supports_vision=False,
|
| 378 |
+
supports_streaming=True,
|
| 379 |
+
tokenizer_backend="huggingface",
|
| 380 |
+
aliases=("llama-3.1-70b-instruct", "meta-llama/Llama-3.1-70B-Instruct"),
|
| 381 |
+
notes="Llama 3.1 70B",
|
| 382 |
+
)
|
| 383 |
+
|
| 384 |
+
_MODELS["llama-3.1-8b"] = ModelInfo(
|
| 385 |
+
name="llama-3.1-8b",
|
| 386 |
+
provider="meta",
|
| 387 |
+
context_window=128000,
|
| 388 |
+
max_output_tokens=4096,
|
| 389 |
+
supports_tools=True,
|
| 390 |
+
supports_vision=False,
|
| 391 |
+
supports_streaming=True,
|
| 392 |
+
tokenizer_backend="huggingface",
|
| 393 |
+
aliases=("llama-3.1-8b-instruct", "meta-llama/Llama-3.1-8B-Instruct"),
|
| 394 |
+
notes="Llama 3.1 8B - Fast and efficient",
|
| 395 |
+
)
|
| 396 |
+
|
| 397 |
+
# ============================================================
|
| 398 |
+
# Mistral Models
|
| 399 |
+
# ============================================================
|
| 400 |
+
|
| 401 |
+
_MODELS["mistral-large"] = ModelInfo(
|
| 402 |
+
name="mistral-large",
|
| 403 |
+
provider="mistral",
|
| 404 |
+
context_window=128000,
|
| 405 |
+
max_output_tokens=4096,
|
| 406 |
+
supports_tools=True,
|
| 407 |
+
supports_vision=False,
|
| 408 |
+
supports_streaming=True,
|
| 409 |
+
tokenizer_backend="huggingface",
|
| 410 |
+
input_cost_per_1m=2.00,
|
| 411 |
+
output_cost_per_1m=6.00,
|
| 412 |
+
pricing_date=date(2025, 1, 6),
|
| 413 |
+
aliases=("mistral-large-latest",),
|
| 414 |
+
notes="Mistral Large - Best capability",
|
| 415 |
+
)
|
| 416 |
+
|
| 417 |
+
_MODELS["mistral-small"] = ModelInfo(
|
| 418 |
+
name="mistral-small",
|
| 419 |
+
provider="mistral",
|
| 420 |
+
context_window=32768,
|
| 421 |
+
max_output_tokens=4096,
|
| 422 |
+
supports_tools=True,
|
| 423 |
+
supports_vision=False,
|
| 424 |
+
supports_streaming=True,
|
| 425 |
+
tokenizer_backend="huggingface",
|
| 426 |
+
input_cost_per_1m=0.20,
|
| 427 |
+
output_cost_per_1m=0.60,
|
| 428 |
+
pricing_date=date(2025, 1, 6),
|
| 429 |
+
aliases=("mistral-small-latest",),
|
| 430 |
+
notes="Mistral Small - Cost-effective",
|
| 431 |
+
)
|
| 432 |
+
|
| 433 |
+
_MODELS["mixtral-8x7b"] = ModelInfo(
|
| 434 |
+
name="mixtral-8x7b",
|
| 435 |
+
provider="mistral",
|
| 436 |
+
context_window=32768,
|
| 437 |
+
max_output_tokens=4096,
|
| 438 |
+
supports_tools=True,
|
| 439 |
+
supports_vision=False,
|
| 440 |
+
supports_streaming=True,
|
| 441 |
+
tokenizer_backend="huggingface",
|
| 442 |
+
aliases=("mixtral-8x7b-instruct",),
|
| 443 |
+
notes="Mixtral 8x7B - MoE architecture",
|
| 444 |
+
)
|
| 445 |
+
|
| 446 |
+
_MODELS["mistral-7b"] = ModelInfo(
|
| 447 |
+
name="mistral-7b",
|
| 448 |
+
provider="mistral",
|
| 449 |
+
context_window=32768,
|
| 450 |
+
max_output_tokens=4096,
|
| 451 |
+
supports_tools=False,
|
| 452 |
+
supports_vision=False,
|
| 453 |
+
supports_streaming=True,
|
| 454 |
+
tokenizer_backend="huggingface",
|
| 455 |
+
aliases=("mistral-7b-instruct",),
|
| 456 |
+
notes="Mistral 7B - Open source",
|
| 457 |
+
)
|
| 458 |
+
|
| 459 |
+
# ============================================================
|
| 460 |
+
# DeepSeek Models
|
| 461 |
+
# ============================================================
|
| 462 |
+
|
| 463 |
+
_MODELS["deepseek-v3"] = ModelInfo(
|
| 464 |
+
name="deepseek-v3",
|
| 465 |
+
provider="deepseek",
|
| 466 |
+
context_window=128000,
|
| 467 |
+
max_output_tokens=8192,
|
| 468 |
+
supports_tools=True,
|
| 469 |
+
supports_vision=False,
|
| 470 |
+
supports_streaming=True,
|
| 471 |
+
tokenizer_backend="huggingface",
|
| 472 |
+
input_cost_per_1m=0.14,
|
| 473 |
+
output_cost_per_1m=0.28,
|
| 474 |
+
pricing_date=date(2025, 1, 6),
|
| 475 |
+
notes="DeepSeek V3 - High performance, low cost",
|
| 476 |
+
)
|
| 477 |
+
|
| 478 |
+
_MODELS["deepseek-coder"] = ModelInfo(
|
| 479 |
+
name="deepseek-coder",
|
| 480 |
+
provider="deepseek",
|
| 481 |
+
context_window=16384,
|
| 482 |
+
max_output_tokens=4096,
|
| 483 |
+
supports_tools=False,
|
| 484 |
+
supports_vision=False,
|
| 485 |
+
supports_streaming=True,
|
| 486 |
+
tokenizer_backend="huggingface",
|
| 487 |
+
notes="DeepSeek Coder - Specialized for code",
|
| 488 |
+
)
|
| 489 |
+
|
| 490 |
+
# ============================================================
|
| 491 |
+
# Qwen Models
|
| 492 |
+
# ============================================================
|
| 493 |
+
|
| 494 |
+
_MODELS["qwen2.5-72b"] = ModelInfo(
|
| 495 |
+
name="qwen2.5-72b",
|
| 496 |
+
provider="alibaba",
|
| 497 |
+
context_window=131072,
|
| 498 |
+
max_output_tokens=8192,
|
| 499 |
+
supports_tools=True,
|
| 500 |
+
supports_vision=False,
|
| 501 |
+
supports_streaming=True,
|
| 502 |
+
tokenizer_backend="huggingface",
|
| 503 |
+
aliases=("qwen2.5-72b-instruct",),
|
| 504 |
+
notes="Qwen 2.5 72B - Strong multilingual",
|
| 505 |
+
)
|
| 506 |
+
|
| 507 |
+
_MODELS["qwen2.5-7b"] = ModelInfo(
|
| 508 |
+
name="qwen2.5-7b",
|
| 509 |
+
provider="alibaba",
|
| 510 |
+
context_window=131072,
|
| 511 |
+
max_output_tokens=8192,
|
| 512 |
+
supports_tools=True,
|
| 513 |
+
supports_vision=False,
|
| 514 |
+
supports_streaming=True,
|
| 515 |
+
tokenizer_backend="huggingface",
|
| 516 |
+
aliases=("qwen2.5-7b-instruct",),
|
| 517 |
+
notes="Qwen 2.5 7B - Efficient",
|
| 518 |
+
)
|
| 519 |
+
|
| 520 |
+
|
| 521 |
+
# Initialize built-in models
|
| 522 |
+
_register_builtin_models()
|
| 523 |
+
|
| 524 |
+
# Build alias lookup
|
| 525 |
+
_ALIASES: dict[str, str] = {}
|
| 526 |
+
for model_name, info in _MODELS.items():
|
| 527 |
+
for alias in info.aliases:
|
| 528 |
+
_ALIASES[alias.lower()] = model_name
|
| 529 |
+
|
| 530 |
+
|
| 531 |
+
class ModelRegistry:
|
| 532 |
+
"""Registry of LLM models and their capabilities.
|
| 533 |
+
|
| 534 |
+
Singleton registry providing access to model information.
|
| 535 |
+
Supports built-in models and custom registration.
|
| 536 |
+
|
| 537 |
+
Example:
|
| 538 |
+
# Get model info
|
| 539 |
+
info = ModelRegistry.get("gpt-4o")
|
| 540 |
+
print(f"Context: {info.context_window}")
|
| 541 |
+
|
| 542 |
+
# Register custom model
|
| 543 |
+
ModelRegistry.register(
|
| 544 |
+
"my-model",
|
| 545 |
+
provider="custom",
|
| 546 |
+
context_window=32000,
|
| 547 |
+
)
|
| 548 |
+
|
| 549 |
+
# List models by provider
|
| 550 |
+
openai_models = ModelRegistry.list_models(provider="openai")
|
| 551 |
+
"""
|
| 552 |
+
|
| 553 |
+
@classmethod
|
| 554 |
+
def get(cls, model: str) -> ModelInfo | None:
|
| 555 |
+
"""Get model information.
|
| 556 |
+
|
| 557 |
+
Args:
|
| 558 |
+
model: Model name or alias.
|
| 559 |
+
|
| 560 |
+
Returns:
|
| 561 |
+
ModelInfo if found, None otherwise.
|
| 562 |
+
"""
|
| 563 |
+
model_lower = model.lower()
|
| 564 |
+
|
| 565 |
+
# Direct lookup
|
| 566 |
+
if model_lower in _MODELS:
|
| 567 |
+
return _MODELS[model_lower]
|
| 568 |
+
|
| 569 |
+
# Alias lookup
|
| 570 |
+
if model_lower in _ALIASES:
|
| 571 |
+
return _MODELS[_ALIASES[model_lower]]
|
| 572 |
+
|
| 573 |
+
# Prefix matching
|
| 574 |
+
for name, info in _MODELS.items():
|
| 575 |
+
if model_lower.startswith(name):
|
| 576 |
+
return info
|
| 577 |
+
|
| 578 |
+
return None
|
| 579 |
+
|
| 580 |
+
@classmethod
|
| 581 |
+
def register(
|
| 582 |
+
cls,
|
| 583 |
+
model: str,
|
| 584 |
+
provider: str,
|
| 585 |
+
context_window: int = 128000,
|
| 586 |
+
**kwargs: Any,
|
| 587 |
+
) -> ModelInfo:
|
| 588 |
+
"""Register a custom model.
|
| 589 |
+
|
| 590 |
+
Args:
|
| 591 |
+
model: Model name.
|
| 592 |
+
provider: Provider name.
|
| 593 |
+
context_window: Maximum context window.
|
| 594 |
+
**kwargs: Additional ModelInfo fields.
|
| 595 |
+
|
| 596 |
+
Returns:
|
| 597 |
+
Registered ModelInfo.
|
| 598 |
+
"""
|
| 599 |
+
info = ModelInfo(
|
| 600 |
+
name=model,
|
| 601 |
+
provider=provider,
|
| 602 |
+
context_window=context_window,
|
| 603 |
+
**kwargs,
|
| 604 |
+
)
|
| 605 |
+
_MODELS[model.lower()] = info
|
| 606 |
+
|
| 607 |
+
# Register aliases
|
| 608 |
+
for alias in info.aliases:
|
| 609 |
+
_ALIASES[alias.lower()] = model.lower()
|
| 610 |
+
|
| 611 |
+
return info
|
| 612 |
+
|
| 613 |
+
@classmethod
|
| 614 |
+
def list_models(
|
| 615 |
+
cls,
|
| 616 |
+
provider: str | None = None,
|
| 617 |
+
supports_tools: bool | None = None,
|
| 618 |
+
supports_vision: bool | None = None,
|
| 619 |
+
min_context: int | None = None,
|
| 620 |
+
) -> list[ModelInfo]:
|
| 621 |
+
"""List models matching criteria.
|
| 622 |
+
|
| 623 |
+
Args:
|
| 624 |
+
provider: Filter by provider.
|
| 625 |
+
supports_tools: Filter by tool support.
|
| 626 |
+
supports_vision: Filter by vision support.
|
| 627 |
+
min_context: Minimum context window.
|
| 628 |
+
|
| 629 |
+
Returns:
|
| 630 |
+
List of matching ModelInfo.
|
| 631 |
+
"""
|
| 632 |
+
results = []
|
| 633 |
+
for info in _MODELS.values():
|
| 634 |
+
if provider and info.provider != provider:
|
| 635 |
+
continue
|
| 636 |
+
if supports_tools is not None and info.supports_tools != supports_tools:
|
| 637 |
+
continue
|
| 638 |
+
if supports_vision is not None and info.supports_vision != supports_vision:
|
| 639 |
+
continue
|
| 640 |
+
if min_context and info.context_window < min_context:
|
| 641 |
+
continue
|
| 642 |
+
results.append(info)
|
| 643 |
+
return results
|
| 644 |
+
|
| 645 |
+
@classmethod
|
| 646 |
+
def list_providers(cls) -> list[str]:
|
| 647 |
+
"""List all known providers.
|
| 648 |
+
|
| 649 |
+
Returns:
|
| 650 |
+
List of provider names.
|
| 651 |
+
"""
|
| 652 |
+
return list(set(info.provider for info in _MODELS.values()))
|
| 653 |
+
|
| 654 |
+
@classmethod
|
| 655 |
+
def get_context_limit(cls, model: str, default: int = 128000) -> int:
|
| 656 |
+
"""Get context limit for a model.
|
| 657 |
+
|
| 658 |
+
Args:
|
| 659 |
+
model: Model name.
|
| 660 |
+
default: Default if model not found.
|
| 661 |
+
|
| 662 |
+
Returns:
|
| 663 |
+
Context window size.
|
| 664 |
+
"""
|
| 665 |
+
info = cls.get(model)
|
| 666 |
+
return info.context_window if info else default
|
| 667 |
+
|
| 668 |
+
@classmethod
|
| 669 |
+
def estimate_cost(
|
| 670 |
+
cls,
|
| 671 |
+
model: str,
|
| 672 |
+
input_tokens: int,
|
| 673 |
+
output_tokens: int,
|
| 674 |
+
cached_tokens: int = 0,
|
| 675 |
+
) -> float | None:
|
| 676 |
+
"""Estimate API cost for a model.
|
| 677 |
+
|
| 678 |
+
Args:
|
| 679 |
+
model: Model name.
|
| 680 |
+
input_tokens: Number of input tokens.
|
| 681 |
+
output_tokens: Number of output tokens.
|
| 682 |
+
cached_tokens: Number of cached input tokens.
|
| 683 |
+
|
| 684 |
+
Returns:
|
| 685 |
+
Estimated cost in USD, or None if pricing unknown.
|
| 686 |
+
"""
|
| 687 |
+
info = cls.get(model)
|
| 688 |
+
if not info or info.input_cost_per_1m is None:
|
| 689 |
+
return None
|
| 690 |
+
|
| 691 |
+
input_cost = (input_tokens / 1_000_000) * info.input_cost_per_1m
|
| 692 |
+
output_cost = (output_tokens / 1_000_000) * (info.output_cost_per_1m or 0)
|
| 693 |
+
|
| 694 |
+
if cached_tokens and info.cached_input_cost_per_1m:
|
| 695 |
+
# Adjust for cached tokens
|
| 696 |
+
regular_input = input_tokens - cached_tokens
|
| 697 |
+
cached_cost = (cached_tokens / 1_000_000) * info.cached_input_cost_per_1m
|
| 698 |
+
input_cost = (regular_input / 1_000_000) * info.input_cost_per_1m + cached_cost
|
| 699 |
+
|
| 700 |
+
return input_cost + output_cost
|
| 701 |
+
|
| 702 |
+
|
| 703 |
+
# Convenience functions
|
| 704 |
+
def get_model_info(model: str) -> ModelInfo | None:
|
| 705 |
+
"""Get information about a model.
|
| 706 |
+
|
| 707 |
+
Args:
|
| 708 |
+
model: Model name or alias.
|
| 709 |
+
|
| 710 |
+
Returns:
|
| 711 |
+
ModelInfo if found, None otherwise.
|
| 712 |
+
"""
|
| 713 |
+
return ModelRegistry.get(model)
|
| 714 |
+
|
| 715 |
+
|
| 716 |
+
def list_models(
|
| 717 |
+
provider: str | None = None,
|
| 718 |
+
**kwargs: Any,
|
| 719 |
+
) -> list[ModelInfo]:
|
| 720 |
+
"""List models matching criteria.
|
| 721 |
+
|
| 722 |
+
Args:
|
| 723 |
+
provider: Filter by provider.
|
| 724 |
+
**kwargs: Additional filter criteria.
|
| 725 |
+
|
| 726 |
+
Returns:
|
| 727 |
+
List of matching ModelInfo.
|
| 728 |
+
"""
|
| 729 |
+
return ModelRegistry.list_models(provider=provider, **kwargs)
|
| 730 |
+
|
| 731 |
+
|
| 732 |
+
def register_model(
|
| 733 |
+
model: str,
|
| 734 |
+
provider: str,
|
| 735 |
+
context_window: int = 128000,
|
| 736 |
+
**kwargs: Any,
|
| 737 |
+
) -> ModelInfo:
|
| 738 |
+
"""Register a custom model.
|
| 739 |
+
|
| 740 |
+
Args:
|
| 741 |
+
model: Model name.
|
| 742 |
+
provider: Provider name.
|
| 743 |
+
context_window: Maximum context window.
|
| 744 |
+
**kwargs: Additional ModelInfo fields.
|
| 745 |
+
|
| 746 |
+
Returns:
|
| 747 |
+
Registered ModelInfo.
|
| 748 |
+
"""
|
| 749 |
+
return ModelRegistry.register(model, provider, context_window, **kwargs)
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
| 4 |
|
| 5 |
import hashlib
|
| 6 |
import re
|
| 7 |
-
from typing import
|
| 8 |
|
| 9 |
from .config import Block, WasteSignals
|
| 10 |
|
|
|
|
| 4 |
|
| 5 |
import hashlib
|
| 6 |
import re
|
| 7 |
+
from typing import TYPE_CHECKING, Any
|
| 8 |
|
| 9 |
from .config import Block, WasteSignals
|
| 10 |
|
|
@@ -4,18 +4,21 @@ This module provides pricing information and cost estimation utilities
|
|
| 4 |
for various LLM providers including OpenAI and Anthropic.
|
| 5 |
"""
|
| 6 |
|
| 7 |
-
from .
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
from .openai_prices import (
|
| 9 |
LAST_UPDATED as OPENAI_LAST_UPDATED,
|
|
|
|
|
|
|
| 10 |
OPENAI_PRICES,
|
| 11 |
get_openai_registry,
|
| 12 |
)
|
| 13 |
-
from .
|
| 14 |
-
LAST_UPDATED as ANTHROPIC_LAST_UPDATED,
|
| 15 |
-
ANTHROPIC_PRICES,
|
| 16 |
-
get_anthropic_registry,
|
| 17 |
-
)
|
| 18 |
-
|
| 19 |
|
| 20 |
__all__ = [
|
| 21 |
# Core classes
|
|
|
|
| 4 |
for various LLM providers including OpenAI and Anthropic.
|
| 5 |
"""
|
| 6 |
|
| 7 |
+
from .anthropic_prices import (
|
| 8 |
+
ANTHROPIC_PRICES,
|
| 9 |
+
get_anthropic_registry,
|
| 10 |
+
)
|
| 11 |
+
from .anthropic_prices import (
|
| 12 |
+
LAST_UPDATED as ANTHROPIC_LAST_UPDATED,
|
| 13 |
+
)
|
| 14 |
from .openai_prices import (
|
| 15 |
LAST_UPDATED as OPENAI_LAST_UPDATED,
|
| 16 |
+
)
|
| 17 |
+
from .openai_prices import (
|
| 18 |
OPENAI_PRICES,
|
| 19 |
get_openai_registry,
|
| 20 |
)
|
| 21 |
+
from .registry import CostEstimate, ModelPricing, PricingRegistry
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
__all__ = [
|
| 24 |
# Core classes
|
|
@@ -4,7 +4,6 @@ from datetime import date
|
|
| 4 |
|
| 5 |
from .registry import ModelPricing, PricingRegistry
|
| 6 |
|
| 7 |
-
|
| 8 |
# Last verified date for pricing information
|
| 9 |
LAST_UPDATED = date(2025, 1, 6)
|
| 10 |
|
|
|
|
| 4 |
|
| 5 |
from .registry import ModelPricing, PricingRegistry
|
| 6 |
|
|
|
|
| 7 |
# Last verified date for pricing information
|
| 8 |
LAST_UPDATED = date(2025, 1, 6)
|
| 9 |
|
|
@@ -4,7 +4,6 @@ from datetime import date
|
|
| 4 |
|
| 5 |
from .registry import ModelPricing, PricingRegistry
|
| 6 |
|
| 7 |
-
|
| 8 |
# Last verified date for pricing information
|
| 9 |
LAST_UPDATED = date(2025, 1, 6)
|
| 10 |
|
|
|
|
| 4 |
|
| 5 |
from .registry import ModelPricing, PricingRegistry
|
| 6 |
|
|
|
|
| 7 |
# Last verified date for pricing information
|
| 8 |
LAST_UPDATED = date(2025, 1, 6)
|
| 9 |
|
|
@@ -2,7 +2,6 @@
|
|
| 2 |
|
| 3 |
from dataclasses import dataclass, field
|
| 4 |
from datetime import date, timedelta
|
| 5 |
-
from typing import Optional
|
| 6 |
|
| 7 |
|
| 8 |
@dataclass(frozen=True)
|
|
@@ -15,11 +14,11 @@ class ModelPricing:
|
|
| 15 |
provider: str
|
| 16 |
input_per_1m: float
|
| 17 |
output_per_1m: float
|
| 18 |
-
cached_input_per_1m:
|
| 19 |
-
batch_input_per_1m:
|
| 20 |
-
batch_output_per_1m:
|
| 21 |
-
context_window:
|
| 22 |
-
notes:
|
| 23 |
|
| 24 |
|
| 25 |
@dataclass
|
|
@@ -27,9 +26,9 @@ class CostEstimate:
|
|
| 27 |
"""Result of a cost estimation calculation."""
|
| 28 |
cost_usd: float
|
| 29 |
breakdown: dict = field(default_factory=dict)
|
| 30 |
-
pricing_date:
|
| 31 |
is_stale: bool = False
|
| 32 |
-
warning:
|
| 33 |
|
| 34 |
|
| 35 |
class PricingRegistry:
|
|
@@ -41,8 +40,8 @@ class PricingRegistry:
|
|
| 41 |
def __init__(
|
| 42 |
self,
|
| 43 |
last_updated: date,
|
| 44 |
-
source_url:
|
| 45 |
-
prices:
|
| 46 |
):
|
| 47 |
"""Initialize the pricing registry.
|
| 48 |
|
|
@@ -55,7 +54,7 @@ class PricingRegistry:
|
|
| 55 |
self.source_url = source_url
|
| 56 |
self.prices: dict[str, ModelPricing] = prices or {}
|
| 57 |
|
| 58 |
-
def get_price(self, model: str) ->
|
| 59 |
"""Get pricing for a specific model.
|
| 60 |
|
| 61 |
Args:
|
|
@@ -75,7 +74,7 @@ class PricingRegistry:
|
|
| 75 |
age = date.today() - self.last_updated
|
| 76 |
return age > timedelta(days=self.STALENESS_THRESHOLD_DAYS)
|
| 77 |
|
| 78 |
-
def staleness_warning(self) ->
|
| 79 |
"""Get a warning message if pricing is stale.
|
| 80 |
|
| 81 |
Returns:
|
|
|
|
| 2 |
|
| 3 |
from dataclasses import dataclass, field
|
| 4 |
from datetime import date, timedelta
|
|
|
|
| 5 |
|
| 6 |
|
| 7 |
@dataclass(frozen=True)
|
|
|
|
| 14 |
provider: str
|
| 15 |
input_per_1m: float
|
| 16 |
output_per_1m: float
|
| 17 |
+
cached_input_per_1m: float | None = None
|
| 18 |
+
batch_input_per_1m: float | None = None
|
| 19 |
+
batch_output_per_1m: float | None = None
|
| 20 |
+
context_window: int | None = None
|
| 21 |
+
notes: str | None = None
|
| 22 |
|
| 23 |
|
| 24 |
@dataclass
|
|
|
|
| 26 |
"""Result of a cost estimation calculation."""
|
| 27 |
cost_usd: float
|
| 28 |
breakdown: dict = field(default_factory=dict)
|
| 29 |
+
pricing_date: date | None = None
|
| 30 |
is_stale: bool = False
|
| 31 |
+
warning: str | None = None
|
| 32 |
|
| 33 |
|
| 34 |
class PricingRegistry:
|
|
|
|
| 40 |
def __init__(
|
| 41 |
self,
|
| 42 |
last_updated: date,
|
| 43 |
+
source_url: str | None = None,
|
| 44 |
+
prices: dict[str, ModelPricing] | None = None,
|
| 45 |
):
|
| 46 |
"""Initialize the pricing registry.
|
| 47 |
|
|
|
|
| 54 |
self.source_url = source_url
|
| 55 |
self.prices: dict[str, ModelPricing] = prices or {}
|
| 56 |
|
| 57 |
+
def get_price(self, model: str) -> ModelPricing | None:
|
| 58 |
"""Get pricing for a specific model.
|
| 59 |
|
| 60 |
Args:
|
|
|
|
| 74 |
age = date.today() - self.last_updated
|
| 75 |
return age > timedelta(days=self.STALENESS_THRESHOLD_DAYS)
|
| 76 |
|
| 77 |
+
def staleness_warning(self) -> str | None:
|
| 78 |
"""Get a warning message if pricing is stale.
|
| 79 |
|
| 80 |
Returns:
|
|
@@ -2,15 +2,60 @@
|
|
| 2 |
|
| 3 |
Providers encapsulate model-specific behavior like tokenization,
|
| 4 |
context limits, and cost estimation.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
from .anthropic import AnthropicProvider
|
| 8 |
from .base import Provider, TokenCounter
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
from .openai import OpenAIProvider
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
__all__ = [
|
|
|
|
| 12 |
"Provider",
|
| 13 |
"TokenCounter",
|
|
|
|
| 14 |
"OpenAIProvider",
|
| 15 |
"AnthropicProvider",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
]
|
|
|
|
| 2 |
|
| 3 |
Providers encapsulate model-specific behavior like tokenization,
|
| 4 |
context limits, and cost estimation.
|
| 5 |
+
|
| 6 |
+
Supported Providers:
|
| 7 |
+
- OpenAIProvider: Native OpenAI models (GPT-4o, o1, etc.)
|
| 8 |
+
- AnthropicProvider: Claude models
|
| 9 |
+
- GoogleProvider: Google Gemini models
|
| 10 |
+
- CohereProvider: Cohere Command models
|
| 11 |
+
- OpenAICompatibleProvider: Universal provider for any OpenAI-compatible API
|
| 12 |
+
(Ollama, vLLM, Together, Groq, Fireworks, LM Studio, etc.)
|
| 13 |
+
- LiteLLMProvider: Universal provider via LiteLLM (100+ providers)
|
| 14 |
"""
|
| 15 |
|
| 16 |
from .anthropic import AnthropicProvider
|
| 17 |
from .base import Provider, TokenCounter
|
| 18 |
+
from .cohere import CohereProvider
|
| 19 |
+
from .google import GoogleProvider
|
| 20 |
+
from .litellm import (
|
| 21 |
+
LiteLLMProvider,
|
| 22 |
+
create_litellm_provider,
|
| 23 |
+
is_litellm_available,
|
| 24 |
+
)
|
| 25 |
from .openai import OpenAIProvider
|
| 26 |
+
from .openai_compatible import (
|
| 27 |
+
ModelCapabilities,
|
| 28 |
+
OpenAICompatibleProvider,
|
| 29 |
+
create_anyscale_provider,
|
| 30 |
+
create_fireworks_provider,
|
| 31 |
+
create_groq_provider,
|
| 32 |
+
create_lmstudio_provider,
|
| 33 |
+
create_ollama_provider,
|
| 34 |
+
create_together_provider,
|
| 35 |
+
create_vllm_provider,
|
| 36 |
+
)
|
| 37 |
|
| 38 |
__all__ = [
|
| 39 |
+
# Base
|
| 40 |
"Provider",
|
| 41 |
"TokenCounter",
|
| 42 |
+
# Native providers
|
| 43 |
"OpenAIProvider",
|
| 44 |
"AnthropicProvider",
|
| 45 |
+
"GoogleProvider",
|
| 46 |
+
"CohereProvider",
|
| 47 |
+
# Universal providers
|
| 48 |
+
"OpenAICompatibleProvider",
|
| 49 |
+
"ModelCapabilities",
|
| 50 |
+
"LiteLLMProvider",
|
| 51 |
+
"is_litellm_available",
|
| 52 |
+
# Factory functions
|
| 53 |
+
"create_ollama_provider",
|
| 54 |
+
"create_together_provider",
|
| 55 |
+
"create_groq_provider",
|
| 56 |
+
"create_fireworks_provider",
|
| 57 |
+
"create_anyscale_provider",
|
| 58 |
+
"create_vllm_provider",
|
| 59 |
+
"create_lmstudio_provider",
|
| 60 |
+
"create_litellm_provider",
|
| 61 |
]
|
|
@@ -0,0 +1,313 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cohere provider for Headroom SDK.
|
| 2 |
+
|
| 3 |
+
Token counting uses Cohere's official tokenize API when a client
|
| 4 |
+
is provided. This gives accurate counts for all content types.
|
| 5 |
+
|
| 6 |
+
Usage:
|
| 7 |
+
import cohere
|
| 8 |
+
from headroom import CohereProvider
|
| 9 |
+
|
| 10 |
+
client = cohere.ClientV2() # Uses CO_API_KEY env var
|
| 11 |
+
provider = CohereProvider(client=client) # Accurate counting via API
|
| 12 |
+
|
| 13 |
+
# Or without client (uses estimation - less accurate)
|
| 14 |
+
provider = CohereProvider() # Warning: approximate counting
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import logging
|
| 20 |
+
import warnings
|
| 21 |
+
from datetime import date
|
| 22 |
+
from typing import Any
|
| 23 |
+
|
| 24 |
+
from headroom.tokenizers import EstimatingTokenCounter
|
| 25 |
+
|
| 26 |
+
from .base import Provider, TokenCounter
|
| 27 |
+
|
| 28 |
+
logger = logging.getLogger(__name__)
|
| 29 |
+
|
| 30 |
+
# Warning flags
|
| 31 |
+
_FALLBACK_WARNING_SHOWN = False
|
| 32 |
+
|
| 33 |
+
# Pricing metadata
|
| 34 |
+
_PRICING_LAST_UPDATED = date(2025, 1, 6)
|
| 35 |
+
|
| 36 |
+
# Cohere model context limits
|
| 37 |
+
_CONTEXT_LIMITS: dict[str, int] = {
|
| 38 |
+
# Command A (latest, 2025)
|
| 39 |
+
"command-a-03-2025": 256000,
|
| 40 |
+
"command-a": 256000,
|
| 41 |
+
# Command R+ (2024)
|
| 42 |
+
"command-r-plus-08-2024": 128000,
|
| 43 |
+
"command-r-plus": 128000,
|
| 44 |
+
# Command R (2024)
|
| 45 |
+
"command-r-08-2024": 128000,
|
| 46 |
+
"command-r": 128000,
|
| 47 |
+
# Command (legacy)
|
| 48 |
+
"command": 4096,
|
| 49 |
+
"command-light": 4096,
|
| 50 |
+
"command-nightly": 128000,
|
| 51 |
+
# Embed models
|
| 52 |
+
"embed-english-v3.0": 512,
|
| 53 |
+
"embed-multilingual-v3.0": 512,
|
| 54 |
+
"embed-english-light-v3.0": 512,
|
| 55 |
+
"embed-multilingual-light-v3.0": 512,
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
# Pricing per 1M tokens (input, output)
|
| 59 |
+
_PRICING: dict[str, tuple[float, float]] = {
|
| 60 |
+
"command-a-03-2025": (2.50, 10.00),
|
| 61 |
+
"command-a": (2.50, 10.00),
|
| 62 |
+
"command-r-plus-08-2024": (2.50, 10.00),
|
| 63 |
+
"command-r-plus": (2.50, 10.00),
|
| 64 |
+
"command-r-08-2024": (0.15, 0.60),
|
| 65 |
+
"command-r": (0.15, 0.60),
|
| 66 |
+
"command": (1.00, 2.00),
|
| 67 |
+
"command-light": (0.30, 0.60),
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class CohereTokenCounter:
|
| 72 |
+
"""Token counter for Cohere models.
|
| 73 |
+
|
| 74 |
+
When a Cohere client is provided, uses the official tokenize API
|
| 75 |
+
for accurate counting. Falls back to estimation when no client
|
| 76 |
+
is available.
|
| 77 |
+
|
| 78 |
+
Usage:
|
| 79 |
+
import cohere
|
| 80 |
+
client = cohere.ClientV2()
|
| 81 |
+
|
| 82 |
+
# With API (accurate)
|
| 83 |
+
counter = CohereTokenCounter("command-r-plus", client=client)
|
| 84 |
+
|
| 85 |
+
# Without API (estimation)
|
| 86 |
+
counter = CohereTokenCounter("command-r-plus")
|
| 87 |
+
"""
|
| 88 |
+
|
| 89 |
+
def __init__(self, model: str, client: Any = None):
|
| 90 |
+
"""Initialize Cohere token counter.
|
| 91 |
+
|
| 92 |
+
Args:
|
| 93 |
+
model: Cohere model name.
|
| 94 |
+
client: Optional cohere.ClientV2 for API-based counting.
|
| 95 |
+
"""
|
| 96 |
+
global _FALLBACK_WARNING_SHOWN
|
| 97 |
+
|
| 98 |
+
self.model = model
|
| 99 |
+
self._client = client
|
| 100 |
+
self._use_api = client is not None
|
| 101 |
+
|
| 102 |
+
# Cohere uses ~4 chars per token
|
| 103 |
+
self._estimator = EstimatingTokenCounter(chars_per_token=4.0)
|
| 104 |
+
|
| 105 |
+
if not self._use_api and not _FALLBACK_WARNING_SHOWN:
|
| 106 |
+
warnings.warn(
|
| 107 |
+
"CohereProvider: No client provided, using estimation. "
|
| 108 |
+
"For accurate counting, pass a Cohere client: "
|
| 109 |
+
"CohereProvider(client=cohere.ClientV2())",
|
| 110 |
+
UserWarning,
|
| 111 |
+
stacklevel=4
|
| 112 |
+
)
|
| 113 |
+
_FALLBACK_WARNING_SHOWN = True
|
| 114 |
+
|
| 115 |
+
def count_text(self, text: str) -> int:
|
| 116 |
+
"""Count tokens in text.
|
| 117 |
+
|
| 118 |
+
Uses tokenize API if client available, otherwise estimates.
|
| 119 |
+
"""
|
| 120 |
+
if not text:
|
| 121 |
+
return 0
|
| 122 |
+
|
| 123 |
+
if self._use_api:
|
| 124 |
+
try:
|
| 125 |
+
response = self._client.tokenize(
|
| 126 |
+
text=text,
|
| 127 |
+
model=self.model,
|
| 128 |
+
)
|
| 129 |
+
return len(response.tokens)
|
| 130 |
+
except Exception as e:
|
| 131 |
+
logger.debug(f"Cohere tokenize API failed: {e}, using estimation")
|
| 132 |
+
|
| 133 |
+
return self._estimator.count_text(text)
|
| 134 |
+
|
| 135 |
+
def count_message(self, message: dict[str, Any]) -> int:
|
| 136 |
+
"""Count tokens in a message."""
|
| 137 |
+
content = self._extract_content(message)
|
| 138 |
+
tokens = self.count_text(content)
|
| 139 |
+
tokens += 4 # Message overhead (role tokens, etc.)
|
| 140 |
+
return tokens
|
| 141 |
+
|
| 142 |
+
def count_messages(self, messages: list[dict[str, Any]]) -> int:
|
| 143 |
+
"""Count tokens in messages."""
|
| 144 |
+
if not messages:
|
| 145 |
+
return 0
|
| 146 |
+
|
| 147 |
+
# For API-based counting, concatenate all content
|
| 148 |
+
if self._use_api:
|
| 149 |
+
try:
|
| 150 |
+
all_content = []
|
| 151 |
+
for msg in messages:
|
| 152 |
+
content = self._extract_content(msg)
|
| 153 |
+
role = msg.get("role", "user")
|
| 154 |
+
all_content.append(f"{role}: {content}")
|
| 155 |
+
|
| 156 |
+
full_text = "\n".join(all_content)
|
| 157 |
+
response = self._client.tokenize(
|
| 158 |
+
text=full_text,
|
| 159 |
+
model=self.model,
|
| 160 |
+
)
|
| 161 |
+
return len(response.tokens)
|
| 162 |
+
except Exception as e:
|
| 163 |
+
logger.debug(f"Cohere tokenize API failed: {e}, using estimation")
|
| 164 |
+
|
| 165 |
+
# Fallback to estimation
|
| 166 |
+
total = sum(self.count_message(msg) for msg in messages)
|
| 167 |
+
total += 3 # Priming tokens
|
| 168 |
+
return total
|
| 169 |
+
|
| 170 |
+
def _extract_content(self, message: dict[str, Any]) -> str:
|
| 171 |
+
"""Extract text content from message."""
|
| 172 |
+
content = message.get("content", "")
|
| 173 |
+
if isinstance(content, str):
|
| 174 |
+
return content
|
| 175 |
+
elif isinstance(content, list):
|
| 176 |
+
parts = []
|
| 177 |
+
for part in content:
|
| 178 |
+
if isinstance(part, dict) and part.get("type") == "text":
|
| 179 |
+
parts.append(part.get("text", ""))
|
| 180 |
+
elif isinstance(part, str):
|
| 181 |
+
parts.append(part)
|
| 182 |
+
return "\n".join(parts)
|
| 183 |
+
return str(content)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
class CohereProvider(Provider):
|
| 187 |
+
"""Provider for Cohere Command models.
|
| 188 |
+
|
| 189 |
+
Supports Command R, Command R+, and Command A model families.
|
| 190 |
+
|
| 191 |
+
Example:
|
| 192 |
+
import cohere
|
| 193 |
+
client = cohere.ClientV2()
|
| 194 |
+
|
| 195 |
+
# With client (accurate token counting via API)
|
| 196 |
+
provider = CohereProvider(client=client)
|
| 197 |
+
|
| 198 |
+
# Without client (estimation-based counting)
|
| 199 |
+
provider = CohereProvider()
|
| 200 |
+
|
| 201 |
+
# Token counting
|
| 202 |
+
counter = provider.get_token_counter("command-r-plus")
|
| 203 |
+
tokens = counter.count_text("Hello, world!")
|
| 204 |
+
|
| 205 |
+
# Context limits
|
| 206 |
+
limit = provider.get_context_limit("command-a") # 256K tokens
|
| 207 |
+
|
| 208 |
+
# Cost estimation
|
| 209 |
+
cost = provider.estimate_cost(
|
| 210 |
+
input_tokens=100000,
|
| 211 |
+
output_tokens=10000,
|
| 212 |
+
model="command-r-plus",
|
| 213 |
+
)
|
| 214 |
+
"""
|
| 215 |
+
|
| 216 |
+
def __init__(self, client: Any = None):
|
| 217 |
+
"""Initialize Cohere provider.
|
| 218 |
+
|
| 219 |
+
Args:
|
| 220 |
+
client: Optional cohere.ClientV2 for API-based token counting.
|
| 221 |
+
If provided, uses tokenize API for accurate counts.
|
| 222 |
+
"""
|
| 223 |
+
self._client = client
|
| 224 |
+
|
| 225 |
+
@property
|
| 226 |
+
def name(self) -> str:
|
| 227 |
+
return "cohere"
|
| 228 |
+
|
| 229 |
+
def supports_model(self, model: str) -> bool:
|
| 230 |
+
"""Check if model is a known Cohere model."""
|
| 231 |
+
model_lower = model.lower()
|
| 232 |
+
if model_lower in _CONTEXT_LIMITS:
|
| 233 |
+
return True
|
| 234 |
+
# Check prefix match
|
| 235 |
+
for prefix in ["command-a", "command-r", "command", "embed-"]:
|
| 236 |
+
if model_lower.startswith(prefix):
|
| 237 |
+
return True
|
| 238 |
+
return False
|
| 239 |
+
|
| 240 |
+
def get_token_counter(self, model: str) -> TokenCounter:
|
| 241 |
+
"""Get token counter for a Cohere model.
|
| 242 |
+
|
| 243 |
+
Uses tokenize API if client was provided, otherwise estimates.
|
| 244 |
+
"""
|
| 245 |
+
if not self.supports_model(model):
|
| 246 |
+
raise ValueError(
|
| 247 |
+
f"Model '{model}' is not recognized as a Cohere model. "
|
| 248 |
+
f"Supported models: {list(_CONTEXT_LIMITS.keys())}"
|
| 249 |
+
)
|
| 250 |
+
return CohereTokenCounter(model, client=self._client)
|
| 251 |
+
|
| 252 |
+
def get_context_limit(self, model: str) -> int:
|
| 253 |
+
"""Get context limit for a Cohere model."""
|
| 254 |
+
model_lower = model.lower()
|
| 255 |
+
|
| 256 |
+
# Direct match
|
| 257 |
+
if model_lower in _CONTEXT_LIMITS:
|
| 258 |
+
return _CONTEXT_LIMITS[model_lower]
|
| 259 |
+
|
| 260 |
+
# Prefix match
|
| 261 |
+
for prefix, limit in [
|
| 262 |
+
("command-a", 256000),
|
| 263 |
+
("command-r-plus", 128000),
|
| 264 |
+
("command-r", 128000),
|
| 265 |
+
("command", 4096),
|
| 266 |
+
("embed-", 512),
|
| 267 |
+
]:
|
| 268 |
+
if model_lower.startswith(prefix):
|
| 269 |
+
return limit
|
| 270 |
+
|
| 271 |
+
raise ValueError(
|
| 272 |
+
f"Unknown context limit for model '{model}'. "
|
| 273 |
+
f"Known models: {list(_CONTEXT_LIMITS.keys())}"
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
def estimate_cost(
|
| 277 |
+
self,
|
| 278 |
+
input_tokens: int,
|
| 279 |
+
output_tokens: int,
|
| 280 |
+
model: str,
|
| 281 |
+
cached_tokens: int = 0,
|
| 282 |
+
) -> float | None:
|
| 283 |
+
"""Estimate cost for Cohere API call.
|
| 284 |
+
|
| 285 |
+
Args:
|
| 286 |
+
input_tokens: Number of input tokens.
|
| 287 |
+
output_tokens: Number of output tokens.
|
| 288 |
+
model: Model name.
|
| 289 |
+
cached_tokens: Not used by Cohere.
|
| 290 |
+
|
| 291 |
+
Returns:
|
| 292 |
+
Estimated cost in USD, or None if pricing unknown.
|
| 293 |
+
"""
|
| 294 |
+
model_lower = model.lower()
|
| 295 |
+
|
| 296 |
+
# Find pricing
|
| 297 |
+
input_price, output_price = None, None
|
| 298 |
+
for model_prefix, (inp, outp) in _PRICING.items():
|
| 299 |
+
if model_lower.startswith(model_prefix):
|
| 300 |
+
input_price, output_price = inp, outp
|
| 301 |
+
break
|
| 302 |
+
|
| 303 |
+
if input_price is None:
|
| 304 |
+
return None
|
| 305 |
+
|
| 306 |
+
input_cost = (input_tokens / 1_000_000) * input_price
|
| 307 |
+
output_cost = (output_tokens / 1_000_000) * output_price
|
| 308 |
+
|
| 309 |
+
return input_cost + output_cost
|
| 310 |
+
|
| 311 |
+
def get_output_buffer(self, model: str, default: int = 4000) -> int:
|
| 312 |
+
"""Get recommended output buffer."""
|
| 313 |
+
return default
|
|
@@ -0,0 +1,372 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Google Gemini provider for Headroom SDK.
|
| 2 |
+
|
| 3 |
+
Supports Google's Gemini models through two interfaces:
|
| 4 |
+
1. OpenAI-compatible endpoint (recommended for Headroom)
|
| 5 |
+
2. Native Google AI SDK (for advanced features)
|
| 6 |
+
|
| 7 |
+
Token counting uses Google's official countTokens API when a client
|
| 8 |
+
is provided. This gives accurate counts for all content types.
|
| 9 |
+
|
| 10 |
+
Usage:
|
| 11 |
+
import google.generativeai as genai
|
| 12 |
+
from headroom import GoogleProvider
|
| 13 |
+
|
| 14 |
+
genai.configure(api_key="your-api-key")
|
| 15 |
+
provider = GoogleProvider(client=genai) # Accurate counting via API
|
| 16 |
+
|
| 17 |
+
# Or without client (uses estimation - less accurate)
|
| 18 |
+
provider = GoogleProvider() # Warning: approximate counting
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import logging
|
| 24 |
+
import warnings
|
| 25 |
+
from datetime import date
|
| 26 |
+
from typing import Any
|
| 27 |
+
|
| 28 |
+
from headroom.tokenizers import EstimatingTokenCounter
|
| 29 |
+
|
| 30 |
+
from .base import Provider, TokenCounter
|
| 31 |
+
|
| 32 |
+
logger = logging.getLogger(__name__)
|
| 33 |
+
|
| 34 |
+
# Warning flags
|
| 35 |
+
_FALLBACK_WARNING_SHOWN = False
|
| 36 |
+
|
| 37 |
+
# Pricing metadata
|
| 38 |
+
_PRICING_LAST_UPDATED = date(2025, 1, 6)
|
| 39 |
+
|
| 40 |
+
# Google model context limits
|
| 41 |
+
_CONTEXT_LIMITS: dict[str, int] = {
|
| 42 |
+
# Gemini 2.0
|
| 43 |
+
"gemini-2.0-flash": 1000000,
|
| 44 |
+
"gemini-2.0-flash-exp": 1000000,
|
| 45 |
+
"gemini-2.0-flash-thinking": 1000000,
|
| 46 |
+
# Gemini 1.5
|
| 47 |
+
"gemini-1.5-pro": 2000000,
|
| 48 |
+
"gemini-1.5-pro-latest": 2000000,
|
| 49 |
+
"gemini-1.5-flash": 1000000,
|
| 50 |
+
"gemini-1.5-flash-latest": 1000000,
|
| 51 |
+
"gemini-1.5-flash-8b": 1000000,
|
| 52 |
+
# Gemini 1.0
|
| 53 |
+
"gemini-1.0-pro": 32768,
|
| 54 |
+
"gemini-pro": 32768,
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
# Pricing per 1M tokens (input, output)
|
| 58 |
+
# Note: Google has different pricing tiers based on context length
|
| 59 |
+
_PRICING: dict[str, tuple[float, float]] = {
|
| 60 |
+
"gemini-2.0-flash": (0.10, 0.40),
|
| 61 |
+
"gemini-2.0-flash-exp": (0.10, 0.40), # Experimental, may change
|
| 62 |
+
"gemini-1.5-pro": (1.25, 5.00), # Up to 128K context
|
| 63 |
+
"gemini-1.5-flash": (0.075, 0.30), # Up to 128K context
|
| 64 |
+
"gemini-1.5-flash-8b": (0.0375, 0.15),
|
| 65 |
+
"gemini-1.0-pro": (0.50, 1.50),
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class GeminiTokenCounter:
|
| 70 |
+
"""Token counter for Gemini models.
|
| 71 |
+
|
| 72 |
+
When a google.generativeai client is provided, uses the official
|
| 73 |
+
countTokens API for accurate counting. Falls back to estimation
|
| 74 |
+
when no client is available.
|
| 75 |
+
|
| 76 |
+
Usage:
|
| 77 |
+
import google.generativeai as genai
|
| 78 |
+
genai.configure(api_key="...")
|
| 79 |
+
|
| 80 |
+
# With API (accurate)
|
| 81 |
+
counter = GeminiTokenCounter("gemini-2.0-flash", client=genai)
|
| 82 |
+
|
| 83 |
+
# Without API (estimation)
|
| 84 |
+
counter = GeminiTokenCounter("gemini-2.0-flash")
|
| 85 |
+
"""
|
| 86 |
+
|
| 87 |
+
def __init__(self, model: str, client: Any = None):
|
| 88 |
+
"""Initialize Gemini token counter.
|
| 89 |
+
|
| 90 |
+
Args:
|
| 91 |
+
model: Gemini model name.
|
| 92 |
+
client: Optional google.generativeai module for API-based counting.
|
| 93 |
+
"""
|
| 94 |
+
global _FALLBACK_WARNING_SHOWN
|
| 95 |
+
|
| 96 |
+
self.model = model
|
| 97 |
+
self._client = client
|
| 98 |
+
self._use_api = client is not None
|
| 99 |
+
self._genai_model = None
|
| 100 |
+
|
| 101 |
+
# Gemini uses ~4 chars per token (similar to GPT models)
|
| 102 |
+
self._estimator = EstimatingTokenCounter(chars_per_token=4.0)
|
| 103 |
+
|
| 104 |
+
if not self._use_api and not _FALLBACK_WARNING_SHOWN:
|
| 105 |
+
warnings.warn(
|
| 106 |
+
"GoogleProvider: No client provided, using estimation. "
|
| 107 |
+
"For accurate counting, pass google.generativeai: "
|
| 108 |
+
"GoogleProvider(client=genai)",
|
| 109 |
+
UserWarning,
|
| 110 |
+
stacklevel=4
|
| 111 |
+
)
|
| 112 |
+
_FALLBACK_WARNING_SHOWN = True
|
| 113 |
+
|
| 114 |
+
def _get_model(self):
|
| 115 |
+
"""Lazy-load the GenerativeModel for API calls."""
|
| 116 |
+
if self._genai_model is None and self._client is not None:
|
| 117 |
+
self._genai_model = self._client.GenerativeModel(self.model)
|
| 118 |
+
return self._genai_model
|
| 119 |
+
|
| 120 |
+
def count_text(self, text: str) -> int:
|
| 121 |
+
"""Count tokens in text.
|
| 122 |
+
|
| 123 |
+
Uses countTokens API if client available, otherwise estimates.
|
| 124 |
+
"""
|
| 125 |
+
if not text:
|
| 126 |
+
return 0
|
| 127 |
+
|
| 128 |
+
if self._use_api:
|
| 129 |
+
try:
|
| 130 |
+
model = self._get_model()
|
| 131 |
+
response = model.count_tokens(text)
|
| 132 |
+
return response.total_tokens
|
| 133 |
+
except Exception as e:
|
| 134 |
+
logger.debug(f"Google countTokens API failed: {e}, using estimation")
|
| 135 |
+
|
| 136 |
+
return self._estimator.count_text(text)
|
| 137 |
+
|
| 138 |
+
def count_message(self, message: dict[str, Any]) -> int:
|
| 139 |
+
"""Count tokens in a message."""
|
| 140 |
+
# For API-based counting, convert message to content and count
|
| 141 |
+
if self._use_api:
|
| 142 |
+
try:
|
| 143 |
+
content = self._message_to_content(message)
|
| 144 |
+
model = self._get_model()
|
| 145 |
+
response = model.count_tokens(content)
|
| 146 |
+
return response.total_tokens
|
| 147 |
+
except Exception as e:
|
| 148 |
+
logger.debug(f"Google countTokens API failed: {e}, using estimation")
|
| 149 |
+
|
| 150 |
+
# Fallback to estimation
|
| 151 |
+
return self._estimate_message(message)
|
| 152 |
+
|
| 153 |
+
def count_messages(self, messages: list[dict[str, Any]]) -> int:
|
| 154 |
+
"""Count tokens in messages.
|
| 155 |
+
|
| 156 |
+
Uses countTokens API with full conversation if available.
|
| 157 |
+
"""
|
| 158 |
+
if not messages:
|
| 159 |
+
return 0
|
| 160 |
+
|
| 161 |
+
if self._use_api:
|
| 162 |
+
try:
|
| 163 |
+
# Convert to Gemini content format
|
| 164 |
+
contents = [self._message_to_content(msg) for msg in messages]
|
| 165 |
+
model = self._get_model()
|
| 166 |
+
response = model.count_tokens(contents)
|
| 167 |
+
return response.total_tokens
|
| 168 |
+
except Exception as e:
|
| 169 |
+
logger.debug(f"Google countTokens API failed: {e}, using estimation")
|
| 170 |
+
|
| 171 |
+
# Fallback to estimation
|
| 172 |
+
total = sum(self._estimate_message(msg) for msg in messages)
|
| 173 |
+
total += 3 # Priming tokens
|
| 174 |
+
return total
|
| 175 |
+
|
| 176 |
+
def _message_to_content(self, message: dict[str, Any]) -> str:
|
| 177 |
+
"""Convert OpenAI-format message to text content for counting."""
|
| 178 |
+
content = message.get("content", "")
|
| 179 |
+
if isinstance(content, str):
|
| 180 |
+
return content
|
| 181 |
+
elif isinstance(content, list):
|
| 182 |
+
parts = []
|
| 183 |
+
for part in content:
|
| 184 |
+
if isinstance(part, dict) and part.get("type") == "text":
|
| 185 |
+
parts.append(part.get("text", ""))
|
| 186 |
+
elif isinstance(part, str):
|
| 187 |
+
parts.append(part)
|
| 188 |
+
return "\n".join(parts)
|
| 189 |
+
return str(content)
|
| 190 |
+
|
| 191 |
+
def _estimate_message(self, message: dict[str, Any]) -> int:
|
| 192 |
+
"""Estimate tokens in a message without API."""
|
| 193 |
+
tokens = 4 # Message overhead
|
| 194 |
+
|
| 195 |
+
role = message.get("role", "")
|
| 196 |
+
tokens += self._estimator.count_text(role)
|
| 197 |
+
|
| 198 |
+
content = message.get("content")
|
| 199 |
+
if content:
|
| 200 |
+
if isinstance(content, str):
|
| 201 |
+
tokens += self._estimator.count_text(content)
|
| 202 |
+
elif isinstance(content, list):
|
| 203 |
+
for part in content:
|
| 204 |
+
if isinstance(part, dict):
|
| 205 |
+
if part.get("type") == "text":
|
| 206 |
+
tokens += self._estimator.count_text(part.get("text", ""))
|
| 207 |
+
elif isinstance(part, str):
|
| 208 |
+
tokens += self._estimator.count_text(part)
|
| 209 |
+
|
| 210 |
+
return tokens
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
class GoogleProvider(Provider):
|
| 214 |
+
"""Provider for Google Gemini models.
|
| 215 |
+
|
| 216 |
+
Supports Gemini 1.5 and 2.0 model families through:
|
| 217 |
+
- OpenAI-compatible endpoint (generativelanguage.googleapis.com)
|
| 218 |
+
- Native Google AI SDK (for accurate token counting)
|
| 219 |
+
|
| 220 |
+
Example:
|
| 221 |
+
import google.generativeai as genai
|
| 222 |
+
genai.configure(api_key="...")
|
| 223 |
+
|
| 224 |
+
# With client (accurate token counting via API)
|
| 225 |
+
provider = GoogleProvider(client=genai)
|
| 226 |
+
|
| 227 |
+
# Without client (estimation-based counting)
|
| 228 |
+
provider = GoogleProvider()
|
| 229 |
+
|
| 230 |
+
# Token counting
|
| 231 |
+
counter = provider.get_token_counter("gemini-2.0-flash")
|
| 232 |
+
tokens = counter.count_text("Hello, world!")
|
| 233 |
+
|
| 234 |
+
# Context limits
|
| 235 |
+
limit = provider.get_context_limit("gemini-1.5-pro") # 2M tokens!
|
| 236 |
+
|
| 237 |
+
# Cost estimation
|
| 238 |
+
cost = provider.estimate_cost(
|
| 239 |
+
input_tokens=100000,
|
| 240 |
+
output_tokens=10000,
|
| 241 |
+
model="gemini-1.5-pro",
|
| 242 |
+
)
|
| 243 |
+
"""
|
| 244 |
+
|
| 245 |
+
# OpenAI-compatible endpoint for Gemini
|
| 246 |
+
OPENAI_COMPATIBLE_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai"
|
| 247 |
+
|
| 248 |
+
def __init__(self, client: Any = None):
|
| 249 |
+
"""Initialize Google provider.
|
| 250 |
+
|
| 251 |
+
Args:
|
| 252 |
+
client: Optional google.generativeai module for API-based token counting.
|
| 253 |
+
If provided, uses countTokens API for accurate counts.
|
| 254 |
+
"""
|
| 255 |
+
self._client = client
|
| 256 |
+
|
| 257 |
+
@property
|
| 258 |
+
def name(self) -> str:
|
| 259 |
+
return "google"
|
| 260 |
+
|
| 261 |
+
def supports_model(self, model: str) -> bool:
|
| 262 |
+
"""Check if model is a known Gemini model."""
|
| 263 |
+
model_lower = model.lower()
|
| 264 |
+
if model_lower in _CONTEXT_LIMITS:
|
| 265 |
+
return True
|
| 266 |
+
# Check prefix match
|
| 267 |
+
for prefix in ["gemini-2", "gemini-1.5", "gemini-1.0", "gemini-pro"]:
|
| 268 |
+
if model_lower.startswith(prefix):
|
| 269 |
+
return True
|
| 270 |
+
return False
|
| 271 |
+
|
| 272 |
+
def get_token_counter(self, model: str) -> TokenCounter:
|
| 273 |
+
"""Get token counter for a Gemini model.
|
| 274 |
+
|
| 275 |
+
Uses countTokens API if client was provided, otherwise estimates.
|
| 276 |
+
"""
|
| 277 |
+
if not self.supports_model(model):
|
| 278 |
+
raise ValueError(
|
| 279 |
+
f"Model '{model}' is not recognized as a Google model. "
|
| 280 |
+
f"Supported models: {list(_CONTEXT_LIMITS.keys())}"
|
| 281 |
+
)
|
| 282 |
+
return GeminiTokenCounter(model, client=self._client)
|
| 283 |
+
|
| 284 |
+
def get_context_limit(self, model: str) -> int:
|
| 285 |
+
"""Get context limit for a Gemini model.
|
| 286 |
+
|
| 287 |
+
Note: Gemini 1.5 Pro has 2M token context!
|
| 288 |
+
"""
|
| 289 |
+
model_lower = model.lower()
|
| 290 |
+
|
| 291 |
+
# Direct match
|
| 292 |
+
if model_lower in _CONTEXT_LIMITS:
|
| 293 |
+
return _CONTEXT_LIMITS[model_lower]
|
| 294 |
+
|
| 295 |
+
# Prefix match
|
| 296 |
+
for prefix, limit in [
|
| 297 |
+
("gemini-2.0", 1000000),
|
| 298 |
+
("gemini-1.5-pro", 2000000),
|
| 299 |
+
("gemini-1.5-flash", 1000000),
|
| 300 |
+
("gemini-1.0", 32768),
|
| 301 |
+
("gemini-pro", 32768),
|
| 302 |
+
]:
|
| 303 |
+
if model_lower.startswith(prefix):
|
| 304 |
+
return limit
|
| 305 |
+
|
| 306 |
+
raise ValueError(
|
| 307 |
+
f"Unknown context limit for model '{model}'. "
|
| 308 |
+
f"Known models: {list(_CONTEXT_LIMITS.keys())}"
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
def estimate_cost(
|
| 312 |
+
self,
|
| 313 |
+
input_tokens: int,
|
| 314 |
+
output_tokens: int,
|
| 315 |
+
model: str,
|
| 316 |
+
cached_tokens: int = 0,
|
| 317 |
+
) -> float | None:
|
| 318 |
+
"""Estimate cost for Gemini API call.
|
| 319 |
+
|
| 320 |
+
Note: Google has tiered pricing based on context length.
|
| 321 |
+
This uses the standard pricing (up to 128K context).
|
| 322 |
+
For >128K context, actual costs may be higher.
|
| 323 |
+
|
| 324 |
+
Args:
|
| 325 |
+
input_tokens: Number of input tokens.
|
| 326 |
+
output_tokens: Number of output tokens.
|
| 327 |
+
model: Model name.
|
| 328 |
+
cached_tokens: Number of cached tokens (not used by Google).
|
| 329 |
+
|
| 330 |
+
Returns:
|
| 331 |
+
Estimated cost in USD, or None if pricing unknown.
|
| 332 |
+
"""
|
| 333 |
+
model_lower = model.lower()
|
| 334 |
+
|
| 335 |
+
# Find pricing
|
| 336 |
+
input_price, output_price = None, None
|
| 337 |
+
for model_prefix, (inp, outp) in _PRICING.items():
|
| 338 |
+
if model_lower.startswith(model_prefix):
|
| 339 |
+
input_price, output_price = inp, outp
|
| 340 |
+
break
|
| 341 |
+
|
| 342 |
+
if input_price is None:
|
| 343 |
+
return None
|
| 344 |
+
|
| 345 |
+
input_cost = (input_tokens / 1_000_000) * input_price
|
| 346 |
+
output_cost = (output_tokens / 1_000_000) * output_price
|
| 347 |
+
|
| 348 |
+
return input_cost + output_cost
|
| 349 |
+
|
| 350 |
+
def get_output_buffer(self, model: str, default: int = 4000) -> int:
|
| 351 |
+
"""Get recommended output buffer."""
|
| 352 |
+
# Gemini models can output up to 8K tokens
|
| 353 |
+
return min(8192, default)
|
| 354 |
+
|
| 355 |
+
@classmethod
|
| 356 |
+
def get_openai_compatible_url(cls, api_key: str) -> str:
|
| 357 |
+
"""Get OpenAI-compatible endpoint URL.
|
| 358 |
+
|
| 359 |
+
Use this with the OpenAI client:
|
| 360 |
+
from openai import OpenAI
|
| 361 |
+
client = OpenAI(
|
| 362 |
+
api_key=api_key,
|
| 363 |
+
base_url=GoogleProvider.get_openai_compatible_url(api_key),
|
| 364 |
+
)
|
| 365 |
+
|
| 366 |
+
Args:
|
| 367 |
+
api_key: Google AI API key.
|
| 368 |
+
|
| 369 |
+
Returns:
|
| 370 |
+
Base URL for OpenAI-compatible requests.
|
| 371 |
+
"""
|
| 372 |
+
return cls.OPENAI_COMPATIBLE_BASE_URL
|
|
@@ -0,0 +1,293 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LiteLLM provider for universal LLM support.
|
| 2 |
+
|
| 3 |
+
LiteLLM provides a unified interface to 100+ LLM providers:
|
| 4 |
+
- OpenAI, Azure OpenAI
|
| 5 |
+
- Anthropic
|
| 6 |
+
- Google (Vertex AI, AI Studio)
|
| 7 |
+
- AWS Bedrock
|
| 8 |
+
- Cohere
|
| 9 |
+
- Replicate
|
| 10 |
+
- Hugging Face
|
| 11 |
+
- Ollama
|
| 12 |
+
- Together AI
|
| 13 |
+
- Groq
|
| 14 |
+
- And many more...
|
| 15 |
+
|
| 16 |
+
This integration allows Headroom to work with any LiteLLM-supported
|
| 17 |
+
model without needing provider-specific implementations.
|
| 18 |
+
|
| 19 |
+
Requires: pip install litellm
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import logging
|
| 25 |
+
from typing import Any
|
| 26 |
+
|
| 27 |
+
from headroom.tokenizers import EstimatingTokenCounter
|
| 28 |
+
|
| 29 |
+
from .base import Provider, TokenCounter
|
| 30 |
+
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
# Check if litellm is available
|
| 34 |
+
try:
|
| 35 |
+
import litellm
|
| 36 |
+
from litellm import get_model_info as litellm_get_model_info
|
| 37 |
+
from litellm import model_cost as litellm_model_cost
|
| 38 |
+
from litellm import token_counter as litellm_token_counter
|
| 39 |
+
|
| 40 |
+
LITELLM_AVAILABLE = True
|
| 41 |
+
except ImportError:
|
| 42 |
+
LITELLM_AVAILABLE = False
|
| 43 |
+
litellm = None
|
| 44 |
+
litellm_token_counter = None
|
| 45 |
+
litellm_model_cost = None
|
| 46 |
+
litellm_get_model_info = None
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def is_litellm_available() -> bool:
|
| 50 |
+
"""Check if LiteLLM is installed.
|
| 51 |
+
|
| 52 |
+
Returns:
|
| 53 |
+
True if litellm is available.
|
| 54 |
+
"""
|
| 55 |
+
return LITELLM_AVAILABLE
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class LiteLLMTokenCounter:
|
| 59 |
+
"""Token counter using LiteLLM's token counting.
|
| 60 |
+
|
| 61 |
+
LiteLLM provides accurate token counting for most providers
|
| 62 |
+
by using the appropriate tokenizer for each model.
|
| 63 |
+
"""
|
| 64 |
+
|
| 65 |
+
def __init__(self, model: str):
|
| 66 |
+
"""Initialize LiteLLM token counter.
|
| 67 |
+
|
| 68 |
+
Args:
|
| 69 |
+
model: Model name in LiteLLM format (e.g., 'gpt-4o', 'claude-3-sonnet').
|
| 70 |
+
"""
|
| 71 |
+
if not LITELLM_AVAILABLE:
|
| 72 |
+
raise RuntimeError(
|
| 73 |
+
"LiteLLM is required for LiteLLMProvider. "
|
| 74 |
+
"Install with: pip install litellm"
|
| 75 |
+
)
|
| 76 |
+
self.model = model
|
| 77 |
+
# Fallback estimator for when litellm counting fails
|
| 78 |
+
self._fallback = EstimatingTokenCounter()
|
| 79 |
+
|
| 80 |
+
def count_text(self, text: str) -> int:
|
| 81 |
+
"""Count tokens in text using LiteLLM."""
|
| 82 |
+
if not text:
|
| 83 |
+
return 0
|
| 84 |
+
try:
|
| 85 |
+
# LiteLLM's token_counter expects messages format
|
| 86 |
+
# We wrap text in a simple message
|
| 87 |
+
return litellm_token_counter(
|
| 88 |
+
model=self.model,
|
| 89 |
+
messages=[{"role": "user", "content": text}],
|
| 90 |
+
)
|
| 91 |
+
except Exception as e:
|
| 92 |
+
logger.debug(f"LiteLLM token count failed for {self.model}: {e}")
|
| 93 |
+
return self._fallback.count_text(text)
|
| 94 |
+
|
| 95 |
+
def count_message(self, message: dict[str, Any]) -> int:
|
| 96 |
+
"""Count tokens in a single message."""
|
| 97 |
+
try:
|
| 98 |
+
return litellm_token_counter(
|
| 99 |
+
model=self.model,
|
| 100 |
+
messages=[message],
|
| 101 |
+
)
|
| 102 |
+
except Exception as e:
|
| 103 |
+
logger.debug(f"LiteLLM message count failed for {self.model}: {e}")
|
| 104 |
+
# Fallback to estimation
|
| 105 |
+
tokens = 4 # Base overhead
|
| 106 |
+
content = message.get("content", "")
|
| 107 |
+
if isinstance(content, str):
|
| 108 |
+
tokens += self._fallback.count_text(content)
|
| 109 |
+
return tokens
|
| 110 |
+
|
| 111 |
+
def count_messages(self, messages: list[dict[str, Any]]) -> int:
|
| 112 |
+
"""Count tokens in messages using LiteLLM."""
|
| 113 |
+
if not messages:
|
| 114 |
+
return 0
|
| 115 |
+
try:
|
| 116 |
+
return litellm_token_counter(
|
| 117 |
+
model=self.model,
|
| 118 |
+
messages=messages,
|
| 119 |
+
)
|
| 120 |
+
except Exception as e:
|
| 121 |
+
logger.debug(f"LiteLLM messages count failed for {self.model}: {e}")
|
| 122 |
+
# Fallback to estimation
|
| 123 |
+
total = sum(self.count_message(msg) for msg in messages)
|
| 124 |
+
total += 3 # Priming
|
| 125 |
+
return total
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
class LiteLLMProvider(Provider):
|
| 129 |
+
"""Provider using LiteLLM for universal model support.
|
| 130 |
+
|
| 131 |
+
LiteLLM supports 100+ LLM providers with a unified interface.
|
| 132 |
+
This provider leverages LiteLLM's:
|
| 133 |
+
- Token counting (accurate for most providers)
|
| 134 |
+
- Model info (context limits, capabilities)
|
| 135 |
+
- Cost estimation (from LiteLLM's model database)
|
| 136 |
+
|
| 137 |
+
Example:
|
| 138 |
+
from headroom.providers import LiteLLMProvider
|
| 139 |
+
|
| 140 |
+
provider = LiteLLMProvider()
|
| 141 |
+
|
| 142 |
+
# Works with any LiteLLM-supported model
|
| 143 |
+
counter = provider.get_token_counter("gpt-4o")
|
| 144 |
+
counter = provider.get_token_counter("claude-3-5-sonnet-20241022")
|
| 145 |
+
counter = provider.get_token_counter("gemini/gemini-1.5-pro")
|
| 146 |
+
counter = provider.get_token_counter("bedrock/anthropic.claude-v2")
|
| 147 |
+
counter = provider.get_token_counter("ollama/llama3")
|
| 148 |
+
|
| 149 |
+
Model Format:
|
| 150 |
+
LiteLLM uses a provider/model format for some providers:
|
| 151 |
+
- OpenAI: "gpt-4o" or "openai/gpt-4o"
|
| 152 |
+
- Anthropic: "claude-3-sonnet" or "anthropic/claude-3-sonnet"
|
| 153 |
+
- Google: "gemini/gemini-1.5-pro"
|
| 154 |
+
- Azure: "azure/gpt-4"
|
| 155 |
+
- Bedrock: "bedrock/anthropic.claude-v2"
|
| 156 |
+
- Ollama: "ollama/llama3"
|
| 157 |
+
|
| 158 |
+
See LiteLLM docs for full model list:
|
| 159 |
+
https://docs.litellm.ai/docs/providers
|
| 160 |
+
"""
|
| 161 |
+
|
| 162 |
+
def __init__(self):
|
| 163 |
+
"""Initialize LiteLLM provider."""
|
| 164 |
+
if not LITELLM_AVAILABLE:
|
| 165 |
+
raise RuntimeError(
|
| 166 |
+
"LiteLLM is required for LiteLLMProvider. "
|
| 167 |
+
"Install with: pip install litellm"
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
@property
|
| 171 |
+
def name(self) -> str:
|
| 172 |
+
return "litellm"
|
| 173 |
+
|
| 174 |
+
def supports_model(self, model: str) -> bool:
|
| 175 |
+
"""Check if LiteLLM supports this model.
|
| 176 |
+
|
| 177 |
+
LiteLLM supports most models, so this returns True
|
| 178 |
+
for any model. Actual support depends on credentials.
|
| 179 |
+
"""
|
| 180 |
+
return True # LiteLLM handles validation
|
| 181 |
+
|
| 182 |
+
def get_token_counter(self, model: str) -> TokenCounter:
|
| 183 |
+
"""Get token counter for a model."""
|
| 184 |
+
return LiteLLMTokenCounter(model)
|
| 185 |
+
|
| 186 |
+
def get_context_limit(self, model: str) -> int:
|
| 187 |
+
"""Get context limit using LiteLLM's model info."""
|
| 188 |
+
try:
|
| 189 |
+
info = litellm_get_model_info(model)
|
| 190 |
+
if info and "max_input_tokens" in info:
|
| 191 |
+
return info["max_input_tokens"]
|
| 192 |
+
if info and "max_tokens" in info:
|
| 193 |
+
return info["max_tokens"]
|
| 194 |
+
except Exception as e:
|
| 195 |
+
logger.debug(f"LiteLLM get_model_info failed for {model}: {e}")
|
| 196 |
+
|
| 197 |
+
# Fallback to reasonable default
|
| 198 |
+
return 128000
|
| 199 |
+
|
| 200 |
+
def get_output_buffer(self, model: str, default: int = 4000) -> int:
|
| 201 |
+
"""Get recommended output buffer."""
|
| 202 |
+
try:
|
| 203 |
+
info = litellm_get_model_info(model)
|
| 204 |
+
if info and "max_output_tokens" in info:
|
| 205 |
+
return min(info["max_output_tokens"], default)
|
| 206 |
+
except Exception:
|
| 207 |
+
pass
|
| 208 |
+
return default
|
| 209 |
+
|
| 210 |
+
def estimate_cost(
|
| 211 |
+
self,
|
| 212 |
+
input_tokens: int,
|
| 213 |
+
output_tokens: int,
|
| 214 |
+
model: str,
|
| 215 |
+
cached_tokens: int = 0,
|
| 216 |
+
) -> float | None:
|
| 217 |
+
"""Estimate cost using LiteLLM's cost database.
|
| 218 |
+
|
| 219 |
+
Args:
|
| 220 |
+
input_tokens: Number of input tokens.
|
| 221 |
+
output_tokens: Number of output tokens.
|
| 222 |
+
model: Model name.
|
| 223 |
+
cached_tokens: Cached tokens (may not be supported by all providers).
|
| 224 |
+
|
| 225 |
+
Returns:
|
| 226 |
+
Estimated cost in USD, or None if pricing unknown.
|
| 227 |
+
"""
|
| 228 |
+
try:
|
| 229 |
+
# LiteLLM's cost calculation
|
| 230 |
+
cost = litellm.completion_cost(
|
| 231 |
+
model=model,
|
| 232 |
+
prompt="", # We're using token counts directly
|
| 233 |
+
completion="",
|
| 234 |
+
prompt_tokens=input_tokens,
|
| 235 |
+
completion_tokens=output_tokens,
|
| 236 |
+
)
|
| 237 |
+
return cost
|
| 238 |
+
except Exception as e:
|
| 239 |
+
logger.debug(f"LiteLLM cost estimation failed for {model}: {e}")
|
| 240 |
+
return None
|
| 241 |
+
|
| 242 |
+
@classmethod
|
| 243 |
+
def list_supported_providers(cls) -> list[str]:
|
| 244 |
+
"""List providers supported by LiteLLM.
|
| 245 |
+
|
| 246 |
+
Returns:
|
| 247 |
+
List of provider names.
|
| 248 |
+
"""
|
| 249 |
+
if not LITELLM_AVAILABLE:
|
| 250 |
+
return []
|
| 251 |
+
|
| 252 |
+
# Major providers supported by LiteLLM
|
| 253 |
+
return [
|
| 254 |
+
"openai",
|
| 255 |
+
"anthropic",
|
| 256 |
+
"azure",
|
| 257 |
+
"google",
|
| 258 |
+
"vertex_ai",
|
| 259 |
+
"bedrock",
|
| 260 |
+
"cohere",
|
| 261 |
+
"replicate",
|
| 262 |
+
"huggingface",
|
| 263 |
+
"ollama",
|
| 264 |
+
"together_ai",
|
| 265 |
+
"groq",
|
| 266 |
+
"fireworks_ai",
|
| 267 |
+
"anyscale",
|
| 268 |
+
"deepinfra",
|
| 269 |
+
"perplexity",
|
| 270 |
+
"mistral",
|
| 271 |
+
"cloudflare",
|
| 272 |
+
"ai21",
|
| 273 |
+
"nlp_cloud",
|
| 274 |
+
"aleph_alpha",
|
| 275 |
+
"petals",
|
| 276 |
+
"baseten",
|
| 277 |
+
"openrouter",
|
| 278 |
+
"vllm",
|
| 279 |
+
"xinference",
|
| 280 |
+
"text-generation-inference",
|
| 281 |
+
]
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def create_litellm_provider() -> LiteLLMProvider:
|
| 285 |
+
"""Create a LiteLLM provider.
|
| 286 |
+
|
| 287 |
+
Returns:
|
| 288 |
+
Configured LiteLLMProvider.
|
| 289 |
+
|
| 290 |
+
Raises:
|
| 291 |
+
RuntimeError: If LiteLLM is not installed.
|
| 292 |
+
"""
|
| 293 |
+
return LiteLLMProvider()
|
|
@@ -6,7 +6,6 @@ Cost estimates are APPROXIMATE - always verify against your actual billing.
|
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
-
import json
|
| 10 |
import warnings
|
| 11 |
from datetime import date
|
| 12 |
from functools import lru_cache
|
|
|
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
|
|
|
| 9 |
import warnings
|
| 10 |
from datetime import date
|
| 11 |
from functools import lru_cache
|
|
@@ -0,0 +1,521 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OpenAI-compatible provider for universal LLM support.
|
| 2 |
+
|
| 3 |
+
This provider supports any LLM service that implements the OpenAI API format:
|
| 4 |
+
- Ollama (local)
|
| 5 |
+
- vLLM (local/cloud)
|
| 6 |
+
- Together AI
|
| 7 |
+
- Groq
|
| 8 |
+
- Fireworks AI
|
| 9 |
+
- Anyscale
|
| 10 |
+
- LM Studio
|
| 11 |
+
- LocalAI
|
| 12 |
+
- Hugging Face Inference Endpoints
|
| 13 |
+
- Azure OpenAI
|
| 14 |
+
- And many more...
|
| 15 |
+
|
| 16 |
+
The key insight: 70%+ of LLM providers use OpenAI-compatible APIs,
|
| 17 |
+
so supporting this format gives near-universal coverage.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import logging
|
| 23 |
+
from dataclasses import dataclass
|
| 24 |
+
from typing import Any
|
| 25 |
+
|
| 26 |
+
from headroom.tokenizers import get_tokenizer
|
| 27 |
+
|
| 28 |
+
from .base import Provider
|
| 29 |
+
|
| 30 |
+
logger = logging.getLogger(__name__)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass
|
| 34 |
+
class ModelCapabilities:
|
| 35 |
+
"""Model capability metadata.
|
| 36 |
+
|
| 37 |
+
Stores information about a model's capabilities and constraints
|
| 38 |
+
that the provider needs for token counting and cost estimation.
|
| 39 |
+
"""
|
| 40 |
+
|
| 41 |
+
model: str
|
| 42 |
+
context_window: int = 128000 # Default to 128K
|
| 43 |
+
max_output_tokens: int = 4096
|
| 44 |
+
supports_tools: bool = True
|
| 45 |
+
supports_vision: bool = False
|
| 46 |
+
supports_streaming: bool = True
|
| 47 |
+
tokenizer_backend: str | None = None # Force specific tokenizer
|
| 48 |
+
input_cost_per_1m: float | None = None # Cost per 1M input tokens
|
| 49 |
+
output_cost_per_1m: float | None = None # Cost per 1M output tokens
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# Default context limits for common open models
|
| 53 |
+
# These are reasonable defaults; users can override
|
| 54 |
+
_DEFAULT_CONTEXT_LIMITS: dict[str, int] = {
|
| 55 |
+
# Llama 3 family
|
| 56 |
+
"llama-3": 8192,
|
| 57 |
+
"llama-3-8b": 8192,
|
| 58 |
+
"llama-3-70b": 8192,
|
| 59 |
+
"llama-3.1": 128000,
|
| 60 |
+
"llama-3.1-8b": 128000,
|
| 61 |
+
"llama-3.1-70b": 128000,
|
| 62 |
+
"llama-3.1-405b": 128000,
|
| 63 |
+
"llama-3.2": 128000,
|
| 64 |
+
"llama-3.3": 128000,
|
| 65 |
+
# Llama 2 family
|
| 66 |
+
"llama-2": 4096,
|
| 67 |
+
"llama-2-7b": 4096,
|
| 68 |
+
"llama-2-13b": 4096,
|
| 69 |
+
"llama-2-70b": 4096,
|
| 70 |
+
"codellama": 16384,
|
| 71 |
+
# Mistral family
|
| 72 |
+
"mistral": 32768,
|
| 73 |
+
"mistral-7b": 32768,
|
| 74 |
+
"mistral-nemo": 128000,
|
| 75 |
+
"mistral-small": 32768,
|
| 76 |
+
"mistral-large": 128000,
|
| 77 |
+
"mixtral": 32768,
|
| 78 |
+
"mixtral-8x7b": 32768,
|
| 79 |
+
"mixtral-8x22b": 65536,
|
| 80 |
+
# Qwen family
|
| 81 |
+
"qwen": 32768,
|
| 82 |
+
"qwen2": 32768,
|
| 83 |
+
"qwen2-7b": 32768,
|
| 84 |
+
"qwen2-72b": 32768,
|
| 85 |
+
"qwen2.5": 131072,
|
| 86 |
+
# DeepSeek
|
| 87 |
+
"deepseek": 32768,
|
| 88 |
+
"deepseek-coder": 16384,
|
| 89 |
+
"deepseek-v2": 128000,
|
| 90 |
+
"deepseek-v3": 128000,
|
| 91 |
+
# Yi
|
| 92 |
+
"yi": 32768,
|
| 93 |
+
"yi-34b": 32768,
|
| 94 |
+
# Phi
|
| 95 |
+
"phi-2": 2048,
|
| 96 |
+
"phi-3": 4096,
|
| 97 |
+
"phi-3-mini": 4096,
|
| 98 |
+
"phi-3-medium": 4096,
|
| 99 |
+
# Others
|
| 100 |
+
"falcon": 2048,
|
| 101 |
+
"falcon-40b": 2048,
|
| 102 |
+
"falcon-180b": 2048,
|
| 103 |
+
"gemma": 8192,
|
| 104 |
+
"gemma-2": 8192,
|
| 105 |
+
"starcoder": 8192,
|
| 106 |
+
"starcoder2": 16384,
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class OpenAICompatibleTokenCounter:
|
| 111 |
+
"""Token counter for OpenAI-compatible providers.
|
| 112 |
+
|
| 113 |
+
Uses the TokenizerRegistry to get the appropriate tokenizer
|
| 114 |
+
for the model, falling back to estimation if needed.
|
| 115 |
+
"""
|
| 116 |
+
|
| 117 |
+
def __init__(
|
| 118 |
+
self,
|
| 119 |
+
model: str,
|
| 120 |
+
tokenizer_backend: str | None = None,
|
| 121 |
+
):
|
| 122 |
+
"""Initialize token counter.
|
| 123 |
+
|
| 124 |
+
Args:
|
| 125 |
+
model: Model name.
|
| 126 |
+
tokenizer_backend: Force specific tokenizer backend.
|
| 127 |
+
"""
|
| 128 |
+
self.model = model
|
| 129 |
+
self._tokenizer = get_tokenizer(model, backend=tokenizer_backend)
|
| 130 |
+
|
| 131 |
+
def count_text(self, text: str) -> int:
|
| 132 |
+
"""Count tokens in text."""
|
| 133 |
+
return self._tokenizer.count_text(text)
|
| 134 |
+
|
| 135 |
+
def count_message(self, message: dict[str, Any]) -> int:
|
| 136 |
+
"""Count tokens in a single message."""
|
| 137 |
+
# Use OpenAI-style message overhead
|
| 138 |
+
tokens = 4 # Base overhead
|
| 139 |
+
|
| 140 |
+
role = message.get("role", "")
|
| 141 |
+
tokens += self.count_text(role)
|
| 142 |
+
|
| 143 |
+
content = message.get("content")
|
| 144 |
+
if content:
|
| 145 |
+
if isinstance(content, str):
|
| 146 |
+
tokens += self.count_text(content)
|
| 147 |
+
elif isinstance(content, list):
|
| 148 |
+
for part in content:
|
| 149 |
+
if isinstance(part, dict):
|
| 150 |
+
if part.get("type") == "text":
|
| 151 |
+
tokens += self.count_text(part.get("text", ""))
|
| 152 |
+
elif isinstance(part, str):
|
| 153 |
+
tokens += self.count_text(part)
|
| 154 |
+
|
| 155 |
+
name = message.get("name")
|
| 156 |
+
if name:
|
| 157 |
+
tokens += self.count_text(name) + 1
|
| 158 |
+
|
| 159 |
+
tool_calls = message.get("tool_calls")
|
| 160 |
+
if tool_calls:
|
| 161 |
+
for tc in tool_calls:
|
| 162 |
+
func = tc.get("function", {})
|
| 163 |
+
tokens += self.count_text(func.get("name", ""))
|
| 164 |
+
tokens += self.count_text(func.get("arguments", ""))
|
| 165 |
+
tokens += 10
|
| 166 |
+
|
| 167 |
+
tool_call_id = message.get("tool_call_id")
|
| 168 |
+
if tool_call_id:
|
| 169 |
+
tokens += self.count_text(tool_call_id) + 2
|
| 170 |
+
|
| 171 |
+
return tokens
|
| 172 |
+
|
| 173 |
+
def count_messages(self, messages: list[dict[str, Any]]) -> int:
|
| 174 |
+
"""Count tokens in a list of messages."""
|
| 175 |
+
total = sum(self.count_message(msg) for msg in messages)
|
| 176 |
+
total += 3 # Priming tokens
|
| 177 |
+
return total
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
class OpenAICompatibleProvider(Provider):
|
| 181 |
+
"""Provider for OpenAI-compatible LLM services.
|
| 182 |
+
|
| 183 |
+
Works with any service implementing the OpenAI chat completions API:
|
| 184 |
+
- Ollama (local)
|
| 185 |
+
- vLLM (local/cloud)
|
| 186 |
+
- Together AI
|
| 187 |
+
- Groq
|
| 188 |
+
- Fireworks AI
|
| 189 |
+
- LM Studio
|
| 190 |
+
- LocalAI
|
| 191 |
+
- And many more...
|
| 192 |
+
|
| 193 |
+
Example:
|
| 194 |
+
# For Ollama
|
| 195 |
+
provider = OpenAICompatibleProvider(
|
| 196 |
+
name="ollama",
|
| 197 |
+
base_url="http://localhost:11434/v1",
|
| 198 |
+
default_model="llama3.1",
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
# For Together AI
|
| 202 |
+
provider = OpenAICompatibleProvider(
|
| 203 |
+
name="together",
|
| 204 |
+
base_url="https://api.together.xyz/v1",
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
# Get token counter for a specific model
|
| 208 |
+
counter = provider.get_token_counter("llama-3.1-8b")
|
| 209 |
+
"""
|
| 210 |
+
|
| 211 |
+
def __init__(
|
| 212 |
+
self,
|
| 213 |
+
name: str = "openai_compatible",
|
| 214 |
+
base_url: str | None = None,
|
| 215 |
+
api_key: str | None = None,
|
| 216 |
+
default_model: str | None = None,
|
| 217 |
+
models: dict[str, ModelCapabilities] | None = None,
|
| 218 |
+
):
|
| 219 |
+
"""Initialize OpenAI-compatible provider.
|
| 220 |
+
|
| 221 |
+
Args:
|
| 222 |
+
name: Provider name for identification.
|
| 223 |
+
base_url: API base URL (e.g., 'http://localhost:11434/v1').
|
| 224 |
+
api_key: API key (if required).
|
| 225 |
+
default_model: Default model for operations.
|
| 226 |
+
models: Custom model configurations.
|
| 227 |
+
"""
|
| 228 |
+
self._name = name
|
| 229 |
+
self.base_url = base_url
|
| 230 |
+
self.api_key = api_key
|
| 231 |
+
self.default_model = default_model
|
| 232 |
+
self._models: dict[str, ModelCapabilities] = models or {}
|
| 233 |
+
|
| 234 |
+
@property
|
| 235 |
+
def name(self) -> str:
|
| 236 |
+
return self._name
|
| 237 |
+
|
| 238 |
+
def register_model(
|
| 239 |
+
self,
|
| 240 |
+
model: str,
|
| 241 |
+
capabilities: ModelCapabilities | None = None,
|
| 242 |
+
**kwargs: Any,
|
| 243 |
+
) -> None:
|
| 244 |
+
"""Register a model with its capabilities.
|
| 245 |
+
|
| 246 |
+
Args:
|
| 247 |
+
model: Model name.
|
| 248 |
+
capabilities: Model capabilities object.
|
| 249 |
+
**kwargs: Alternative way to specify capabilities.
|
| 250 |
+
"""
|
| 251 |
+
if capabilities is not None:
|
| 252 |
+
self._models[model] = capabilities
|
| 253 |
+
else:
|
| 254 |
+
self._models[model] = ModelCapabilities(model=model, **kwargs)
|
| 255 |
+
|
| 256 |
+
def supports_model(self, model: str) -> bool:
|
| 257 |
+
"""Check if model is supported.
|
| 258 |
+
|
| 259 |
+
OpenAI-compatible providers support any model by default,
|
| 260 |
+
using estimation for token counting.
|
| 261 |
+
"""
|
| 262 |
+
return True # Always return True - we can estimate
|
| 263 |
+
|
| 264 |
+
def get_token_counter(self, model: str) -> OpenAICompatibleTokenCounter:
|
| 265 |
+
"""Get token counter for a model.
|
| 266 |
+
|
| 267 |
+
Uses the TokenizerRegistry to find the best tokenizer,
|
| 268 |
+
with fallback to estimation.
|
| 269 |
+
"""
|
| 270 |
+
tokenizer_backend = None
|
| 271 |
+
|
| 272 |
+
# Check for registered model with specific tokenizer
|
| 273 |
+
if model in self._models:
|
| 274 |
+
tokenizer_backend = self._models[model].tokenizer_backend
|
| 275 |
+
|
| 276 |
+
return OpenAICompatibleTokenCounter(model, tokenizer_backend)
|
| 277 |
+
|
| 278 |
+
def get_context_limit(self, model: str) -> int:
|
| 279 |
+
"""Get context limit for a model.
|
| 280 |
+
|
| 281 |
+
Priority:
|
| 282 |
+
1. Registered model capabilities
|
| 283 |
+
2. Default limits for known models
|
| 284 |
+
3. Prefix matching
|
| 285 |
+
4. Default 128K
|
| 286 |
+
"""
|
| 287 |
+
# Check registered models
|
| 288 |
+
if model in self._models:
|
| 289 |
+
return self._models[model].context_window
|
| 290 |
+
|
| 291 |
+
model_lower = model.lower()
|
| 292 |
+
|
| 293 |
+
# Check default limits
|
| 294 |
+
if model_lower in _DEFAULT_CONTEXT_LIMITS:
|
| 295 |
+
return _DEFAULT_CONTEXT_LIMITS[model_lower]
|
| 296 |
+
|
| 297 |
+
# Prefix match
|
| 298 |
+
for prefix, limit in _DEFAULT_CONTEXT_LIMITS.items():
|
| 299 |
+
if model_lower.startswith(prefix):
|
| 300 |
+
return limit
|
| 301 |
+
|
| 302 |
+
# Default to 128K for modern models
|
| 303 |
+
return 128000
|
| 304 |
+
|
| 305 |
+
def get_output_buffer(self, model: str, default: int = 4000) -> int:
|
| 306 |
+
"""Get recommended output buffer."""
|
| 307 |
+
if model in self._models:
|
| 308 |
+
return min(self._models[model].max_output_tokens, default)
|
| 309 |
+
return default
|
| 310 |
+
|
| 311 |
+
def estimate_cost(
|
| 312 |
+
self,
|
| 313 |
+
input_tokens: int,
|
| 314 |
+
output_tokens: int,
|
| 315 |
+
model: str,
|
| 316 |
+
cached_tokens: int = 0,
|
| 317 |
+
) -> float | None:
|
| 318 |
+
"""Estimate cost if pricing is configured.
|
| 319 |
+
|
| 320 |
+
Args:
|
| 321 |
+
input_tokens: Number of input tokens.
|
| 322 |
+
output_tokens: Number of output tokens.
|
| 323 |
+
model: Model name.
|
| 324 |
+
cached_tokens: Number of cached tokens.
|
| 325 |
+
|
| 326 |
+
Returns:
|
| 327 |
+
Estimated cost in USD, or None if pricing unknown.
|
| 328 |
+
"""
|
| 329 |
+
if model not in self._models:
|
| 330 |
+
return None
|
| 331 |
+
|
| 332 |
+
caps = self._models[model]
|
| 333 |
+
if caps.input_cost_per_1m is None or caps.output_cost_per_1m is None:
|
| 334 |
+
return None
|
| 335 |
+
|
| 336 |
+
input_cost = (input_tokens / 1_000_000) * caps.input_cost_per_1m
|
| 337 |
+
output_cost = (output_tokens / 1_000_000) * caps.output_cost_per_1m
|
| 338 |
+
|
| 339 |
+
return input_cost + output_cost
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
# Pre-configured provider factories for common services
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def create_ollama_provider(
|
| 346 |
+
base_url: str = "http://localhost:11434/v1",
|
| 347 |
+
) -> OpenAICompatibleProvider:
|
| 348 |
+
"""Create provider for Ollama.
|
| 349 |
+
|
| 350 |
+
Ollama is a popular local LLM runner that supports many open models.
|
| 351 |
+
|
| 352 |
+
Args:
|
| 353 |
+
base_url: Ollama API URL (default: http://localhost:11434/v1).
|
| 354 |
+
|
| 355 |
+
Returns:
|
| 356 |
+
Configured provider.
|
| 357 |
+
"""
|
| 358 |
+
return OpenAICompatibleProvider(
|
| 359 |
+
name="ollama",
|
| 360 |
+
base_url=base_url,
|
| 361 |
+
)
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def create_together_provider(
|
| 365 |
+
api_key: str | None = None,
|
| 366 |
+
) -> OpenAICompatibleProvider:
|
| 367 |
+
"""Create provider for Together AI.
|
| 368 |
+
|
| 369 |
+
Together AI offers high-performance inference for open models.
|
| 370 |
+
|
| 371 |
+
Args:
|
| 372 |
+
api_key: Together AI API key.
|
| 373 |
+
|
| 374 |
+
Returns:
|
| 375 |
+
Configured provider with Together AI pricing.
|
| 376 |
+
"""
|
| 377 |
+
provider = OpenAICompatibleProvider(
|
| 378 |
+
name="together",
|
| 379 |
+
base_url="https://api.together.xyz/v1",
|
| 380 |
+
api_key=api_key,
|
| 381 |
+
)
|
| 382 |
+
|
| 383 |
+
# Register common Together models with pricing
|
| 384 |
+
# Pricing as of Jan 2025 (verify current rates)
|
| 385 |
+
provider.register_model(
|
| 386 |
+
"meta-llama/Llama-3.1-8B-Instruct-Turbo",
|
| 387 |
+
context_window=128000,
|
| 388 |
+
input_cost_per_1m=0.18,
|
| 389 |
+
output_cost_per_1m=0.18,
|
| 390 |
+
)
|
| 391 |
+
provider.register_model(
|
| 392 |
+
"meta-llama/Llama-3.1-70B-Instruct-Turbo",
|
| 393 |
+
context_window=128000,
|
| 394 |
+
input_cost_per_1m=0.88,
|
| 395 |
+
output_cost_per_1m=0.88,
|
| 396 |
+
)
|
| 397 |
+
provider.register_model(
|
| 398 |
+
"meta-llama/Llama-3.1-405B-Instruct-Turbo",
|
| 399 |
+
context_window=128000,
|
| 400 |
+
input_cost_per_1m=3.50,
|
| 401 |
+
output_cost_per_1m=3.50,
|
| 402 |
+
)
|
| 403 |
+
|
| 404 |
+
return provider
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
def create_groq_provider(
|
| 408 |
+
api_key: str | None = None,
|
| 409 |
+
) -> OpenAICompatibleProvider:
|
| 410 |
+
"""Create provider for Groq.
|
| 411 |
+
|
| 412 |
+
Groq offers ultra-fast inference on custom hardware.
|
| 413 |
+
|
| 414 |
+
Args:
|
| 415 |
+
api_key: Groq API key.
|
| 416 |
+
|
| 417 |
+
Returns:
|
| 418 |
+
Configured provider with Groq pricing.
|
| 419 |
+
"""
|
| 420 |
+
provider = OpenAICompatibleProvider(
|
| 421 |
+
name="groq",
|
| 422 |
+
base_url="https://api.groq.com/openai/v1",
|
| 423 |
+
api_key=api_key,
|
| 424 |
+
)
|
| 425 |
+
|
| 426 |
+
# Register common Groq models with pricing
|
| 427 |
+
# Pricing as of Jan 2025 (verify current rates)
|
| 428 |
+
provider.register_model(
|
| 429 |
+
"llama-3.1-8b-instant",
|
| 430 |
+
context_window=128000,
|
| 431 |
+
input_cost_per_1m=0.05,
|
| 432 |
+
output_cost_per_1m=0.08,
|
| 433 |
+
)
|
| 434 |
+
provider.register_model(
|
| 435 |
+
"llama-3.1-70b-versatile",
|
| 436 |
+
context_window=128000,
|
| 437 |
+
input_cost_per_1m=0.59,
|
| 438 |
+
output_cost_per_1m=0.79,
|
| 439 |
+
)
|
| 440 |
+
provider.register_model(
|
| 441 |
+
"mixtral-8x7b-32768",
|
| 442 |
+
context_window=32768,
|
| 443 |
+
input_cost_per_1m=0.24,
|
| 444 |
+
output_cost_per_1m=0.24,
|
| 445 |
+
)
|
| 446 |
+
|
| 447 |
+
return provider
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
def create_fireworks_provider(
|
| 451 |
+
api_key: str | None = None,
|
| 452 |
+
) -> OpenAICompatibleProvider:
|
| 453 |
+
"""Create provider for Fireworks AI.
|
| 454 |
+
|
| 455 |
+
Args:
|
| 456 |
+
api_key: Fireworks API key.
|
| 457 |
+
|
| 458 |
+
Returns:
|
| 459 |
+
Configured provider.
|
| 460 |
+
"""
|
| 461 |
+
return OpenAICompatibleProvider(
|
| 462 |
+
name="fireworks",
|
| 463 |
+
base_url="https://api.fireworks.ai/inference/v1",
|
| 464 |
+
api_key=api_key,
|
| 465 |
+
)
|
| 466 |
+
|
| 467 |
+
|
| 468 |
+
def create_anyscale_provider(
|
| 469 |
+
api_key: str | None = None,
|
| 470 |
+
) -> OpenAICompatibleProvider:
|
| 471 |
+
"""Create provider for Anyscale Endpoints.
|
| 472 |
+
|
| 473 |
+
Args:
|
| 474 |
+
api_key: Anyscale API key.
|
| 475 |
+
|
| 476 |
+
Returns:
|
| 477 |
+
Configured provider.
|
| 478 |
+
"""
|
| 479 |
+
return OpenAICompatibleProvider(
|
| 480 |
+
name="anyscale",
|
| 481 |
+
base_url="https://api.endpoints.anyscale.com/v1",
|
| 482 |
+
api_key=api_key,
|
| 483 |
+
)
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
def create_vllm_provider(
|
| 487 |
+
base_url: str,
|
| 488 |
+
) -> OpenAICompatibleProvider:
|
| 489 |
+
"""Create provider for vLLM server.
|
| 490 |
+
|
| 491 |
+
vLLM is a high-performance inference engine.
|
| 492 |
+
|
| 493 |
+
Args:
|
| 494 |
+
base_url: vLLM server URL (e.g., 'http://localhost:8000/v1').
|
| 495 |
+
|
| 496 |
+
Returns:
|
| 497 |
+
Configured provider.
|
| 498 |
+
"""
|
| 499 |
+
return OpenAICompatibleProvider(
|
| 500 |
+
name="vllm",
|
| 501 |
+
base_url=base_url,
|
| 502 |
+
)
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
def create_lmstudio_provider(
|
| 506 |
+
base_url: str = "http://localhost:1234/v1",
|
| 507 |
+
) -> OpenAICompatibleProvider:
|
| 508 |
+
"""Create provider for LM Studio.
|
| 509 |
+
|
| 510 |
+
LM Studio is a desktop app for running local LLMs.
|
| 511 |
+
|
| 512 |
+
Args:
|
| 513 |
+
base_url: LM Studio API URL.
|
| 514 |
+
|
| 515 |
+
Returns:
|
| 516 |
+
Configured provider.
|
| 517 |
+
"""
|
| 518 |
+
return OpenAICompatibleProvider(
|
| 519 |
+
name="lmstudio",
|
| 520 |
+
base_url=base_url,
|
| 521 |
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Headroom Proxy Server.
|
| 2 |
+
|
| 3 |
+
A transparent proxy that sits between LLM clients (Claude Code, Cursor, etc.)
|
| 4 |
+
and LLM APIs (Anthropic, OpenAI), applying Headroom optimizations.
|
| 5 |
+
|
| 6 |
+
Usage:
|
| 7 |
+
# Start the proxy
|
| 8 |
+
python -m headroom.proxy.server
|
| 9 |
+
|
| 10 |
+
# Use with Claude Code
|
| 11 |
+
ANTHROPIC_BASE_URL=http://localhost:8787 claude
|
| 12 |
+
|
| 13 |
+
# Use with Cursor (if using Anthropic)
|
| 14 |
+
Set base URL in Cursor settings to http://localhost:8787
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from .server import create_app, run_server
|
| 18 |
+
|
| 19 |
+
__all__ = ["create_app", "run_server"]
|
|
@@ -0,0 +1,1399 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Headroom Proxy Server - Production Ready.
|
| 2 |
+
|
| 3 |
+
A full-featured LLM proxy with optimization, caching, rate limiting,
|
| 4 |
+
and observability.
|
| 5 |
+
|
| 6 |
+
Features:
|
| 7 |
+
- Context optimization (SmartCrusher, CacheAligner, RollingWindow)
|
| 8 |
+
- Semantic caching (save costs on repeated queries)
|
| 9 |
+
- Rate limiting (token bucket)
|
| 10 |
+
- Retry with exponential backoff
|
| 11 |
+
- Cost tracking and budgets
|
| 12 |
+
- Request tagging and metadata
|
| 13 |
+
- Provider fallback
|
| 14 |
+
- Prometheus metrics
|
| 15 |
+
- Full request/response logging
|
| 16 |
+
|
| 17 |
+
Usage:
|
| 18 |
+
python -m headroom.proxy.server --port 8787
|
| 19 |
+
|
| 20 |
+
# With Claude Code:
|
| 21 |
+
ANTHROPIC_BASE_URL=http://localhost:8787 claude
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import argparse
|
| 27 |
+
import asyncio
|
| 28 |
+
import hashlib
|
| 29 |
+
import json
|
| 30 |
+
import logging
|
| 31 |
+
import random
|
| 32 |
+
import sys
|
| 33 |
+
import time
|
| 34 |
+
from collections import defaultdict
|
| 35 |
+
from dataclasses import asdict, dataclass
|
| 36 |
+
from datetime import datetime, timedelta
|
| 37 |
+
from pathlib import Path
|
| 38 |
+
from typing import Literal
|
| 39 |
+
|
| 40 |
+
import httpx
|
| 41 |
+
|
| 42 |
+
try:
|
| 43 |
+
import uvicorn
|
| 44 |
+
from fastapi import FastAPI, Header, HTTPException, Request, Response
|
| 45 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 46 |
+
from fastapi.responses import PlainTextResponse, StreamingResponse
|
| 47 |
+
FASTAPI_AVAILABLE = True
|
| 48 |
+
except ImportError:
|
| 49 |
+
FASTAPI_AVAILABLE = False
|
| 50 |
+
|
| 51 |
+
# Add parent to path for imports
|
| 52 |
+
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
| 53 |
+
|
| 54 |
+
from headroom.config import CacheAlignerConfig, RollingWindowConfig, SmartCrusherConfig
|
| 55 |
+
from headroom.providers import AnthropicProvider, OpenAIProvider
|
| 56 |
+
from headroom.tokenizers import get_tokenizer
|
| 57 |
+
from headroom.transforms import CacheAligner, RollingWindow, SmartCrusher, TransformPipeline
|
| 58 |
+
|
| 59 |
+
logging.basicConfig(
|
| 60 |
+
level=logging.INFO,
|
| 61 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
| 62 |
+
)
|
| 63 |
+
logger = logging.getLogger("headroom.proxy")
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
# =============================================================================
|
| 67 |
+
# Data Models
|
| 68 |
+
# =============================================================================
|
| 69 |
+
|
| 70 |
+
@dataclass
|
| 71 |
+
class RequestLog:
|
| 72 |
+
"""Complete log of a single request."""
|
| 73 |
+
request_id: str
|
| 74 |
+
timestamp: str
|
| 75 |
+
provider: str
|
| 76 |
+
model: str
|
| 77 |
+
|
| 78 |
+
# Tokens
|
| 79 |
+
input_tokens_original: int
|
| 80 |
+
input_tokens_optimized: int
|
| 81 |
+
output_tokens: int | None
|
| 82 |
+
tokens_saved: int
|
| 83 |
+
savings_percent: float
|
| 84 |
+
|
| 85 |
+
# Cost
|
| 86 |
+
estimated_cost_usd: float | None
|
| 87 |
+
estimated_savings_usd: float | None
|
| 88 |
+
|
| 89 |
+
# Performance
|
| 90 |
+
optimization_latency_ms: float
|
| 91 |
+
total_latency_ms: float | None
|
| 92 |
+
|
| 93 |
+
# Metadata
|
| 94 |
+
tags: dict[str, str]
|
| 95 |
+
cache_hit: bool
|
| 96 |
+
transforms_applied: list[str]
|
| 97 |
+
|
| 98 |
+
# Request/Response (optional, for debugging)
|
| 99 |
+
request_messages: list[dict] | None = None
|
| 100 |
+
response_content: str | None = None
|
| 101 |
+
error: str | None = None
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@dataclass
|
| 105 |
+
class CacheEntry:
|
| 106 |
+
"""Cached response entry."""
|
| 107 |
+
response_body: bytes
|
| 108 |
+
response_headers: dict[str, str]
|
| 109 |
+
created_at: datetime
|
| 110 |
+
ttl_seconds: int
|
| 111 |
+
hit_count: int = 0
|
| 112 |
+
tokens_saved_per_hit: int = 0
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@dataclass
|
| 116 |
+
class RateLimitState:
|
| 117 |
+
"""Token bucket rate limiter state."""
|
| 118 |
+
tokens: float
|
| 119 |
+
last_update: float
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
@dataclass
|
| 123 |
+
class ProxyConfig:
|
| 124 |
+
"""Proxy configuration."""
|
| 125 |
+
# Server
|
| 126 |
+
host: str = "127.0.0.1"
|
| 127 |
+
port: int = 8787
|
| 128 |
+
|
| 129 |
+
# Optimization
|
| 130 |
+
optimize: bool = True
|
| 131 |
+
min_tokens_to_crush: int = 500
|
| 132 |
+
max_items_after_crush: int = 50
|
| 133 |
+
keep_last_turns: int = 4
|
| 134 |
+
|
| 135 |
+
# Caching
|
| 136 |
+
cache_enabled: bool = True
|
| 137 |
+
cache_ttl_seconds: int = 3600 # 1 hour
|
| 138 |
+
cache_max_entries: int = 1000
|
| 139 |
+
|
| 140 |
+
# Rate limiting
|
| 141 |
+
rate_limit_enabled: bool = True
|
| 142 |
+
rate_limit_requests_per_minute: int = 60
|
| 143 |
+
rate_limit_tokens_per_minute: int = 100000
|
| 144 |
+
|
| 145 |
+
# Retry
|
| 146 |
+
retry_enabled: bool = True
|
| 147 |
+
retry_max_attempts: int = 3
|
| 148 |
+
retry_base_delay_ms: int = 1000
|
| 149 |
+
retry_max_delay_ms: int = 30000
|
| 150 |
+
|
| 151 |
+
# Cost tracking
|
| 152 |
+
cost_tracking_enabled: bool = True
|
| 153 |
+
budget_limit_usd: float | None = None # None = unlimited
|
| 154 |
+
budget_period: Literal["hourly", "daily", "monthly"] = "daily"
|
| 155 |
+
|
| 156 |
+
# Logging
|
| 157 |
+
log_requests: bool = True
|
| 158 |
+
log_file: str | None = None
|
| 159 |
+
log_full_messages: bool = False # Privacy: don't log content by default
|
| 160 |
+
|
| 161 |
+
# Fallback
|
| 162 |
+
fallback_enabled: bool = False
|
| 163 |
+
fallback_provider: str | None = None # "openai" or "anthropic"
|
| 164 |
+
|
| 165 |
+
# Timeouts
|
| 166 |
+
request_timeout_seconds: int = 300
|
| 167 |
+
connect_timeout_seconds: int = 10
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
# =============================================================================
|
| 171 |
+
# Caching
|
| 172 |
+
# =============================================================================
|
| 173 |
+
|
| 174 |
+
class SemanticCache:
|
| 175 |
+
"""Simple semantic cache based on message content hash."""
|
| 176 |
+
|
| 177 |
+
def __init__(self, max_entries: int = 1000, ttl_seconds: int = 3600):
|
| 178 |
+
self.max_entries = max_entries
|
| 179 |
+
self.ttl_seconds = ttl_seconds
|
| 180 |
+
self._cache: dict[str, CacheEntry] = {}
|
| 181 |
+
self._access_order: list[str] = []
|
| 182 |
+
|
| 183 |
+
def _compute_key(self, messages: list[dict], model: str) -> str:
|
| 184 |
+
"""Compute cache key from messages and model."""
|
| 185 |
+
# Normalize messages for consistent hashing
|
| 186 |
+
normalized = json.dumps({
|
| 187 |
+
"model": model,
|
| 188 |
+
"messages": messages,
|
| 189 |
+
}, sort_keys=True)
|
| 190 |
+
return hashlib.sha256(normalized.encode()).hexdigest()[:32]
|
| 191 |
+
|
| 192 |
+
def get(self, messages: list[dict], model: str) -> CacheEntry | None:
|
| 193 |
+
"""Get cached response if exists and not expired."""
|
| 194 |
+
key = self._compute_key(messages, model)
|
| 195 |
+
entry = self._cache.get(key)
|
| 196 |
+
|
| 197 |
+
if entry is None:
|
| 198 |
+
return None
|
| 199 |
+
|
| 200 |
+
# Check expiration
|
| 201 |
+
age = (datetime.now() - entry.created_at).total_seconds()
|
| 202 |
+
if age > entry.ttl_seconds:
|
| 203 |
+
del self._cache[key]
|
| 204 |
+
self._access_order.remove(key)
|
| 205 |
+
return None
|
| 206 |
+
|
| 207 |
+
entry.hit_count += 1
|
| 208 |
+
return entry
|
| 209 |
+
|
| 210 |
+
def set(
|
| 211 |
+
self,
|
| 212 |
+
messages: list[dict],
|
| 213 |
+
model: str,
|
| 214 |
+
response_body: bytes,
|
| 215 |
+
response_headers: dict[str, str],
|
| 216 |
+
tokens_saved: int = 0,
|
| 217 |
+
):
|
| 218 |
+
"""Cache a response."""
|
| 219 |
+
key = self._compute_key(messages, model)
|
| 220 |
+
|
| 221 |
+
# Evict if at capacity (LRU)
|
| 222 |
+
while len(self._cache) >= self.max_entries and self._access_order:
|
| 223 |
+
oldest_key = self._access_order.pop(0)
|
| 224 |
+
self._cache.pop(oldest_key, None)
|
| 225 |
+
|
| 226 |
+
self._cache[key] = CacheEntry(
|
| 227 |
+
response_body=response_body,
|
| 228 |
+
response_headers=response_headers,
|
| 229 |
+
created_at=datetime.now(),
|
| 230 |
+
ttl_seconds=self.ttl_seconds,
|
| 231 |
+
tokens_saved_per_hit=tokens_saved,
|
| 232 |
+
)
|
| 233 |
+
self._access_order.append(key)
|
| 234 |
+
|
| 235 |
+
def stats(self) -> dict:
|
| 236 |
+
"""Get cache statistics."""
|
| 237 |
+
total_hits = sum(e.hit_count for e in self._cache.values())
|
| 238 |
+
return {
|
| 239 |
+
"entries": len(self._cache),
|
| 240 |
+
"max_entries": self.max_entries,
|
| 241 |
+
"total_hits": total_hits,
|
| 242 |
+
"ttl_seconds": self.ttl_seconds,
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
def clear(self):
|
| 246 |
+
"""Clear all cache entries."""
|
| 247 |
+
self._cache.clear()
|
| 248 |
+
self._access_order.clear()
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
# =============================================================================
|
| 252 |
+
# Rate Limiting
|
| 253 |
+
# =============================================================================
|
| 254 |
+
|
| 255 |
+
class TokenBucketRateLimiter:
|
| 256 |
+
"""Token bucket rate limiter for requests and tokens."""
|
| 257 |
+
|
| 258 |
+
def __init__(
|
| 259 |
+
self,
|
| 260 |
+
requests_per_minute: int = 60,
|
| 261 |
+
tokens_per_minute: int = 100000,
|
| 262 |
+
):
|
| 263 |
+
self.requests_per_minute = requests_per_minute
|
| 264 |
+
self.tokens_per_minute = tokens_per_minute
|
| 265 |
+
|
| 266 |
+
# Per-key buckets (key = API key or IP)
|
| 267 |
+
self._request_buckets: dict[str, RateLimitState] = defaultdict(
|
| 268 |
+
lambda: RateLimitState(tokens=requests_per_minute, last_update=time.time())
|
| 269 |
+
)
|
| 270 |
+
self._token_buckets: dict[str, RateLimitState] = defaultdict(
|
| 271 |
+
lambda: RateLimitState(tokens=tokens_per_minute, last_update=time.time())
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
def _refill(self, state: RateLimitState, rate_per_minute: float) -> float:
|
| 275 |
+
"""Refill bucket based on elapsed time."""
|
| 276 |
+
now = time.time()
|
| 277 |
+
elapsed = now - state.last_update
|
| 278 |
+
refill = elapsed * (rate_per_minute / 60.0)
|
| 279 |
+
state.tokens = min(rate_per_minute, state.tokens + refill)
|
| 280 |
+
state.last_update = now
|
| 281 |
+
return state.tokens
|
| 282 |
+
|
| 283 |
+
def check_request(self, key: str = "default") -> tuple[bool, float]:
|
| 284 |
+
"""Check if request is allowed. Returns (allowed, wait_seconds)."""
|
| 285 |
+
state = self._request_buckets[key]
|
| 286 |
+
available = self._refill(state, self.requests_per_minute)
|
| 287 |
+
|
| 288 |
+
if available >= 1:
|
| 289 |
+
state.tokens -= 1
|
| 290 |
+
return True, 0
|
| 291 |
+
|
| 292 |
+
wait_seconds = (1 - available) * (60.0 / self.requests_per_minute)
|
| 293 |
+
return False, wait_seconds
|
| 294 |
+
|
| 295 |
+
def check_tokens(self, key: str, token_count: int) -> tuple[bool, float]:
|
| 296 |
+
"""Check if token usage is allowed."""
|
| 297 |
+
state = self._token_buckets[key]
|
| 298 |
+
available = self._refill(state, self.tokens_per_minute)
|
| 299 |
+
|
| 300 |
+
if available >= token_count:
|
| 301 |
+
state.tokens -= token_count
|
| 302 |
+
return True, 0
|
| 303 |
+
|
| 304 |
+
wait_seconds = (token_count - available) * (60.0 / self.tokens_per_minute)
|
| 305 |
+
return False, wait_seconds
|
| 306 |
+
|
| 307 |
+
def stats(self) -> dict:
|
| 308 |
+
"""Get rate limiter statistics."""
|
| 309 |
+
return {
|
| 310 |
+
"requests_per_minute": self.requests_per_minute,
|
| 311 |
+
"tokens_per_minute": self.tokens_per_minute,
|
| 312 |
+
"active_keys": len(self._request_buckets),
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
# =============================================================================
|
| 317 |
+
# Cost Tracking
|
| 318 |
+
# =============================================================================
|
| 319 |
+
|
| 320 |
+
class CostTracker:
|
| 321 |
+
"""Track costs and enforce budgets."""
|
| 322 |
+
|
| 323 |
+
# Pricing per 1M tokens (input, output, cached_input)
|
| 324 |
+
PRICING = {
|
| 325 |
+
# Anthropic
|
| 326 |
+
"claude-3-5-sonnet": (3.00, 15.00, 0.30),
|
| 327 |
+
"claude-3-5-haiku": (0.80, 4.00, 0.08),
|
| 328 |
+
"claude-3-opus": (15.00, 75.00, 1.50),
|
| 329 |
+
"claude-sonnet-4": (3.00, 15.00, 0.30),
|
| 330 |
+
"claude-opus-4": (15.00, 75.00, 1.50),
|
| 331 |
+
# OpenAI
|
| 332 |
+
"gpt-4o": (2.50, 10.00, 1.25),
|
| 333 |
+
"gpt-4o-mini": (0.15, 0.60, 0.075),
|
| 334 |
+
"o1": (15.00, 60.00, 7.50),
|
| 335 |
+
"o1-mini": (1.10, 4.40, 0.55),
|
| 336 |
+
"o3-mini": (1.10, 4.40, 0.55),
|
| 337 |
+
"gpt-4-turbo": (10.00, 30.00, 5.00),
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
def __init__(self, budget_limit_usd: float | None = None, budget_period: str = "daily"):
|
| 341 |
+
self.budget_limit_usd = budget_limit_usd
|
| 342 |
+
self.budget_period = budget_period
|
| 343 |
+
|
| 344 |
+
# Cost tracking
|
| 345 |
+
self._costs: list[tuple[datetime, float]] = []
|
| 346 |
+
self._total_cost_usd: float = 0
|
| 347 |
+
self._total_savings_usd: float = 0
|
| 348 |
+
|
| 349 |
+
def _get_pricing(self, model: str) -> tuple[float, float, float] | None:
|
| 350 |
+
"""Get pricing for model."""
|
| 351 |
+
model_lower = model.lower()
|
| 352 |
+
for prefix, pricing in self.PRICING.items():
|
| 353 |
+
if prefix in model_lower:
|
| 354 |
+
return pricing
|
| 355 |
+
return None
|
| 356 |
+
|
| 357 |
+
def estimate_cost(
|
| 358 |
+
self,
|
| 359 |
+
model: str,
|
| 360 |
+
input_tokens: int,
|
| 361 |
+
output_tokens: int,
|
| 362 |
+
cached_tokens: int = 0,
|
| 363 |
+
) -> float | None:
|
| 364 |
+
"""Estimate cost in USD."""
|
| 365 |
+
pricing = self._get_pricing(model)
|
| 366 |
+
if pricing is None:
|
| 367 |
+
return None
|
| 368 |
+
|
| 369 |
+
input_price, output_price, cached_price = pricing
|
| 370 |
+
|
| 371 |
+
regular_input = input_tokens - cached_tokens
|
| 372 |
+
cost = (
|
| 373 |
+
(regular_input / 1_000_000) * input_price +
|
| 374 |
+
(cached_tokens / 1_000_000) * cached_price +
|
| 375 |
+
(output_tokens / 1_000_000) * output_price
|
| 376 |
+
)
|
| 377 |
+
return cost
|
| 378 |
+
|
| 379 |
+
def record_cost(self, cost_usd: float):
|
| 380 |
+
"""Record a cost."""
|
| 381 |
+
self._costs.append((datetime.now(), cost_usd))
|
| 382 |
+
self._total_cost_usd += cost_usd
|
| 383 |
+
|
| 384 |
+
def record_savings(self, savings_usd: float):
|
| 385 |
+
"""Record savings from optimization."""
|
| 386 |
+
self._total_savings_usd += savings_usd
|
| 387 |
+
|
| 388 |
+
def get_period_cost(self) -> float:
|
| 389 |
+
"""Get cost for current budget period."""
|
| 390 |
+
now = datetime.now()
|
| 391 |
+
|
| 392 |
+
if self.budget_period == "hourly":
|
| 393 |
+
cutoff = now - timedelta(hours=1)
|
| 394 |
+
elif self.budget_period == "daily":
|
| 395 |
+
cutoff = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
| 396 |
+
else: # monthly
|
| 397 |
+
cutoff = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
| 398 |
+
|
| 399 |
+
return sum(cost for ts, cost in self._costs if ts >= cutoff)
|
| 400 |
+
|
| 401 |
+
def check_budget(self) -> tuple[bool, float]:
|
| 402 |
+
"""Check if within budget. Returns (allowed, remaining)."""
|
| 403 |
+
if self.budget_limit_usd is None:
|
| 404 |
+
return True, float('inf')
|
| 405 |
+
|
| 406 |
+
period_cost = self.get_period_cost()
|
| 407 |
+
remaining = self.budget_limit_usd - period_cost
|
| 408 |
+
return remaining > 0, max(0, remaining)
|
| 409 |
+
|
| 410 |
+
def stats(self) -> dict:
|
| 411 |
+
"""Get cost statistics."""
|
| 412 |
+
return {
|
| 413 |
+
"total_cost_usd": round(self._total_cost_usd, 4),
|
| 414 |
+
"total_savings_usd": round(self._total_savings_usd, 4),
|
| 415 |
+
"period_cost_usd": round(self.get_period_cost(), 4),
|
| 416 |
+
"budget_limit_usd": self.budget_limit_usd,
|
| 417 |
+
"budget_period": self.budget_period,
|
| 418 |
+
"budget_remaining_usd": round(self.check_budget()[1], 4) if self.budget_limit_usd else None,
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
# =============================================================================
|
| 423 |
+
# Prometheus Metrics
|
| 424 |
+
# =============================================================================
|
| 425 |
+
|
| 426 |
+
class PrometheusMetrics:
|
| 427 |
+
"""Prometheus-compatible metrics."""
|
| 428 |
+
|
| 429 |
+
def __init__(self):
|
| 430 |
+
self.requests_total = 0
|
| 431 |
+
self.requests_by_provider: dict[str, int] = defaultdict(int)
|
| 432 |
+
self.requests_by_model: dict[str, int] = defaultdict(int)
|
| 433 |
+
self.requests_cached = 0
|
| 434 |
+
self.requests_rate_limited = 0
|
| 435 |
+
self.requests_failed = 0
|
| 436 |
+
|
| 437 |
+
self.tokens_input_total = 0
|
| 438 |
+
self.tokens_output_total = 0
|
| 439 |
+
self.tokens_saved_total = 0
|
| 440 |
+
|
| 441 |
+
self.latency_sum_ms = 0.0
|
| 442 |
+
self.latency_count = 0
|
| 443 |
+
|
| 444 |
+
self.cost_total_usd = 0.0
|
| 445 |
+
self.savings_total_usd = 0.0
|
| 446 |
+
|
| 447 |
+
def record_request(
|
| 448 |
+
self,
|
| 449 |
+
provider: str,
|
| 450 |
+
model: str,
|
| 451 |
+
input_tokens: int,
|
| 452 |
+
output_tokens: int,
|
| 453 |
+
tokens_saved: int,
|
| 454 |
+
latency_ms: float,
|
| 455 |
+
cached: bool = False,
|
| 456 |
+
cost_usd: float = 0,
|
| 457 |
+
savings_usd: float = 0,
|
| 458 |
+
):
|
| 459 |
+
"""Record metrics for a request."""
|
| 460 |
+
self.requests_total += 1
|
| 461 |
+
self.requests_by_provider[provider] += 1
|
| 462 |
+
self.requests_by_model[model] += 1
|
| 463 |
+
|
| 464 |
+
if cached:
|
| 465 |
+
self.requests_cached += 1
|
| 466 |
+
|
| 467 |
+
self.tokens_input_total += input_tokens
|
| 468 |
+
self.tokens_output_total += output_tokens
|
| 469 |
+
self.tokens_saved_total += tokens_saved
|
| 470 |
+
|
| 471 |
+
self.latency_sum_ms += latency_ms
|
| 472 |
+
self.latency_count += 1
|
| 473 |
+
|
| 474 |
+
self.cost_total_usd += cost_usd
|
| 475 |
+
self.savings_total_usd += savings_usd
|
| 476 |
+
|
| 477 |
+
def record_rate_limited(self):
|
| 478 |
+
self.requests_rate_limited += 1
|
| 479 |
+
|
| 480 |
+
def record_failed(self):
|
| 481 |
+
self.requests_failed += 1
|
| 482 |
+
|
| 483 |
+
def export(self) -> str:
|
| 484 |
+
"""Export metrics in Prometheus format."""
|
| 485 |
+
lines = [
|
| 486 |
+
"# HELP headroom_requests_total Total number of requests",
|
| 487 |
+
"# TYPE headroom_requests_total counter",
|
| 488 |
+
f"headroom_requests_total {self.requests_total}",
|
| 489 |
+
"",
|
| 490 |
+
"# HELP headroom_requests_cached_total Cached request count",
|
| 491 |
+
"# TYPE headroom_requests_cached_total counter",
|
| 492 |
+
f"headroom_requests_cached_total {self.requests_cached}",
|
| 493 |
+
"",
|
| 494 |
+
"# HELP headroom_requests_rate_limited_total Rate limited requests",
|
| 495 |
+
"# TYPE headroom_requests_rate_limited_total counter",
|
| 496 |
+
f"headroom_requests_rate_limited_total {self.requests_rate_limited}",
|
| 497 |
+
"",
|
| 498 |
+
"# HELP headroom_requests_failed_total Failed requests",
|
| 499 |
+
"# TYPE headroom_requests_failed_total counter",
|
| 500 |
+
f"headroom_requests_failed_total {self.requests_failed}",
|
| 501 |
+
"",
|
| 502 |
+
"# HELP headroom_tokens_input_total Total input tokens",
|
| 503 |
+
"# TYPE headroom_tokens_input_total counter",
|
| 504 |
+
f"headroom_tokens_input_total {self.tokens_input_total}",
|
| 505 |
+
"",
|
| 506 |
+
"# HELP headroom_tokens_output_total Total output tokens",
|
| 507 |
+
"# TYPE headroom_tokens_output_total counter",
|
| 508 |
+
f"headroom_tokens_output_total {self.tokens_output_total}",
|
| 509 |
+
"",
|
| 510 |
+
"# HELP headroom_tokens_saved_total Tokens saved by optimization",
|
| 511 |
+
"# TYPE headroom_tokens_saved_total counter",
|
| 512 |
+
f"headroom_tokens_saved_total {self.tokens_saved_total}",
|
| 513 |
+
"",
|
| 514 |
+
"# HELP headroom_latency_ms_sum Sum of request latencies",
|
| 515 |
+
"# TYPE headroom_latency_ms_sum counter",
|
| 516 |
+
f"headroom_latency_ms_sum {self.latency_sum_ms:.2f}",
|
| 517 |
+
"",
|
| 518 |
+
"# HELP headroom_cost_usd_total Total cost in USD",
|
| 519 |
+
"# TYPE headroom_cost_usd_total counter",
|
| 520 |
+
f"headroom_cost_usd_total {self.cost_total_usd:.6f}",
|
| 521 |
+
"",
|
| 522 |
+
"# HELP headroom_savings_usd_total Total savings in USD",
|
| 523 |
+
"# TYPE headroom_savings_usd_total counter",
|
| 524 |
+
f"headroom_savings_usd_total {self.savings_total_usd:.6f}",
|
| 525 |
+
]
|
| 526 |
+
|
| 527 |
+
# Per-provider metrics
|
| 528 |
+
lines.extend([
|
| 529 |
+
"",
|
| 530 |
+
"# HELP headroom_requests_by_provider Requests by provider",
|
| 531 |
+
"# TYPE headroom_requests_by_provider counter",
|
| 532 |
+
])
|
| 533 |
+
for provider, count in self.requests_by_provider.items():
|
| 534 |
+
lines.append(f'headroom_requests_by_provider{{provider="{provider}"}} {count}')
|
| 535 |
+
|
| 536 |
+
# Per-model metrics
|
| 537 |
+
lines.extend([
|
| 538 |
+
"",
|
| 539 |
+
"# HELP headroom_requests_by_model Requests by model",
|
| 540 |
+
"# TYPE headroom_requests_by_model counter",
|
| 541 |
+
])
|
| 542 |
+
for model, count in self.requests_by_model.items():
|
| 543 |
+
lines.append(f'headroom_requests_by_model{{model="{model}"}} {count}')
|
| 544 |
+
|
| 545 |
+
return "\n".join(lines)
|
| 546 |
+
|
| 547 |
+
|
| 548 |
+
# =============================================================================
|
| 549 |
+
# Request Logger
|
| 550 |
+
# =============================================================================
|
| 551 |
+
|
| 552 |
+
class RequestLogger:
|
| 553 |
+
"""Log requests to JSONL file."""
|
| 554 |
+
|
| 555 |
+
def __init__(self, log_file: str | None = None, log_full_messages: bool = False):
|
| 556 |
+
self.log_file = Path(log_file) if log_file else None
|
| 557 |
+
self.log_full_messages = log_full_messages
|
| 558 |
+
self._logs: list[RequestLog] = []
|
| 559 |
+
|
| 560 |
+
if self.log_file:
|
| 561 |
+
self.log_file.parent.mkdir(parents=True, exist_ok=True)
|
| 562 |
+
|
| 563 |
+
def log(self, entry: RequestLog):
|
| 564 |
+
"""Log a request."""
|
| 565 |
+
self._logs.append(entry)
|
| 566 |
+
|
| 567 |
+
if self.log_file:
|
| 568 |
+
with open(self.log_file, "a") as f:
|
| 569 |
+
log_dict = asdict(entry)
|
| 570 |
+
if not self.log_full_messages:
|
| 571 |
+
log_dict.pop("request_messages", None)
|
| 572 |
+
log_dict.pop("response_content", None)
|
| 573 |
+
f.write(json.dumps(log_dict) + "\n")
|
| 574 |
+
|
| 575 |
+
def get_recent(self, n: int = 100) -> list[dict]:
|
| 576 |
+
"""Get recent log entries."""
|
| 577 |
+
entries = self._logs[-n:]
|
| 578 |
+
return [
|
| 579 |
+
{k: v for k, v in asdict(e).items()
|
| 580 |
+
if k not in ("request_messages", "response_content")}
|
| 581 |
+
for e in entries
|
| 582 |
+
]
|
| 583 |
+
|
| 584 |
+
def stats(self) -> dict:
|
| 585 |
+
"""Get logging statistics."""
|
| 586 |
+
return {
|
| 587 |
+
"total_logged": len(self._logs),
|
| 588 |
+
"log_file": str(self.log_file) if self.log_file else None,
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
|
| 592 |
+
# =============================================================================
|
| 593 |
+
# Main Proxy
|
| 594 |
+
# =============================================================================
|
| 595 |
+
|
| 596 |
+
class HeadroomProxy:
|
| 597 |
+
"""Production-ready Headroom optimization proxy."""
|
| 598 |
+
|
| 599 |
+
ANTHROPIC_API_URL = "https://api.anthropic.com"
|
| 600 |
+
OPENAI_API_URL = "https://api.openai.com"
|
| 601 |
+
|
| 602 |
+
def __init__(self, config: ProxyConfig):
|
| 603 |
+
self.config = config
|
| 604 |
+
|
| 605 |
+
# Initialize providers
|
| 606 |
+
self.anthropic_provider = AnthropicProvider()
|
| 607 |
+
self.openai_provider = OpenAIProvider()
|
| 608 |
+
|
| 609 |
+
# Initialize transforms
|
| 610 |
+
transforms = [
|
| 611 |
+
CacheAligner(CacheAlignerConfig(enabled=True)),
|
| 612 |
+
SmartCrusher(SmartCrusherConfig(
|
| 613 |
+
enabled=True,
|
| 614 |
+
min_tokens_to_crush=config.min_tokens_to_crush,
|
| 615 |
+
max_items_after_crush=config.max_items_after_crush,
|
| 616 |
+
)),
|
| 617 |
+
RollingWindow(RollingWindowConfig(
|
| 618 |
+
enabled=True,
|
| 619 |
+
keep_system=True,
|
| 620 |
+
keep_last_turns=config.keep_last_turns,
|
| 621 |
+
)),
|
| 622 |
+
]
|
| 623 |
+
|
| 624 |
+
self.anthropic_pipeline = TransformPipeline(
|
| 625 |
+
transforms=transforms,
|
| 626 |
+
provider=self.anthropic_provider,
|
| 627 |
+
)
|
| 628 |
+
self.openai_pipeline = TransformPipeline(
|
| 629 |
+
transforms=transforms,
|
| 630 |
+
provider=self.openai_provider,
|
| 631 |
+
)
|
| 632 |
+
|
| 633 |
+
# Initialize components
|
| 634 |
+
self.cache = SemanticCache(
|
| 635 |
+
max_entries=config.cache_max_entries,
|
| 636 |
+
ttl_seconds=config.cache_ttl_seconds,
|
| 637 |
+
) if config.cache_enabled else None
|
| 638 |
+
|
| 639 |
+
self.rate_limiter = TokenBucketRateLimiter(
|
| 640 |
+
requests_per_minute=config.rate_limit_requests_per_minute,
|
| 641 |
+
tokens_per_minute=config.rate_limit_tokens_per_minute,
|
| 642 |
+
) if config.rate_limit_enabled else None
|
| 643 |
+
|
| 644 |
+
self.cost_tracker = CostTracker(
|
| 645 |
+
budget_limit_usd=config.budget_limit_usd,
|
| 646 |
+
budget_period=config.budget_period,
|
| 647 |
+
) if config.cost_tracking_enabled else None
|
| 648 |
+
|
| 649 |
+
self.metrics = PrometheusMetrics()
|
| 650 |
+
|
| 651 |
+
self.logger = RequestLogger(
|
| 652 |
+
log_file=config.log_file,
|
| 653 |
+
log_full_messages=config.log_full_messages,
|
| 654 |
+
) if config.log_requests else None
|
| 655 |
+
|
| 656 |
+
# HTTP client
|
| 657 |
+
self.http_client: httpx.AsyncClient | None = None
|
| 658 |
+
|
| 659 |
+
# Request counter for IDs
|
| 660 |
+
self._request_counter = 0
|
| 661 |
+
|
| 662 |
+
async def startup(self):
|
| 663 |
+
"""Initialize async resources."""
|
| 664 |
+
self.http_client = httpx.AsyncClient(
|
| 665 |
+
timeout=httpx.Timeout(
|
| 666 |
+
connect=self.config.connect_timeout_seconds,
|
| 667 |
+
read=self.config.request_timeout_seconds,
|
| 668 |
+
write=self.config.request_timeout_seconds,
|
| 669 |
+
pool=self.config.connect_timeout_seconds,
|
| 670 |
+
)
|
| 671 |
+
)
|
| 672 |
+
logger.info("Headroom Proxy started")
|
| 673 |
+
logger.info(f"Optimization: {'ENABLED' if self.config.optimize else 'DISABLED'}")
|
| 674 |
+
logger.info(f"Caching: {'ENABLED' if self.config.cache_enabled else 'DISABLED'}")
|
| 675 |
+
logger.info(f"Rate Limiting: {'ENABLED' if self.config.rate_limit_enabled else 'DISABLED'}")
|
| 676 |
+
|
| 677 |
+
async def shutdown(self):
|
| 678 |
+
"""Cleanup async resources."""
|
| 679 |
+
if self.http_client:
|
| 680 |
+
await self.http_client.aclose()
|
| 681 |
+
|
| 682 |
+
# Print final stats
|
| 683 |
+
self._print_summary()
|
| 684 |
+
|
| 685 |
+
def _print_summary(self):
|
| 686 |
+
"""Print session summary."""
|
| 687 |
+
m = self.metrics
|
| 688 |
+
logger.info("=" * 70)
|
| 689 |
+
logger.info("HEADROOM PROXY SESSION SUMMARY")
|
| 690 |
+
logger.info("=" * 70)
|
| 691 |
+
logger.info(f"Total requests: {m.requests_total}")
|
| 692 |
+
logger.info(f"Cached responses: {m.requests_cached}")
|
| 693 |
+
logger.info(f"Rate limited: {m.requests_rate_limited}")
|
| 694 |
+
logger.info(f"Failed: {m.requests_failed}")
|
| 695 |
+
logger.info(f"Input tokens: {m.tokens_input_total:,}")
|
| 696 |
+
logger.info(f"Output tokens: {m.tokens_output_total:,}")
|
| 697 |
+
logger.info(f"Tokens saved: {m.tokens_saved_total:,}")
|
| 698 |
+
if m.tokens_input_total > 0:
|
| 699 |
+
savings_pct = (m.tokens_saved_total / (m.tokens_input_total + m.tokens_saved_total)) * 100
|
| 700 |
+
logger.info(f"Token savings: {savings_pct:.1f}%")
|
| 701 |
+
logger.info(f"Total cost: ${m.cost_total_usd:.4f}")
|
| 702 |
+
logger.info(f"Total savings: ${m.savings_total_usd:.4f}")
|
| 703 |
+
if m.latency_count > 0:
|
| 704 |
+
avg_latency = m.latency_sum_ms / m.latency_count
|
| 705 |
+
logger.info(f"Avg latency: {avg_latency:.0f}ms")
|
| 706 |
+
logger.info("=" * 70)
|
| 707 |
+
|
| 708 |
+
def _next_request_id(self) -> str:
|
| 709 |
+
"""Generate unique request ID."""
|
| 710 |
+
self._request_counter += 1
|
| 711 |
+
return f"hr_{int(time.time())}_{self._request_counter:06d}"
|
| 712 |
+
|
| 713 |
+
def _extract_tags(self, headers: dict) -> dict[str, str]:
|
| 714 |
+
"""Extract Headroom tags from headers."""
|
| 715 |
+
tags = {}
|
| 716 |
+
for key, value in headers.items():
|
| 717 |
+
if key.lower().startswith("x-headroom-"):
|
| 718 |
+
tag_name = key.lower().replace("x-headroom-", "")
|
| 719 |
+
tags[tag_name] = value
|
| 720 |
+
return tags
|
| 721 |
+
|
| 722 |
+
async def _retry_request(
|
| 723 |
+
self,
|
| 724 |
+
method: str,
|
| 725 |
+
url: str,
|
| 726 |
+
headers: dict,
|
| 727 |
+
body: dict,
|
| 728 |
+
stream: bool = False,
|
| 729 |
+
) -> httpx.Response:
|
| 730 |
+
"""Make request with retry and exponential backoff."""
|
| 731 |
+
last_error = None
|
| 732 |
+
|
| 733 |
+
for attempt in range(self.config.retry_max_attempts):
|
| 734 |
+
try:
|
| 735 |
+
if stream:
|
| 736 |
+
# For streaming, we return early - retry happens at higher level
|
| 737 |
+
return await self.http_client.post(url, json=body, headers=headers)
|
| 738 |
+
else:
|
| 739 |
+
response = await self.http_client.post(url, json=body, headers=headers)
|
| 740 |
+
|
| 741 |
+
# Don't retry client errors (4xx)
|
| 742 |
+
if 400 <= response.status_code < 500:
|
| 743 |
+
return response
|
| 744 |
+
|
| 745 |
+
# Retry server errors (5xx)
|
| 746 |
+
if response.status_code >= 500:
|
| 747 |
+
raise httpx.HTTPStatusError(
|
| 748 |
+
f"Server error: {response.status_code}",
|
| 749 |
+
request=response.request,
|
| 750 |
+
response=response,
|
| 751 |
+
)
|
| 752 |
+
|
| 753 |
+
return response
|
| 754 |
+
|
| 755 |
+
except (httpx.ConnectError, httpx.ReadTimeout, httpx.HTTPStatusError) as e:
|
| 756 |
+
last_error = e
|
| 757 |
+
|
| 758 |
+
if not self.config.retry_enabled or attempt >= self.config.retry_max_attempts - 1:
|
| 759 |
+
raise
|
| 760 |
+
|
| 761 |
+
# Exponential backoff with jitter
|
| 762 |
+
delay = min(
|
| 763 |
+
self.config.retry_base_delay_ms * (2 ** attempt),
|
| 764 |
+
self.config.retry_max_delay_ms,
|
| 765 |
+
)
|
| 766 |
+
delay_with_jitter = delay * (0.5 + random.random())
|
| 767 |
+
|
| 768 |
+
logger.warning(
|
| 769 |
+
f"Request failed (attempt {attempt + 1}), retrying in {delay_with_jitter:.0f}ms: {e}"
|
| 770 |
+
)
|
| 771 |
+
await asyncio.sleep(delay_with_jitter / 1000)
|
| 772 |
+
|
| 773 |
+
raise last_error
|
| 774 |
+
|
| 775 |
+
async def handle_anthropic_messages(
|
| 776 |
+
self,
|
| 777 |
+
request: Request,
|
| 778 |
+
) -> Response | StreamingResponse:
|
| 779 |
+
"""Handle Anthropic /v1/messages endpoint."""
|
| 780 |
+
start_time = time.time()
|
| 781 |
+
request_id = self._next_request_id()
|
| 782 |
+
|
| 783 |
+
# Parse request
|
| 784 |
+
body = await request.json()
|
| 785 |
+
model = body.get("model", "unknown")
|
| 786 |
+
messages = body.get("messages", [])
|
| 787 |
+
stream = body.get("stream", False)
|
| 788 |
+
|
| 789 |
+
# Extract headers and tags
|
| 790 |
+
headers = {k: v for k, v in request.headers.items()}
|
| 791 |
+
headers.pop("host", None)
|
| 792 |
+
headers.pop("content-length", None)
|
| 793 |
+
tags = self._extract_tags(headers)
|
| 794 |
+
|
| 795 |
+
# Rate limiting
|
| 796 |
+
if self.rate_limiter:
|
| 797 |
+
rate_key = headers.get("x-api-key", "default")[:16]
|
| 798 |
+
allowed, wait_seconds = self.rate_limiter.check_request(rate_key)
|
| 799 |
+
if not allowed:
|
| 800 |
+
self.metrics.record_rate_limited()
|
| 801 |
+
raise HTTPException(
|
| 802 |
+
status_code=429,
|
| 803 |
+
detail=f"Rate limited. Retry after {wait_seconds:.1f}s",
|
| 804 |
+
headers={"Retry-After": str(int(wait_seconds) + 1)},
|
| 805 |
+
)
|
| 806 |
+
|
| 807 |
+
# Budget check
|
| 808 |
+
if self.cost_tracker:
|
| 809 |
+
allowed, remaining = self.cost_tracker.check_budget()
|
| 810 |
+
if not allowed:
|
| 811 |
+
raise HTTPException(
|
| 812 |
+
status_code=429,
|
| 813 |
+
detail=f"Budget exceeded for {self.config.budget_period} period",
|
| 814 |
+
)
|
| 815 |
+
|
| 816 |
+
# Check cache (non-streaming only)
|
| 817 |
+
cache_hit = False
|
| 818 |
+
if self.cache and not stream:
|
| 819 |
+
cached = self.cache.get(messages, model)
|
| 820 |
+
if cached:
|
| 821 |
+
cache_hit = True
|
| 822 |
+
optimization_latency = (time.time() - start_time) * 1000
|
| 823 |
+
|
| 824 |
+
self.metrics.record_request(
|
| 825 |
+
provider="anthropic",
|
| 826 |
+
model=model,
|
| 827 |
+
input_tokens=0,
|
| 828 |
+
output_tokens=0,
|
| 829 |
+
tokens_saved=cached.tokens_saved_per_hit,
|
| 830 |
+
latency_ms=optimization_latency,
|
| 831 |
+
cached=True,
|
| 832 |
+
)
|
| 833 |
+
|
| 834 |
+
return Response(
|
| 835 |
+
content=cached.response_body,
|
| 836 |
+
headers=cached.response_headers,
|
| 837 |
+
media_type="application/json",
|
| 838 |
+
)
|
| 839 |
+
|
| 840 |
+
# Count original tokens
|
| 841 |
+
tokenizer = get_tokenizer(model)
|
| 842 |
+
original_tokens = sum(
|
| 843 |
+
tokenizer.count_text(str(m.get("content", "")))
|
| 844 |
+
for m in messages
|
| 845 |
+
)
|
| 846 |
+
|
| 847 |
+
# Apply optimization
|
| 848 |
+
transforms_applied = []
|
| 849 |
+
optimized_messages = messages
|
| 850 |
+
optimized_tokens = original_tokens
|
| 851 |
+
|
| 852 |
+
if self.config.optimize and messages:
|
| 853 |
+
try:
|
| 854 |
+
context_limit = self.anthropic_provider.get_context_limit(model)
|
| 855 |
+
result = self.anthropic_pipeline.apply(
|
| 856 |
+
messages=messages,
|
| 857 |
+
model=model,
|
| 858 |
+
model_limit=context_limit,
|
| 859 |
+
)
|
| 860 |
+
|
| 861 |
+
if result.messages != messages:
|
| 862 |
+
optimized_messages = result.messages
|
| 863 |
+
transforms_applied = result.transforms_applied
|
| 864 |
+
optimized_tokens = sum(
|
| 865 |
+
tokenizer.count_text(str(m.get("content", "")))
|
| 866 |
+
for m in optimized_messages
|
| 867 |
+
)
|
| 868 |
+
except Exception as e:
|
| 869 |
+
logger.warning(f"Optimization failed: {e}")
|
| 870 |
+
|
| 871 |
+
tokens_saved = original_tokens - optimized_tokens
|
| 872 |
+
optimization_latency = (time.time() - start_time) * 1000
|
| 873 |
+
|
| 874 |
+
# Update body
|
| 875 |
+
body["messages"] = optimized_messages
|
| 876 |
+
|
| 877 |
+
# Forward request
|
| 878 |
+
url = f"{self.ANTHROPIC_API_URL}/v1/messages"
|
| 879 |
+
|
| 880 |
+
try:
|
| 881 |
+
if stream:
|
| 882 |
+
return await self._stream_response(
|
| 883 |
+
url, headers, body, "anthropic", model, request_id,
|
| 884 |
+
original_tokens, optimized_tokens, tokens_saved,
|
| 885 |
+
transforms_applied, tags, optimization_latency,
|
| 886 |
+
)
|
| 887 |
+
else:
|
| 888 |
+
response = await self._retry_request("POST", url, headers, body)
|
| 889 |
+
total_latency = (time.time() - start_time) * 1000
|
| 890 |
+
|
| 891 |
+
# Parse response for output tokens
|
| 892 |
+
output_tokens = 0
|
| 893 |
+
try:
|
| 894 |
+
resp_json = response.json()
|
| 895 |
+
usage = resp_json.get("usage", {})
|
| 896 |
+
output_tokens = usage.get("output_tokens", 0)
|
| 897 |
+
except:
|
| 898 |
+
pass
|
| 899 |
+
|
| 900 |
+
# Calculate cost
|
| 901 |
+
cost_usd = None
|
| 902 |
+
savings_usd = None
|
| 903 |
+
if self.cost_tracker:
|
| 904 |
+
cost_usd = self.cost_tracker.estimate_cost(
|
| 905 |
+
model, optimized_tokens, output_tokens
|
| 906 |
+
)
|
| 907 |
+
original_cost = self.cost_tracker.estimate_cost(
|
| 908 |
+
model, original_tokens, output_tokens
|
| 909 |
+
)
|
| 910 |
+
if cost_usd and original_cost:
|
| 911 |
+
savings_usd = original_cost - cost_usd
|
| 912 |
+
self.cost_tracker.record_cost(cost_usd)
|
| 913 |
+
self.cost_tracker.record_savings(savings_usd)
|
| 914 |
+
|
| 915 |
+
# Cache response
|
| 916 |
+
if self.cache and response.status_code == 200:
|
| 917 |
+
self.cache.set(
|
| 918 |
+
messages, model,
|
| 919 |
+
response.content,
|
| 920 |
+
dict(response.headers),
|
| 921 |
+
tokens_saved=tokens_saved,
|
| 922 |
+
)
|
| 923 |
+
|
| 924 |
+
# Record metrics
|
| 925 |
+
self.metrics.record_request(
|
| 926 |
+
provider="anthropic",
|
| 927 |
+
model=model,
|
| 928 |
+
input_tokens=optimized_tokens,
|
| 929 |
+
output_tokens=output_tokens,
|
| 930 |
+
tokens_saved=tokens_saved,
|
| 931 |
+
latency_ms=total_latency,
|
| 932 |
+
cost_usd=cost_usd or 0,
|
| 933 |
+
savings_usd=savings_usd or 0,
|
| 934 |
+
)
|
| 935 |
+
|
| 936 |
+
# Log request
|
| 937 |
+
if self.logger:
|
| 938 |
+
self.logger.log(RequestLog(
|
| 939 |
+
request_id=request_id,
|
| 940 |
+
timestamp=datetime.now().isoformat(),
|
| 941 |
+
provider="anthropic",
|
| 942 |
+
model=model,
|
| 943 |
+
input_tokens_original=original_tokens,
|
| 944 |
+
input_tokens_optimized=optimized_tokens,
|
| 945 |
+
output_tokens=output_tokens,
|
| 946 |
+
tokens_saved=tokens_saved,
|
| 947 |
+
savings_percent=(tokens_saved / original_tokens * 100) if original_tokens > 0 else 0,
|
| 948 |
+
estimated_cost_usd=cost_usd,
|
| 949 |
+
estimated_savings_usd=savings_usd,
|
| 950 |
+
optimization_latency_ms=optimization_latency,
|
| 951 |
+
total_latency_ms=total_latency,
|
| 952 |
+
tags=tags,
|
| 953 |
+
cache_hit=cache_hit,
|
| 954 |
+
transforms_applied=transforms_applied,
|
| 955 |
+
request_messages=messages if self.config.log_full_messages else None,
|
| 956 |
+
))
|
| 957 |
+
|
| 958 |
+
# Log to console
|
| 959 |
+
if tokens_saved > 0:
|
| 960 |
+
logger.info(
|
| 961 |
+
f"[{request_id}] {model}: {original_tokens:,} β {optimized_tokens:,} "
|
| 962 |
+
f"(saved {tokens_saved:,} tokens, ${savings_usd:.4f})" if savings_usd else
|
| 963 |
+
f"[{request_id}] {model}: {original_tokens:,} β {optimized_tokens:,} "
|
| 964 |
+
f"(saved {tokens_saved:,} tokens)"
|
| 965 |
+
)
|
| 966 |
+
|
| 967 |
+
return Response(
|
| 968 |
+
content=response.content,
|
| 969 |
+
status_code=response.status_code,
|
| 970 |
+
headers=dict(response.headers),
|
| 971 |
+
)
|
| 972 |
+
|
| 973 |
+
except Exception as e:
|
| 974 |
+
self.metrics.record_failed()
|
| 975 |
+
logger.error(f"[{request_id}] Request failed: {e}")
|
| 976 |
+
|
| 977 |
+
# Try fallback if enabled
|
| 978 |
+
if self.config.fallback_enabled and self.config.fallback_provider == "openai":
|
| 979 |
+
logger.info(f"[{request_id}] Attempting fallback to OpenAI")
|
| 980 |
+
# Convert to OpenAI format and retry
|
| 981 |
+
# (simplified - would need message format conversion)
|
| 982 |
+
|
| 983 |
+
raise HTTPException(status_code=502, detail=str(e))
|
| 984 |
+
|
| 985 |
+
async def _stream_response(
|
| 986 |
+
self,
|
| 987 |
+
url: str,
|
| 988 |
+
headers: dict,
|
| 989 |
+
body: dict,
|
| 990 |
+
provider: str,
|
| 991 |
+
model: str,
|
| 992 |
+
request_id: str,
|
| 993 |
+
original_tokens: int,
|
| 994 |
+
optimized_tokens: int,
|
| 995 |
+
tokens_saved: int,
|
| 996 |
+
transforms_applied: list[str],
|
| 997 |
+
tags: dict[str, str],
|
| 998 |
+
optimization_latency: float,
|
| 999 |
+
) -> StreamingResponse:
|
| 1000 |
+
"""Stream response with metrics tracking."""
|
| 1001 |
+
start_time = time.time()
|
| 1002 |
+
|
| 1003 |
+
async def generate():
|
| 1004 |
+
output_chunks = []
|
| 1005 |
+
try:
|
| 1006 |
+
async with self.http_client.stream("POST", url, json=body, headers=headers) as response:
|
| 1007 |
+
async for chunk in response.aiter_bytes():
|
| 1008 |
+
output_chunks.append(chunk)
|
| 1009 |
+
yield chunk
|
| 1010 |
+
finally:
|
| 1011 |
+
# Record metrics after stream completes
|
| 1012 |
+
total_latency = (time.time() - start_time) * 1000
|
| 1013 |
+
|
| 1014 |
+
# Estimate output tokens from chunks (rough)
|
| 1015 |
+
total_output = b"".join(output_chunks)
|
| 1016 |
+
output_tokens = len(total_output) // 4 # Rough estimate
|
| 1017 |
+
|
| 1018 |
+
self.metrics.record_request(
|
| 1019 |
+
provider=provider,
|
| 1020 |
+
model=model,
|
| 1021 |
+
input_tokens=optimized_tokens,
|
| 1022 |
+
output_tokens=output_tokens,
|
| 1023 |
+
tokens_saved=tokens_saved,
|
| 1024 |
+
latency_ms=total_latency,
|
| 1025 |
+
)
|
| 1026 |
+
|
| 1027 |
+
if tokens_saved > 0:
|
| 1028 |
+
logger.info(
|
| 1029 |
+
f"[{request_id}] {model}: saved {tokens_saved:,} tokens (streaming)"
|
| 1030 |
+
)
|
| 1031 |
+
|
| 1032 |
+
return StreamingResponse(
|
| 1033 |
+
generate(),
|
| 1034 |
+
media_type="text/event-stream",
|
| 1035 |
+
)
|
| 1036 |
+
|
| 1037 |
+
async def handle_openai_chat(
|
| 1038 |
+
self,
|
| 1039 |
+
request: Request,
|
| 1040 |
+
) -> Response | StreamingResponse:
|
| 1041 |
+
"""Handle OpenAI /v1/chat/completions endpoint."""
|
| 1042 |
+
start_time = time.time()
|
| 1043 |
+
request_id = self._next_request_id()
|
| 1044 |
+
|
| 1045 |
+
body = await request.json()
|
| 1046 |
+
model = body.get("model", "unknown")
|
| 1047 |
+
messages = body.get("messages", [])
|
| 1048 |
+
stream = body.get("stream", False)
|
| 1049 |
+
|
| 1050 |
+
headers = {k: v for k, v in request.headers.items()}
|
| 1051 |
+
headers.pop("host", None)
|
| 1052 |
+
headers.pop("content-length", None)
|
| 1053 |
+
tags = self._extract_tags(headers)
|
| 1054 |
+
|
| 1055 |
+
# Rate limiting
|
| 1056 |
+
if self.rate_limiter:
|
| 1057 |
+
rate_key = headers.get("authorization", "default")[:20]
|
| 1058 |
+
allowed, wait_seconds = self.rate_limiter.check_request(rate_key)
|
| 1059 |
+
if not allowed:
|
| 1060 |
+
self.metrics.record_rate_limited()
|
| 1061 |
+
raise HTTPException(
|
| 1062 |
+
status_code=429,
|
| 1063 |
+
detail=f"Rate limited. Retry after {wait_seconds:.1f}s",
|
| 1064 |
+
)
|
| 1065 |
+
|
| 1066 |
+
# Check cache
|
| 1067 |
+
cache_hit = False
|
| 1068 |
+
if self.cache and not stream:
|
| 1069 |
+
cached = self.cache.get(messages, model)
|
| 1070 |
+
if cached:
|
| 1071 |
+
cache_hit = True
|
| 1072 |
+
self.metrics.record_request(
|
| 1073 |
+
provider="openai", model=model,
|
| 1074 |
+
input_tokens=0, output_tokens=0,
|
| 1075 |
+
tokens_saved=cached.tokens_saved_per_hit,
|
| 1076 |
+
latency_ms=(time.time() - start_time) * 1000,
|
| 1077 |
+
cached=True,
|
| 1078 |
+
)
|
| 1079 |
+
return Response(content=cached.response_body, headers=cached.response_headers)
|
| 1080 |
+
|
| 1081 |
+
# Token counting
|
| 1082 |
+
tokenizer = get_tokenizer(model)
|
| 1083 |
+
original_tokens = sum(
|
| 1084 |
+
tokenizer.count_text(str(m.get("content", "")))
|
| 1085 |
+
for m in messages
|
| 1086 |
+
)
|
| 1087 |
+
|
| 1088 |
+
# Optimization
|
| 1089 |
+
transforms_applied = []
|
| 1090 |
+
optimized_messages = messages
|
| 1091 |
+
optimized_tokens = original_tokens
|
| 1092 |
+
|
| 1093 |
+
if self.config.optimize and messages:
|
| 1094 |
+
try:
|
| 1095 |
+
context_limit = self.openai_provider.get_context_limit(model)
|
| 1096 |
+
result = self.openai_pipeline.apply(
|
| 1097 |
+
messages=messages,
|
| 1098 |
+
model=model,
|
| 1099 |
+
model_limit=context_limit,
|
| 1100 |
+
)
|
| 1101 |
+
if result.messages != messages:
|
| 1102 |
+
optimized_messages = result.messages
|
| 1103 |
+
transforms_applied = result.transforms_applied
|
| 1104 |
+
optimized_tokens = sum(
|
| 1105 |
+
tokenizer.count_text(str(m.get("content", "")))
|
| 1106 |
+
for m in optimized_messages
|
| 1107 |
+
)
|
| 1108 |
+
except Exception as e:
|
| 1109 |
+
logger.warning(f"Optimization failed: {e}")
|
| 1110 |
+
|
| 1111 |
+
tokens_saved = original_tokens - optimized_tokens
|
| 1112 |
+
optimization_latency = (time.time() - start_time) * 1000
|
| 1113 |
+
|
| 1114 |
+
body["messages"] = optimized_messages
|
| 1115 |
+
url = f"{self.OPENAI_API_URL}/v1/chat/completions"
|
| 1116 |
+
|
| 1117 |
+
try:
|
| 1118 |
+
if stream:
|
| 1119 |
+
return await self._stream_response(
|
| 1120 |
+
url, headers, body, "openai", model, request_id,
|
| 1121 |
+
original_tokens, optimized_tokens, tokens_saved,
|
| 1122 |
+
transforms_applied, tags, optimization_latency,
|
| 1123 |
+
)
|
| 1124 |
+
else:
|
| 1125 |
+
response = await self._retry_request("POST", url, headers, body)
|
| 1126 |
+
total_latency = (time.time() - start_time) * 1000
|
| 1127 |
+
|
| 1128 |
+
output_tokens = 0
|
| 1129 |
+
try:
|
| 1130 |
+
resp_json = response.json()
|
| 1131 |
+
usage = resp_json.get("usage", {})
|
| 1132 |
+
output_tokens = usage.get("completion_tokens", 0)
|
| 1133 |
+
except:
|
| 1134 |
+
pass
|
| 1135 |
+
|
| 1136 |
+
# Cost tracking
|
| 1137 |
+
cost_usd = savings_usd = None
|
| 1138 |
+
if self.cost_tracker:
|
| 1139 |
+
cost_usd = self.cost_tracker.estimate_cost(model, optimized_tokens, output_tokens)
|
| 1140 |
+
original_cost = self.cost_tracker.estimate_cost(model, original_tokens, output_tokens)
|
| 1141 |
+
if cost_usd and original_cost:
|
| 1142 |
+
savings_usd = original_cost - cost_usd
|
| 1143 |
+
self.cost_tracker.record_cost(cost_usd)
|
| 1144 |
+
self.cost_tracker.record_savings(savings_usd)
|
| 1145 |
+
|
| 1146 |
+
# Cache
|
| 1147 |
+
if self.cache and response.status_code == 200:
|
| 1148 |
+
self.cache.set(messages, model, response.content, dict(response.headers), tokens_saved)
|
| 1149 |
+
|
| 1150 |
+
# Metrics
|
| 1151 |
+
self.metrics.record_request(
|
| 1152 |
+
provider="openai", model=model,
|
| 1153 |
+
input_tokens=optimized_tokens, output_tokens=output_tokens,
|
| 1154 |
+
tokens_saved=tokens_saved, latency_ms=total_latency,
|
| 1155 |
+
cost_usd=cost_usd or 0, savings_usd=savings_usd or 0,
|
| 1156 |
+
)
|
| 1157 |
+
|
| 1158 |
+
if tokens_saved > 0:
|
| 1159 |
+
logger.info(
|
| 1160 |
+
f"[{request_id}] {model}: {original_tokens:,} β {optimized_tokens:,} "
|
| 1161 |
+
f"(saved {tokens_saved:,} tokens)"
|
| 1162 |
+
)
|
| 1163 |
+
|
| 1164 |
+
return Response(
|
| 1165 |
+
content=response.content,
|
| 1166 |
+
status_code=response.status_code,
|
| 1167 |
+
headers=dict(response.headers),
|
| 1168 |
+
)
|
| 1169 |
+
except Exception as e:
|
| 1170 |
+
self.metrics.record_failed()
|
| 1171 |
+
raise HTTPException(status_code=502, detail=str(e))
|
| 1172 |
+
|
| 1173 |
+
async def handle_passthrough(self, request: Request, base_url: str) -> Response:
|
| 1174 |
+
"""Pass through request unchanged."""
|
| 1175 |
+
path = request.url.path
|
| 1176 |
+
url = f"{base_url}{path}"
|
| 1177 |
+
|
| 1178 |
+
headers = {k: v for k, v in request.headers.items()}
|
| 1179 |
+
headers.pop("host", None)
|
| 1180 |
+
|
| 1181 |
+
body = await request.body()
|
| 1182 |
+
|
| 1183 |
+
response = await self.http_client.request(
|
| 1184 |
+
method=request.method,
|
| 1185 |
+
url=url,
|
| 1186 |
+
headers=headers,
|
| 1187 |
+
content=body,
|
| 1188 |
+
)
|
| 1189 |
+
|
| 1190 |
+
return Response(
|
| 1191 |
+
content=response.content,
|
| 1192 |
+
status_code=response.status_code,
|
| 1193 |
+
headers=dict(response.headers),
|
| 1194 |
+
)
|
| 1195 |
+
|
| 1196 |
+
|
| 1197 |
+
# =============================================================================
|
| 1198 |
+
# FastAPI App
|
| 1199 |
+
# =============================================================================
|
| 1200 |
+
|
| 1201 |
+
def create_app(config: ProxyConfig | None = None) -> FastAPI:
|
| 1202 |
+
"""Create FastAPI application."""
|
| 1203 |
+
if not FASTAPI_AVAILABLE:
|
| 1204 |
+
raise ImportError("FastAPI required. Install: pip install fastapi uvicorn httpx")
|
| 1205 |
+
|
| 1206 |
+
config = config or ProxyConfig()
|
| 1207 |
+
|
| 1208 |
+
app = FastAPI(
|
| 1209 |
+
title="Headroom Proxy",
|
| 1210 |
+
description="Production-ready LLM optimization proxy",
|
| 1211 |
+
version="1.0.0",
|
| 1212 |
+
)
|
| 1213 |
+
|
| 1214 |
+
# CORS
|
| 1215 |
+
app.add_middleware(
|
| 1216 |
+
CORSMiddleware,
|
| 1217 |
+
allow_origins=["*"],
|
| 1218 |
+
allow_credentials=True,
|
| 1219 |
+
allow_methods=["*"],
|
| 1220 |
+
allow_headers=["*"],
|
| 1221 |
+
)
|
| 1222 |
+
|
| 1223 |
+
proxy = HeadroomProxy(config)
|
| 1224 |
+
|
| 1225 |
+
@app.on_event("startup")
|
| 1226 |
+
async def startup():
|
| 1227 |
+
await proxy.startup()
|
| 1228 |
+
|
| 1229 |
+
@app.on_event("shutdown")
|
| 1230 |
+
async def shutdown():
|
| 1231 |
+
await proxy.shutdown()
|
| 1232 |
+
|
| 1233 |
+
# Health & Metrics
|
| 1234 |
+
@app.get("/health")
|
| 1235 |
+
async def health():
|
| 1236 |
+
return {
|
| 1237 |
+
"status": "healthy",
|
| 1238 |
+
"version": "1.0.0",
|
| 1239 |
+
"config": {
|
| 1240 |
+
"optimize": config.optimize,
|
| 1241 |
+
"cache": config.cache_enabled,
|
| 1242 |
+
"rate_limit": config.rate_limit_enabled,
|
| 1243 |
+
}
|
| 1244 |
+
}
|
| 1245 |
+
|
| 1246 |
+
@app.get("/stats")
|
| 1247 |
+
async def stats():
|
| 1248 |
+
m = proxy.metrics
|
| 1249 |
+
return {
|
| 1250 |
+
"requests": {
|
| 1251 |
+
"total": m.requests_total,
|
| 1252 |
+
"cached": m.requests_cached,
|
| 1253 |
+
"rate_limited": m.requests_rate_limited,
|
| 1254 |
+
"failed": m.requests_failed,
|
| 1255 |
+
},
|
| 1256 |
+
"tokens": {
|
| 1257 |
+
"input": m.tokens_input_total,
|
| 1258 |
+
"output": m.tokens_output_total,
|
| 1259 |
+
"saved": m.tokens_saved_total,
|
| 1260 |
+
"savings_percent": round(
|
| 1261 |
+
(m.tokens_saved_total / (m.tokens_input_total + m.tokens_saved_total) * 100)
|
| 1262 |
+
if m.tokens_input_total > 0 else 0, 2
|
| 1263 |
+
),
|
| 1264 |
+
},
|
| 1265 |
+
"cost": proxy.cost_tracker.stats() if proxy.cost_tracker else None,
|
| 1266 |
+
"cache": proxy.cache.stats() if proxy.cache else None,
|
| 1267 |
+
"rate_limiter": proxy.rate_limiter.stats() if proxy.rate_limiter else None,
|
| 1268 |
+
"recent_requests": proxy.logger.get_recent(10) if proxy.logger else [],
|
| 1269 |
+
}
|
| 1270 |
+
|
| 1271 |
+
@app.get("/metrics")
|
| 1272 |
+
async def metrics():
|
| 1273 |
+
"""Prometheus metrics endpoint."""
|
| 1274 |
+
return PlainTextResponse(
|
| 1275 |
+
proxy.metrics.export(),
|
| 1276 |
+
media_type="text/plain; version=0.0.4",
|
| 1277 |
+
)
|
| 1278 |
+
|
| 1279 |
+
@app.post("/cache/clear")
|
| 1280 |
+
async def clear_cache():
|
| 1281 |
+
"""Clear the response cache."""
|
| 1282 |
+
if proxy.cache:
|
| 1283 |
+
proxy.cache.clear()
|
| 1284 |
+
return {"status": "cleared"}
|
| 1285 |
+
return {"status": "cache disabled"}
|
| 1286 |
+
|
| 1287 |
+
# Anthropic endpoints
|
| 1288 |
+
@app.post("/v1/messages")
|
| 1289 |
+
async def anthropic_messages(request: Request):
|
| 1290 |
+
return await proxy.handle_anthropic_messages(request)
|
| 1291 |
+
|
| 1292 |
+
@app.post("/v1/messages/count_tokens")
|
| 1293 |
+
async def anthropic_count_tokens(request: Request):
|
| 1294 |
+
return await proxy.handle_passthrough(request, proxy.ANTHROPIC_API_URL)
|
| 1295 |
+
|
| 1296 |
+
# OpenAI endpoints
|
| 1297 |
+
@app.post("/v1/chat/completions")
|
| 1298 |
+
async def openai_chat(request: Request):
|
| 1299 |
+
return await proxy.handle_openai_chat(request)
|
| 1300 |
+
|
| 1301 |
+
# Passthrough
|
| 1302 |
+
@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"])
|
| 1303 |
+
async def passthrough(request: Request, path: str):
|
| 1304 |
+
if "anthropic" in request.headers.get("user-agent", "").lower():
|
| 1305 |
+
base_url = proxy.ANTHROPIC_API_URL
|
| 1306 |
+
else:
|
| 1307 |
+
base_url = proxy.OPENAI_API_URL
|
| 1308 |
+
return await proxy.handle_passthrough(request, base_url)
|
| 1309 |
+
|
| 1310 |
+
return app
|
| 1311 |
+
|
| 1312 |
+
|
| 1313 |
+
def run_server(config: ProxyConfig | None = None):
|
| 1314 |
+
"""Run the proxy server."""
|
| 1315 |
+
if not FASTAPI_AVAILABLE:
|
| 1316 |
+
print("ERROR: FastAPI required. Install: pip install fastapi uvicorn httpx")
|
| 1317 |
+
sys.exit(1)
|
| 1318 |
+
|
| 1319 |
+
config = config or ProxyConfig()
|
| 1320 |
+
app = create_app(config)
|
| 1321 |
+
|
| 1322 |
+
print(f"""
|
| 1323 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1324 |
+
β HEADROOM PROXY SERVER β
|
| 1325 |
+
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£
|
| 1326 |
+
β Version: 1.0.0 β
|
| 1327 |
+
β Listening: http://{config.host}:{config.port:<5} β
|
| 1328 |
+
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£
|
| 1329 |
+
β FEATURES: β
|
| 1330 |
+
β Optimization: {'ENABLED ' if config.optimize else 'DISABLED'} β
|
| 1331 |
+
β Caching: {'ENABLED ' if config.cache_enabled else 'DISABLED'} (TTL: {config.cache_ttl_seconds}s) β
|
| 1332 |
+
β Rate Limiting: {'ENABLED ' if config.rate_limit_enabled else 'DISABLED'} ({config.rate_limit_requests_per_minute} req/min, {config.rate_limit_tokens_per_minute:,} tok/min) β
|
| 1333 |
+
β Retry: {'ENABLED ' if config.retry_enabled else 'DISABLED'} (max {config.retry_max_attempts} attempts) β
|
| 1334 |
+
β Cost Tracking: {'ENABLED ' if config.cost_tracking_enabled else 'DISABLED'} (budget: {'$' + str(config.budget_limit_usd) + '/' + config.budget_period if config.budget_limit_usd else 'unlimited'}) β
|
| 1335 |
+
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£
|
| 1336 |
+
β USAGE: β
|
| 1337 |
+
β Claude Code: ANTHROPIC_BASE_URL=http://{config.host}:{config.port} claude β
|
| 1338 |
+
β Cursor: Set base URL in settings β
|
| 1339 |
+
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£
|
| 1340 |
+
β ENDPOINTS: β
|
| 1341 |
+
β /health Health check β
|
| 1342 |
+
β /stats Detailed statistics β
|
| 1343 |
+
β /metrics Prometheus metrics β
|
| 1344 |
+
β /cache/clear Clear response cache β
|
| 1345 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1346 |
+
""")
|
| 1347 |
+
|
| 1348 |
+
uvicorn.run(app, host=config.host, port=config.port, log_level="warning")
|
| 1349 |
+
|
| 1350 |
+
|
| 1351 |
+
if __name__ == "__main__":
|
| 1352 |
+
parser = argparse.ArgumentParser(description="Headroom Proxy Server")
|
| 1353 |
+
|
| 1354 |
+
# Server
|
| 1355 |
+
parser.add_argument("--host", default="127.0.0.1")
|
| 1356 |
+
parser.add_argument("--port", type=int, default=8787)
|
| 1357 |
+
|
| 1358 |
+
# Optimization
|
| 1359 |
+
parser.add_argument("--no-optimize", action="store_true", help="Disable optimization")
|
| 1360 |
+
parser.add_argument("--min-tokens", type=int, default=500, help="Min tokens to crush")
|
| 1361 |
+
parser.add_argument("--max-items", type=int, default=50, help="Max items after crush")
|
| 1362 |
+
|
| 1363 |
+
# Caching
|
| 1364 |
+
parser.add_argument("--no-cache", action="store_true", help="Disable caching")
|
| 1365 |
+
parser.add_argument("--cache-ttl", type=int, default=3600, help="Cache TTL seconds")
|
| 1366 |
+
|
| 1367 |
+
# Rate limiting
|
| 1368 |
+
parser.add_argument("--no-rate-limit", action="store_true", help="Disable rate limiting")
|
| 1369 |
+
parser.add_argument("--rpm", type=int, default=60, help="Requests per minute")
|
| 1370 |
+
parser.add_argument("--tpm", type=int, default=100000, help="Tokens per minute")
|
| 1371 |
+
|
| 1372 |
+
# Cost
|
| 1373 |
+
parser.add_argument("--budget", type=float, help="Budget limit in USD")
|
| 1374 |
+
parser.add_argument("--budget-period", choices=["hourly", "daily", "monthly"], default="daily")
|
| 1375 |
+
|
| 1376 |
+
# Logging
|
| 1377 |
+
parser.add_argument("--log-file", help="Log file path")
|
| 1378 |
+
parser.add_argument("--log-messages", action="store_true", help="Log full messages")
|
| 1379 |
+
|
| 1380 |
+
args = parser.parse_args()
|
| 1381 |
+
|
| 1382 |
+
config = ProxyConfig(
|
| 1383 |
+
host=args.host,
|
| 1384 |
+
port=args.port,
|
| 1385 |
+
optimize=not args.no_optimize,
|
| 1386 |
+
min_tokens_to_crush=args.min_tokens,
|
| 1387 |
+
max_items_after_crush=args.max_items,
|
| 1388 |
+
cache_enabled=not args.no_cache,
|
| 1389 |
+
cache_ttl_seconds=args.cache_ttl,
|
| 1390 |
+
rate_limit_enabled=not args.no_rate_limit,
|
| 1391 |
+
rate_limit_requests_per_minute=args.rpm,
|
| 1392 |
+
rate_limit_tokens_per_minute=args.tpm,
|
| 1393 |
+
budget_limit_usd=args.budget,
|
| 1394 |
+
budget_period=args.budget_period,
|
| 1395 |
+
log_file=args.log_file,
|
| 1396 |
+
log_full_messages=args.log_messages,
|
| 1397 |
+
)
|
| 1398 |
+
|
| 1399 |
+
run_server(config)
|
|
File without changes
|
|
@@ -20,9 +20,8 @@ from __future__ import annotations
|
|
| 20 |
import math
|
| 21 |
import re
|
| 22 |
from collections import Counter
|
| 23 |
-
from typing import Any
|
| 24 |
|
| 25 |
-
from .base import RelevanceScore, RelevanceScorer
|
| 26 |
|
| 27 |
|
| 28 |
class BM25Scorer(RelevanceScorer):
|
|
|
|
| 20 |
import math
|
| 21 |
import re
|
| 22 |
from collections import Counter
|
|
|
|
| 23 |
|
| 24 |
+
from .base import RelevanceScore, RelevanceScorer
|
| 25 |
|
| 26 |
|
| 27 |
class BM25Scorer(RelevanceScorer):
|
|
@@ -20,7 +20,7 @@ Limitations:
|
|
| 20 |
from __future__ import annotations
|
| 21 |
|
| 22 |
import logging
|
| 23 |
-
from typing import TYPE_CHECKING
|
| 24 |
|
| 25 |
import numpy as np
|
| 26 |
|
|
@@ -71,7 +71,7 @@ class EmbeddingScorer(RelevanceScorer):
|
|
| 71 |
Requires sentence-transformers: pip install headroom[relevance]
|
| 72 |
"""
|
| 73 |
|
| 74 |
-
_model_cache: dict[str,
|
| 75 |
|
| 76 |
def __init__(
|
| 77 |
self,
|
|
@@ -93,7 +93,7 @@ class EmbeddingScorer(RelevanceScorer):
|
|
| 93 |
self.model_name = model_name
|
| 94 |
self.device = device
|
| 95 |
self.cache_model = cache_model
|
| 96 |
-
self._model:
|
| 97 |
self._available: bool | None = None
|
| 98 |
|
| 99 |
@classmethod
|
|
@@ -110,7 +110,7 @@ class EmbeddingScorer(RelevanceScorer):
|
|
| 110 |
except ImportError:
|
| 111 |
return False
|
| 112 |
|
| 113 |
-
def _get_model(self) ->
|
| 114 |
"""Get or load the sentence transformer model.
|
| 115 |
|
| 116 |
Returns:
|
|
|
|
| 20 |
from __future__ import annotations
|
| 21 |
|
| 22 |
import logging
|
| 23 |
+
from typing import TYPE_CHECKING
|
| 24 |
|
| 25 |
import numpy as np
|
| 26 |
|
|
|
|
| 71 |
Requires sentence-transformers: pip install headroom[relevance]
|
| 72 |
"""
|
| 73 |
|
| 74 |
+
_model_cache: dict[str, SentenceTransformer] = {}
|
| 75 |
|
| 76 |
def __init__(
|
| 77 |
self,
|
|
|
|
| 93 |
self.model_name = model_name
|
| 94 |
self.device = device
|
| 95 |
self.cache_model = cache_model
|
| 96 |
+
self._model: SentenceTransformer | None = None
|
| 97 |
self._available: bool | None = None
|
| 98 |
|
| 99 |
@classmethod
|
|
|
|
| 110 |
except ImportError:
|
| 111 |
return False
|
| 112 |
|
| 113 |
+
def _get_model(self) -> SentenceTransformer:
|
| 114 |
"""Get or load the sentence transformer model.
|
| 115 |
|
| 116 |
Returns:
|
|
@@ -11,7 +11,6 @@ from jinja2 import Template
|
|
| 11 |
from ..storage import create_storage
|
| 12 |
from ..utils import estimate_cost, format_cost
|
| 13 |
|
| 14 |
-
|
| 15 |
# HTML template embedded as string
|
| 16 |
REPORT_TEMPLATE = """
|
| 17 |
<!DOCTYPE html>
|
|
|
|
| 11 |
from ..storage import create_storage
|
| 12 |
from ..utils import estimate_cost, format_cost
|
| 13 |
|
|
|
|
| 14 |
# HTML template embedded as string
|
| 15 |
REPORT_TEMPLATE = """
|
| 16 |
<!DOCTYPE html>
|
|
@@ -3,8 +3,9 @@
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
from abc import ABC, abstractmethod
|
|
|
|
| 6 |
from datetime import datetime
|
| 7 |
-
from typing import Any
|
| 8 |
|
| 9 |
from ..config import RequestMetrics
|
| 10 |
|
|
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
from abc import ABC, abstractmethod
|
| 6 |
+
from collections.abc import Iterator
|
| 7 |
from datetime import datetime
|
| 8 |
+
from typing import Any
|
| 9 |
|
| 10 |
from ..config import RequestMetrics
|
| 11 |
|