chopratejas commited on
Commit
175746c
Β·
1 Parent(s): 9c7d451

Prepare for OSS release v0.2.0

Browse files

This 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

This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. .github/FUNDING.yml +7 -0
  2. .github/ISSUE_TEMPLATE/bug_report.md +53 -0
  3. .github/ISSUE_TEMPLATE/config.yml +8 -0
  4. .github/ISSUE_TEMPLATE/feature_request.md +44 -0
  5. .github/PULL_REQUEST_TEMPLATE.md +56 -0
  6. .github/workflows/ci.yml +108 -0
  7. .github/workflows/publish.yml +31 -0
  8. .gitignore +62 -6
  9. CHANGELOG.md +120 -0
  10. CODE_OF_CONDUCT.md +133 -0
  11. CONTRIBUTING.md +209 -0
  12. LICENSE +190 -0
  13. NOTICE +43 -0
  14. README.md +176 -198
  15. SECURITY.md +65 -0
  16. benchmarks/__init__.py +1 -1
  17. EXPLANATION.md β†’ docs/ARCHITECTURE.md +0 -0
  18. docs/README.md +28 -0
  19. docs/api.md +346 -0
  20. docs/getting-started.md +109 -0
  21. docs/proxy.md +173 -0
  22. docs/transforms.md +198 -0
  23. examples/README.md +133 -0
  24. headroom/__init__.py +9 -9
  25. headroom/cli.py +185 -0
  26. headroom/client.py +2 -1
  27. headroom/config.py +2 -2
  28. headroom/integrations/__init__.py +4 -5
  29. headroom/integrations/langchain.py +3 -2
  30. headroom/integrations/mcp.py +3 -2
  31. headroom/models/__init__.py +39 -0
  32. headroom/models/registry.py +749 -0
  33. headroom/parser.py +1 -1
  34. headroom/pricing/__init__.py +10 -7
  35. headroom/pricing/anthropic_prices.py +0 -1
  36. headroom/pricing/openai_prices.py +0 -1
  37. headroom/pricing/registry.py +11 -12
  38. headroom/providers/__init__.py +45 -0
  39. headroom/providers/cohere.py +313 -0
  40. headroom/providers/google.py +372 -0
  41. headroom/providers/litellm.py +293 -0
  42. headroom/providers/openai.py +0 -1
  43. headroom/providers/openai_compatible.py +521 -0
  44. headroom/proxy/__init__.py +19 -0
  45. headroom/proxy/server.py +1399 -0
  46. headroom/py.typed +0 -0
  47. headroom/relevance/bm25.py +1 -2
  48. headroom/relevance/embedding.py +4 -4
  49. headroom/reporting/generator.py +0 -1
  50. headroom/storage/base.py +2 -1
.github/FUNDING.yml ADDED
@@ -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"]
.github/ISSUE_TEMPLATE/bug_report.md ADDED
@@ -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.)
.github/ISSUE_TEMPLATE/config.yml ADDED
@@ -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
.github/ISSUE_TEMPLATE/feature_request.md ADDED
@@ -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?
.github/PULL_REQUEST_TEMPLATE.md ADDED
@@ -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.
.github/workflows/ci.yml ADDED
@@ -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/
.github/workflows/publish.yml ADDED
@@ -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
.gitignore CHANGED
@@ -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
- # Logs
101
  *.log
102
  logs/
 
103
 
104
  # Temporary files
105
  tmp/
106
  temp/
107
  *.tmp
108
  *.bak
 
109
 
110
- # Benchmark results (keep the framework, not results)
111
- /tmp/
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
+ .\#*
CHANGELOG.md ADDED
@@ -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
CODE_OF_CONDUCT.md ADDED
@@ -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
CONTRIBUTING.md ADDED
@@ -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!
LICENSE ADDED
@@ -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.
NOTICE ADDED
@@ -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
README.md CHANGED
@@ -1,285 +1,263 @@
1
- # Headroom
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
- A safe, deterministic Context Budget Controller for LLM APIs.
4
-
5
- **Increase effective TPM headroom. Reduce latency. Never break correctness.**
6
-
7
- ## Features
8
 
9
- - **Context MRI (Audit Mode)**: Analyze context waste without modifying requests
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
- ## Installation
17
 
18
  ```bash
19
  pip install headroom
20
- ```
21
 
22
- Or install from source:
 
23
 
24
- ```bash
25
- git clone https://github.com/headroom-sdk/headroom
26
- cd headroom
27
- pip install -e ".[dev]"
 
28
  ```
29
 
30
- ## Quick Start
 
 
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=base,
40
- store_url="sqlite:///headroom.db",
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
- ## Modes
56
-
57
- ### Audit Mode (Default)
58
-
59
- Observe and log without making changes:
60
 
61
  ```python
62
- client = HeadroomClient(
63
- original_client=base,
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
- ```python
76
- response = client.chat.completions.create(
77
- model="gpt-4o",
78
- messages=[...],
79
- headroom_mode="optimize", # Enable optimization
80
- )
81
  ```
82
 
83
- ### Simulate Mode
84
 
85
- Preview what optimizations would do:
86
 
87
  ```python
88
- plan = client.chat.completions.simulate(
89
- model="gpt-4o",
90
- messages=[...],
91
- )
92
 
93
- print(f"Tokens before: {plan.tokens_before}")
94
- print(f"Tokens after: {plan.tokens_after}")
95
- print(f"Tokens saved: {plan.tokens_saved}")
96
- print(f"Transforms: {plan.transforms}")
97
- print(f"Estimated savings: {plan.estimated_savings}")
 
98
  ```
99
 
100
- ## Configuration
101
-
102
- ### Headroom Parameters
103
-
104
- All headroom parameters are optional:
105
 
106
  ```python
107
- response = client.chat.completions.create(
108
- model="gpt-4o",
109
- messages=[...],
110
 
111
- # Headroom-specific parameters
112
- headroom_mode="optimize", # "audit" | "optimize"
113
- headroom_output_buffer_tokens=4000, # Reserve for output
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
- ### Model Context Limits
126
-
127
- Override default context limits:
128
 
129
  ```python
130
- client = HeadroomClient(
131
- original_client=base,
132
- model_context_limits={
133
- "gpt-4o": 128000,
134
- "my-custom-model": 32000,
135
- },
136
- )
137
  ```
138
 
139
- ## Transforms
140
 
141
- ### 1. Tool Output Compression
 
 
 
 
142
 
143
- Compresses large tool outputs while preserving structure:
 
 
 
 
144
 
145
- - Truncates long arrays (keeps first N items)
146
- - Truncates long strings with markers
147
- - Limits nesting depth
148
- - **Safe**: Malformed JSON is never modified
149
 
150
- ```python
151
- # Before: 50KB tool response
152
- {"results": [{"id": 1, ...}, {"id": 2, ...}, ... 1000 items ...]}
153
 
154
- # After: ~2KB with marker
155
- {"results": [{"id": 1, ...}, ..., {"__headroom_truncated": 995}]}
156
- <headroom:tool_digest sha256="abc123">
157
  ```
158
 
159
- ### 2. Cache Alignment
160
-
161
- Stabilizes prefixes for better cache hit rates:
162
 
163
- - Extracts dynamic dates from system prompts
164
- - Normalizes whitespace
165
- - Computes stable prefix hash
166
 
167
  ```python
168
- # Before: Cache miss every day due to date
169
- "You are helpful. Current Date: 2024-01-15"
170
-
171
- # After: Stable prefix, date moved to context
172
- "You are helpful.
173
-
174
- [Context: Current Date: 2024-01-15]"
175
  ```
176
 
177
- ### 3. Rolling Window
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
- from headroom import generate_report
192
-
193
- generate_report(
194
- store_url="sqlite:///headroom.db",
195
- output_path="report.html",
196
- )
197
  ```
198
 
199
- Reports include:
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
- stream = client.chat.completions.create(
220
- model="gpt-4o",
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
- ## Storage Options
231
-
232
- ### SQLite (Default)
233
 
234
  ```python
 
 
235
  client = HeadroomClient(
236
  original_client=base,
237
- store_url="sqlite:///headroom.db",
 
 
 
 
 
 
 
238
  )
239
  ```
240
 
241
- ### JSONL
242
 
243
- ```python
244
- client = HeadroomClient(
245
- original_client=base,
246
- store_url="jsonl:///var/log/headroom.jsonl",
247
- )
248
- ```
 
 
249
 
250
- ## Metrics
251
 
252
- Access stored metrics programmatically:
253
 
254
- ```python
255
- # Get recent metrics
256
- metrics = client.get_metrics(limit=100)
 
257
 
258
- # Get summary stats
259
- summary = client.get_summary()
260
- print(f"Total tokens saved: {summary['total_tokens_saved']}")
261
- ```
262
 
263
- ## Development
 
 
 
 
 
264
 
265
- ```bash
266
- # Install dev dependencies
267
- pip install -e ".[dev]"
268
 
269
- # Run tests
270
- pytest
 
 
 
271
 
272
- # Run linter
273
- ruff check .
 
274
 
275
- # Type check
276
- mypy headroom
 
 
 
 
277
  ```
278
 
279
  ## License
280
 
281
- MIT
282
 
283
- ## Contributing
 
 
 
 
 
 
 
284
 
285
- Contributions welcome! Please read the contributing guidelines first.
 
 
 
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>
SECURITY.md ADDED
@@ -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!
benchmarks/__init__.py CHANGED
@@ -21,7 +21,7 @@ Performance Targets:
21
  - HybridScorer: < 50ms for 100 items (with embeddings)
22
  """
23
 
24
- __version__ = "0.1.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,
EXPLANATION.md β†’ docs/ARCHITECTURE.md RENAMED
File without changes
docs/README.md ADDED
@@ -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
docs/api.md ADDED
@@ -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
+ ```
docs/getting-started.md ADDED
@@ -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
docs/proxy.md ADDED
@@ -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
+ ```
docs/transforms.md ADDED
@@ -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
examples/README.md ADDED
@@ -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
+ ```
headroom/__init__.py CHANGED
@@ -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
 
headroom/cli.py ADDED
@@ -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())
headroom/client.py CHANGED
@@ -2,8 +2,9 @@
2
 
3
  from __future__ import annotations
4
 
 
5
  from datetime import datetime
6
- from typing import Any, Iterator
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,
headroom/config.py CHANGED
@@ -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: "DiffArtifact | None" = None # Populated if generate_diff_artifact=True
305
- cache_metrics: "CachePrefixMetrics | None" = None # Populated by CacheAligner
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
headroom/integrations/__init__.py CHANGED
@@ -8,21 +8,20 @@ Install LangChain support: pip install headroom[langchain]
8
  """
9
 
10
  from .langchain import (
11
- HeadroomChatModel,
12
  HeadroomCallbackHandler,
13
- optimize_messages,
14
  HeadroomRunnable,
 
15
  )
16
-
17
  from .mcp import (
18
- HeadroomMCPCompressor,
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__ = [
headroom/integrations/langchain.py CHANGED
@@ -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, Iterator, List, Optional, Sequence, Union
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) -> "HeadroomChatModel":
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(
headroom/integrations/mcp.py CHANGED
@@ -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, Callable
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
headroom/models/__init__.py ADDED
@@ -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
+ ]
headroom/models/registry.py ADDED
@@ -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)
headroom/parser.py CHANGED
@@ -4,7 +4,7 @@ from __future__ import annotations
4
 
5
  import hashlib
6
  import re
7
- from typing import Any, TYPE_CHECKING
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
 
headroom/pricing/__init__.py CHANGED
@@ -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 .registry import CostEstimate, ModelPricing, PricingRegistry
 
 
 
 
 
 
8
  from .openai_prices import (
9
  LAST_UPDATED as OPENAI_LAST_UPDATED,
 
 
10
  OPENAI_PRICES,
11
  get_openai_registry,
12
  )
13
- from .anthropic_prices import (
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
headroom/pricing/anthropic_prices.py CHANGED
@@ -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
 
headroom/pricing/openai_prices.py CHANGED
@@ -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
 
headroom/pricing/registry.py CHANGED
@@ -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: Optional[float] = None
19
- batch_input_per_1m: Optional[float] = None
20
- batch_output_per_1m: Optional[float] = None
21
- context_window: Optional[int] = None
22
- notes: Optional[str] = None
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: Optional[date] = None
31
  is_stale: bool = False
32
- warning: Optional[str] = None
33
 
34
 
35
  class PricingRegistry:
@@ -41,8 +40,8 @@ class PricingRegistry:
41
  def __init__(
42
  self,
43
  last_updated: date,
44
- source_url: Optional[str] = None,
45
- prices: Optional[dict[str, ModelPricing]] = None,
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) -> Optional[ModelPricing]:
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) -> Optional[str]:
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:
headroom/providers/__init__.py CHANGED
@@ -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
  ]
headroom/providers/cohere.py ADDED
@@ -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
headroom/providers/google.py ADDED
@@ -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
headroom/providers/litellm.py ADDED
@@ -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()
headroom/providers/openai.py CHANGED
@@ -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
headroom/providers/openai_compatible.py ADDED
@@ -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
+ )
headroom/proxy/__init__.py ADDED
@@ -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"]
headroom/proxy/server.py ADDED
@@ -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)
headroom/py.typed ADDED
File without changes
headroom/relevance/bm25.py CHANGED
@@ -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, default_batch_score
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):
headroom/relevance/embedding.py CHANGED
@@ -20,7 +20,7 @@ Limitations:
20
  from __future__ import annotations
21
 
22
  import logging
23
- from typing import TYPE_CHECKING, Any
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, "SentenceTransformer"] = {}
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: "SentenceTransformer | None" = None
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) -> "SentenceTransformer":
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:
headroom/reporting/generator.py CHANGED
@@ -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>
headroom/storage/base.py CHANGED
@@ -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, Iterator
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