From 175746cc268522241f183936c99f9c61527bf4dd Mon Sep 17 00:00:00 2001 From: chopratejas Date: Wed, 7 Jan 2026 11:36:44 -0800 Subject: [PATCH] Prepare for OSS release v0.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/FUNDING.yml | 7 + .github/ISSUE_TEMPLATE/bug_report.md | 53 + .github/ISSUE_TEMPLATE/config.yml | 8 + .github/ISSUE_TEMPLATE/feature_request.md | 44 + .github/PULL_REQUEST_TEMPLATE.md | 56 + .github/workflows/ci.yml | 108 ++ .github/workflows/publish.yml | 31 + .gitignore | 68 +- CHANGELOG.md | 120 ++ CODE_OF_CONDUCT.md | 133 ++ CONTRIBUTING.md | 209 +++ LICENSE | 190 +++ NOTICE | 43 + README.md | 408 +++--- SECURITY.md | 65 + benchmarks/__init__.py | 2 +- EXPLANATION.md => docs/ARCHITECTURE.md | 0 docs/README.md | 28 + docs/api.md | 346 +++++ docs/getting-started.md | 109 ++ docs/proxy.md | 173 +++ docs/transforms.md | 198 +++ examples/README.md | 133 ++ headroom/__init__.py | 18 +- headroom/cli.py | 185 +++ headroom/client.py | 3 +- headroom/config.py | 4 +- headroom/integrations/__init__.py | 9 +- headroom/integrations/langchain.py | 5 +- headroom/integrations/mcp.py | 5 +- headroom/models/__init__.py | 39 + headroom/models/registry.py | 749 +++++++++++ headroom/parser.py | 2 +- headroom/pricing/__init__.py | 19 +- headroom/pricing/anthropic_prices.py | 1 - headroom/pricing/openai_prices.py | 1 - headroom/pricing/registry.py | 23 +- headroom/providers/__init__.py | 45 + headroom/providers/cohere.py | 313 +++++ headroom/providers/google.py | 372 ++++++ headroom/providers/litellm.py | 293 +++++ headroom/providers/openai.py | 1 - headroom/providers/openai_compatible.py | 521 ++++++++ headroom/proxy/__init__.py | 19 + headroom/proxy/server.py | 1399 +++++++++++++++++++++ headroom/py.typed | 0 headroom/relevance/bm25.py | 3 +- headroom/relevance/embedding.py | 8 +- headroom/reporting/generator.py | 1 - headroom/storage/base.py | 3 +- headroom/storage/jsonl.py | 3 +- headroom/storage/sqlite.py | 3 +- headroom/tokenizers/__init__.py | 72 ++ headroom/tokenizers/base.py | 203 +++ headroom/tokenizers/estimator.py | 199 +++ headroom/tokenizers/huggingface.py | 316 +++++ headroom/tokenizers/mistral.py | 244 ++++ headroom/tokenizers/registry.py | 398 ++++++ headroom/tokenizers/tiktoken_counter.py | 247 ++++ headroom/transforms/pipeline.py | 3 +- headroom/transforms/smart_crusher.py | 5 +- headroom/transforms/tool_crusher.py | 1 - pyproject.toml | 88 +- tests/test_models.py | 260 ++++ tests/test_providers/test_cohere.py | 124 ++ tests/test_providers/test_universal.py | 293 +++++ tests/test_tokenizers.py | 443 +++++++ 67 files changed, 9184 insertions(+), 291 deletions(-) create mode 100644 .github/FUNDING.yml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 NOTICE create mode 100644 SECURITY.md rename EXPLANATION.md => docs/ARCHITECTURE.md (100%) create mode 100644 docs/README.md create mode 100644 docs/api.md create mode 100644 docs/getting-started.md create mode 100644 docs/proxy.md create mode 100644 docs/transforms.md create mode 100644 examples/README.md create mode 100644 headroom/cli.py create mode 100644 headroom/models/__init__.py create mode 100644 headroom/models/registry.py create mode 100644 headroom/providers/cohere.py create mode 100644 headroom/providers/google.py create mode 100644 headroom/providers/litellm.py create mode 100644 headroom/providers/openai_compatible.py create mode 100644 headroom/proxy/__init__.py create mode 100644 headroom/proxy/server.py create mode 100644 headroom/py.typed create mode 100644 headroom/tokenizers/__init__.py create mode 100644 headroom/tokenizers/base.py create mode 100644 headroom/tokenizers/estimator.py create mode 100644 headroom/tokenizers/huggingface.py create mode 100644 headroom/tokenizers/mistral.py create mode 100644 headroom/tokenizers/registry.py create mode 100644 headroom/tokenizers/tiktoken_counter.py create mode 100644 tests/test_models.py create mode 100644 tests/test_providers/test_cohere.py create mode 100644 tests/test_providers/test_universal.py create mode 100644 tests/test_tokenizers.py diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..e4977c1d1 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,7 @@ +# These are supported funding model platforms + +github: [headroom-sdk] +# patreon: headroom +# open_collective: headroom +# ko_fi: headroom +# custom: ["https://headroom.dev/sponsor"] diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..57287b766 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,53 @@ +--- +name: Bug Report +about: Report a bug to help us improve Headroom +title: '[BUG] ' +labels: bug +assignees: '' +--- + +## Description + +A clear and concise description of what the bug is. + +## To Reproduce + +Steps to reproduce the behavior: + +1. Install headroom with '...' +2. Run this code '...' +3. See error + +## Expected Behavior + +What you expected to happen. + +## Actual Behavior + +What actually happened. + +## Code Sample + +```python +# Minimal code to reproduce the issue +from headroom import HeadroomClient + +# Your code here +``` + +## Error Output + +``` +Paste any error messages or stack traces here +``` + +## Environment + +- **Headroom version**: (run `python -c "import headroom; print(headroom.__version__)"`) +- **Python version**: (run `python --version`) +- **OS**: (e.g., macOS 14.0, Ubuntu 22.04, Windows 11) +- **LLM Provider**: (e.g., OpenAI, Anthropic) + +## Additional Context + +Add any other context about the problem here (logs, screenshots, etc.) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..171b37791 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Questions & Discussions + url: https://github.com/headroom-sdk/headroom/discussions + about: Ask questions and discuss ideas in GitHub Discussions + - name: Documentation + url: https://headroom.dev/docs + about: Check out the documentation for guides and API reference diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..d070946b3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,44 @@ +--- +name: Feature Request +about: Suggest a new feature for Headroom +title: '[FEATURE] ' +labels: enhancement +assignees: '' +--- + +## Problem Statement + +A clear description of the problem you're trying to solve. +Ex: "I'm always frustrated when..." + +## Proposed Solution + +Describe the solution you'd like. Be as specific as possible. + +## Use Case + +Explain your use case and why this feature would be valuable: + +- What type of application are you building? +- How would this feature help you? +- How many tokens/cost would this save? + +## Alternatives Considered + +Describe any alternative solutions or features you've considered. + +## Example API (Optional) + +If you have ideas about how the API should look: + +```python +# How you'd like to use this feature +from headroom import SomeNewFeature + +# Example usage +``` + +## Additional Context + +- Are you willing to contribute this feature? +- Any relevant links, papers, or prior art? diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..772b643b5 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,56 @@ +## Description + +Brief description of changes and motivation. + +Fixes #(issue number) + +## Type of Change + +- [ ] Bug fix (non-breaking change that fixes an issue) +- [ ] New feature (non-breaking change that adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to change) +- [ ] Documentation update +- [ ] Performance improvement +- [ ] Code refactoring (no functional changes) + +## Changes Made + +- Change 1 +- Change 2 +- Change 3 + +## Testing + +Describe the tests you ran to verify your changes: + +- [ ] Unit tests pass (`pytest`) +- [ ] Linting passes (`ruff check .`) +- [ ] Type checking passes (`mypy headroom`) +- [ ] New tests added for new functionality +- [ ] Manual testing performed + +## Test Output + +``` +# Paste relevant test output here +pytest -v tests/test_your_feature.py +``` + +## Checklist + +- [ ] My code follows the project's style guidelines +- [ ] I have performed a self-review of my code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation +- [ ] My changes generate no new warnings +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes +- [ ] I have updated the CHANGELOG.md if applicable + +## Screenshots (if applicable) + +Add screenshots to help explain your changes. + +## Additional Notes + +Any additional information that reviewers should know. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..1a6ddcded --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,108 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Cache pip packages + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-pip-${{ matrix.python-version }}- + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run linting + run: | + ruff check . + ruff format --check . + + - name: Run type checking + run: | + mypy headroom --ignore-missing-imports + + - name: Run tests + run: | + pytest -v --tb=short + + - name: Run tests with coverage + if: matrix.python-version == '3.11' + run: | + pytest --cov=headroom --cov-report=xml --cov-report=term-missing + + - name: Upload coverage to Codecov + if: matrix.python-version == '3.11' + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml + fail_ci_if_error: false + + test-extras: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install with relevance extras + run: | + python -m pip install --upgrade pip + pip install -e ".[dev,relevance]" + + - name: Run relevance tests + run: | + pytest tests/test_relevance.py -v + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install build tools + run: | + python -m pip install --upgrade pip build twine + + - name: Build package + run: | + python -m build + + - name: Check package + run: | + twine check dist/* + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 000000000..c4a60a3a3 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,31 @@ +name: Publish to PyPI + +on: + release: + types: [published] + +jobs: + publish: + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write # For trusted publishing + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install build tools + run: | + python -m pip install --upgrade pip build + + - name: Build package + run: | + python -m build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index 51f60f915..a515b1cae 100644 --- a/.gitignore +++ b/.gitignore @@ -20,9 +20,11 @@ parts/ sdist/ var/ wheels/ +share/python-wheels/ *.egg-info/ .installed.cfg *.egg +MANIFEST # PyInstaller *.manifest @@ -45,6 +47,7 @@ coverage.xml *.py,cover .hypothesis/ .pytest_cache/ +pytest_cache/ # Translations *.mo @@ -59,16 +62,19 @@ venv/ ENV/ env.bak/ venv.bak/ +.python-version -# Secrets and API keys +# Secrets and API keys - NEVER commit these *.pem *.key secrets.json credentials.json .secrets api_keys.txt +.anthropic +.openai -# IDE +# IDE and editors .idea/ .vscode/ *.swp @@ -77,36 +83,86 @@ api_keys.txt .project .pydevproject .settings/ +*.sublime-project +*.sublime-workspace +.spyproject +.spyderproject # Jupyter Notebook .ipynb_checkpoints +*.ipynb # macOS .DS_Store .AppleDouble .LSOverride +._* # Thumbnails +Icon? ._* +# Windows +Thumbs.db +ehthumbs.db +Desktop.ini + +# Linux +*~ + # Local configuration local_settings.py *.local.py +*.local.json +*.local.yaml -# Database +# Database files *.db +*.sqlite *.sqlite3 -# Logs +# Log files *.log logs/ +log/ # Temporary files tmp/ temp/ *.tmp *.bak +*.swp -# Benchmark results (keep the framework, not results) -/tmp/ +# Benchmark results (keep framework, not results) +.benchmarks/ benchmark_results.json +benchmark_results/ + +# DeepEval cache +.deepeval/ + +# Headroom specific +headroom.db +headroom_*.db +*.jsonl +!tests/fixtures/*.jsonl + +# Documentation build +docs/_build/ +site/ + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Ruff +.ruff_cache/ + +# pyright +pyrightconfig.json + +# Editor backup files +*~ +\#*\# +.\#* diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..221c032d8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,120 @@ +# Changelog + +All notable changes to Headroom will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Production-ready proxy server with caching, rate limiting, and metrics +- CLI command `headroom proxy` to start the proxy server + +## [0.2.0] - 2025-01-07 + +### Added +- **SmartCrusher**: Statistical compression for tool outputs + - Keeps first/last K items, errors, anomalies, and relevance matches + - Variance-based change point detection + - Pattern detection (time series, logs, search results) +- **Relevance Scoring Engine**: ML-powered item relevance + - `BM25Scorer`: Fast keyword matching (zero dependencies) + - `EmbeddingScorer`: Semantic similarity with sentence-transformers + - `HybridScorer`: Adaptive combination of both methods +- **CacheAligner**: Prefix stabilization for better cache hits + - Dynamic date extraction + - Whitespace normalization + - Stable prefix hashing +- **RollingWindow**: Context management within token limits + - Drops oldest tool units first + - Never orphans tool results + - Preserves recent turns +- **Multi-Provider Support**: + - Anthropic with official `count_tokens` API + - Google with official `countTokens` API + - Cohere with official `tokenize` API + - Mistral with official tokenizer + - LiteLLM for unified interface +- **Integrations**: + - LangChain callback handler (`HeadroomOptimizer`) + - MCP (Model Context Protocol) utilities +- **Proxy Server** (`headroom.proxy`): + - Semantic caching with LRU eviction + - Token bucket rate limiting + - Retry with exponential backoff + - Cost tracking with budget enforcement + - Prometheus metrics endpoint + - Request logging (JSONL) +- **Pricing Registry**: Centralized model pricing with staleness tracking +- **Benchmarks**: Performance benchmarks for transforms and relevance scoring + +### Changed +- Improved token counting accuracy across all providers +- Enhanced tool output compression with relevance-aware selection + +### Fixed +- Mistral tokenizer API compatibility +- Google token counting for multi-turn conversations + +## [0.1.0] - 2025-01-05 + +### Added +- Initial release +- `HeadroomClient`: OpenAI-compatible client wrapper +- `ToolCrusher`: Basic tool output compression +- Audit mode for observation without modification +- Optimize mode for applying transforms +- Simulate mode for previewing changes +- SQLite and JSONL storage backends +- HTML report generation +- Streaming support + +### Safety Guarantees +- Never removes human content +- Never breaks tool ordering +- Parse failures are no-ops +- Preserves recency (last N turns) + +--- + +## Migration Guide + +### From 0.1.x to 0.2.x + +The 0.2.0 release is backward compatible. New features are opt-in: + +```python +# Old code still works +from headroom import HeadroomClient, OpenAIProvider + +# New SmartCrusher (replaces ToolCrusher for better compression) +from headroom import SmartCrusher, SmartCrusherConfig + +config = SmartCrusherConfig( + min_tokens_to_crush=200, + max_items_after_crush=50, +) +crusher = SmartCrusher(config) + +# New relevance scoring +from headroom import create_scorer + +scorer = create_scorer("hybrid") # or "bm25" for zero deps +``` + +### Using the Proxy + +New in 0.2.0 - run Headroom as a proxy server: + +```bash +# Start the proxy +python -m headroom.proxy.server --port 8787 + +# Use with Claude Code +ANTHROPIC_BASE_URL=http://localhost:8787 claude +``` + +[Unreleased]: https://github.com/headroom-sdk/headroom/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/headroom-sdk/headroom/compare/v0.1.0...v0.2.0 +[0.1.0]: https://github.com/headroom-sdk/headroom/releases/tag/v0.1.0 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..542c3103c --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,133 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +**conduct@headroom.dev**. + +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..4f06f8a37 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,209 @@ +# Contributing to Headroom + +Thank you for your interest in contributing to Headroom! This document provides guidelines and instructions for contributing. + +## Code of Conduct + +By participating in this project, you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md). + +## How to Contribute + +### Reporting Bugs + +Before creating a bug report, please check existing issues to avoid duplicates. When creating a bug report, include: + +- **Clear title** describing the issue +- **Steps to reproduce** the behavior +- **Expected behavior** vs what actually happened +- **Environment details** (Python version, OS, Headroom version) +- **Code samples** or minimal reproduction if possible + +### Suggesting Features + +Feature requests are welcome! Please: + +- Check existing issues/discussions first +- Clearly describe the use case and motivation +- Explain how it fits with Headroom's goals (context optimization, safety, determinism) + +### Pull Requests + +1. **Fork the repository** and create your branch from `main` +2. **Install development dependencies**: + ```bash + pip install -e ".[dev]" + ``` +3. **Make your changes** following our coding standards +4. **Add tests** for new functionality +5. **Run the test suite**: + ```bash + pytest + ``` +6. **Run linting**: + ```bash + ruff check . + ruff format . + ``` +7. **Update documentation** if needed +8. **Submit your PR** with a clear description + +## Development Setup + +```bash +# Clone the repository +git clone https://github.com/headroom-sdk/headroom.git +cd headroom + +# Create a virtual environment +python -m venv .venv +source .venv/bin/activate # or `.venv\Scripts\activate` on Windows + +# Install in development mode with all dependencies +pip install -e ".[dev,relevance,proxy]" + +# Run tests +pytest + +# Run tests with coverage +pytest --cov=headroom --cov-report=html +``` + +## Coding Standards + +### Style + +- We use [Ruff](https://github.com/astral-sh/ruff) for linting and formatting +- Line length: 100 characters +- Use type hints for all public functions +- Follow PEP 8 naming conventions + +### Code Organization + +``` +headroom/ +├── __init__.py # Public API exports +├── client.py # HeadroomClient wrapper +├── config.py # Configuration dataclasses +├── transforms/ # Context transforms +│ ├── smart_crusher.py # Statistical compression +│ ├── cache_aligner.py # Cache optimization +│ └── rolling_window.py# Context windowing +├── relevance/ # Relevance scoring +├── providers/ # LLM provider adapters +├── proxy/ # Proxy server +└── storage/ # Metrics storage +``` + +### Testing + +- Write tests for all new functionality +- Use pytest fixtures for common setup +- Test edge cases and error conditions +- Aim for >80% coverage on new code + +Example test structure: +```python +class TestSmartCrusher: + """Tests for SmartCrusher transform.""" + + def test_compresses_large_arrays(self): + """Should compress arrays above token threshold.""" + ... + + def test_preserves_errors(self): + """Should never drop items containing errors.""" + ... +``` + +### Documentation + +- Add docstrings to all public classes and functions +- Use Google-style docstrings +- Update README.md for user-facing changes +- Add examples for new features + +```python +def compress_tool_output( + content: str, + max_items: int = 50, +) -> str: + """Compress tool output while preserving important items. + + Args: + content: The tool output content (usually JSON). + max_items: Maximum items to keep in arrays. + + Returns: + Compressed content string. + + Raises: + ValueError: If content is not valid JSON. + + Example: + >>> compress_tool_output('[{"id": 1}, {"id": 2}]', max_items=1) + '[{"id": 1}]' + """ +``` + +## Pull Request Guidelines + +### PR Title Format + +Use conventional commit style: +- `feat: Add semantic caching to proxy` +- `fix: Handle empty tool outputs correctly` +- `docs: Update proxy documentation` +- `test: Add tests for CacheAligner` +- `refactor: Simplify rolling window logic` + +### PR Description + +Include: +- **What** changes were made +- **Why** the changes were needed +- **How** to test the changes +- **Breaking changes** if any + +### Review Process + +1. All PRs require at least one review +2. CI must pass (tests, linting, type checking) +3. Maintain or improve test coverage +4. Update CHANGELOG.md for notable changes + +## Architecture Decisions + +### Safety First + +Headroom's core principle is **safety**. When in doubt: +- Never drop user/assistant content +- Never break tool call/response pairing +- Malformed content passes through unchanged +- Prefer false negatives over false positives + +### Performance + +- Transforms should add <50ms latency at P99 +- Use lazy loading for optional dependencies +- Profile before optimizing + +### Compatibility + +- Support Python 3.10+ +- Core functionality has minimal dependencies +- Optional features use extras (e.g., `pip install headroom[relevance]`) + +## Getting Help + +- **Questions**: Open a [Discussion](https://github.com/headroom-sdk/headroom/discussions) +- **Bugs**: Open an [Issue](https://github.com/headroom-sdk/headroom/issues) +- **Security**: Email security@headroom.dev (do not open public issues) + +## Recognition + +Contributors are recognized in: +- The CHANGELOG for their contributions +- The GitHub contributors page +- Release notes for significant features + +Thank you for contributing to Headroom! diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..6ee2620b1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2025 Headroom Contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 000000000..547f55d09 --- /dev/null +++ b/NOTICE @@ -0,0 +1,43 @@ +Headroom +Copyright 2025 Headroom Contributors + +This product includes software developed by the Headroom Contributors. + +Third-Party Licenses +==================== + +This software uses the following third-party libraries: + +tiktoken +-------- +Copyright (c) 2022 OpenAI, Shantanu Jain +Licensed under the MIT License +https://github.com/openai/tiktoken + +Pydantic +-------- +Copyright (c) 2017 to present Pydantic Services Inc. and individual contributors +Licensed under the MIT License +https://github.com/pydantic/pydantic + +sentence-transformers (optional dependency) +------------------------------------------- +Copyright 2019 Nils Reimers +Licensed under the Apache License 2.0 +https://github.com/UKPLab/sentence-transformers + +Note: Some pretrained sentence-transformer models may have additional licensing +restrictions based on their training data. Please verify model-specific licenses +before commercial use. + +FastAPI (optional dependency) +----------------------------- +Copyright (c) 2018 Sebastián Ramírez +Licensed under the MIT License +https://github.com/tiangolo/fastapi + +NumPy (optional dependency) +--------------------------- +Copyright (c) 2005-2024, NumPy Developers +Licensed under the BSD 3-Clause License +https://github.com/numpy/numpy diff --git a/README.md b/README.md index 013dec72a..37c2a97c8 100644 --- a/README.md +++ b/README.md @@ -1,285 +1,263 @@ -# Headroom +

+

Headroom

+

+ The Context Optimization Layer for LLM Applications +

+

+ Cut your LLM costs by 50-90% without losing accuracy +

+

-A safe, deterministic Context Budget Controller for LLM APIs. +

+ + CI + + + PyPI + + + Python + + + License + +

-**Increase effective TPM headroom. Reduce latency. Never break correctness.** +--- -## Features +## The Problem -- **Context MRI (Audit Mode)**: Analyze context waste without modifying requests -- **Tool Output Compression**: Safely compress large tool outputs -- **Cache-Aligned Prefixes**: Optimize for provider caching (OpenAI, etc.) -- **Rolling Window Management**: Keep context within token limits -- **Streaming Support**: Full pass-through streaming with metrics -- **Simulate Mode**: Preview optimizations before applying +AI coding agents and tool-using applications generate **massive contexts**: -## Installation +- Tool outputs with 1000s of search results, log entries, API responses +- Long conversation histories that hit token limits +- System prompts with dynamic dates that break provider caching + +**Result**: You pay for tokens you don't need, and cache hits are rare. + +## The Solution + +Headroom is a **smart compression layer** that sits between your app and LLM providers. It applies three transforms: + +| Transform | What It Does | Savings | +|-----------|--------------|---------| +| **SmartCrusher** | Compresses tool outputs statistically (keeps errors, anomalies, relevant items) | 70-90% | +| **CacheAligner** | Stabilizes prefixes so provider caching works | Up to 10x | +| **RollingWindow** | Manages context within limits without breaking tool calls | Prevents failures | + +**Zero accuracy loss** - we keep what matters: errors, anomalies, relevant items. + +## Quick Start + +### Option 1: Proxy (Recommended) + +Run Headroom as a proxy server - works with any client: ```bash pip install headroom + +# Start the proxy +headroom proxy --port 8787 + +# Use with Claude Code +ANTHROPIC_BASE_URL=http://localhost:8787 claude + +# Use with any OpenAI-compatible client +OPENAI_BASE_URL=http://localhost:8787/v1 your-app ``` -Or install from source: +### Option 2: Python SDK -```bash -git clone https://github.com/headroom-sdk/headroom -cd headroom -pip install -e ".[dev]" -``` - -## Quick Start +Wrap your existing client: ```python from headroom import HeadroomClient from openai import OpenAI -# Wrap any OpenAI-compatible client -base = OpenAI(api_key="...") client = HeadroomClient( - original_client=base, - store_url="sqlite:///headroom.db", - default_mode="audit", # Start in observation mode + original_client=OpenAI(), + default_mode="optimize", ) # Use exactly like the original client response = client.chat.completions.create( model="gpt-4o", - messages=[ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": "Hello!"}, - ], + messages=[...], ) -print(response.choices[0].message.content) +``` + +### Option 3: LangChain Integration + +```python +from langchain_openai import ChatOpenAI +from headroom.integrations import HeadroomOptimizer + +llm = ChatOpenAI(model="gpt-4o", callbacks=[HeadroomOptimizer()]) +``` + +## Features + +### Smart Tool Output Compression + +```python +# Before: 50KB tool response with 1000 items +{"results": [{"id": 1, ...}, {"id": 2, ...}, ... 1000 items ...]} + +# After: ~2KB with important items preserved +# - First 3 items (context) +# - Last 2 items (recency) +# - All error items +# - Anomalous values (> 2 std dev) +# - Items matching user's query +``` + +### Cache-Aligned Prefixes + +```python +# Before: Cache miss every day due to changing date +"You are helpful. Today is January 7, 2025." + +# After: Stable prefix (cache hit!) + dynamic context +"You are helpful." +# [Dynamic context moved to end] +``` + +### Rolling Window + +```python +# Automatically manages context within token limits +# - Drops oldest tool outputs first +# - Never orphans tool call/response pairs +# - Always preserves system prompt and recent turns +``` + +### Production Proxy Features + +- **Semantic Caching**: LRU cache with TTL for repeated queries +- **Rate Limiting**: Token bucket (requests + tokens per minute) +- **Cost Tracking**: Budget enforcement (hourly/daily/monthly) +- **Prometheus Metrics**: `/metrics` endpoint for monitoring +- **Request Logging**: JSONL logs for debugging + +## Installation + +```bash +# Core (minimal dependencies) +pip install headroom + +# With semantic relevance scoring +pip install headroom[relevance] + +# With proxy server +pip install headroom[proxy] + +# Everything +pip install headroom[all] ``` ## Modes -### Audit Mode (Default) - -Observe and log without making changes: +### Audit Mode (Observe Only) ```python -client = HeadroomClient( - original_client=base, - default_mode="audit", -) - -# Logs metrics to SQLite but doesn't modify requests -response = client.chat.completions.create(...) +client = HeadroomClient(original_client=base, default_mode="audit") +# Logs metrics but doesn't modify requests ``` -### Optimize Mode - -Apply safe, deterministic transforms: +### Optimize Mode (Apply Transforms) ```python -response = client.chat.completions.create( - model="gpt-4o", - messages=[...], - headroom_mode="optimize", # Enable optimization -) +client = HeadroomClient(original_client=base, default_mode="optimize") +# Applies safe, deterministic transforms ``` -### Simulate Mode - -Preview what optimizations would do: +### Simulate Mode (Preview) ```python -plan = client.chat.completions.simulate( - model="gpt-4o", - messages=[...], -) - -print(f"Tokens before: {plan.tokens_before}") -print(f"Tokens after: {plan.tokens_after}") -print(f"Tokens saved: {plan.tokens_saved}") -print(f"Transforms: {plan.transforms}") -print(f"Estimated savings: {plan.estimated_savings}") +plan = client.chat.completions.simulate(model="gpt-4o", messages=[...]) +print(f"Would save {plan.tokens_saved} tokens ({plan.savings_percent:.1f}%)") ``` ## Configuration -### Headroom Parameters - -All headroom parameters are optional: - ```python -response = client.chat.completions.create( - model="gpt-4o", - messages=[...], +from headroom import HeadroomClient, SmartCrusherConfig - # Headroom-specific parameters - headroom_mode="optimize", # "audit" | "optimize" - headroom_output_buffer_tokens=4000, # Reserve for output - headroom_keep_turns=2, # Never drop last N turns - headroom_tool_profiles={ # Per-tool compression - "search": {"max_array_items": 5}, - }, - - # All other OpenAI parameters work normally - temperature=0.7, - max_tokens=1000, -) -``` - -### Model Context Limits - -Override default context limits: - -```python client = HeadroomClient( original_client=base, - model_context_limits={ - "gpt-4o": 128000, - "my-custom-model": 32000, - }, + default_mode="optimize", + smart_crusher_config=SmartCrusherConfig( + min_tokens_to_crush=200, # Only compress if > 200 tokens + max_items_after_crush=50, # Keep at most 50 items + keep_first=3, # Always keep first 3 + keep_last=2, # Always keep last 2 + relevance_threshold=0.3, # Keep items with relevance > 0.3 + ), ) ``` -## Transforms +## Supported Providers -### 1. Tool Output Compression - -Compresses large tool outputs while preserving structure: - -- Truncates long arrays (keeps first N items) -- Truncates long strings with markers -- Limits nesting depth -- **Safe**: Malformed JSON is never modified - -```python -# Before: 50KB tool response -{"results": [{"id": 1, ...}, {"id": 2, ...}, ... 1000 items ...]} - -# After: ~2KB with marker -{"results": [{"id": 1, ...}, ..., {"__headroom_truncated": 995}]} - -``` - -### 2. Cache Alignment - -Stabilizes prefixes for better cache hit rates: - -- Extracts dynamic dates from system prompts -- Normalizes whitespace -- Computes stable prefix hash - -```python -# Before: Cache miss every day due to date -"You are helpful. Current Date: 2024-01-15" - -# After: Stable prefix, date moved to context -"You are helpful. - -[Context: Current Date: 2024-01-15]" -``` - -### 3. Rolling Window - -Keeps context within token limits: - -- Drops oldest tool call units first -- Never orphans tool responses -- Preserves system prompt and recent turns -- Inserts dropped context markers - -## Reporting - -Generate HTML reports of context waste: - -```python -from headroom import generate_report - -generate_report( - store_url="sqlite:///headroom.db", - output_path="report.html", -) -``` - -Reports include: -- Waste histogram by category -- Top high-waste requests -- Cache alignment analysis -- Actionable recommendations +| Provider | Token Counting | Status | +|----------|----------------|--------| +| OpenAI | tiktoken | Full support | +| Anthropic | Official API | Full support | +| Google | Official API | Full support | +| Cohere | Official API | Full support | +| Mistral | Official tokenizer | Full support | +| LiteLLM | Via provider | Full support | ## Safety Guarantees Headroom follows strict safety rules: -1. **Never removes human content**: User/assistant text is sacred -2. **Never breaks tool ordering**: Tool calls and responses stay paired -3. **Parse failures are no-ops**: Malformed content passes through unchanged -4. **Preserves recency**: Last N turns are always kept +1. **Never removes human content** - User/assistant text is sacred +2. **Never breaks tool ordering** - Tool calls and responses stay paired +3. **Parse failures are no-ops** - Malformed content passes through unchanged +4. **Preserves recency** - Last N turns are always kept -## Streaming +## Benchmarks -Full streaming support: +| Scenario | Before | After | Savings | +|----------|--------|-------|---------| +| Search results (1000 items) | 45,000 tokens | 4,500 tokens | 90% | +| Log analysis (500 entries) | 22,000 tokens | 3,300 tokens | 85% | +| API response (nested JSON) | 15,000 tokens | 2,250 tokens | 85% | +| Long conversation (50 turns) | 80,000 tokens | 32,000 tokens | 60% | -```python -stream = client.chat.completions.create( - model="gpt-4o", - messages=[...], - stream=True, - headroom_mode="optimize", -) +## Documentation -for chunk in stream: - print(chunk.choices[0].delta.content, end="") -``` +- [Getting Started Guide](docs/getting-started.md) +- [Proxy Server Documentation](docs/proxy.md) +- [Transform Reference](docs/transforms.md) +- [API Reference](docs/api.md) +- [Examples](examples/) -## Storage Options +## Contributing -### SQLite (Default) - -```python -client = HeadroomClient( - original_client=base, - store_url="sqlite:///headroom.db", -) -``` - -### JSONL - -```python -client = HeadroomClient( - original_client=base, - store_url="jsonl:///var/log/headroom.jsonl", -) -``` - -## Metrics - -Access stored metrics programmatically: - -```python -# Get recent metrics -metrics = client.get_metrics(limit=100) - -# Get summary stats -summary = client.get_summary() -print(f"Total tokens saved: {summary['total_tokens_saved']}") -``` - -## Development +We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details. ```bash -# Install dev dependencies +# Development setup +git clone https://github.com/headroom-sdk/headroom.git +cd headroom pip install -e ".[dev]" - -# Run tests pytest - -# Run linter -ruff check . - -# Type check -mypy headroom ``` ## License -MIT +Apache License 2.0 - see [LICENSE](LICENSE) for details. -## Contributing +## Links -Contributions welcome! Please read the contributing guidelines first. +- [GitHub](https://github.com/headroom-sdk/headroom) +- [PyPI](https://pypi.org/project/headroom/) +- [Documentation](https://headroom.dev/docs) +- [Discord](https://discord.gg/headroom) + +--- + +

+ Built with care for the AI developer community +

diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..c44a9237a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,65 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| 0.2.x | :white_check_mark: | +| 0.1.x | :x: | + +## Reporting a Vulnerability + +We take security vulnerabilities seriously. If you discover a security issue, please report it responsibly. + +### How to Report + +**Please DO NOT open a public GitHub issue for security vulnerabilities.** + +Instead, please email us at: **security@headroom.dev** + +Include the following information: +- Type of vulnerability (e.g., injection, data exposure, authentication bypass) +- Full path of the affected source file(s) +- Step-by-step instructions to reproduce the issue +- Proof-of-concept or exploit code (if possible) +- Impact assessment + +### What to Expect + +1. **Acknowledgment**: We will acknowledge receipt within 48 hours +2. **Assessment**: We will assess the vulnerability and determine its severity +3. **Updates**: We will keep you informed of our progress +4. **Resolution**: We aim to resolve critical issues within 7 days +5. **Credit**: With your permission, we will credit you in the security advisory + +### Security Best Practices for Users + +When using Headroom: + +1. **API Keys**: Never commit API keys. Use environment variables. +2. **Proxy Exposure**: Don't expose the proxy server to the public internet without authentication +3. **Log Files**: Be aware that request logs may contain sensitive information +4. **Budget Limits**: Set budget limits to prevent unexpected costs + +### Scope + +The following are in scope for security reports: +- Headroom Python package (`pip install headroom`) +- Headroom proxy server +- Official integrations (LangChain, MCP) + +The following are out of scope: +- Third-party integrations not maintained by us +- Issues in dependencies (report these to the upstream project) +- Social engineering attacks + +## Security Features + +Headroom includes several security features: + +- **No credential storage**: We never store or log API keys +- **Passthrough mode**: Sensitive content passes through unchanged by default +- **Input validation**: All inputs are validated before processing +- **Safe defaults**: Security-conscious defaults out of the box + +Thank you for helping keep Headroom and its users safe! diff --git a/benchmarks/__init__.py b/benchmarks/__init__.py index 294ba572f..c4ebeb257 100644 --- a/benchmarks/__init__.py +++ b/benchmarks/__init__.py @@ -21,7 +21,7 @@ Performance Targets: - HybridScorer: < 50ms for 100 items (with embeddings) """ -__version__ = "0.1.0" +__version__ = "0.2.0" from .scenarios.tool_outputs import ( generate_api_responses, diff --git a/EXPLANATION.md b/docs/ARCHITECTURE.md similarity index 100% rename from EXPLANATION.md rename to docs/ARCHITECTURE.md diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..68aed4ff7 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,28 @@ +# Headroom Documentation + +Welcome to the Headroom documentation. + +## Quick Links + +- [Getting Started](getting-started.md) +- [Proxy Server](proxy.md) +- [Transforms](transforms.md) +- [API Reference](api.md) +- [Architecture](ARCHITECTURE.md) + +## Overview + +Headroom is the Context Optimization Layer for LLM applications. It reduces your LLM costs by 50-90% through intelligent context compression. + +### Core Concepts + +1. **Transforms**: Stateless functions that modify message arrays to reduce tokens +2. **Providers**: Adapters for different LLM providers (OpenAI, Anthropic, etc.) +3. **Pipeline**: Chains multiple transforms together +4. **Proxy**: HTTP server that applies transforms transparently + +### Getting Help + +- [GitHub Issues](https://github.com/headroom-sdk/headroom/issues) - Bug reports +- [GitHub Discussions](https://github.com/headroom-sdk/headroom/discussions) - Questions +- [Discord](https://discord.gg/headroom) - Community chat diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 000000000..a9d033963 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,346 @@ +# API Reference + +## HeadroomClient + +The main entry point for Headroom SDK. + +```python +from headroom import HeadroomClient +from openai import OpenAI + +client = HeadroomClient( + original_client=OpenAI(), + default_mode="optimize", +) +``` + +### Constructor Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `original_client` | `OpenAI \| Anthropic` | Required | The underlying LLM client | +| `provider` | `Provider` | Auto-detected | Token counting provider | +| `default_mode` | `str` | `"audit"` | Default mode: "audit", "optimize", "off" | +| `store_url` | `str` | `None` | Storage URL for metrics | +| `smart_crusher_config` | `SmartCrusherConfig` | Default | Compression settings | +| `cache_aligner_config` | `CacheAlignerConfig` | Default | Cache alignment settings | +| `rolling_window_config` | `RollingWindowConfig` | Default | Context window settings | + +### Methods + +#### `chat.completions.create(**kwargs)` + +Create a chat completion with optional optimization. + +```python +response = client.chat.completions.create( + model="gpt-4o", + messages=[...], + headroom_mode="optimize", # Override default mode +) +``` + +**Additional Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `headroom_mode` | `str` | Override mode for this request | +| `headroom_query` | `str` | Query for relevance scoring | + +#### `chat.completions.simulate(**kwargs)` + +Preview optimization without making an API call. + +```python +plan = client.chat.completions.simulate( + model="gpt-4o", + messages=[...], +) + +print(f"Tokens before: {plan.tokens_before}") +print(f"Tokens after: {plan.tokens_after}") +print(f"Savings: {plan.savings_percent:.1f}%") +``` + +**Returns:** `SimulationResult` + +--- + +## Configuration Classes + +### SmartCrusherConfig + +```python +from headroom import SmartCrusherConfig + +config = SmartCrusherConfig( + min_tokens_to_crush=200, + max_items_after_crush=50, + keep_first=3, + keep_last=2, + relevance_threshold=0.3, + anomaly_std_threshold=2.0, + preserve_errors=True, +) +``` + +### CacheAlignerConfig + +```python +from headroom import CacheAlignerConfig + +config = CacheAlignerConfig( + extract_dates=True, + normalize_whitespace=True, + stable_prefix_min_tokens=100, +) +``` + +### RollingWindowConfig + +```python +from headroom import RollingWindowConfig + +config = RollingWindowConfig( + max_tokens=100000, + preserve_system=True, + preserve_recent_turns=5, + drop_oldest_first=True, +) +``` + +### RelevanceScorerConfig + +```python +from headroom import RelevanceScorerConfig + +config = RelevanceScorerConfig( + scorer_type="bm25", # "bm25", "embedding", or "hybrid" + embedding_model=None, # Model name for embedding scorer + hybrid_alpha=0.5, # Weight for hybrid scoring +) +``` + +--- + +## Data Models + +### SimulationResult + +Returned by `simulate()`. + +```python +@dataclass +class SimulationResult: + tokens_before: int + tokens_after: int + tokens_saved: int + savings_percent: float + transforms_applied: list[str] + waste_signals: WasteSignals +``` + +### RequestMetrics + +Metrics for a single request. + +```python +@dataclass +class RequestMetrics: + request_id: str + timestamp: datetime + model: str + tokens_input_before: int + tokens_input_after: int + tokens_output: int + cost_before: float + cost_after: float + transforms_applied: list[str] +``` + +### WasteSignals + +Detected waste in the request. + +```python +@dataclass +class WasteSignals: + json_bloat_tokens: int + html_noise_tokens: int + whitespace_tokens: int + dynamic_date_tokens: int + repetition_tokens: int +``` + +--- + +## Providers + +### OpenAIProvider + +```python +from headroom import OpenAIProvider + +provider = OpenAIProvider() + +# Get token counter +counter = provider.get_token_counter("gpt-4o") +tokens = counter.count_text("Hello, world!") + +# Get context limit +limit = provider.get_context_limit("gpt-4o") # 128000 + +# Estimate cost +cost = provider.estimate_cost( + input_tokens=1000, + output_tokens=500, + model="gpt-4o", +) +``` + +### AnthropicProvider + +```python +from headroom import AnthropicProvider +from anthropic import Anthropic + +provider = AnthropicProvider(client=Anthropic()) + +counter = provider.get_token_counter("claude-3-5-sonnet-latest") +tokens = counter.count_messages(messages) # Accurate count via API +``` + +--- + +## Relevance Scoring + +### BM25Scorer + +Fast keyword-based scoring (zero dependencies). + +```python +from headroom import BM25Scorer + +scorer = BM25Scorer() +scores = scorer.score_items( + items=["item 1", "item 2", ...], + query="search query", +) +``` + +### EmbeddingScorer + +Semantic similarity scoring (requires `sentence-transformers`). + +```python +from headroom import EmbeddingScorer, embedding_available + +if embedding_available(): + scorer = EmbeddingScorer(model="all-MiniLM-L6-v2") + scores = scorer.score_items(items, query) +``` + +### HybridScorer + +Combines BM25 and embeddings. + +```python +from headroom import HybridScorer + +scorer = HybridScorer(alpha=0.5) # 50% BM25, 50% embedding +scores = scorer.score_items(items, query) +``` + +### create_scorer() + +Factory function to create scorers. + +```python +from headroom import create_scorer + +# Auto-select best available scorer +scorer = create_scorer() + +# Explicitly choose type +scorer = create_scorer(scorer_type="hybrid", alpha=0.7) +``` + +--- + +## Transforms (Direct Use) + +### SmartCrusher + +```python +from headroom import SmartCrusher + +crusher = SmartCrusher() +result = crusher.crush( + data={"results": [...]}, + query="user query", +) +``` + +### CacheAligner + +```python +from headroom import CacheAligner + +aligner = CacheAligner() +result = aligner.align(messages) +``` + +### RollingWindow + +```python +from headroom import RollingWindow + +window = RollingWindow(config) +result = window.apply(messages, max_tokens=100000) +``` + +### TransformPipeline + +```python +from headroom import TransformPipeline + +pipeline = TransformPipeline([ + SmartCrusher(), + CacheAligner(), + RollingWindow(), +]) + +result = pipeline.transform(messages) +``` + +--- + +## Utilities + +### Tokenizer + +```python +from headroom import Tokenizer, count_tokens_text, count_tokens_messages + +# Quick counting +tokens = count_tokens_text("Hello, world!", model="gpt-4o") + +# With tokenizer instance +tokenizer = Tokenizer(model="gpt-4o") +tokens = tokenizer.count_text("Hello") +tokens = tokenizer.count_messages(messages) +``` + +### generate_report() + +Generate HTML/Markdown reports from stored metrics. + +```python +from headroom import generate_report + +report = generate_report( + store_url="sqlite:///headroom.db", + format="html", + period="day", +) +``` diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 000000000..66c1e0ebe --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,109 @@ +# Getting Started with Headroom + +This guide will help you get up and running with Headroom in under 5 minutes. + +## Installation + +```bash +# Core package (minimal dependencies) +pip install headroom + +# With proxy server +pip install headroom[proxy] + +# With semantic relevance (for smarter compression) +pip install headroom[relevance] + +# Everything +pip install headroom[all] +``` + +## Quick Start: Proxy Mode (Recommended) + +The easiest way to use Headroom is as a proxy server: + +```bash +# Start the proxy +headroom proxy --port 8787 +``` + +Then point your LLM client at it: + +```bash +# Claude Code +ANTHROPIC_BASE_URL=http://localhost:8787 claude + +# OpenAI-compatible clients +OPENAI_BASE_URL=http://localhost:8787/v1 your-app +``` + +That's it! All your requests now go through Headroom and get optimized automatically. + +## Quick Start: Python SDK + +If you want programmatic control: + +```python +from headroom import HeadroomClient +from openai import OpenAI + +# Create a wrapped client +client = HeadroomClient( + original_client=OpenAI(), + default_mode="optimize", +) + +# Use exactly like the original +response = client.chat.completions.create( + model="gpt-4o", + messages=[ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Hello!"}, + ], +) +``` + +## Modes + +### Audit Mode + +Observe without modifying: + +```python +client = HeadroomClient( + original_client=OpenAI(), + default_mode="audit", +) +# Logs metrics but doesn't change requests +``` + +### Optimize Mode + +Apply transforms to reduce tokens: + +```python +client = HeadroomClient( + original_client=OpenAI(), + default_mode="optimize", +) +# Compresses tool outputs, aligns cache prefixes, etc. +``` + +### Simulate Mode + +Preview what optimizations would do: + +```python +plan = client.chat.completions.simulate( + model="gpt-4o", + messages=[...], +) +print(f"Would save {plan.tokens_saved} tokens") +print(f"Transforms: {plan.transforms_applied}") +``` + +## Next Steps + +- [Proxy Server Documentation](proxy.md) - Configure the proxy +- [Transforms Reference](transforms.md) - Understand each transform +- [API Reference](api.md) - Full API documentation diff --git a/docs/proxy.md b/docs/proxy.md new file mode 100644 index 000000000..b95d61c00 --- /dev/null +++ b/docs/proxy.md @@ -0,0 +1,173 @@ +# Proxy Server Documentation + +The Headroom proxy server is a production-ready HTTP server that applies context optimization to all requests passing through it. + +## Starting the Proxy + +```bash +# Basic usage +headroom proxy + +# Custom port +headroom proxy --port 8080 + +# With all options +headroom proxy \ + --host 0.0.0.0 \ + --port 8787 \ + --log-file /var/log/headroom.jsonl \ + --budget 100.0 +``` + +## Command Line Options + +| Option | Default | Description | +|--------|---------|-------------| +| `--host` | `127.0.0.1` | Host to bind to | +| `--port` | `8787` | Port to bind to | +| `--no-optimize` | `false` | Disable optimization (passthrough mode) | +| `--no-cache` | `false` | Disable semantic caching | +| `--no-rate-limit` | `false` | Disable rate limiting | +| `--log-file` | None | Path to JSONL log file | +| `--budget` | None | Daily budget limit in USD | + +## API Endpoints + +### Health Check + +```bash +curl http://localhost:8787/health +``` + +Response: +```json +{ + "status": "healthy", + "optimize": true, + "stats": { + "total_requests": 42, + "tokens_saved": 15000, + "savings_percent": 45.2 + } +} +``` + +### Detailed Statistics + +```bash +curl http://localhost:8787/stats +``` + +### Prometheus Metrics + +```bash +curl http://localhost:8787/metrics +``` + +### LLM APIs + +The proxy supports both Anthropic and OpenAI API formats: + +```bash +# Anthropic format +POST /v1/messages + +# OpenAI format +POST /v1/chat/completions +``` + +## Using with Claude Code + +```bash +# Start proxy +headroom proxy --port 8787 + +# In another terminal +ANTHROPIC_BASE_URL=http://localhost:8787 claude +``` + +## Using with Cursor + +1. Start the proxy: `headroom proxy` +2. In Cursor settings, set the base URL to `http://localhost:8787` + +## Using with OpenAI SDK + +```python +from openai import OpenAI + +client = OpenAI( + base_url="http://localhost:8787/v1", + api_key="your-api-key", # Still needed for upstream +) +``` + +## Features + +### Semantic Caching + +The proxy caches responses for repeated queries: + +- LRU eviction with configurable max entries +- TTL-based expiration +- Cache key based on message content hash + +### Rate Limiting + +Token bucket rate limiting protects against runaway costs: + +- Configurable requests per minute +- Configurable tokens per minute +- Per-API-key tracking + +### Cost Tracking + +Track spending and enforce budgets: + +- Real-time cost estimation +- Budget periods: hourly, daily, monthly +- Automatic request rejection when over budget + +### Prometheus Metrics + +Export metrics for monitoring: + +``` +headroom_requests_total +headroom_tokens_saved_total +headroom_cost_usd_total +headroom_latency_ms_sum +``` + +## Configuration via Environment + +```bash +export HEADROOM_HOST=0.0.0.0 +export HEADROOM_PORT=8787 +export HEADROOM_BUDGET=100.0 +headroom proxy +``` + +## Running in Production + +For production deployments: + +```bash +# Use a process manager +pip install gunicorn + +# Run with gunicorn +gunicorn headroom.proxy.server:app \ + --workers 4 \ + --bind 0.0.0.0:8787 \ + --worker-class uvicorn.workers.UvicornWorker +``` + +Or with Docker: + +```dockerfile +FROM python:3.11-slim +RUN pip install headroom[proxy] +EXPOSE 8787 +CMD ["headroom", "proxy", "--host", "0.0.0.0"] +``` diff --git a/docs/transforms.md b/docs/transforms.md new file mode 100644 index 000000000..736f50320 --- /dev/null +++ b/docs/transforms.md @@ -0,0 +1,198 @@ +# Transform Reference + +Headroom provides three core transforms that work together to optimize LLM context. + +## SmartCrusher + +Statistical compression for JSON tool outputs. + +### How It Works + +SmartCrusher analyzes JSON arrays and selectively keeps important items: + +1. **First/Last items** - Context for pagination and recency +2. **Error items** - 100% preservation of error states +3. **Anomalies** - Statistical outliers (> 2 std dev from mean) +4. **Relevant items** - Matches to user's query via BM25/embeddings +5. **Change points** - Significant transitions in data + +### Configuration + +```python +from headroom import SmartCrusherConfig + +config = SmartCrusherConfig( + min_tokens_to_crush=200, # Only compress if > 200 tokens + max_items_after_crush=50, # Keep at most 50 items + keep_first=3, # Always keep first 3 items + keep_last=2, # Always keep last 2 items + relevance_threshold=0.3, # Keep items with relevance > 0.3 + anomaly_std_threshold=2.0, # Keep items > 2 std dev from mean + preserve_errors=True, # Always keep error items +) +``` + +### Example + +```python +from headroom import SmartCrusher + +crusher = SmartCrusher(config) + +# Before: 1000 search results (45,000 tokens) +tool_output = {"results": [...1000 items...]} + +# After: ~50 important items (4,500 tokens) - 90% reduction +compressed = crusher.crush(tool_output, query="user's question") +``` + +### What Gets Preserved + +| Category | Preserved | Why | +|----------|-----------|-----| +| Errors | 100% | Critical for debugging | +| First N | 100% | Context/pagination | +| Last N | 100% | Recency | +| Anomalies | All | Unusual values matter | +| Relevant | Top K | Match user's query | +| Others | Sampled | Statistical representation | + +--- + +## CacheAligner + +Prefix stabilization for improved cache hit rates. + +### The Problem + +LLM providers cache request prefixes. But dynamic content breaks caching: + +``` +"You are helpful. Today is January 7, 2025." # Changes daily = no cache +``` + +### The Solution + +CacheAligner extracts dynamic content to stabilize the prefix: + +```python +from headroom import CacheAligner + +aligner = CacheAligner() +result = aligner.align(messages) + +# Static prefix (cacheable): +# "You are helpful." + +# Dynamic content moved to end: +# [Current date context] +``` + +### Configuration + +```python +from headroom import CacheAlignerConfig + +config = CacheAlignerConfig( + extract_dates=True, # Move dates to dynamic section + normalize_whitespace=True, # Consistent spacing + stable_prefix_min_tokens=100, # Min prefix size for alignment +) +``` + +### Cache Hit Improvement + +| Scenario | Before | After | +|----------|--------|-------| +| Daily date in prompt | 0% hits | ~95% hits | +| Dynamic user context | ~10% hits | ~80% hits | +| Consistent prompts | ~90% hits | ~95% hits | + +--- + +## RollingWindow + +Context management within token limits. + +### The Problem + +Long conversations exceed context limits. Naive truncation breaks tool calls: + +``` +[tool_call: search] # Kept +[tool_result: ...] # Dropped = orphaned call! +``` + +### The Solution + +RollingWindow drops complete tool units, preserving pairs: + +```python +from headroom import RollingWindow + +window = RollingWindow(config) +result = window.apply(messages, max_tokens=100000) + +# Guarantees: +# 1. Tool calls paired with results +# 2. System prompt preserved +# 3. Recent turns kept +# 4. Oldest tool outputs dropped first +``` + +### Configuration + +```python +from headroom import RollingWindowConfig + +config = RollingWindowConfig( + max_tokens=100000, # Target token limit + preserve_system=True, # Always keep system prompt + preserve_recent_turns=5, # Keep last 5 user/assistant turns + drop_oldest_first=True, # Remove oldest tool outputs +) +``` + +### Drop Priority + +1. **Oldest tool outputs** - First to go +2. **Old assistant messages** - Summary preserved +3. **Old user messages** - Only if necessary +4. **Never dropped**: System prompt, recent turns, active tool pairs + +--- + +## TransformPipeline + +Combine transforms for optimal results. + +```python +from headroom import TransformPipeline, SmartCrusher, CacheAligner, RollingWindow + +pipeline = TransformPipeline([ + SmartCrusher(), # First: compress tool outputs + CacheAligner(), # Then: stabilize prefix + RollingWindow(), # Finally: fit in context +]) + +result = pipeline.transform(messages) +print(f"Saved {result.tokens_saved} tokens") +``` + +### Recommended Order + +1. **SmartCrusher** - Reduce individual messages +2. **CacheAligner** - Optimize for caching +3. **RollingWindow** - Final size constraint + +--- + +## Safety Guarantees + +All transforms follow strict safety rules: + +1. **Never remove human content** - User/assistant text is sacred +2. **Never break tool ordering** - Calls and results stay paired +3. **Parse failures are no-ops** - Malformed content passes through +4. **Preserves recency** - Last N turns always kept +5. **100% error preservation** - Error items never dropped diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 000000000..e4fbf240d --- /dev/null +++ b/examples/README.md @@ -0,0 +1,133 @@ +# Headroom Examples + +This directory contains examples demonstrating Headroom's capabilities. + +## Quick Start Examples + +### basic_usage.py + +Basic integration with OpenAI client: + +```bash +export OPENAI_API_KEY='your-key' +python examples/basic_usage.py +``` + +### anthropic_example.py + +Integration with Anthropic Claude: + +```bash +export ANTHROPIC_API_KEY='your-key' +python examples/anthropic_example.py +``` + +### streaming_example.py + +Streaming responses with optimization: + +```bash +export OPENAI_API_KEY='your-key' +python examples/streaming_example.py +``` + +## Evaluation Examples + +### smart_vs_naive_eval.py + +Compare SmartCrusher against naive truncation: + +```bash +export OPENAI_API_KEY='your-key' +python examples/smart_vs_naive_eval.py +``` + +### real_world_eval.py + +Comprehensive evaluation with Anthropic models: + +```bash +export ANTHROPIC_API_KEY='your-key' +python examples/real_world_eval.py +``` + +### real_world_openai_eval.py + +Comprehensive evaluation with OpenAI models: + +```bash +export OPENAI_API_KEY='your-key' +python examples/real_world_openai_eval.py +``` + +## Demo Directories + +### langchain_demo/ + +Full LangChain agent integration demo: + +```bash +# No API key needed for compression demo +PYTHONPATH=. python -m examples.langchain_demo.show_compression + +# Full comparison (requires API key) +export OPENAI_API_KEY='your-key' +PYTHONPATH=. python -m examples.langchain_demo.run_comparison +``` + +See [langchain_demo/README.md](langchain_demo/README.md) for details. + +### mcp_demo/ + +MCP (Model Context Protocol) integration demo: + +```bash +export OPENAI_API_KEY='your-key' +PYTHONPATH=. python -m examples.mcp_demo.run_agent_eval +``` + +## Running Examples + +All examples can be run from the repository root: + +```bash +# Install dependencies +pip install -e ".[dev]" + +# Run any example +python examples/.py +``` + +## Expected Results + +| Example | Token Savings | Notes | +|---------|---------------|-------| +| basic_usage | 50-70% | Simple tool output compression | +| langchain_demo | 70-85% | Real agent with multiple tools | +| mcp_demo | 60-80% | MCP tool outputs | +| real_world_eval | 50-90% | Varies by scenario | + +## Troubleshooting + +**ModuleNotFoundError: No module named 'headroom'** + +Run from the repository root with PYTHONPATH: + +```bash +PYTHONPATH=. python examples/basic_usage.py +``` + +Or install in development mode: + +```bash +pip install -e . +``` + +**API Key Errors** + +Ensure your API keys are set: + +```bash +export OPENAI_API_KEY='sk-...' +export ANTHROPIC_API_KEY='sk-ant-...' +``` diff --git a/headroom/__init__.py b/headroom/__init__.py index 839b39f2f..fe02d8e5b 100644 --- a/headroom/__init__.py +++ b/headroom/__init__.py @@ -57,15 +57,6 @@ from .config import ( WasteSignals, ) from .providers import AnthropicProvider, OpenAIProvider, Provider, TokenCounter -from .reporting import generate_report -from .tokenizer import Tokenizer, count_tokens_messages, count_tokens_text -from .transforms import ( - CacheAligner, - RollingWindow, - SmartCrusher, - ToolCrusher, - TransformPipeline, -) from .relevance import ( BM25Scorer, EmbeddingScorer, @@ -75,6 +66,15 @@ from .relevance import ( create_scorer, embedding_available, ) +from .reporting import generate_report +from .tokenizer import Tokenizer, count_tokens_messages, count_tokens_text +from .transforms import ( + CacheAligner, + RollingWindow, + SmartCrusher, + ToolCrusher, + TransformPipeline, +) __version__ = "0.2.0" diff --git a/headroom/cli.py b/headroom/cli.py new file mode 100644 index 000000000..65a4db367 --- /dev/null +++ b/headroom/cli.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""Headroom CLI - The Context Optimization Layer for LLM Applications. + +Usage: + headroom proxy [OPTIONS] Start the optimization proxy server + headroom --version Show version + headroom --help Show this help message + +Examples: + # Start proxy on default port (8787) + headroom proxy + + # Start proxy on custom port + headroom proxy --port 8080 + + # Start with optimization disabled (passthrough mode) + headroom proxy --no-optimize + + # Use with Claude Code + ANTHROPIC_BASE_URL=http://localhost:8787 claude +""" + +from __future__ import annotations + +import argparse +import sys + + +def get_version() -> str: + """Get the current version.""" + try: + from headroom import __version__ + return __version__ + except ImportError: + return "unknown" + + +def cmd_proxy(args: argparse.Namespace) -> int: + """Start the proxy server.""" + try: + from headroom.proxy.server import ProxyConfig, run_server + except ImportError as e: + print("Error: Proxy dependencies not installed. Run: pip install headroom[proxy]") + print(f"Details: {e}") + return 1 + + config = ProxyConfig( + host=args.host, + port=args.port, + optimize=not args.no_optimize, + cache_enabled=not args.no_cache, + rate_limit_enabled=not args.no_rate_limit, + log_file=args.log_file, + budget_limit_usd=args.budget, + ) + + print(f""" +╔═══════════════════════════════════════════════════════════════════════╗ +║ HEADROOM PROXY ║ +║ The Context Optimization Layer for LLM Applications ║ +╚═══════════════════════════════════════════════════════════════════════╝ + +Starting proxy server... + + URL: http://{config.host}:{config.port} + Optimization: {'ENABLED' if config.optimize else 'DISABLED'} + Caching: {'ENABLED' if config.cache_enabled else 'DISABLED'} + Rate Limit: {'ENABLED' if config.rate_limit_enabled else 'DISABLED'} + +Usage with Claude Code: + ANTHROPIC_BASE_URL=http://{config.host}:{config.port} claude + +Usage with OpenAI-compatible clients: + OPENAI_BASE_URL=http://{config.host}:{config.port}/v1 your-app + +Endpoints: + GET /health Health check + GET /stats Detailed statistics + GET /metrics Prometheus metrics + POST /v1/messages Anthropic API + POST /v1/chat/completions OpenAI API + +Press Ctrl+C to stop. +""") + + try: + run_server(config) + except KeyboardInterrupt: + print("\nShutting down...") + return 0 + + return 0 + + +def cmd_version(args: argparse.Namespace) -> int: + """Print version information.""" + print(f"headroom {get_version()}") + return 0 + + +def main(argv: list[str] | None = None) -> int: + """Main CLI entry point.""" + parser = argparse.ArgumentParser( + prog="headroom", + description="The Context Optimization Layer for LLM Applications", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + headroom proxy Start proxy on port 8787 + headroom proxy --port 8080 Start proxy on port 8080 + headroom proxy --no-optimize Passthrough mode (no optimization) + +Environment Variables: + ANTHROPIC_API_KEY Your Anthropic API key (for proxying) + OPENAI_API_KEY Your OpenAI API key (for proxying) + +Documentation: https://github.com/headroom-sdk/headroom + """, + ) + + parser.add_argument( + "--version", "-V", + action="store_true", + help="Show version and exit", + ) + + subparsers = parser.add_subparsers(dest="command", help="Commands") + + # Proxy command + proxy_parser = subparsers.add_parser( + "proxy", + help="Start the optimization proxy server", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + proxy_parser.add_argument( + "--host", + default="127.0.0.1", + help="Host to bind to (default: 127.0.0.1)", + ) + proxy_parser.add_argument( + "--port", "-p", + type=int, + default=8787, + help="Port to bind to (default: 8787)", + ) + proxy_parser.add_argument( + "--no-optimize", + action="store_true", + help="Disable optimization (passthrough mode)", + ) + proxy_parser.add_argument( + "--no-cache", + action="store_true", + help="Disable semantic caching", + ) + proxy_parser.add_argument( + "--no-rate-limit", + action="store_true", + help="Disable rate limiting", + ) + proxy_parser.add_argument( + "--log-file", + help="Path to JSONL log file", + ) + proxy_parser.add_argument( + "--budget", + type=float, + help="Daily budget limit in USD", + ) + proxy_parser.set_defaults(func=cmd_proxy) + + args = parser.parse_args(argv) + + if args.version: + return cmd_version(args) + + if args.command is None: + parser.print_help() + return 0 + + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/headroom/client.py b/headroom/client.py index 75d9ddcae..a77cda7b2 100644 --- a/headroom/client.py +++ b/headroom/client.py @@ -2,8 +2,9 @@ from __future__ import annotations +from collections.abc import Iterator from datetime import datetime -from typing import Any, Iterator +from typing import Any from .config import ( HeadroomConfig, diff --git a/headroom/config.py b/headroom/config.py index ba0fc4df8..770d544fa 100644 --- a/headroom/config.py +++ b/headroom/config.py @@ -301,8 +301,8 @@ class TransformResult: transforms_applied: list[str] markers_inserted: list[str] = field(default_factory=list) warnings: list[str] = field(default_factory=list) - diff_artifact: "DiffArtifact | None" = None # Populated if generate_diff_artifact=True - cache_metrics: "CachePrefixMetrics | None" = None # Populated by CacheAligner + diff_artifact: DiffArtifact | None = None # Populated if generate_diff_artifact=True + cache_metrics: CachePrefixMetrics | None = None # Populated by CacheAligner @dataclass diff --git a/headroom/integrations/__init__.py b/headroom/integrations/__init__.py index e780eab50..99e50f7ca 100644 --- a/headroom/integrations/__init__.py +++ b/headroom/integrations/__init__.py @@ -8,21 +8,20 @@ Install LangChain support: pip install headroom[langchain] """ from .langchain import ( - HeadroomChatModel, HeadroomCallbackHandler, - optimize_messages, + HeadroomChatModel, HeadroomRunnable, + optimize_messages, ) - from .mcp import ( - HeadroomMCPCompressor, + DEFAULT_MCP_PROFILES, HeadroomMCPClientWrapper, + HeadroomMCPCompressor, MCPCompressionResult, MCPToolProfile, compress_tool_result, compress_tool_result_with_metrics, create_headroom_mcp_proxy, - DEFAULT_MCP_PROFILES, ) __all__ = [ diff --git a/headroom/integrations/langchain.py b/headroom/integrations/langchain.py index ca83d2cef..781d1cead 100644 --- a/headroom/integrations/langchain.py +++ b/headroom/integrations/langchain.py @@ -29,9 +29,10 @@ from __future__ import annotations import json import logging +from collections.abc import Iterator, Sequence from dataclasses import dataclass from datetime import datetime -from typing import Any, Iterator, List, Optional, Sequence, Union +from typing import Any from uuid import uuid4 # LangChain imports - these are optional dependencies @@ -378,7 +379,7 @@ class HeadroomChatModel(BaseChatModel): **kwargs, ) - def bind_tools(self, tools: Sequence[Any], **kwargs) -> "HeadroomChatModel": + def bind_tools(self, tools: Sequence[Any], **kwargs) -> HeadroomChatModel: """Bind tools to the wrapped model.""" new_wrapped = self.wrapped_model.bind_tools(tools, **kwargs) return HeadroomChatModel( diff --git a/headroom/integrations/mcp.py b/headroom/integrations/mcp.py index 06f80de82..aa102b8a8 100644 --- a/headroom/integrations/mcp.py +++ b/headroom/integrations/mcp.py @@ -48,12 +48,13 @@ from __future__ import annotations import json import re +from collections.abc import Callable from dataclasses import dataclass, field -from typing import Any, Callable +from typing import Any from headroom.config import HeadroomConfig, SmartCrusherConfig -from headroom.transforms import SmartCrusher from headroom.providers import OpenAIProvider +from headroom.transforms import SmartCrusher @dataclass diff --git a/headroom/models/__init__.py b/headroom/models/__init__.py new file mode 100644 index 000000000..8c76b3fa2 --- /dev/null +++ b/headroom/models/__init__.py @@ -0,0 +1,39 @@ +"""Model registry and capabilities database. + +Provides a centralized registry of LLM models with their capabilities, +context limits, pricing, and provider information. + +Usage: + from headroom.models import ModelRegistry, get_model_info + + # Get info about a model + info = get_model_info("gpt-4o") + print(f"Context: {info.context_window}") + print(f"Provider: {info.provider}") + + # List all models from a provider + models = ModelRegistry.list_models(provider="openai") + + # Register a custom model + ModelRegistry.register( + "my-custom-model", + provider="custom", + context_window=32000, + ) +""" + +from .registry import ( + ModelInfo, + ModelRegistry, + get_model_info, + list_models, + register_model, +) + +__all__ = [ + "ModelRegistry", + "ModelInfo", + "get_model_info", + "list_models", + "register_model", +] diff --git a/headroom/models/registry.py b/headroom/models/registry.py new file mode 100644 index 000000000..47340c674 --- /dev/null +++ b/headroom/models/registry.py @@ -0,0 +1,749 @@ +"""Model registry with capabilities database. + +Centralized database of LLM models with their capabilities, context limits, +pricing, and provider information. Supports dynamic registration of custom +models and automatic provider detection. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from typing import Any + + +@dataclass(frozen=True) +class ModelInfo: + """Information about an LLM model. + + Attributes: + name: Model identifier. + provider: Provider name (openai, anthropic, etc.). + context_window: Maximum context window in tokens. + max_output_tokens: Maximum output tokens. + supports_tools: Whether model supports tool/function calling. + supports_vision: Whether model supports image inputs. + supports_streaming: Whether model supports streaming responses. + supports_json_mode: Whether model supports JSON output mode. + tokenizer_backend: Tokenizer backend to use. + input_cost_per_1m: Cost per 1M input tokens in USD. + output_cost_per_1m: Cost per 1M output tokens in USD. + cached_input_cost_per_1m: Cost per 1M cached input tokens. + pricing_date: Date pricing was last updated. + aliases: Alternative names for the model. + notes: Additional notes about the model. + """ + + name: str + provider: str + context_window: int = 128000 + max_output_tokens: int = 4096 + supports_tools: bool = True + supports_vision: bool = False + supports_streaming: bool = True + supports_json_mode: bool = True + tokenizer_backend: str | None = None + input_cost_per_1m: float | None = None + output_cost_per_1m: float | None = None + cached_input_cost_per_1m: float | None = None + pricing_date: date | None = None + aliases: tuple[str, ...] = () + notes: str = "" + + +# Built-in model database +# Pricing as of January 2025 - verify current rates +_MODELS: dict[str, ModelInfo] = {} + + +def _register_builtin_models() -> None: + """Register built-in models.""" + + # ============================================================ + # OpenAI Models + # ============================================================ + + # GPT-4o family + _MODELS["gpt-4o"] = ModelInfo( + name="gpt-4o", + provider="openai", + context_window=128000, + max_output_tokens=16384, + supports_tools=True, + supports_vision=True, + supports_streaming=True, + tokenizer_backend="tiktoken", + input_cost_per_1m=2.50, + output_cost_per_1m=10.00, + cached_input_cost_per_1m=1.25, + pricing_date=date(2025, 1, 6), + aliases=("gpt-4o-2024-11-20", "gpt-4o-2024-08-06", "gpt-4o-2024-05-13"), + notes="Latest GPT-4o with vision and tools", + ) + + _MODELS["gpt-4o-mini"] = ModelInfo( + name="gpt-4o-mini", + provider="openai", + context_window=128000, + max_output_tokens=16384, + supports_tools=True, + supports_vision=True, + supports_streaming=True, + tokenizer_backend="tiktoken", + input_cost_per_1m=0.15, + output_cost_per_1m=0.60, + cached_input_cost_per_1m=0.075, + pricing_date=date(2025, 1, 6), + aliases=("gpt-4o-mini-2024-07-18",), + notes="Cost-effective GPT-4o variant", + ) + + # o1 reasoning models + _MODELS["o1"] = ModelInfo( + name="o1", + provider="openai", + context_window=200000, + max_output_tokens=100000, + supports_tools=True, + supports_vision=True, + supports_streaming=True, + tokenizer_backend="tiktoken", + input_cost_per_1m=15.00, + output_cost_per_1m=60.00, + cached_input_cost_per_1m=7.50, + pricing_date=date(2025, 1, 6), + notes="Full reasoning model with extended thinking", + ) + + _MODELS["o1-mini"] = ModelInfo( + name="o1-mini", + provider="openai", + context_window=128000, + max_output_tokens=65536, + supports_tools=True, + supports_vision=False, + supports_streaming=True, + tokenizer_backend="tiktoken", + input_cost_per_1m=1.10, + output_cost_per_1m=4.40, + cached_input_cost_per_1m=0.55, + pricing_date=date(2025, 1, 6), + notes="Fast reasoning model", + ) + + _MODELS["o3-mini"] = ModelInfo( + name="o3-mini", + provider="openai", + context_window=200000, + max_output_tokens=100000, + supports_tools=True, + supports_vision=True, + supports_streaming=True, + tokenizer_backend="tiktoken", + input_cost_per_1m=1.10, + output_cost_per_1m=4.40, + cached_input_cost_per_1m=0.55, + pricing_date=date(2025, 1, 6), + notes="Latest reasoning model", + ) + + # GPT-4 Turbo + _MODELS["gpt-4-turbo"] = ModelInfo( + name="gpt-4-turbo", + provider="openai", + context_window=128000, + max_output_tokens=4096, + supports_tools=True, + supports_vision=True, + supports_streaming=True, + tokenizer_backend="tiktoken", + input_cost_per_1m=10.00, + output_cost_per_1m=30.00, + cached_input_cost_per_1m=5.00, + pricing_date=date(2025, 1, 6), + aliases=("gpt-4-turbo-preview", "gpt-4-turbo-2024-04-09"), + notes="GPT-4 Turbo with vision", + ) + + # GPT-4 + _MODELS["gpt-4"] = ModelInfo( + name="gpt-4", + provider="openai", + context_window=8192, + max_output_tokens=4096, + supports_tools=True, + supports_vision=False, + supports_streaming=True, + tokenizer_backend="tiktoken", + input_cost_per_1m=30.00, + output_cost_per_1m=60.00, + pricing_date=date(2025, 1, 6), + aliases=("gpt-4-0613",), + notes="Original GPT-4", + ) + + _MODELS["gpt-4-32k"] = ModelInfo( + name="gpt-4-32k", + provider="openai", + context_window=32768, + max_output_tokens=4096, + supports_tools=True, + supports_vision=False, + supports_streaming=True, + tokenizer_backend="tiktoken", + input_cost_per_1m=60.00, + output_cost_per_1m=120.00, + pricing_date=date(2025, 1, 6), + notes="Extended context GPT-4", + ) + + # GPT-3.5 + _MODELS["gpt-3.5-turbo"] = ModelInfo( + name="gpt-3.5-turbo", + provider="openai", + context_window=16385, + max_output_tokens=4096, + supports_tools=True, + supports_vision=False, + supports_streaming=True, + tokenizer_backend="tiktoken", + input_cost_per_1m=0.50, + output_cost_per_1m=1.50, + cached_input_cost_per_1m=0.25, + pricing_date=date(2025, 1, 6), + aliases=("gpt-3.5-turbo-0125", "gpt-3.5-turbo-1106"), + notes="Fast and cost-effective", + ) + + # ============================================================ + # Anthropic Models + # ============================================================ + + _MODELS["claude-3-5-sonnet-20241022"] = ModelInfo( + name="claude-3-5-sonnet-20241022", + provider="anthropic", + context_window=200000, + max_output_tokens=8192, + supports_tools=True, + supports_vision=True, + supports_streaming=True, + tokenizer_backend="anthropic", + input_cost_per_1m=3.00, + output_cost_per_1m=15.00, + cached_input_cost_per_1m=0.30, + pricing_date=date(2025, 1, 6), + aliases=("claude-3-5-sonnet-latest", "claude-sonnet-4-20250514"), + notes="Claude 3.5 Sonnet - Best balance of speed and capability", + ) + + _MODELS["claude-3-5-haiku-20241022"] = ModelInfo( + name="claude-3-5-haiku-20241022", + provider="anthropic", + context_window=200000, + max_output_tokens=8192, + supports_tools=True, + supports_vision=True, + supports_streaming=True, + tokenizer_backend="anthropic", + input_cost_per_1m=0.80, + output_cost_per_1m=4.00, + cached_input_cost_per_1m=0.08, + pricing_date=date(2025, 1, 6), + aliases=("claude-3-5-haiku-latest",), + notes="Claude 3.5 Haiku - Fast and cost-effective", + ) + + _MODELS["claude-3-opus-20240229"] = ModelInfo( + name="claude-3-opus-20240229", + provider="anthropic", + context_window=200000, + max_output_tokens=4096, + supports_tools=True, + supports_vision=True, + supports_streaming=True, + tokenizer_backend="anthropic", + input_cost_per_1m=15.00, + output_cost_per_1m=75.00, + cached_input_cost_per_1m=1.50, + pricing_date=date(2025, 1, 6), + aliases=("claude-3-opus-latest",), + notes="Claude 3 Opus - Most capable", + ) + + _MODELS["claude-3-haiku-20240307"] = ModelInfo( + name="claude-3-haiku-20240307", + provider="anthropic", + context_window=200000, + max_output_tokens=4096, + supports_tools=True, + supports_vision=True, + supports_streaming=True, + tokenizer_backend="anthropic", + input_cost_per_1m=0.25, + output_cost_per_1m=1.25, + cached_input_cost_per_1m=0.03, + pricing_date=date(2025, 1, 6), + notes="Claude 3 Haiku - Legacy fast model", + ) + + # ============================================================ + # Google Models + # ============================================================ + + _MODELS["gemini-2.0-flash"] = ModelInfo( + name="gemini-2.0-flash", + provider="google", + context_window=1000000, + max_output_tokens=8192, + supports_tools=True, + supports_vision=True, + supports_streaming=True, + tokenizer_backend="google", + input_cost_per_1m=0.10, + output_cost_per_1m=0.40, + pricing_date=date(2025, 1, 6), + aliases=("gemini-2.0-flash-exp",), + notes="Gemini 2.0 Flash - Fast multimodal", + ) + + _MODELS["gemini-1.5-pro"] = ModelInfo( + name="gemini-1.5-pro", + provider="google", + context_window=2000000, + max_output_tokens=8192, + supports_tools=True, + supports_vision=True, + supports_streaming=True, + tokenizer_backend="google", + input_cost_per_1m=1.25, + output_cost_per_1m=5.00, + pricing_date=date(2025, 1, 6), + aliases=("gemini-1.5-pro-latest",), + notes="Gemini 1.5 Pro - 2M context window", + ) + + _MODELS["gemini-1.5-flash"] = ModelInfo( + name="gemini-1.5-flash", + provider="google", + context_window=1000000, + max_output_tokens=8192, + supports_tools=True, + supports_vision=True, + supports_streaming=True, + tokenizer_backend="google", + input_cost_per_1m=0.075, + output_cost_per_1m=0.30, + pricing_date=date(2025, 1, 6), + aliases=("gemini-1.5-flash-latest",), + notes="Gemini 1.5 Flash - Cost-effective", + ) + + # ============================================================ + # Meta Llama Models (open source) + # ============================================================ + + _MODELS["llama-3.3-70b"] = ModelInfo( + name="llama-3.3-70b", + provider="meta", + context_window=128000, + max_output_tokens=4096, + supports_tools=True, + supports_vision=False, + supports_streaming=True, + tokenizer_backend="huggingface", + aliases=("llama-3.3-70b-instruct", "meta-llama/Llama-3.3-70B-Instruct"), + notes="Llama 3.3 70B - Open source", + ) + + _MODELS["llama-3.1-405b"] = ModelInfo( + name="llama-3.1-405b", + provider="meta", + context_window=128000, + max_output_tokens=4096, + supports_tools=True, + supports_vision=False, + supports_streaming=True, + tokenizer_backend="huggingface", + aliases=("llama-3.1-405b-instruct", "meta-llama/Llama-3.1-405B-Instruct"), + notes="Llama 3.1 405B - Largest open source", + ) + + _MODELS["llama-3.1-70b"] = ModelInfo( + name="llama-3.1-70b", + provider="meta", + context_window=128000, + max_output_tokens=4096, + supports_tools=True, + supports_vision=False, + supports_streaming=True, + tokenizer_backend="huggingface", + aliases=("llama-3.1-70b-instruct", "meta-llama/Llama-3.1-70B-Instruct"), + notes="Llama 3.1 70B", + ) + + _MODELS["llama-3.1-8b"] = ModelInfo( + name="llama-3.1-8b", + provider="meta", + context_window=128000, + max_output_tokens=4096, + supports_tools=True, + supports_vision=False, + supports_streaming=True, + tokenizer_backend="huggingface", + aliases=("llama-3.1-8b-instruct", "meta-llama/Llama-3.1-8B-Instruct"), + notes="Llama 3.1 8B - Fast and efficient", + ) + + # ============================================================ + # Mistral Models + # ============================================================ + + _MODELS["mistral-large"] = ModelInfo( + name="mistral-large", + provider="mistral", + context_window=128000, + max_output_tokens=4096, + supports_tools=True, + supports_vision=False, + supports_streaming=True, + tokenizer_backend="huggingface", + input_cost_per_1m=2.00, + output_cost_per_1m=6.00, + pricing_date=date(2025, 1, 6), + aliases=("mistral-large-latest",), + notes="Mistral Large - Best capability", + ) + + _MODELS["mistral-small"] = ModelInfo( + name="mistral-small", + provider="mistral", + context_window=32768, + max_output_tokens=4096, + supports_tools=True, + supports_vision=False, + supports_streaming=True, + tokenizer_backend="huggingface", + input_cost_per_1m=0.20, + output_cost_per_1m=0.60, + pricing_date=date(2025, 1, 6), + aliases=("mistral-small-latest",), + notes="Mistral Small - Cost-effective", + ) + + _MODELS["mixtral-8x7b"] = ModelInfo( + name="mixtral-8x7b", + provider="mistral", + context_window=32768, + max_output_tokens=4096, + supports_tools=True, + supports_vision=False, + supports_streaming=True, + tokenizer_backend="huggingface", + aliases=("mixtral-8x7b-instruct",), + notes="Mixtral 8x7B - MoE architecture", + ) + + _MODELS["mistral-7b"] = ModelInfo( + name="mistral-7b", + provider="mistral", + context_window=32768, + max_output_tokens=4096, + supports_tools=False, + supports_vision=False, + supports_streaming=True, + tokenizer_backend="huggingface", + aliases=("mistral-7b-instruct",), + notes="Mistral 7B - Open source", + ) + + # ============================================================ + # DeepSeek Models + # ============================================================ + + _MODELS["deepseek-v3"] = ModelInfo( + name="deepseek-v3", + provider="deepseek", + context_window=128000, + max_output_tokens=8192, + supports_tools=True, + supports_vision=False, + supports_streaming=True, + tokenizer_backend="huggingface", + input_cost_per_1m=0.14, + output_cost_per_1m=0.28, + pricing_date=date(2025, 1, 6), + notes="DeepSeek V3 - High performance, low cost", + ) + + _MODELS["deepseek-coder"] = ModelInfo( + name="deepseek-coder", + provider="deepseek", + context_window=16384, + max_output_tokens=4096, + supports_tools=False, + supports_vision=False, + supports_streaming=True, + tokenizer_backend="huggingface", + notes="DeepSeek Coder - Specialized for code", + ) + + # ============================================================ + # Qwen Models + # ============================================================ + + _MODELS["qwen2.5-72b"] = ModelInfo( + name="qwen2.5-72b", + provider="alibaba", + context_window=131072, + max_output_tokens=8192, + supports_tools=True, + supports_vision=False, + supports_streaming=True, + tokenizer_backend="huggingface", + aliases=("qwen2.5-72b-instruct",), + notes="Qwen 2.5 72B - Strong multilingual", + ) + + _MODELS["qwen2.5-7b"] = ModelInfo( + name="qwen2.5-7b", + provider="alibaba", + context_window=131072, + max_output_tokens=8192, + supports_tools=True, + supports_vision=False, + supports_streaming=True, + tokenizer_backend="huggingface", + aliases=("qwen2.5-7b-instruct",), + notes="Qwen 2.5 7B - Efficient", + ) + + +# Initialize built-in models +_register_builtin_models() + +# Build alias lookup +_ALIASES: dict[str, str] = {} +for model_name, info in _MODELS.items(): + for alias in info.aliases: + _ALIASES[alias.lower()] = model_name + + +class ModelRegistry: + """Registry of LLM models and their capabilities. + + Singleton registry providing access to model information. + Supports built-in models and custom registration. + + Example: + # Get model info + info = ModelRegistry.get("gpt-4o") + print(f"Context: {info.context_window}") + + # Register custom model + ModelRegistry.register( + "my-model", + provider="custom", + context_window=32000, + ) + + # List models by provider + openai_models = ModelRegistry.list_models(provider="openai") + """ + + @classmethod + def get(cls, model: str) -> ModelInfo | None: + """Get model information. + + Args: + model: Model name or alias. + + Returns: + ModelInfo if found, None otherwise. + """ + model_lower = model.lower() + + # Direct lookup + if model_lower in _MODELS: + return _MODELS[model_lower] + + # Alias lookup + if model_lower in _ALIASES: + return _MODELS[_ALIASES[model_lower]] + + # Prefix matching + for name, info in _MODELS.items(): + if model_lower.startswith(name): + return info + + return None + + @classmethod + def register( + cls, + model: str, + provider: str, + context_window: int = 128000, + **kwargs: Any, + ) -> ModelInfo: + """Register a custom model. + + Args: + model: Model name. + provider: Provider name. + context_window: Maximum context window. + **kwargs: Additional ModelInfo fields. + + Returns: + Registered ModelInfo. + """ + info = ModelInfo( + name=model, + provider=provider, + context_window=context_window, + **kwargs, + ) + _MODELS[model.lower()] = info + + # Register aliases + for alias in info.aliases: + _ALIASES[alias.lower()] = model.lower() + + return info + + @classmethod + def list_models( + cls, + provider: str | None = None, + supports_tools: bool | None = None, + supports_vision: bool | None = None, + min_context: int | None = None, + ) -> list[ModelInfo]: + """List models matching criteria. + + Args: + provider: Filter by provider. + supports_tools: Filter by tool support. + supports_vision: Filter by vision support. + min_context: Minimum context window. + + Returns: + List of matching ModelInfo. + """ + results = [] + for info in _MODELS.values(): + if provider and info.provider != provider: + continue + if supports_tools is not None and info.supports_tools != supports_tools: + continue + if supports_vision is not None and info.supports_vision != supports_vision: + continue + if min_context and info.context_window < min_context: + continue + results.append(info) + return results + + @classmethod + def list_providers(cls) -> list[str]: + """List all known providers. + + Returns: + List of provider names. + """ + return list(set(info.provider for info in _MODELS.values())) + + @classmethod + def get_context_limit(cls, model: str, default: int = 128000) -> int: + """Get context limit for a model. + + Args: + model: Model name. + default: Default if model not found. + + Returns: + Context window size. + """ + info = cls.get(model) + return info.context_window if info else default + + @classmethod + def estimate_cost( + cls, + model: str, + input_tokens: int, + output_tokens: int, + cached_tokens: int = 0, + ) -> float | None: + """Estimate API cost for a model. + + Args: + model: Model name. + input_tokens: Number of input tokens. + output_tokens: Number of output tokens. + cached_tokens: Number of cached input tokens. + + Returns: + Estimated cost in USD, or None if pricing unknown. + """ + info = cls.get(model) + if not info or info.input_cost_per_1m is None: + return None + + input_cost = (input_tokens / 1_000_000) * info.input_cost_per_1m + output_cost = (output_tokens / 1_000_000) * (info.output_cost_per_1m or 0) + + if cached_tokens and info.cached_input_cost_per_1m: + # Adjust for cached tokens + regular_input = input_tokens - cached_tokens + cached_cost = (cached_tokens / 1_000_000) * info.cached_input_cost_per_1m + input_cost = (regular_input / 1_000_000) * info.input_cost_per_1m + cached_cost + + return input_cost + output_cost + + +# Convenience functions +def get_model_info(model: str) -> ModelInfo | None: + """Get information about a model. + + Args: + model: Model name or alias. + + Returns: + ModelInfo if found, None otherwise. + """ + return ModelRegistry.get(model) + + +def list_models( + provider: str | None = None, + **kwargs: Any, +) -> list[ModelInfo]: + """List models matching criteria. + + Args: + provider: Filter by provider. + **kwargs: Additional filter criteria. + + Returns: + List of matching ModelInfo. + """ + return ModelRegistry.list_models(provider=provider, **kwargs) + + +def register_model( + model: str, + provider: str, + context_window: int = 128000, + **kwargs: Any, +) -> ModelInfo: + """Register a custom model. + + Args: + model: Model name. + provider: Provider name. + context_window: Maximum context window. + **kwargs: Additional ModelInfo fields. + + Returns: + Registered ModelInfo. + """ + return ModelRegistry.register(model, provider, context_window, **kwargs) diff --git a/headroom/parser.py b/headroom/parser.py index 3f1552f8e..78e4185c2 100644 --- a/headroom/parser.py +++ b/headroom/parser.py @@ -4,7 +4,7 @@ from __future__ import annotations import hashlib import re -from typing import Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Any from .config import Block, WasteSignals diff --git a/headroom/pricing/__init__.py b/headroom/pricing/__init__.py index 235b65091..e3ce72bd2 100644 --- a/headroom/pricing/__init__.py +++ b/headroom/pricing/__init__.py @@ -4,18 +4,21 @@ This module provides pricing information and cost estimation utilities for various LLM providers including OpenAI and Anthropic. """ -from .registry import CostEstimate, ModelPricing, PricingRegistry -from .openai_prices import ( - LAST_UPDATED as OPENAI_LAST_UPDATED, - OPENAI_PRICES, - get_openai_registry, -) from .anthropic_prices import ( - LAST_UPDATED as ANTHROPIC_LAST_UPDATED, ANTHROPIC_PRICES, get_anthropic_registry, ) - +from .anthropic_prices import ( + LAST_UPDATED as ANTHROPIC_LAST_UPDATED, +) +from .openai_prices import ( + LAST_UPDATED as OPENAI_LAST_UPDATED, +) +from .openai_prices import ( + OPENAI_PRICES, + get_openai_registry, +) +from .registry import CostEstimate, ModelPricing, PricingRegistry __all__ = [ # Core classes diff --git a/headroom/pricing/anthropic_prices.py b/headroom/pricing/anthropic_prices.py index a6551744f..67e96fcb4 100644 --- a/headroom/pricing/anthropic_prices.py +++ b/headroom/pricing/anthropic_prices.py @@ -4,7 +4,6 @@ from datetime import date from .registry import ModelPricing, PricingRegistry - # Last verified date for pricing information LAST_UPDATED = date(2025, 1, 6) diff --git a/headroom/pricing/openai_prices.py b/headroom/pricing/openai_prices.py index 6d3790c25..328c7fc41 100644 --- a/headroom/pricing/openai_prices.py +++ b/headroom/pricing/openai_prices.py @@ -4,7 +4,6 @@ from datetime import date from .registry import ModelPricing, PricingRegistry - # Last verified date for pricing information LAST_UPDATED = date(2025, 1, 6) diff --git a/headroom/pricing/registry.py b/headroom/pricing/registry.py index 1bd61033f..e1a0eb940 100644 --- a/headroom/pricing/registry.py +++ b/headroom/pricing/registry.py @@ -2,7 +2,6 @@ from dataclasses import dataclass, field from datetime import date, timedelta -from typing import Optional @dataclass(frozen=True) @@ -15,11 +14,11 @@ class ModelPricing: provider: str input_per_1m: float output_per_1m: float - cached_input_per_1m: Optional[float] = None - batch_input_per_1m: Optional[float] = None - batch_output_per_1m: Optional[float] = None - context_window: Optional[int] = None - notes: Optional[str] = None + cached_input_per_1m: float | None = None + batch_input_per_1m: float | None = None + batch_output_per_1m: float | None = None + context_window: int | None = None + notes: str | None = None @dataclass @@ -27,9 +26,9 @@ class CostEstimate: """Result of a cost estimation calculation.""" cost_usd: float breakdown: dict = field(default_factory=dict) - pricing_date: Optional[date] = None + pricing_date: date | None = None is_stale: bool = False - warning: Optional[str] = None + warning: str | None = None class PricingRegistry: @@ -41,8 +40,8 @@ class PricingRegistry: def __init__( self, last_updated: date, - source_url: Optional[str] = None, - prices: Optional[dict[str, ModelPricing]] = None, + source_url: str | None = None, + prices: dict[str, ModelPricing] | None = None, ): """Initialize the pricing registry. @@ -55,7 +54,7 @@ class PricingRegistry: self.source_url = source_url self.prices: dict[str, ModelPricing] = prices or {} - def get_price(self, model: str) -> Optional[ModelPricing]: + def get_price(self, model: str) -> ModelPricing | None: """Get pricing for a specific model. Args: @@ -75,7 +74,7 @@ class PricingRegistry: age = date.today() - self.last_updated return age > timedelta(days=self.STALENESS_THRESHOLD_DAYS) - def staleness_warning(self) -> Optional[str]: + def staleness_warning(self) -> str | None: """Get a warning message if pricing is stale. Returns: diff --git a/headroom/providers/__init__.py b/headroom/providers/__init__.py index 7a3943684..105c87b54 100644 --- a/headroom/providers/__init__.py +++ b/headroom/providers/__init__.py @@ -2,15 +2,60 @@ Providers encapsulate model-specific behavior like tokenization, context limits, and cost estimation. + +Supported Providers: +- OpenAIProvider: Native OpenAI models (GPT-4o, o1, etc.) +- AnthropicProvider: Claude models +- GoogleProvider: Google Gemini models +- CohereProvider: Cohere Command models +- OpenAICompatibleProvider: Universal provider for any OpenAI-compatible API + (Ollama, vLLM, Together, Groq, Fireworks, LM Studio, etc.) +- LiteLLMProvider: Universal provider via LiteLLM (100+ providers) """ from .anthropic import AnthropicProvider from .base import Provider, TokenCounter +from .cohere import CohereProvider +from .google import GoogleProvider +from .litellm import ( + LiteLLMProvider, + create_litellm_provider, + is_litellm_available, +) from .openai import OpenAIProvider +from .openai_compatible import ( + ModelCapabilities, + OpenAICompatibleProvider, + create_anyscale_provider, + create_fireworks_provider, + create_groq_provider, + create_lmstudio_provider, + create_ollama_provider, + create_together_provider, + create_vllm_provider, +) __all__ = [ + # Base "Provider", "TokenCounter", + # Native providers "OpenAIProvider", "AnthropicProvider", + "GoogleProvider", + "CohereProvider", + # Universal providers + "OpenAICompatibleProvider", + "ModelCapabilities", + "LiteLLMProvider", + "is_litellm_available", + # Factory functions + "create_ollama_provider", + "create_together_provider", + "create_groq_provider", + "create_fireworks_provider", + "create_anyscale_provider", + "create_vllm_provider", + "create_lmstudio_provider", + "create_litellm_provider", ] diff --git a/headroom/providers/cohere.py b/headroom/providers/cohere.py new file mode 100644 index 000000000..ed736a20f --- /dev/null +++ b/headroom/providers/cohere.py @@ -0,0 +1,313 @@ +"""Cohere provider for Headroom SDK. + +Token counting uses Cohere's official tokenize API when a client +is provided. This gives accurate counts for all content types. + +Usage: + import cohere + from headroom import CohereProvider + + client = cohere.ClientV2() # Uses CO_API_KEY env var + provider = CohereProvider(client=client) # Accurate counting via API + + # Or without client (uses estimation - less accurate) + provider = CohereProvider() # Warning: approximate counting +""" + +from __future__ import annotations + +import logging +import warnings +from datetime import date +from typing import Any + +from headroom.tokenizers import EstimatingTokenCounter + +from .base import Provider, TokenCounter + +logger = logging.getLogger(__name__) + +# Warning flags +_FALLBACK_WARNING_SHOWN = False + +# Pricing metadata +_PRICING_LAST_UPDATED = date(2025, 1, 6) + +# Cohere model context limits +_CONTEXT_LIMITS: dict[str, int] = { + # Command A (latest, 2025) + "command-a-03-2025": 256000, + "command-a": 256000, + # Command R+ (2024) + "command-r-plus-08-2024": 128000, + "command-r-plus": 128000, + # Command R (2024) + "command-r-08-2024": 128000, + "command-r": 128000, + # Command (legacy) + "command": 4096, + "command-light": 4096, + "command-nightly": 128000, + # Embed models + "embed-english-v3.0": 512, + "embed-multilingual-v3.0": 512, + "embed-english-light-v3.0": 512, + "embed-multilingual-light-v3.0": 512, +} + +# Pricing per 1M tokens (input, output) +_PRICING: dict[str, tuple[float, float]] = { + "command-a-03-2025": (2.50, 10.00), + "command-a": (2.50, 10.00), + "command-r-plus-08-2024": (2.50, 10.00), + "command-r-plus": (2.50, 10.00), + "command-r-08-2024": (0.15, 0.60), + "command-r": (0.15, 0.60), + "command": (1.00, 2.00), + "command-light": (0.30, 0.60), +} + + +class CohereTokenCounter: + """Token counter for Cohere models. + + When a Cohere client is provided, uses the official tokenize API + for accurate counting. Falls back to estimation when no client + is available. + + Usage: + import cohere + client = cohere.ClientV2() + + # With API (accurate) + counter = CohereTokenCounter("command-r-plus", client=client) + + # Without API (estimation) + counter = CohereTokenCounter("command-r-plus") + """ + + def __init__(self, model: str, client: Any = None): + """Initialize Cohere token counter. + + Args: + model: Cohere model name. + client: Optional cohere.ClientV2 for API-based counting. + """ + global _FALLBACK_WARNING_SHOWN + + self.model = model + self._client = client + self._use_api = client is not None + + # Cohere uses ~4 chars per token + self._estimator = EstimatingTokenCounter(chars_per_token=4.0) + + if not self._use_api and not _FALLBACK_WARNING_SHOWN: + warnings.warn( + "CohereProvider: No client provided, using estimation. " + "For accurate counting, pass a Cohere client: " + "CohereProvider(client=cohere.ClientV2())", + UserWarning, + stacklevel=4 + ) + _FALLBACK_WARNING_SHOWN = True + + def count_text(self, text: str) -> int: + """Count tokens in text. + + Uses tokenize API if client available, otherwise estimates. + """ + if not text: + return 0 + + if self._use_api: + try: + response = self._client.tokenize( + text=text, + model=self.model, + ) + return len(response.tokens) + except Exception as e: + logger.debug(f"Cohere tokenize API failed: {e}, using estimation") + + return self._estimator.count_text(text) + + def count_message(self, message: dict[str, Any]) -> int: + """Count tokens in a message.""" + content = self._extract_content(message) + tokens = self.count_text(content) + tokens += 4 # Message overhead (role tokens, etc.) + return tokens + + def count_messages(self, messages: list[dict[str, Any]]) -> int: + """Count tokens in messages.""" + if not messages: + return 0 + + # For API-based counting, concatenate all content + if self._use_api: + try: + all_content = [] + for msg in messages: + content = self._extract_content(msg) + role = msg.get("role", "user") + all_content.append(f"{role}: {content}") + + full_text = "\n".join(all_content) + response = self._client.tokenize( + text=full_text, + model=self.model, + ) + return len(response.tokens) + except Exception as e: + logger.debug(f"Cohere tokenize API failed: {e}, using estimation") + + # Fallback to estimation + total = sum(self.count_message(msg) for msg in messages) + total += 3 # Priming tokens + return total + + def _extract_content(self, message: dict[str, Any]) -> str: + """Extract text content from message.""" + content = message.get("content", "") + if isinstance(content, str): + return content + elif isinstance(content, list): + parts = [] + for part in content: + if isinstance(part, dict) and part.get("type") == "text": + parts.append(part.get("text", "")) + elif isinstance(part, str): + parts.append(part) + return "\n".join(parts) + return str(content) + + +class CohereProvider(Provider): + """Provider for Cohere Command models. + + Supports Command R, Command R+, and Command A model families. + + Example: + import cohere + client = cohere.ClientV2() + + # With client (accurate token counting via API) + provider = CohereProvider(client=client) + + # Without client (estimation-based counting) + provider = CohereProvider() + + # Token counting + counter = provider.get_token_counter("command-r-plus") + tokens = counter.count_text("Hello, world!") + + # Context limits + limit = provider.get_context_limit("command-a") # 256K tokens + + # Cost estimation + cost = provider.estimate_cost( + input_tokens=100000, + output_tokens=10000, + model="command-r-plus", + ) + """ + + def __init__(self, client: Any = None): + """Initialize Cohere provider. + + Args: + client: Optional cohere.ClientV2 for API-based token counting. + If provided, uses tokenize API for accurate counts. + """ + self._client = client + + @property + def name(self) -> str: + return "cohere" + + def supports_model(self, model: str) -> bool: + """Check if model is a known Cohere model.""" + model_lower = model.lower() + if model_lower in _CONTEXT_LIMITS: + return True + # Check prefix match + for prefix in ["command-a", "command-r", "command", "embed-"]: + if model_lower.startswith(prefix): + return True + return False + + def get_token_counter(self, model: str) -> TokenCounter: + """Get token counter for a Cohere model. + + Uses tokenize API if client was provided, otherwise estimates. + """ + if not self.supports_model(model): + raise ValueError( + f"Model '{model}' is not recognized as a Cohere model. " + f"Supported models: {list(_CONTEXT_LIMITS.keys())}" + ) + return CohereTokenCounter(model, client=self._client) + + def get_context_limit(self, model: str) -> int: + """Get context limit for a Cohere model.""" + model_lower = model.lower() + + # Direct match + if model_lower in _CONTEXT_LIMITS: + return _CONTEXT_LIMITS[model_lower] + + # Prefix match + for prefix, limit in [ + ("command-a", 256000), + ("command-r-plus", 128000), + ("command-r", 128000), + ("command", 4096), + ("embed-", 512), + ]: + if model_lower.startswith(prefix): + return limit + + raise ValueError( + f"Unknown context limit for model '{model}'. " + f"Known models: {list(_CONTEXT_LIMITS.keys())}" + ) + + def estimate_cost( + self, + input_tokens: int, + output_tokens: int, + model: str, + cached_tokens: int = 0, + ) -> float | None: + """Estimate cost for Cohere API call. + + Args: + input_tokens: Number of input tokens. + output_tokens: Number of output tokens. + model: Model name. + cached_tokens: Not used by Cohere. + + Returns: + Estimated cost in USD, or None if pricing unknown. + """ + model_lower = model.lower() + + # Find pricing + input_price, output_price = None, None + for model_prefix, (inp, outp) in _PRICING.items(): + if model_lower.startswith(model_prefix): + input_price, output_price = inp, outp + break + + if input_price is None: + return None + + input_cost = (input_tokens / 1_000_000) * input_price + output_cost = (output_tokens / 1_000_000) * output_price + + return input_cost + output_cost + + def get_output_buffer(self, model: str, default: int = 4000) -> int: + """Get recommended output buffer.""" + return default diff --git a/headroom/providers/google.py b/headroom/providers/google.py new file mode 100644 index 000000000..1b7b0b9d0 --- /dev/null +++ b/headroom/providers/google.py @@ -0,0 +1,372 @@ +"""Google Gemini provider for Headroom SDK. + +Supports Google's Gemini models through two interfaces: +1. OpenAI-compatible endpoint (recommended for Headroom) +2. Native Google AI SDK (for advanced features) + +Token counting uses Google's official countTokens API when a client +is provided. This gives accurate counts for all content types. + +Usage: + import google.generativeai as genai + from headroom import GoogleProvider + + genai.configure(api_key="your-api-key") + provider = GoogleProvider(client=genai) # Accurate counting via API + + # Or without client (uses estimation - less accurate) + provider = GoogleProvider() # Warning: approximate counting +""" + +from __future__ import annotations + +import logging +import warnings +from datetime import date +from typing import Any + +from headroom.tokenizers import EstimatingTokenCounter + +from .base import Provider, TokenCounter + +logger = logging.getLogger(__name__) + +# Warning flags +_FALLBACK_WARNING_SHOWN = False + +# Pricing metadata +_PRICING_LAST_UPDATED = date(2025, 1, 6) + +# Google model context limits +_CONTEXT_LIMITS: dict[str, int] = { + # Gemini 2.0 + "gemini-2.0-flash": 1000000, + "gemini-2.0-flash-exp": 1000000, + "gemini-2.0-flash-thinking": 1000000, + # Gemini 1.5 + "gemini-1.5-pro": 2000000, + "gemini-1.5-pro-latest": 2000000, + "gemini-1.5-flash": 1000000, + "gemini-1.5-flash-latest": 1000000, + "gemini-1.5-flash-8b": 1000000, + # Gemini 1.0 + "gemini-1.0-pro": 32768, + "gemini-pro": 32768, +} + +# Pricing per 1M tokens (input, output) +# Note: Google has different pricing tiers based on context length +_PRICING: dict[str, tuple[float, float]] = { + "gemini-2.0-flash": (0.10, 0.40), + "gemini-2.0-flash-exp": (0.10, 0.40), # Experimental, may change + "gemini-1.5-pro": (1.25, 5.00), # Up to 128K context + "gemini-1.5-flash": (0.075, 0.30), # Up to 128K context + "gemini-1.5-flash-8b": (0.0375, 0.15), + "gemini-1.0-pro": (0.50, 1.50), +} + + +class GeminiTokenCounter: + """Token counter for Gemini models. + + When a google.generativeai client is provided, uses the official + countTokens API for accurate counting. Falls back to estimation + when no client is available. + + Usage: + import google.generativeai as genai + genai.configure(api_key="...") + + # With API (accurate) + counter = GeminiTokenCounter("gemini-2.0-flash", client=genai) + + # Without API (estimation) + counter = GeminiTokenCounter("gemini-2.0-flash") + """ + + def __init__(self, model: str, client: Any = None): + """Initialize Gemini token counter. + + Args: + model: Gemini model name. + client: Optional google.generativeai module for API-based counting. + """ + global _FALLBACK_WARNING_SHOWN + + self.model = model + self._client = client + self._use_api = client is not None + self._genai_model = None + + # Gemini uses ~4 chars per token (similar to GPT models) + self._estimator = EstimatingTokenCounter(chars_per_token=4.0) + + if not self._use_api and not _FALLBACK_WARNING_SHOWN: + warnings.warn( + "GoogleProvider: No client provided, using estimation. " + "For accurate counting, pass google.generativeai: " + "GoogleProvider(client=genai)", + UserWarning, + stacklevel=4 + ) + _FALLBACK_WARNING_SHOWN = True + + def _get_model(self): + """Lazy-load the GenerativeModel for API calls.""" + if self._genai_model is None and self._client is not None: + self._genai_model = self._client.GenerativeModel(self.model) + return self._genai_model + + def count_text(self, text: str) -> int: + """Count tokens in text. + + Uses countTokens API if client available, otherwise estimates. + """ + if not text: + return 0 + + if self._use_api: + try: + model = self._get_model() + response = model.count_tokens(text) + return response.total_tokens + except Exception as e: + logger.debug(f"Google countTokens API failed: {e}, using estimation") + + return self._estimator.count_text(text) + + def count_message(self, message: dict[str, Any]) -> int: + """Count tokens in a message.""" + # For API-based counting, convert message to content and count + if self._use_api: + try: + content = self._message_to_content(message) + model = self._get_model() + response = model.count_tokens(content) + return response.total_tokens + except Exception as e: + logger.debug(f"Google countTokens API failed: {e}, using estimation") + + # Fallback to estimation + return self._estimate_message(message) + + def count_messages(self, messages: list[dict[str, Any]]) -> int: + """Count tokens in messages. + + Uses countTokens API with full conversation if available. + """ + if not messages: + return 0 + + if self._use_api: + try: + # Convert to Gemini content format + contents = [self._message_to_content(msg) for msg in messages] + model = self._get_model() + response = model.count_tokens(contents) + return response.total_tokens + except Exception as e: + logger.debug(f"Google countTokens API failed: {e}, using estimation") + + # Fallback to estimation + total = sum(self._estimate_message(msg) for msg in messages) + total += 3 # Priming tokens + return total + + def _message_to_content(self, message: dict[str, Any]) -> str: + """Convert OpenAI-format message to text content for counting.""" + content = message.get("content", "") + if isinstance(content, str): + return content + elif isinstance(content, list): + parts = [] + for part in content: + if isinstance(part, dict) and part.get("type") == "text": + parts.append(part.get("text", "")) + elif isinstance(part, str): + parts.append(part) + return "\n".join(parts) + return str(content) + + def _estimate_message(self, message: dict[str, Any]) -> int: + """Estimate tokens in a message without API.""" + tokens = 4 # Message overhead + + role = message.get("role", "") + tokens += self._estimator.count_text(role) + + content = message.get("content") + if content: + if isinstance(content, str): + tokens += self._estimator.count_text(content) + elif isinstance(content, list): + for part in content: + if isinstance(part, dict): + if part.get("type") == "text": + tokens += self._estimator.count_text(part.get("text", "")) + elif isinstance(part, str): + tokens += self._estimator.count_text(part) + + return tokens + + +class GoogleProvider(Provider): + """Provider for Google Gemini models. + + Supports Gemini 1.5 and 2.0 model families through: + - OpenAI-compatible endpoint (generativelanguage.googleapis.com) + - Native Google AI SDK (for accurate token counting) + + Example: + import google.generativeai as genai + genai.configure(api_key="...") + + # With client (accurate token counting via API) + provider = GoogleProvider(client=genai) + + # Without client (estimation-based counting) + provider = GoogleProvider() + + # Token counting + counter = provider.get_token_counter("gemini-2.0-flash") + tokens = counter.count_text("Hello, world!") + + # Context limits + limit = provider.get_context_limit("gemini-1.5-pro") # 2M tokens! + + # Cost estimation + cost = provider.estimate_cost( + input_tokens=100000, + output_tokens=10000, + model="gemini-1.5-pro", + ) + """ + + # OpenAI-compatible endpoint for Gemini + OPENAI_COMPATIBLE_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai" + + def __init__(self, client: Any = None): + """Initialize Google provider. + + Args: + client: Optional google.generativeai module for API-based token counting. + If provided, uses countTokens API for accurate counts. + """ + self._client = client + + @property + def name(self) -> str: + return "google" + + def supports_model(self, model: str) -> bool: + """Check if model is a known Gemini model.""" + model_lower = model.lower() + if model_lower in _CONTEXT_LIMITS: + return True + # Check prefix match + for prefix in ["gemini-2", "gemini-1.5", "gemini-1.0", "gemini-pro"]: + if model_lower.startswith(prefix): + return True + return False + + def get_token_counter(self, model: str) -> TokenCounter: + """Get token counter for a Gemini model. + + Uses countTokens API if client was provided, otherwise estimates. + """ + if not self.supports_model(model): + raise ValueError( + f"Model '{model}' is not recognized as a Google model. " + f"Supported models: {list(_CONTEXT_LIMITS.keys())}" + ) + return GeminiTokenCounter(model, client=self._client) + + def get_context_limit(self, model: str) -> int: + """Get context limit for a Gemini model. + + Note: Gemini 1.5 Pro has 2M token context! + """ + model_lower = model.lower() + + # Direct match + if model_lower in _CONTEXT_LIMITS: + return _CONTEXT_LIMITS[model_lower] + + # Prefix match + for prefix, limit in [ + ("gemini-2.0", 1000000), + ("gemini-1.5-pro", 2000000), + ("gemini-1.5-flash", 1000000), + ("gemini-1.0", 32768), + ("gemini-pro", 32768), + ]: + if model_lower.startswith(prefix): + return limit + + raise ValueError( + f"Unknown context limit for model '{model}'. " + f"Known models: {list(_CONTEXT_LIMITS.keys())}" + ) + + def estimate_cost( + self, + input_tokens: int, + output_tokens: int, + model: str, + cached_tokens: int = 0, + ) -> float | None: + """Estimate cost for Gemini API call. + + Note: Google has tiered pricing based on context length. + This uses the standard pricing (up to 128K context). + For >128K context, actual costs may be higher. + + Args: + input_tokens: Number of input tokens. + output_tokens: Number of output tokens. + model: Model name. + cached_tokens: Number of cached tokens (not used by Google). + + Returns: + Estimated cost in USD, or None if pricing unknown. + """ + model_lower = model.lower() + + # Find pricing + input_price, output_price = None, None + for model_prefix, (inp, outp) in _PRICING.items(): + if model_lower.startswith(model_prefix): + input_price, output_price = inp, outp + break + + if input_price is None: + return None + + input_cost = (input_tokens / 1_000_000) * input_price + output_cost = (output_tokens / 1_000_000) * output_price + + return input_cost + output_cost + + def get_output_buffer(self, model: str, default: int = 4000) -> int: + """Get recommended output buffer.""" + # Gemini models can output up to 8K tokens + return min(8192, default) + + @classmethod + def get_openai_compatible_url(cls, api_key: str) -> str: + """Get OpenAI-compatible endpoint URL. + + Use this with the OpenAI client: + from openai import OpenAI + client = OpenAI( + api_key=api_key, + base_url=GoogleProvider.get_openai_compatible_url(api_key), + ) + + Args: + api_key: Google AI API key. + + Returns: + Base URL for OpenAI-compatible requests. + """ + return cls.OPENAI_COMPATIBLE_BASE_URL diff --git a/headroom/providers/litellm.py b/headroom/providers/litellm.py new file mode 100644 index 000000000..d1e72814e --- /dev/null +++ b/headroom/providers/litellm.py @@ -0,0 +1,293 @@ +"""LiteLLM provider for universal LLM support. + +LiteLLM provides a unified interface to 100+ LLM providers: +- OpenAI, Azure OpenAI +- Anthropic +- Google (Vertex AI, AI Studio) +- AWS Bedrock +- Cohere +- Replicate +- Hugging Face +- Ollama +- Together AI +- Groq +- And many more... + +This integration allows Headroom to work with any LiteLLM-supported +model without needing provider-specific implementations. + +Requires: pip install litellm +""" + +from __future__ import annotations + +import logging +from typing import Any + +from headroom.tokenizers import EstimatingTokenCounter + +from .base import Provider, TokenCounter + +logger = logging.getLogger(__name__) + +# Check if litellm is available +try: + import litellm + from litellm import get_model_info as litellm_get_model_info + from litellm import model_cost as litellm_model_cost + from litellm import token_counter as litellm_token_counter + + LITELLM_AVAILABLE = True +except ImportError: + LITELLM_AVAILABLE = False + litellm = None + litellm_token_counter = None + litellm_model_cost = None + litellm_get_model_info = None + + +def is_litellm_available() -> bool: + """Check if LiteLLM is installed. + + Returns: + True if litellm is available. + """ + return LITELLM_AVAILABLE + + +class LiteLLMTokenCounter: + """Token counter using LiteLLM's token counting. + + LiteLLM provides accurate token counting for most providers + by using the appropriate tokenizer for each model. + """ + + def __init__(self, model: str): + """Initialize LiteLLM token counter. + + Args: + model: Model name in LiteLLM format (e.g., 'gpt-4o', 'claude-3-sonnet'). + """ + if not LITELLM_AVAILABLE: + raise RuntimeError( + "LiteLLM is required for LiteLLMProvider. " + "Install with: pip install litellm" + ) + self.model = model + # Fallback estimator for when litellm counting fails + self._fallback = EstimatingTokenCounter() + + def count_text(self, text: str) -> int: + """Count tokens in text using LiteLLM.""" + if not text: + return 0 + try: + # LiteLLM's token_counter expects messages format + # We wrap text in a simple message + return litellm_token_counter( + model=self.model, + messages=[{"role": "user", "content": text}], + ) + except Exception as e: + logger.debug(f"LiteLLM token count failed for {self.model}: {e}") + return self._fallback.count_text(text) + + def count_message(self, message: dict[str, Any]) -> int: + """Count tokens in a single message.""" + try: + return litellm_token_counter( + model=self.model, + messages=[message], + ) + except Exception as e: + logger.debug(f"LiteLLM message count failed for {self.model}: {e}") + # Fallback to estimation + tokens = 4 # Base overhead + content = message.get("content", "") + if isinstance(content, str): + tokens += self._fallback.count_text(content) + return tokens + + def count_messages(self, messages: list[dict[str, Any]]) -> int: + """Count tokens in messages using LiteLLM.""" + if not messages: + return 0 + try: + return litellm_token_counter( + model=self.model, + messages=messages, + ) + except Exception as e: + logger.debug(f"LiteLLM messages count failed for {self.model}: {e}") + # Fallback to estimation + total = sum(self.count_message(msg) for msg in messages) + total += 3 # Priming + return total + + +class LiteLLMProvider(Provider): + """Provider using LiteLLM for universal model support. + + LiteLLM supports 100+ LLM providers with a unified interface. + This provider leverages LiteLLM's: + - Token counting (accurate for most providers) + - Model info (context limits, capabilities) + - Cost estimation (from LiteLLM's model database) + + Example: + from headroom.providers import LiteLLMProvider + + provider = LiteLLMProvider() + + # Works with any LiteLLM-supported model + counter = provider.get_token_counter("gpt-4o") + counter = provider.get_token_counter("claude-3-5-sonnet-20241022") + counter = provider.get_token_counter("gemini/gemini-1.5-pro") + counter = provider.get_token_counter("bedrock/anthropic.claude-v2") + counter = provider.get_token_counter("ollama/llama3") + + Model Format: + LiteLLM uses a provider/model format for some providers: + - OpenAI: "gpt-4o" or "openai/gpt-4o" + - Anthropic: "claude-3-sonnet" or "anthropic/claude-3-sonnet" + - Google: "gemini/gemini-1.5-pro" + - Azure: "azure/gpt-4" + - Bedrock: "bedrock/anthropic.claude-v2" + - Ollama: "ollama/llama3" + + See LiteLLM docs for full model list: + https://docs.litellm.ai/docs/providers + """ + + def __init__(self): + """Initialize LiteLLM provider.""" + if not LITELLM_AVAILABLE: + raise RuntimeError( + "LiteLLM is required for LiteLLMProvider. " + "Install with: pip install litellm" + ) + + @property + def name(self) -> str: + return "litellm" + + def supports_model(self, model: str) -> bool: + """Check if LiteLLM supports this model. + + LiteLLM supports most models, so this returns True + for any model. Actual support depends on credentials. + """ + return True # LiteLLM handles validation + + def get_token_counter(self, model: str) -> TokenCounter: + """Get token counter for a model.""" + return LiteLLMTokenCounter(model) + + def get_context_limit(self, model: str) -> int: + """Get context limit using LiteLLM's model info.""" + try: + info = litellm_get_model_info(model) + if info and "max_input_tokens" in info: + return info["max_input_tokens"] + if info and "max_tokens" in info: + return info["max_tokens"] + except Exception as e: + logger.debug(f"LiteLLM get_model_info failed for {model}: {e}") + + # Fallback to reasonable default + return 128000 + + def get_output_buffer(self, model: str, default: int = 4000) -> int: + """Get recommended output buffer.""" + try: + info = litellm_get_model_info(model) + if info and "max_output_tokens" in info: + return min(info["max_output_tokens"], default) + except Exception: + pass + return default + + def estimate_cost( + self, + input_tokens: int, + output_tokens: int, + model: str, + cached_tokens: int = 0, + ) -> float | None: + """Estimate cost using LiteLLM's cost database. + + Args: + input_tokens: Number of input tokens. + output_tokens: Number of output tokens. + model: Model name. + cached_tokens: Cached tokens (may not be supported by all providers). + + Returns: + Estimated cost in USD, or None if pricing unknown. + """ + try: + # LiteLLM's cost calculation + cost = litellm.completion_cost( + model=model, + prompt="", # We're using token counts directly + completion="", + prompt_tokens=input_tokens, + completion_tokens=output_tokens, + ) + return cost + except Exception as e: + logger.debug(f"LiteLLM cost estimation failed for {model}: {e}") + return None + + @classmethod + def list_supported_providers(cls) -> list[str]: + """List providers supported by LiteLLM. + + Returns: + List of provider names. + """ + if not LITELLM_AVAILABLE: + return [] + + # Major providers supported by LiteLLM + return [ + "openai", + "anthropic", + "azure", + "google", + "vertex_ai", + "bedrock", + "cohere", + "replicate", + "huggingface", + "ollama", + "together_ai", + "groq", + "fireworks_ai", + "anyscale", + "deepinfra", + "perplexity", + "mistral", + "cloudflare", + "ai21", + "nlp_cloud", + "aleph_alpha", + "petals", + "baseten", + "openrouter", + "vllm", + "xinference", + "text-generation-inference", + ] + + +def create_litellm_provider() -> LiteLLMProvider: + """Create a LiteLLM provider. + + Returns: + Configured LiteLLMProvider. + + Raises: + RuntimeError: If LiteLLM is not installed. + """ + return LiteLLMProvider() diff --git a/headroom/providers/openai.py b/headroom/providers/openai.py index 7bbe7dea8..963b8fdc4 100644 --- a/headroom/providers/openai.py +++ b/headroom/providers/openai.py @@ -6,7 +6,6 @@ Cost estimates are APPROXIMATE - always verify against your actual billing. from __future__ import annotations -import json import warnings from datetime import date from functools import lru_cache diff --git a/headroom/providers/openai_compatible.py b/headroom/providers/openai_compatible.py new file mode 100644 index 000000000..daf8a3fd9 --- /dev/null +++ b/headroom/providers/openai_compatible.py @@ -0,0 +1,521 @@ +"""OpenAI-compatible provider for universal LLM support. + +This provider supports any LLM service that implements the OpenAI API format: +- Ollama (local) +- vLLM (local/cloud) +- Together AI +- Groq +- Fireworks AI +- Anyscale +- LM Studio +- LocalAI +- Hugging Face Inference Endpoints +- Azure OpenAI +- And many more... + +The key insight: 70%+ of LLM providers use OpenAI-compatible APIs, +so supporting this format gives near-universal coverage. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any + +from headroom.tokenizers import get_tokenizer + +from .base import Provider + +logger = logging.getLogger(__name__) + + +@dataclass +class ModelCapabilities: + """Model capability metadata. + + Stores information about a model's capabilities and constraints + that the provider needs for token counting and cost estimation. + """ + + model: str + context_window: int = 128000 # Default to 128K + max_output_tokens: int = 4096 + supports_tools: bool = True + supports_vision: bool = False + supports_streaming: bool = True + tokenizer_backend: str | None = None # Force specific tokenizer + input_cost_per_1m: float | None = None # Cost per 1M input tokens + output_cost_per_1m: float | None = None # Cost per 1M output tokens + + +# Default context limits for common open models +# These are reasonable defaults; users can override +_DEFAULT_CONTEXT_LIMITS: dict[str, int] = { + # Llama 3 family + "llama-3": 8192, + "llama-3-8b": 8192, + "llama-3-70b": 8192, + "llama-3.1": 128000, + "llama-3.1-8b": 128000, + "llama-3.1-70b": 128000, + "llama-3.1-405b": 128000, + "llama-3.2": 128000, + "llama-3.3": 128000, + # Llama 2 family + "llama-2": 4096, + "llama-2-7b": 4096, + "llama-2-13b": 4096, + "llama-2-70b": 4096, + "codellama": 16384, + # Mistral family + "mistral": 32768, + "mistral-7b": 32768, + "mistral-nemo": 128000, + "mistral-small": 32768, + "mistral-large": 128000, + "mixtral": 32768, + "mixtral-8x7b": 32768, + "mixtral-8x22b": 65536, + # Qwen family + "qwen": 32768, + "qwen2": 32768, + "qwen2-7b": 32768, + "qwen2-72b": 32768, + "qwen2.5": 131072, + # DeepSeek + "deepseek": 32768, + "deepseek-coder": 16384, + "deepseek-v2": 128000, + "deepseek-v3": 128000, + # Yi + "yi": 32768, + "yi-34b": 32768, + # Phi + "phi-2": 2048, + "phi-3": 4096, + "phi-3-mini": 4096, + "phi-3-medium": 4096, + # Others + "falcon": 2048, + "falcon-40b": 2048, + "falcon-180b": 2048, + "gemma": 8192, + "gemma-2": 8192, + "starcoder": 8192, + "starcoder2": 16384, +} + + +class OpenAICompatibleTokenCounter: + """Token counter for OpenAI-compatible providers. + + Uses the TokenizerRegistry to get the appropriate tokenizer + for the model, falling back to estimation if needed. + """ + + def __init__( + self, + model: str, + tokenizer_backend: str | None = None, + ): + """Initialize token counter. + + Args: + model: Model name. + tokenizer_backend: Force specific tokenizer backend. + """ + self.model = model + self._tokenizer = get_tokenizer(model, backend=tokenizer_backend) + + def count_text(self, text: str) -> int: + """Count tokens in text.""" + return self._tokenizer.count_text(text) + + def count_message(self, message: dict[str, Any]) -> int: + """Count tokens in a single message.""" + # Use OpenAI-style message overhead + tokens = 4 # Base overhead + + role = message.get("role", "") + tokens += self.count_text(role) + + content = message.get("content") + if content: + if isinstance(content, str): + tokens += self.count_text(content) + elif isinstance(content, list): + for part in content: + if isinstance(part, dict): + if part.get("type") == "text": + tokens += self.count_text(part.get("text", "")) + elif isinstance(part, str): + tokens += self.count_text(part) + + name = message.get("name") + if name: + tokens += self.count_text(name) + 1 + + tool_calls = message.get("tool_calls") + if tool_calls: + for tc in tool_calls: + func = tc.get("function", {}) + tokens += self.count_text(func.get("name", "")) + tokens += self.count_text(func.get("arguments", "")) + tokens += 10 + + tool_call_id = message.get("tool_call_id") + if tool_call_id: + tokens += self.count_text(tool_call_id) + 2 + + return tokens + + def count_messages(self, messages: list[dict[str, Any]]) -> int: + """Count tokens in a list of messages.""" + total = sum(self.count_message(msg) for msg in messages) + total += 3 # Priming tokens + return total + + +class OpenAICompatibleProvider(Provider): + """Provider for OpenAI-compatible LLM services. + + Works with any service implementing the OpenAI chat completions API: + - Ollama (local) + - vLLM (local/cloud) + - Together AI + - Groq + - Fireworks AI + - LM Studio + - LocalAI + - And many more... + + Example: + # For Ollama + provider = OpenAICompatibleProvider( + name="ollama", + base_url="http://localhost:11434/v1", + default_model="llama3.1", + ) + + # For Together AI + provider = OpenAICompatibleProvider( + name="together", + base_url="https://api.together.xyz/v1", + ) + + # Get token counter for a specific model + counter = provider.get_token_counter("llama-3.1-8b") + """ + + def __init__( + self, + name: str = "openai_compatible", + base_url: str | None = None, + api_key: str | None = None, + default_model: str | None = None, + models: dict[str, ModelCapabilities] | None = None, + ): + """Initialize OpenAI-compatible provider. + + Args: + name: Provider name for identification. + base_url: API base URL (e.g., 'http://localhost:11434/v1'). + api_key: API key (if required). + default_model: Default model for operations. + models: Custom model configurations. + """ + self._name = name + self.base_url = base_url + self.api_key = api_key + self.default_model = default_model + self._models: dict[str, ModelCapabilities] = models or {} + + @property + def name(self) -> str: + return self._name + + def register_model( + self, + model: str, + capabilities: ModelCapabilities | None = None, + **kwargs: Any, + ) -> None: + """Register a model with its capabilities. + + Args: + model: Model name. + capabilities: Model capabilities object. + **kwargs: Alternative way to specify capabilities. + """ + if capabilities is not None: + self._models[model] = capabilities + else: + self._models[model] = ModelCapabilities(model=model, **kwargs) + + def supports_model(self, model: str) -> bool: + """Check if model is supported. + + OpenAI-compatible providers support any model by default, + using estimation for token counting. + """ + return True # Always return True - we can estimate + + def get_token_counter(self, model: str) -> OpenAICompatibleTokenCounter: + """Get token counter for a model. + + Uses the TokenizerRegistry to find the best tokenizer, + with fallback to estimation. + """ + tokenizer_backend = None + + # Check for registered model with specific tokenizer + if model in self._models: + tokenizer_backend = self._models[model].tokenizer_backend + + return OpenAICompatibleTokenCounter(model, tokenizer_backend) + + def get_context_limit(self, model: str) -> int: + """Get context limit for a model. + + Priority: + 1. Registered model capabilities + 2. Default limits for known models + 3. Prefix matching + 4. Default 128K + """ + # Check registered models + if model in self._models: + return self._models[model].context_window + + model_lower = model.lower() + + # Check default limits + if model_lower in _DEFAULT_CONTEXT_LIMITS: + return _DEFAULT_CONTEXT_LIMITS[model_lower] + + # Prefix match + for prefix, limit in _DEFAULT_CONTEXT_LIMITS.items(): + if model_lower.startswith(prefix): + return limit + + # Default to 128K for modern models + return 128000 + + def get_output_buffer(self, model: str, default: int = 4000) -> int: + """Get recommended output buffer.""" + if model in self._models: + return min(self._models[model].max_output_tokens, default) + return default + + def estimate_cost( + self, + input_tokens: int, + output_tokens: int, + model: str, + cached_tokens: int = 0, + ) -> float | None: + """Estimate cost if pricing is configured. + + Args: + input_tokens: Number of input tokens. + output_tokens: Number of output tokens. + model: Model name. + cached_tokens: Number of cached tokens. + + Returns: + Estimated cost in USD, or None if pricing unknown. + """ + if model not in self._models: + return None + + caps = self._models[model] + if caps.input_cost_per_1m is None or caps.output_cost_per_1m is None: + return None + + input_cost = (input_tokens / 1_000_000) * caps.input_cost_per_1m + output_cost = (output_tokens / 1_000_000) * caps.output_cost_per_1m + + return input_cost + output_cost + + +# Pre-configured provider factories for common services + + +def create_ollama_provider( + base_url: str = "http://localhost:11434/v1", +) -> OpenAICompatibleProvider: + """Create provider for Ollama. + + Ollama is a popular local LLM runner that supports many open models. + + Args: + base_url: Ollama API URL (default: http://localhost:11434/v1). + + Returns: + Configured provider. + """ + return OpenAICompatibleProvider( + name="ollama", + base_url=base_url, + ) + + +def create_together_provider( + api_key: str | None = None, +) -> OpenAICompatibleProvider: + """Create provider for Together AI. + + Together AI offers high-performance inference for open models. + + Args: + api_key: Together AI API key. + + Returns: + Configured provider with Together AI pricing. + """ + provider = OpenAICompatibleProvider( + name="together", + base_url="https://api.together.xyz/v1", + api_key=api_key, + ) + + # Register common Together models with pricing + # Pricing as of Jan 2025 (verify current rates) + provider.register_model( + "meta-llama/Llama-3.1-8B-Instruct-Turbo", + context_window=128000, + input_cost_per_1m=0.18, + output_cost_per_1m=0.18, + ) + provider.register_model( + "meta-llama/Llama-3.1-70B-Instruct-Turbo", + context_window=128000, + input_cost_per_1m=0.88, + output_cost_per_1m=0.88, + ) + provider.register_model( + "meta-llama/Llama-3.1-405B-Instruct-Turbo", + context_window=128000, + input_cost_per_1m=3.50, + output_cost_per_1m=3.50, + ) + + return provider + + +def create_groq_provider( + api_key: str | None = None, +) -> OpenAICompatibleProvider: + """Create provider for Groq. + + Groq offers ultra-fast inference on custom hardware. + + Args: + api_key: Groq API key. + + Returns: + Configured provider with Groq pricing. + """ + provider = OpenAICompatibleProvider( + name="groq", + base_url="https://api.groq.com/openai/v1", + api_key=api_key, + ) + + # Register common Groq models with pricing + # Pricing as of Jan 2025 (verify current rates) + provider.register_model( + "llama-3.1-8b-instant", + context_window=128000, + input_cost_per_1m=0.05, + output_cost_per_1m=0.08, + ) + provider.register_model( + "llama-3.1-70b-versatile", + context_window=128000, + input_cost_per_1m=0.59, + output_cost_per_1m=0.79, + ) + provider.register_model( + "mixtral-8x7b-32768", + context_window=32768, + input_cost_per_1m=0.24, + output_cost_per_1m=0.24, + ) + + return provider + + +def create_fireworks_provider( + api_key: str | None = None, +) -> OpenAICompatibleProvider: + """Create provider for Fireworks AI. + + Args: + api_key: Fireworks API key. + + Returns: + Configured provider. + """ + return OpenAICompatibleProvider( + name="fireworks", + base_url="https://api.fireworks.ai/inference/v1", + api_key=api_key, + ) + + +def create_anyscale_provider( + api_key: str | None = None, +) -> OpenAICompatibleProvider: + """Create provider for Anyscale Endpoints. + + Args: + api_key: Anyscale API key. + + Returns: + Configured provider. + """ + return OpenAICompatibleProvider( + name="anyscale", + base_url="https://api.endpoints.anyscale.com/v1", + api_key=api_key, + ) + + +def create_vllm_provider( + base_url: str, +) -> OpenAICompatibleProvider: + """Create provider for vLLM server. + + vLLM is a high-performance inference engine. + + Args: + base_url: vLLM server URL (e.g., 'http://localhost:8000/v1'). + + Returns: + Configured provider. + """ + return OpenAICompatibleProvider( + name="vllm", + base_url=base_url, + ) + + +def create_lmstudio_provider( + base_url: str = "http://localhost:1234/v1", +) -> OpenAICompatibleProvider: + """Create provider for LM Studio. + + LM Studio is a desktop app for running local LLMs. + + Args: + base_url: LM Studio API URL. + + Returns: + Configured provider. + """ + return OpenAICompatibleProvider( + name="lmstudio", + base_url=base_url, + ) diff --git a/headroom/proxy/__init__.py b/headroom/proxy/__init__.py new file mode 100644 index 000000000..3d1c39745 --- /dev/null +++ b/headroom/proxy/__init__.py @@ -0,0 +1,19 @@ +"""Headroom Proxy Server. + +A transparent proxy that sits between LLM clients (Claude Code, Cursor, etc.) +and LLM APIs (Anthropic, OpenAI), applying Headroom optimizations. + +Usage: + # Start the proxy + python -m headroom.proxy.server + + # Use with Claude Code + ANTHROPIC_BASE_URL=http://localhost:8787 claude + + # Use with Cursor (if using Anthropic) + Set base URL in Cursor settings to http://localhost:8787 +""" + +from .server import create_app, run_server + +__all__ = ["create_app", "run_server"] diff --git a/headroom/proxy/server.py b/headroom/proxy/server.py new file mode 100644 index 000000000..546adf135 --- /dev/null +++ b/headroom/proxy/server.py @@ -0,0 +1,1399 @@ +"""Headroom Proxy Server - Production Ready. + +A full-featured LLM proxy with optimization, caching, rate limiting, +and observability. + +Features: +- Context optimization (SmartCrusher, CacheAligner, RollingWindow) +- Semantic caching (save costs on repeated queries) +- Rate limiting (token bucket) +- Retry with exponential backoff +- Cost tracking and budgets +- Request tagging and metadata +- Provider fallback +- Prometheus metrics +- Full request/response logging + +Usage: + python -m headroom.proxy.server --port 8787 + + # With Claude Code: + ANTHROPIC_BASE_URL=http://localhost:8787 claude +""" + +from __future__ import annotations + +import argparse +import asyncio +import hashlib +import json +import logging +import random +import sys +import time +from collections import defaultdict +from dataclasses import asdict, dataclass +from datetime import datetime, timedelta +from pathlib import Path +from typing import Literal + +import httpx + +try: + import uvicorn + from fastapi import FastAPI, Header, HTTPException, Request, Response + from fastapi.middleware.cors import CORSMiddleware + from fastapi.responses import PlainTextResponse, StreamingResponse + FASTAPI_AVAILABLE = True +except ImportError: + FASTAPI_AVAILABLE = False + +# Add parent to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from headroom.config import CacheAlignerConfig, RollingWindowConfig, SmartCrusherConfig +from headroom.providers import AnthropicProvider, OpenAIProvider +from headroom.tokenizers import get_tokenizer +from headroom.transforms import CacheAligner, RollingWindow, SmartCrusher, TransformPipeline + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger("headroom.proxy") + + +# ============================================================================= +# Data Models +# ============================================================================= + +@dataclass +class RequestLog: + """Complete log of a single request.""" + request_id: str + timestamp: str + provider: str + model: str + + # Tokens + input_tokens_original: int + input_tokens_optimized: int + output_tokens: int | None + tokens_saved: int + savings_percent: float + + # Cost + estimated_cost_usd: float | None + estimated_savings_usd: float | None + + # Performance + optimization_latency_ms: float + total_latency_ms: float | None + + # Metadata + tags: dict[str, str] + cache_hit: bool + transforms_applied: list[str] + + # Request/Response (optional, for debugging) + request_messages: list[dict] | None = None + response_content: str | None = None + error: str | None = None + + +@dataclass +class CacheEntry: + """Cached response entry.""" + response_body: bytes + response_headers: dict[str, str] + created_at: datetime + ttl_seconds: int + hit_count: int = 0 + tokens_saved_per_hit: int = 0 + + +@dataclass +class RateLimitState: + """Token bucket rate limiter state.""" + tokens: float + last_update: float + + +@dataclass +class ProxyConfig: + """Proxy configuration.""" + # Server + host: str = "127.0.0.1" + port: int = 8787 + + # Optimization + optimize: bool = True + min_tokens_to_crush: int = 500 + max_items_after_crush: int = 50 + keep_last_turns: int = 4 + + # Caching + cache_enabled: bool = True + cache_ttl_seconds: int = 3600 # 1 hour + cache_max_entries: int = 1000 + + # Rate limiting + rate_limit_enabled: bool = True + rate_limit_requests_per_minute: int = 60 + rate_limit_tokens_per_minute: int = 100000 + + # Retry + retry_enabled: bool = True + retry_max_attempts: int = 3 + retry_base_delay_ms: int = 1000 + retry_max_delay_ms: int = 30000 + + # Cost tracking + cost_tracking_enabled: bool = True + budget_limit_usd: float | None = None # None = unlimited + budget_period: Literal["hourly", "daily", "monthly"] = "daily" + + # Logging + log_requests: bool = True + log_file: str | None = None + log_full_messages: bool = False # Privacy: don't log content by default + + # Fallback + fallback_enabled: bool = False + fallback_provider: str | None = None # "openai" or "anthropic" + + # Timeouts + request_timeout_seconds: int = 300 + connect_timeout_seconds: int = 10 + + +# ============================================================================= +# Caching +# ============================================================================= + +class SemanticCache: + """Simple semantic cache based on message content hash.""" + + def __init__(self, max_entries: int = 1000, ttl_seconds: int = 3600): + self.max_entries = max_entries + self.ttl_seconds = ttl_seconds + self._cache: dict[str, CacheEntry] = {} + self._access_order: list[str] = [] + + def _compute_key(self, messages: list[dict], model: str) -> str: + """Compute cache key from messages and model.""" + # Normalize messages for consistent hashing + normalized = json.dumps({ + "model": model, + "messages": messages, + }, sort_keys=True) + return hashlib.sha256(normalized.encode()).hexdigest()[:32] + + def get(self, messages: list[dict], model: str) -> CacheEntry | None: + """Get cached response if exists and not expired.""" + key = self._compute_key(messages, model) + entry = self._cache.get(key) + + if entry is None: + return None + + # Check expiration + age = (datetime.now() - entry.created_at).total_seconds() + if age > entry.ttl_seconds: + del self._cache[key] + self._access_order.remove(key) + return None + + entry.hit_count += 1 + return entry + + def set( + self, + messages: list[dict], + model: str, + response_body: bytes, + response_headers: dict[str, str], + tokens_saved: int = 0, + ): + """Cache a response.""" + key = self._compute_key(messages, model) + + # Evict if at capacity (LRU) + while len(self._cache) >= self.max_entries and self._access_order: + oldest_key = self._access_order.pop(0) + self._cache.pop(oldest_key, None) + + self._cache[key] = CacheEntry( + response_body=response_body, + response_headers=response_headers, + created_at=datetime.now(), + ttl_seconds=self.ttl_seconds, + tokens_saved_per_hit=tokens_saved, + ) + self._access_order.append(key) + + def stats(self) -> dict: + """Get cache statistics.""" + total_hits = sum(e.hit_count for e in self._cache.values()) + return { + "entries": len(self._cache), + "max_entries": self.max_entries, + "total_hits": total_hits, + "ttl_seconds": self.ttl_seconds, + } + + def clear(self): + """Clear all cache entries.""" + self._cache.clear() + self._access_order.clear() + + +# ============================================================================= +# Rate Limiting +# ============================================================================= + +class TokenBucketRateLimiter: + """Token bucket rate limiter for requests and tokens.""" + + def __init__( + self, + requests_per_minute: int = 60, + tokens_per_minute: int = 100000, + ): + self.requests_per_minute = requests_per_minute + self.tokens_per_minute = tokens_per_minute + + # Per-key buckets (key = API key or IP) + self._request_buckets: dict[str, RateLimitState] = defaultdict( + lambda: RateLimitState(tokens=requests_per_minute, last_update=time.time()) + ) + self._token_buckets: dict[str, RateLimitState] = defaultdict( + lambda: RateLimitState(tokens=tokens_per_minute, last_update=time.time()) + ) + + def _refill(self, state: RateLimitState, rate_per_minute: float) -> float: + """Refill bucket based on elapsed time.""" + now = time.time() + elapsed = now - state.last_update + refill = elapsed * (rate_per_minute / 60.0) + state.tokens = min(rate_per_minute, state.tokens + refill) + state.last_update = now + return state.tokens + + def check_request(self, key: str = "default") -> tuple[bool, float]: + """Check if request is allowed. Returns (allowed, wait_seconds).""" + state = self._request_buckets[key] + available = self._refill(state, self.requests_per_minute) + + if available >= 1: + state.tokens -= 1 + return True, 0 + + wait_seconds = (1 - available) * (60.0 / self.requests_per_minute) + return False, wait_seconds + + def check_tokens(self, key: str, token_count: int) -> tuple[bool, float]: + """Check if token usage is allowed.""" + state = self._token_buckets[key] + available = self._refill(state, self.tokens_per_minute) + + if available >= token_count: + state.tokens -= token_count + return True, 0 + + wait_seconds = (token_count - available) * (60.0 / self.tokens_per_minute) + return False, wait_seconds + + def stats(self) -> dict: + """Get rate limiter statistics.""" + return { + "requests_per_minute": self.requests_per_minute, + "tokens_per_minute": self.tokens_per_minute, + "active_keys": len(self._request_buckets), + } + + +# ============================================================================= +# Cost Tracking +# ============================================================================= + +class CostTracker: + """Track costs and enforce budgets.""" + + # Pricing per 1M tokens (input, output, cached_input) + PRICING = { + # Anthropic + "claude-3-5-sonnet": (3.00, 15.00, 0.30), + "claude-3-5-haiku": (0.80, 4.00, 0.08), + "claude-3-opus": (15.00, 75.00, 1.50), + "claude-sonnet-4": (3.00, 15.00, 0.30), + "claude-opus-4": (15.00, 75.00, 1.50), + # OpenAI + "gpt-4o": (2.50, 10.00, 1.25), + "gpt-4o-mini": (0.15, 0.60, 0.075), + "o1": (15.00, 60.00, 7.50), + "o1-mini": (1.10, 4.40, 0.55), + "o3-mini": (1.10, 4.40, 0.55), + "gpt-4-turbo": (10.00, 30.00, 5.00), + } + + def __init__(self, budget_limit_usd: float | None = None, budget_period: str = "daily"): + self.budget_limit_usd = budget_limit_usd + self.budget_period = budget_period + + # Cost tracking + self._costs: list[tuple[datetime, float]] = [] + self._total_cost_usd: float = 0 + self._total_savings_usd: float = 0 + + def _get_pricing(self, model: str) -> tuple[float, float, float] | None: + """Get pricing for model.""" + model_lower = model.lower() + for prefix, pricing in self.PRICING.items(): + if prefix in model_lower: + return pricing + return None + + def estimate_cost( + self, + model: str, + input_tokens: int, + output_tokens: int, + cached_tokens: int = 0, + ) -> float | None: + """Estimate cost in USD.""" + pricing = self._get_pricing(model) + if pricing is None: + return None + + input_price, output_price, cached_price = pricing + + regular_input = input_tokens - cached_tokens + cost = ( + (regular_input / 1_000_000) * input_price + + (cached_tokens / 1_000_000) * cached_price + + (output_tokens / 1_000_000) * output_price + ) + return cost + + def record_cost(self, cost_usd: float): + """Record a cost.""" + self._costs.append((datetime.now(), cost_usd)) + self._total_cost_usd += cost_usd + + def record_savings(self, savings_usd: float): + """Record savings from optimization.""" + self._total_savings_usd += savings_usd + + def get_period_cost(self) -> float: + """Get cost for current budget period.""" + now = datetime.now() + + if self.budget_period == "hourly": + cutoff = now - timedelta(hours=1) + elif self.budget_period == "daily": + cutoff = now.replace(hour=0, minute=0, second=0, microsecond=0) + else: # monthly + cutoff = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + return sum(cost for ts, cost in self._costs if ts >= cutoff) + + def check_budget(self) -> tuple[bool, float]: + """Check if within budget. Returns (allowed, remaining).""" + if self.budget_limit_usd is None: + return True, float('inf') + + period_cost = self.get_period_cost() + remaining = self.budget_limit_usd - period_cost + return remaining > 0, max(0, remaining) + + def stats(self) -> dict: + """Get cost statistics.""" + return { + "total_cost_usd": round(self._total_cost_usd, 4), + "total_savings_usd": round(self._total_savings_usd, 4), + "period_cost_usd": round(self.get_period_cost(), 4), + "budget_limit_usd": self.budget_limit_usd, + "budget_period": self.budget_period, + "budget_remaining_usd": round(self.check_budget()[1], 4) if self.budget_limit_usd else None, + } + + +# ============================================================================= +# Prometheus Metrics +# ============================================================================= + +class PrometheusMetrics: + """Prometheus-compatible metrics.""" + + def __init__(self): + self.requests_total = 0 + self.requests_by_provider: dict[str, int] = defaultdict(int) + self.requests_by_model: dict[str, int] = defaultdict(int) + self.requests_cached = 0 + self.requests_rate_limited = 0 + self.requests_failed = 0 + + self.tokens_input_total = 0 + self.tokens_output_total = 0 + self.tokens_saved_total = 0 + + self.latency_sum_ms = 0.0 + self.latency_count = 0 + + self.cost_total_usd = 0.0 + self.savings_total_usd = 0.0 + + def record_request( + self, + provider: str, + model: str, + input_tokens: int, + output_tokens: int, + tokens_saved: int, + latency_ms: float, + cached: bool = False, + cost_usd: float = 0, + savings_usd: float = 0, + ): + """Record metrics for a request.""" + self.requests_total += 1 + self.requests_by_provider[provider] += 1 + self.requests_by_model[model] += 1 + + if cached: + self.requests_cached += 1 + + self.tokens_input_total += input_tokens + self.tokens_output_total += output_tokens + self.tokens_saved_total += tokens_saved + + self.latency_sum_ms += latency_ms + self.latency_count += 1 + + self.cost_total_usd += cost_usd + self.savings_total_usd += savings_usd + + def record_rate_limited(self): + self.requests_rate_limited += 1 + + def record_failed(self): + self.requests_failed += 1 + + def export(self) -> str: + """Export metrics in Prometheus format.""" + lines = [ + "# HELP headroom_requests_total Total number of requests", + "# TYPE headroom_requests_total counter", + f"headroom_requests_total {self.requests_total}", + "", + "# HELP headroom_requests_cached_total Cached request count", + "# TYPE headroom_requests_cached_total counter", + f"headroom_requests_cached_total {self.requests_cached}", + "", + "# HELP headroom_requests_rate_limited_total Rate limited requests", + "# TYPE headroom_requests_rate_limited_total counter", + f"headroom_requests_rate_limited_total {self.requests_rate_limited}", + "", + "# HELP headroom_requests_failed_total Failed requests", + "# TYPE headroom_requests_failed_total counter", + f"headroom_requests_failed_total {self.requests_failed}", + "", + "# HELP headroom_tokens_input_total Total input tokens", + "# TYPE headroom_tokens_input_total counter", + f"headroom_tokens_input_total {self.tokens_input_total}", + "", + "# HELP headroom_tokens_output_total Total output tokens", + "# TYPE headroom_tokens_output_total counter", + f"headroom_tokens_output_total {self.tokens_output_total}", + "", + "# HELP headroom_tokens_saved_total Tokens saved by optimization", + "# TYPE headroom_tokens_saved_total counter", + f"headroom_tokens_saved_total {self.tokens_saved_total}", + "", + "# HELP headroom_latency_ms_sum Sum of request latencies", + "# TYPE headroom_latency_ms_sum counter", + f"headroom_latency_ms_sum {self.latency_sum_ms:.2f}", + "", + "# HELP headroom_cost_usd_total Total cost in USD", + "# TYPE headroom_cost_usd_total counter", + f"headroom_cost_usd_total {self.cost_total_usd:.6f}", + "", + "# HELP headroom_savings_usd_total Total savings in USD", + "# TYPE headroom_savings_usd_total counter", + f"headroom_savings_usd_total {self.savings_total_usd:.6f}", + ] + + # Per-provider metrics + lines.extend([ + "", + "# HELP headroom_requests_by_provider Requests by provider", + "# TYPE headroom_requests_by_provider counter", + ]) + for provider, count in self.requests_by_provider.items(): + lines.append(f'headroom_requests_by_provider{{provider="{provider}"}} {count}') + + # Per-model metrics + lines.extend([ + "", + "# HELP headroom_requests_by_model Requests by model", + "# TYPE headroom_requests_by_model counter", + ]) + for model, count in self.requests_by_model.items(): + lines.append(f'headroom_requests_by_model{{model="{model}"}} {count}') + + return "\n".join(lines) + + +# ============================================================================= +# Request Logger +# ============================================================================= + +class RequestLogger: + """Log requests to JSONL file.""" + + def __init__(self, log_file: str | None = None, log_full_messages: bool = False): + self.log_file = Path(log_file) if log_file else None + self.log_full_messages = log_full_messages + self._logs: list[RequestLog] = [] + + if self.log_file: + self.log_file.parent.mkdir(parents=True, exist_ok=True) + + def log(self, entry: RequestLog): + """Log a request.""" + self._logs.append(entry) + + if self.log_file: + with open(self.log_file, "a") as f: + log_dict = asdict(entry) + if not self.log_full_messages: + log_dict.pop("request_messages", None) + log_dict.pop("response_content", None) + f.write(json.dumps(log_dict) + "\n") + + def get_recent(self, n: int = 100) -> list[dict]: + """Get recent log entries.""" + entries = self._logs[-n:] + return [ + {k: v for k, v in asdict(e).items() + if k not in ("request_messages", "response_content")} + for e in entries + ] + + def stats(self) -> dict: + """Get logging statistics.""" + return { + "total_logged": len(self._logs), + "log_file": str(self.log_file) if self.log_file else None, + } + + +# ============================================================================= +# Main Proxy +# ============================================================================= + +class HeadroomProxy: + """Production-ready Headroom optimization proxy.""" + + ANTHROPIC_API_URL = "https://api.anthropic.com" + OPENAI_API_URL = "https://api.openai.com" + + def __init__(self, config: ProxyConfig): + self.config = config + + # Initialize providers + self.anthropic_provider = AnthropicProvider() + self.openai_provider = OpenAIProvider() + + # Initialize transforms + transforms = [ + CacheAligner(CacheAlignerConfig(enabled=True)), + SmartCrusher(SmartCrusherConfig( + enabled=True, + min_tokens_to_crush=config.min_tokens_to_crush, + max_items_after_crush=config.max_items_after_crush, + )), + RollingWindow(RollingWindowConfig( + enabled=True, + keep_system=True, + keep_last_turns=config.keep_last_turns, + )), + ] + + self.anthropic_pipeline = TransformPipeline( + transforms=transforms, + provider=self.anthropic_provider, + ) + self.openai_pipeline = TransformPipeline( + transforms=transforms, + provider=self.openai_provider, + ) + + # Initialize components + self.cache = SemanticCache( + max_entries=config.cache_max_entries, + ttl_seconds=config.cache_ttl_seconds, + ) if config.cache_enabled else None + + self.rate_limiter = TokenBucketRateLimiter( + requests_per_minute=config.rate_limit_requests_per_minute, + tokens_per_minute=config.rate_limit_tokens_per_minute, + ) if config.rate_limit_enabled else None + + self.cost_tracker = CostTracker( + budget_limit_usd=config.budget_limit_usd, + budget_period=config.budget_period, + ) if config.cost_tracking_enabled else None + + self.metrics = PrometheusMetrics() + + self.logger = RequestLogger( + log_file=config.log_file, + log_full_messages=config.log_full_messages, + ) if config.log_requests else None + + # HTTP client + self.http_client: httpx.AsyncClient | None = None + + # Request counter for IDs + self._request_counter = 0 + + async def startup(self): + """Initialize async resources.""" + self.http_client = httpx.AsyncClient( + timeout=httpx.Timeout( + connect=self.config.connect_timeout_seconds, + read=self.config.request_timeout_seconds, + write=self.config.request_timeout_seconds, + pool=self.config.connect_timeout_seconds, + ) + ) + logger.info("Headroom Proxy started") + logger.info(f"Optimization: {'ENABLED' if self.config.optimize else 'DISABLED'}") + logger.info(f"Caching: {'ENABLED' if self.config.cache_enabled else 'DISABLED'}") + logger.info(f"Rate Limiting: {'ENABLED' if self.config.rate_limit_enabled else 'DISABLED'}") + + async def shutdown(self): + """Cleanup async resources.""" + if self.http_client: + await self.http_client.aclose() + + # Print final stats + self._print_summary() + + def _print_summary(self): + """Print session summary.""" + m = self.metrics + logger.info("=" * 70) + logger.info("HEADROOM PROXY SESSION SUMMARY") + logger.info("=" * 70) + logger.info(f"Total requests: {m.requests_total}") + logger.info(f"Cached responses: {m.requests_cached}") + logger.info(f"Rate limited: {m.requests_rate_limited}") + logger.info(f"Failed: {m.requests_failed}") + logger.info(f"Input tokens: {m.tokens_input_total:,}") + logger.info(f"Output tokens: {m.tokens_output_total:,}") + logger.info(f"Tokens saved: {m.tokens_saved_total:,}") + if m.tokens_input_total > 0: + savings_pct = (m.tokens_saved_total / (m.tokens_input_total + m.tokens_saved_total)) * 100 + logger.info(f"Token savings: {savings_pct:.1f}%") + logger.info(f"Total cost: ${m.cost_total_usd:.4f}") + logger.info(f"Total savings: ${m.savings_total_usd:.4f}") + if m.latency_count > 0: + avg_latency = m.latency_sum_ms / m.latency_count + logger.info(f"Avg latency: {avg_latency:.0f}ms") + logger.info("=" * 70) + + def _next_request_id(self) -> str: + """Generate unique request ID.""" + self._request_counter += 1 + return f"hr_{int(time.time())}_{self._request_counter:06d}" + + def _extract_tags(self, headers: dict) -> dict[str, str]: + """Extract Headroom tags from headers.""" + tags = {} + for key, value in headers.items(): + if key.lower().startswith("x-headroom-"): + tag_name = key.lower().replace("x-headroom-", "") + tags[tag_name] = value + return tags + + async def _retry_request( + self, + method: str, + url: str, + headers: dict, + body: dict, + stream: bool = False, + ) -> httpx.Response: + """Make request with retry and exponential backoff.""" + last_error = None + + for attempt in range(self.config.retry_max_attempts): + try: + if stream: + # For streaming, we return early - retry happens at higher level + return await self.http_client.post(url, json=body, headers=headers) + else: + response = await self.http_client.post(url, json=body, headers=headers) + + # Don't retry client errors (4xx) + if 400 <= response.status_code < 500: + return response + + # Retry server errors (5xx) + if response.status_code >= 500: + raise httpx.HTTPStatusError( + f"Server error: {response.status_code}", + request=response.request, + response=response, + ) + + return response + + except (httpx.ConnectError, httpx.ReadTimeout, httpx.HTTPStatusError) as e: + last_error = e + + if not self.config.retry_enabled or attempt >= self.config.retry_max_attempts - 1: + raise + + # Exponential backoff with jitter + delay = min( + self.config.retry_base_delay_ms * (2 ** attempt), + self.config.retry_max_delay_ms, + ) + delay_with_jitter = delay * (0.5 + random.random()) + + logger.warning( + f"Request failed (attempt {attempt + 1}), retrying in {delay_with_jitter:.0f}ms: {e}" + ) + await asyncio.sleep(delay_with_jitter / 1000) + + raise last_error + + async def handle_anthropic_messages( + self, + request: Request, + ) -> Response | StreamingResponse: + """Handle Anthropic /v1/messages endpoint.""" + start_time = time.time() + request_id = self._next_request_id() + + # Parse request + body = await request.json() + model = body.get("model", "unknown") + messages = body.get("messages", []) + stream = body.get("stream", False) + + # Extract headers and tags + headers = {k: v for k, v in request.headers.items()} + headers.pop("host", None) + headers.pop("content-length", None) + tags = self._extract_tags(headers) + + # Rate limiting + if self.rate_limiter: + rate_key = headers.get("x-api-key", "default")[:16] + allowed, wait_seconds = self.rate_limiter.check_request(rate_key) + if not allowed: + self.metrics.record_rate_limited() + raise HTTPException( + status_code=429, + detail=f"Rate limited. Retry after {wait_seconds:.1f}s", + headers={"Retry-After": str(int(wait_seconds) + 1)}, + ) + + # Budget check + if self.cost_tracker: + allowed, remaining = self.cost_tracker.check_budget() + if not allowed: + raise HTTPException( + status_code=429, + detail=f"Budget exceeded for {self.config.budget_period} period", + ) + + # Check cache (non-streaming only) + cache_hit = False + if self.cache and not stream: + cached = self.cache.get(messages, model) + if cached: + cache_hit = True + optimization_latency = (time.time() - start_time) * 1000 + + self.metrics.record_request( + provider="anthropic", + model=model, + input_tokens=0, + output_tokens=0, + tokens_saved=cached.tokens_saved_per_hit, + latency_ms=optimization_latency, + cached=True, + ) + + return Response( + content=cached.response_body, + headers=cached.response_headers, + media_type="application/json", + ) + + # Count original tokens + tokenizer = get_tokenizer(model) + original_tokens = sum( + tokenizer.count_text(str(m.get("content", ""))) + for m in messages + ) + + # Apply optimization + transforms_applied = [] + optimized_messages = messages + optimized_tokens = original_tokens + + if self.config.optimize and messages: + try: + context_limit = self.anthropic_provider.get_context_limit(model) + result = self.anthropic_pipeline.apply( + messages=messages, + model=model, + model_limit=context_limit, + ) + + if result.messages != messages: + optimized_messages = result.messages + transforms_applied = result.transforms_applied + optimized_tokens = sum( + tokenizer.count_text(str(m.get("content", ""))) + for m in optimized_messages + ) + except Exception as e: + logger.warning(f"Optimization failed: {e}") + + tokens_saved = original_tokens - optimized_tokens + optimization_latency = (time.time() - start_time) * 1000 + + # Update body + body["messages"] = optimized_messages + + # Forward request + url = f"{self.ANTHROPIC_API_URL}/v1/messages" + + try: + if stream: + return await self._stream_response( + url, headers, body, "anthropic", model, request_id, + original_tokens, optimized_tokens, tokens_saved, + transforms_applied, tags, optimization_latency, + ) + else: + response = await self._retry_request("POST", url, headers, body) + total_latency = (time.time() - start_time) * 1000 + + # Parse response for output tokens + output_tokens = 0 + try: + resp_json = response.json() + usage = resp_json.get("usage", {}) + output_tokens = usage.get("output_tokens", 0) + except: + pass + + # Calculate cost + cost_usd = None + savings_usd = None + if self.cost_tracker: + cost_usd = self.cost_tracker.estimate_cost( + model, optimized_tokens, output_tokens + ) + original_cost = self.cost_tracker.estimate_cost( + model, original_tokens, output_tokens + ) + if cost_usd and original_cost: + savings_usd = original_cost - cost_usd + self.cost_tracker.record_cost(cost_usd) + self.cost_tracker.record_savings(savings_usd) + + # Cache response + if self.cache and response.status_code == 200: + self.cache.set( + messages, model, + response.content, + dict(response.headers), + tokens_saved=tokens_saved, + ) + + # Record metrics + self.metrics.record_request( + provider="anthropic", + model=model, + input_tokens=optimized_tokens, + output_tokens=output_tokens, + tokens_saved=tokens_saved, + latency_ms=total_latency, + cost_usd=cost_usd or 0, + savings_usd=savings_usd or 0, + ) + + # Log request + if self.logger: + self.logger.log(RequestLog( + request_id=request_id, + timestamp=datetime.now().isoformat(), + provider="anthropic", + model=model, + input_tokens_original=original_tokens, + input_tokens_optimized=optimized_tokens, + output_tokens=output_tokens, + tokens_saved=tokens_saved, + savings_percent=(tokens_saved / original_tokens * 100) if original_tokens > 0 else 0, + estimated_cost_usd=cost_usd, + estimated_savings_usd=savings_usd, + optimization_latency_ms=optimization_latency, + total_latency_ms=total_latency, + tags=tags, + cache_hit=cache_hit, + transforms_applied=transforms_applied, + request_messages=messages if self.config.log_full_messages else None, + )) + + # Log to console + if tokens_saved > 0: + logger.info( + f"[{request_id}] {model}: {original_tokens:,} → {optimized_tokens:,} " + f"(saved {tokens_saved:,} tokens, ${savings_usd:.4f})" if savings_usd else + f"[{request_id}] {model}: {original_tokens:,} → {optimized_tokens:,} " + f"(saved {tokens_saved:,} tokens)" + ) + + return Response( + content=response.content, + status_code=response.status_code, + headers=dict(response.headers), + ) + + except Exception as e: + self.metrics.record_failed() + logger.error(f"[{request_id}] Request failed: {e}") + + # Try fallback if enabled + if self.config.fallback_enabled and self.config.fallback_provider == "openai": + logger.info(f"[{request_id}] Attempting fallback to OpenAI") + # Convert to OpenAI format and retry + # (simplified - would need message format conversion) + + raise HTTPException(status_code=502, detail=str(e)) + + async def _stream_response( + self, + url: str, + headers: dict, + body: dict, + provider: str, + model: str, + request_id: str, + original_tokens: int, + optimized_tokens: int, + tokens_saved: int, + transforms_applied: list[str], + tags: dict[str, str], + optimization_latency: float, + ) -> StreamingResponse: + """Stream response with metrics tracking.""" + start_time = time.time() + + async def generate(): + output_chunks = [] + try: + async with self.http_client.stream("POST", url, json=body, headers=headers) as response: + async for chunk in response.aiter_bytes(): + output_chunks.append(chunk) + yield chunk + finally: + # Record metrics after stream completes + total_latency = (time.time() - start_time) * 1000 + + # Estimate output tokens from chunks (rough) + total_output = b"".join(output_chunks) + output_tokens = len(total_output) // 4 # Rough estimate + + self.metrics.record_request( + provider=provider, + model=model, + input_tokens=optimized_tokens, + output_tokens=output_tokens, + tokens_saved=tokens_saved, + latency_ms=total_latency, + ) + + if tokens_saved > 0: + logger.info( + f"[{request_id}] {model}: saved {tokens_saved:,} tokens (streaming)" + ) + + return StreamingResponse( + generate(), + media_type="text/event-stream", + ) + + async def handle_openai_chat( + self, + request: Request, + ) -> Response | StreamingResponse: + """Handle OpenAI /v1/chat/completions endpoint.""" + start_time = time.time() + request_id = self._next_request_id() + + body = await request.json() + model = body.get("model", "unknown") + messages = body.get("messages", []) + stream = body.get("stream", False) + + headers = {k: v for k, v in request.headers.items()} + headers.pop("host", None) + headers.pop("content-length", None) + tags = self._extract_tags(headers) + + # Rate limiting + if self.rate_limiter: + rate_key = headers.get("authorization", "default")[:20] + allowed, wait_seconds = self.rate_limiter.check_request(rate_key) + if not allowed: + self.metrics.record_rate_limited() + raise HTTPException( + status_code=429, + detail=f"Rate limited. Retry after {wait_seconds:.1f}s", + ) + + # Check cache + cache_hit = False + if self.cache and not stream: + cached = self.cache.get(messages, model) + if cached: + cache_hit = True + self.metrics.record_request( + provider="openai", model=model, + input_tokens=0, output_tokens=0, + tokens_saved=cached.tokens_saved_per_hit, + latency_ms=(time.time() - start_time) * 1000, + cached=True, + ) + return Response(content=cached.response_body, headers=cached.response_headers) + + # Token counting + tokenizer = get_tokenizer(model) + original_tokens = sum( + tokenizer.count_text(str(m.get("content", ""))) + for m in messages + ) + + # Optimization + transforms_applied = [] + optimized_messages = messages + optimized_tokens = original_tokens + + if self.config.optimize and messages: + try: + context_limit = self.openai_provider.get_context_limit(model) + result = self.openai_pipeline.apply( + messages=messages, + model=model, + model_limit=context_limit, + ) + if result.messages != messages: + optimized_messages = result.messages + transforms_applied = result.transforms_applied + optimized_tokens = sum( + tokenizer.count_text(str(m.get("content", ""))) + for m in optimized_messages + ) + except Exception as e: + logger.warning(f"Optimization failed: {e}") + + tokens_saved = original_tokens - optimized_tokens + optimization_latency = (time.time() - start_time) * 1000 + + body["messages"] = optimized_messages + url = f"{self.OPENAI_API_URL}/v1/chat/completions" + + try: + if stream: + return await self._stream_response( + url, headers, body, "openai", model, request_id, + original_tokens, optimized_tokens, tokens_saved, + transforms_applied, tags, optimization_latency, + ) + else: + response = await self._retry_request("POST", url, headers, body) + total_latency = (time.time() - start_time) * 1000 + + output_tokens = 0 + try: + resp_json = response.json() + usage = resp_json.get("usage", {}) + output_tokens = usage.get("completion_tokens", 0) + except: + pass + + # Cost tracking + cost_usd = savings_usd = None + if self.cost_tracker: + cost_usd = self.cost_tracker.estimate_cost(model, optimized_tokens, output_tokens) + original_cost = self.cost_tracker.estimate_cost(model, original_tokens, output_tokens) + if cost_usd and original_cost: + savings_usd = original_cost - cost_usd + self.cost_tracker.record_cost(cost_usd) + self.cost_tracker.record_savings(savings_usd) + + # Cache + if self.cache and response.status_code == 200: + self.cache.set(messages, model, response.content, dict(response.headers), tokens_saved) + + # Metrics + self.metrics.record_request( + provider="openai", model=model, + input_tokens=optimized_tokens, output_tokens=output_tokens, + tokens_saved=tokens_saved, latency_ms=total_latency, + cost_usd=cost_usd or 0, savings_usd=savings_usd or 0, + ) + + if tokens_saved > 0: + logger.info( + f"[{request_id}] {model}: {original_tokens:,} → {optimized_tokens:,} " + f"(saved {tokens_saved:,} tokens)" + ) + + return Response( + content=response.content, + status_code=response.status_code, + headers=dict(response.headers), + ) + except Exception as e: + self.metrics.record_failed() + raise HTTPException(status_code=502, detail=str(e)) + + async def handle_passthrough(self, request: Request, base_url: str) -> Response: + """Pass through request unchanged.""" + path = request.url.path + url = f"{base_url}{path}" + + headers = {k: v for k, v in request.headers.items()} + headers.pop("host", None) + + body = await request.body() + + response = await self.http_client.request( + method=request.method, + url=url, + headers=headers, + content=body, + ) + + return Response( + content=response.content, + status_code=response.status_code, + headers=dict(response.headers), + ) + + +# ============================================================================= +# FastAPI App +# ============================================================================= + +def create_app(config: ProxyConfig | None = None) -> FastAPI: + """Create FastAPI application.""" + if not FASTAPI_AVAILABLE: + raise ImportError("FastAPI required. Install: pip install fastapi uvicorn httpx") + + config = config or ProxyConfig() + + app = FastAPI( + title="Headroom Proxy", + description="Production-ready LLM optimization proxy", + version="1.0.0", + ) + + # CORS + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + proxy = HeadroomProxy(config) + + @app.on_event("startup") + async def startup(): + await proxy.startup() + + @app.on_event("shutdown") + async def shutdown(): + await proxy.shutdown() + + # Health & Metrics + @app.get("/health") + async def health(): + return { + "status": "healthy", + "version": "1.0.0", + "config": { + "optimize": config.optimize, + "cache": config.cache_enabled, + "rate_limit": config.rate_limit_enabled, + } + } + + @app.get("/stats") + async def stats(): + m = proxy.metrics + return { + "requests": { + "total": m.requests_total, + "cached": m.requests_cached, + "rate_limited": m.requests_rate_limited, + "failed": m.requests_failed, + }, + "tokens": { + "input": m.tokens_input_total, + "output": m.tokens_output_total, + "saved": m.tokens_saved_total, + "savings_percent": round( + (m.tokens_saved_total / (m.tokens_input_total + m.tokens_saved_total) * 100) + if m.tokens_input_total > 0 else 0, 2 + ), + }, + "cost": proxy.cost_tracker.stats() if proxy.cost_tracker else None, + "cache": proxy.cache.stats() if proxy.cache else None, + "rate_limiter": proxy.rate_limiter.stats() if proxy.rate_limiter else None, + "recent_requests": proxy.logger.get_recent(10) if proxy.logger else [], + } + + @app.get("/metrics") + async def metrics(): + """Prometheus metrics endpoint.""" + return PlainTextResponse( + proxy.metrics.export(), + media_type="text/plain; version=0.0.4", + ) + + @app.post("/cache/clear") + async def clear_cache(): + """Clear the response cache.""" + if proxy.cache: + proxy.cache.clear() + return {"status": "cleared"} + return {"status": "cache disabled"} + + # Anthropic endpoints + @app.post("/v1/messages") + async def anthropic_messages(request: Request): + return await proxy.handle_anthropic_messages(request) + + @app.post("/v1/messages/count_tokens") + async def anthropic_count_tokens(request: Request): + return await proxy.handle_passthrough(request, proxy.ANTHROPIC_API_URL) + + # OpenAI endpoints + @app.post("/v1/chat/completions") + async def openai_chat(request: Request): + return await proxy.handle_openai_chat(request) + + # Passthrough + @app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"]) + async def passthrough(request: Request, path: str): + if "anthropic" in request.headers.get("user-agent", "").lower(): + base_url = proxy.ANTHROPIC_API_URL + else: + base_url = proxy.OPENAI_API_URL + return await proxy.handle_passthrough(request, base_url) + + return app + + +def run_server(config: ProxyConfig | None = None): + """Run the proxy server.""" + if not FASTAPI_AVAILABLE: + print("ERROR: FastAPI required. Install: pip install fastapi uvicorn httpx") + sys.exit(1) + + config = config or ProxyConfig() + app = create_app(config) + + print(f""" +╔══════════════════════════════════════════════════════════════════════╗ +║ HEADROOM PROXY SERVER ║ +╠══════════════════════════════════════════════════════════════════════╣ +║ Version: 1.0.0 ║ +║ Listening: http://{config.host}:{config.port:<5} ║ +╠══════════════════════════════════════════════════════════════════════╣ +║ FEATURES: ║ +║ Optimization: {'ENABLED ' if config.optimize else 'DISABLED'} ║ +║ Caching: {'ENABLED ' if config.cache_enabled else 'DISABLED'} (TTL: {config.cache_ttl_seconds}s) ║ +║ 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) ║ +║ Retry: {'ENABLED ' if config.retry_enabled else 'DISABLED'} (max {config.retry_max_attempts} attempts) ║ +║ 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'}) ║ +╠══════════════════════════════════════════════════════════════════════╣ +║ USAGE: ║ +║ Claude Code: ANTHROPIC_BASE_URL=http://{config.host}:{config.port} claude ║ +║ Cursor: Set base URL in settings ║ +╠══════════════════════════════════════════════════════════════════════╣ +║ ENDPOINTS: ║ +║ /health Health check ║ +║ /stats Detailed statistics ║ +║ /metrics Prometheus metrics ║ +║ /cache/clear Clear response cache ║ +╚══════════════════════════════════════════════════════════════════════╝ +""") + + uvicorn.run(app, host=config.host, port=config.port, log_level="warning") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Headroom Proxy Server") + + # Server + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8787) + + # Optimization + parser.add_argument("--no-optimize", action="store_true", help="Disable optimization") + parser.add_argument("--min-tokens", type=int, default=500, help="Min tokens to crush") + parser.add_argument("--max-items", type=int, default=50, help="Max items after crush") + + # Caching + parser.add_argument("--no-cache", action="store_true", help="Disable caching") + parser.add_argument("--cache-ttl", type=int, default=3600, help="Cache TTL seconds") + + # Rate limiting + parser.add_argument("--no-rate-limit", action="store_true", help="Disable rate limiting") + parser.add_argument("--rpm", type=int, default=60, help="Requests per minute") + parser.add_argument("--tpm", type=int, default=100000, help="Tokens per minute") + + # Cost + parser.add_argument("--budget", type=float, help="Budget limit in USD") + parser.add_argument("--budget-period", choices=["hourly", "daily", "monthly"], default="daily") + + # Logging + parser.add_argument("--log-file", help="Log file path") + parser.add_argument("--log-messages", action="store_true", help="Log full messages") + + args = parser.parse_args() + + config = ProxyConfig( + host=args.host, + port=args.port, + optimize=not args.no_optimize, + min_tokens_to_crush=args.min_tokens, + max_items_after_crush=args.max_items, + cache_enabled=not args.no_cache, + cache_ttl_seconds=args.cache_ttl, + rate_limit_enabled=not args.no_rate_limit, + rate_limit_requests_per_minute=args.rpm, + rate_limit_tokens_per_minute=args.tpm, + budget_limit_usd=args.budget, + budget_period=args.budget_period, + log_file=args.log_file, + log_full_messages=args.log_messages, + ) + + run_server(config) diff --git a/headroom/py.typed b/headroom/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/headroom/relevance/bm25.py b/headroom/relevance/bm25.py index cd7cbea5f..7d77ed192 100644 --- a/headroom/relevance/bm25.py +++ b/headroom/relevance/bm25.py @@ -20,9 +20,8 @@ from __future__ import annotations import math import re from collections import Counter -from typing import Any -from .base import RelevanceScore, RelevanceScorer, default_batch_score +from .base import RelevanceScore, RelevanceScorer class BM25Scorer(RelevanceScorer): diff --git a/headroom/relevance/embedding.py b/headroom/relevance/embedding.py index 443291fec..48158b957 100644 --- a/headroom/relevance/embedding.py +++ b/headroom/relevance/embedding.py @@ -20,7 +20,7 @@ Limitations: from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING import numpy as np @@ -71,7 +71,7 @@ class EmbeddingScorer(RelevanceScorer): Requires sentence-transformers: pip install headroom[relevance] """ - _model_cache: dict[str, "SentenceTransformer"] = {} + _model_cache: dict[str, SentenceTransformer] = {} def __init__( self, @@ -93,7 +93,7 @@ class EmbeddingScorer(RelevanceScorer): self.model_name = model_name self.device = device self.cache_model = cache_model - self._model: "SentenceTransformer | None" = None + self._model: SentenceTransformer | None = None self._available: bool | None = None @classmethod @@ -110,7 +110,7 @@ class EmbeddingScorer(RelevanceScorer): except ImportError: return False - def _get_model(self) -> "SentenceTransformer": + def _get_model(self) -> SentenceTransformer: """Get or load the sentence transformer model. Returns: diff --git a/headroom/reporting/generator.py b/headroom/reporting/generator.py index 258a88096..1ffdb951d 100644 --- a/headroom/reporting/generator.py +++ b/headroom/reporting/generator.py @@ -11,7 +11,6 @@ from jinja2 import Template from ..storage import create_storage from ..utils import estimate_cost, format_cost - # HTML template embedded as string REPORT_TEMPLATE = """ diff --git a/headroom/storage/base.py b/headroom/storage/base.py index 30be5c772..a71ff6466 100644 --- a/headroom/storage/base.py +++ b/headroom/storage/base.py @@ -3,8 +3,9 @@ from __future__ import annotations from abc import ABC, abstractmethod +from collections.abc import Iterator from datetime import datetime -from typing import Any, Iterator +from typing import Any from ..config import RequestMetrics diff --git a/headroom/storage/jsonl.py b/headroom/storage/jsonl.py index a0a8b7b0a..b44ef2535 100644 --- a/headroom/storage/jsonl.py +++ b/headroom/storage/jsonl.py @@ -3,9 +3,10 @@ from __future__ import annotations import json +from collections.abc import Iterator from datetime import datetime from pathlib import Path -from typing import Any, Iterator +from typing import Any from ..config import RequestMetrics from ..utils import format_timestamp, parse_timestamp diff --git a/headroom/storage/sqlite.py b/headroom/storage/sqlite.py index a1081a252..dbb0343d0 100644 --- a/headroom/storage/sqlite.py +++ b/headroom/storage/sqlite.py @@ -4,9 +4,10 @@ from __future__ import annotations import json import sqlite3 +from collections.abc import Iterator from datetime import datetime from pathlib import Path -from typing import Any, Iterator +from typing import Any from ..config import RequestMetrics from ..utils import format_timestamp, parse_timestamp diff --git a/headroom/tokenizers/__init__.py b/headroom/tokenizers/__init__.py new file mode 100644 index 000000000..7522a188c --- /dev/null +++ b/headroom/tokenizers/__init__.py @@ -0,0 +1,72 @@ +"""Pluggable tokenizer system for universal LLM support. + +This module provides a registry-based tokenizer system that supports +multiple backends: + +1. tiktoken - OpenAI models (GPT-3.5, GPT-4, GPT-4o) +2. HuggingFace - Open models (Llama, Mistral, Falcon, etc.) +3. Anthropic - Claude models (via SDK or estimation) +4. Estimation - Fallback for unknown models + +Usage: + from headroom.tokenizers import TokenizerRegistry, get_tokenizer + + # Auto-detect tokenizer from model name + tokenizer = get_tokenizer("gpt-4o") + tokens = tokenizer.count_text("Hello, world!") + + # Get tokenizer for specific backend + tokenizer = get_tokenizer("llama-3-8b", backend="huggingface") + + # Register custom tokenizer + TokenizerRegistry.register("my-model", my_tokenizer) +""" + +from .base import BaseTokenizer, TokenCounter +from .estimator import CharacterCounter, EstimatingTokenCounter +from .registry import ( + TokenizerRegistry, + get_tokenizer, + list_supported_models, + register_tokenizer, +) +from .tiktoken_counter import TiktokenCounter + + +# Lazy imports for optional dependencies +def get_huggingface_tokenizer(): + """Get HuggingFaceTokenizer class (requires transformers).""" + from .huggingface import HuggingFaceTokenizer + return HuggingFaceTokenizer + + +def get_mistral_tokenizer(): + """Get MistralTokenizer class (requires mistral-common).""" + from .mistral import MistralTokenizer + return MistralTokenizer + + +def is_mistral_tokenizer_available() -> bool: + """Check if Mistral tokenizer is available.""" + from .mistral import is_mistral_available + return is_mistral_available() + + +__all__ = [ + # Registry + "TokenizerRegistry", + "get_tokenizer", + "register_tokenizer", + "list_supported_models", + # Base classes + "TokenCounter", + "BaseTokenizer", + # Implementations + "TiktokenCounter", + "EstimatingTokenCounter", + "CharacterCounter", + # Lazy loaders + "get_huggingface_tokenizer", + "get_mistral_tokenizer", + "is_mistral_tokenizer_available", +] diff --git a/headroom/tokenizers/base.py b/headroom/tokenizers/base.py new file mode 100644 index 000000000..21a95397a --- /dev/null +++ b/headroom/tokenizers/base.py @@ -0,0 +1,203 @@ +"""Base classes for tokenizer implementations. + +Defines the TokenCounter protocol and BaseTokenizer class that all +tokenizer backends must implement. +""" + +from __future__ import annotations + +import json +from abc import ABC, abstractmethod +from typing import Any, Protocol, runtime_checkable + + +@runtime_checkable +class TokenCounter(Protocol): + """Protocol for token counting implementations. + + Any class implementing this protocol can be used with Headroom + for token counting. This allows integration with various + tokenizer backends (tiktoken, HuggingFace, custom, etc.). + """ + + def count_text(self, text: str) -> int: + """Count tokens in a text string. + + Args: + text: The text to count tokens for. + + Returns: + Number of tokens in the text. + """ + ... + + def count_messages(self, messages: list[dict[str, Any]]) -> int: + """Count tokens in a list of chat messages. + + Args: + messages: List of message dicts with 'role' and 'content'. + + Returns: + Total token count including message overhead. + """ + ... + + +class BaseTokenizer(ABC): + """Abstract base class for tokenizer implementations. + + Provides common functionality for counting messages while + requiring subclasses to implement text tokenization. + """ + + # Token overhead per message (role, formatting, etc.) + # Override in subclasses for model-specific overhead + MESSAGE_OVERHEAD = 4 + REPLY_OVERHEAD = 3 # Assistant reply start tokens + + @abstractmethod + def count_text(self, text: str) -> int: + """Count tokens in a text string. Must be implemented by subclasses.""" + pass + + def count_messages(self, messages: list[dict[str, Any]]) -> int: + """Count tokens in a list of chat messages. + + Uses OpenAI-style message counting as the baseline, which + works well for most models. + + Args: + messages: List of message dicts. + + Returns: + Total token count. + """ + total = 0 + + for message in messages: + # Base message overhead + total += self.MESSAGE_OVERHEAD + + # Count role + role = message.get("role", "") + total += self.count_text(role) + + # Count content + content = message.get("content") + if content is not None: + if isinstance(content, str): + total += self.count_text(content) + elif isinstance(content, list): + # Multi-part content (images, tool results, etc.) + total += self._count_content_parts(content) + + # Count tool calls + tool_calls = message.get("tool_calls") + if tool_calls: + total += self._count_tool_calls(tool_calls) + + # Count function call (legacy) + function_call = message.get("function_call") + if function_call: + total += self._count_function_call(function_call) + + # Count name field + name = message.get("name") + if name: + total += self.count_text(name) + total += 1 # Name field overhead + + # Reply start overhead + total += self.REPLY_OVERHEAD + + return total + + def _count_content_parts(self, parts: list[Any]) -> int: + """Count tokens in multi-part content.""" + total = 0 + for part in parts: + if isinstance(part, dict): + part_type = part.get("type", "") + + if part_type == "text": + total += self.count_text(part.get("text", "")) + elif part_type == "image_url": + # Images have fixed token cost (varies by model) + total += 85 # Base image token count + elif part_type == "tool_result": + content = part.get("content", "") + if isinstance(content, str): + total += self.count_text(content) + else: + total += self.count_text(json.dumps(content)) + elif part_type == "tool_use": + total += self.count_text(part.get("name", "")) + total += self.count_text(json.dumps(part.get("input", {}))) + else: + # Unknown type - estimate from JSON + total += self.count_text(json.dumps(part)) + elif isinstance(part, str): + total += self.count_text(part) + + return total + + def _count_tool_calls(self, tool_calls: list[dict[str, Any]]) -> int: + """Count tokens in tool calls.""" + total = 0 + for call in tool_calls: + total += 4 # Tool call overhead + + if "function" in call: + func = call["function"] + total += self.count_text(func.get("name", "")) + total += self.count_text(func.get("arguments", "")) + + if "id" in call: + total += self.count_text(call["id"]) + + return total + + def _count_function_call(self, function_call: dict[str, Any]) -> int: + """Count tokens in legacy function call.""" + total = 4 # Function call overhead + total += self.count_text(function_call.get("name", "")) + total += self.count_text(function_call.get("arguments", "")) + return total + + def encode(self, text: str) -> list[int]: + """Encode text to token IDs. + + Optional method - not all backends support encoding. + Default implementation raises NotImplementedError. + + Args: + text: Text to encode. + + Returns: + List of token IDs. + + Raises: + NotImplementedError: If encoding is not supported. + """ + raise NotImplementedError( + f"{self.__class__.__name__} does not support encoding" + ) + + def decode(self, tokens: list[int]) -> str: + """Decode token IDs to text. + + Optional method - not all backends support decoding. + Default implementation raises NotImplementedError. + + Args: + tokens: List of token IDs. + + Returns: + Decoded text. + + Raises: + NotImplementedError: If decoding is not supported. + """ + raise NotImplementedError( + f"{self.__class__.__name__} does not support decoding" + ) diff --git a/headroom/tokenizers/estimator.py b/headroom/tokenizers/estimator.py new file mode 100644 index 000000000..d2537c019 --- /dev/null +++ b/headroom/tokenizers/estimator.py @@ -0,0 +1,199 @@ +"""Estimation-based token counter for fallback scenarios. + +When no exact tokenizer is available (e.g., unknown models, missing +dependencies), this provides a reasonable approximation based on +character/word heuristics calibrated against real tokenizers. +""" + +from __future__ import annotations + +import json +import re +from typing import Any + +from .base import BaseTokenizer + + +class EstimatingTokenCounter(BaseTokenizer): + """Token counter using estimation heuristics. + + This is the fallback tokenizer used when: + - Model is unknown/unsupported + - Required tokenizer library not installed + - Speed is prioritized over accuracy + + The estimation is calibrated against tiktoken cl100k_base and + provides ~90% accuracy for typical text. It tends to slightly + overestimate, which is safer for context window management. + + Estimation Strategy: + - Base: ~4 characters per token (calibrated against GPT-4) + - Adjustments for code, URLs, numbers, whitespace + - Special handling for JSON structure + + Example: + counter = EstimatingTokenCounter() + tokens = counter.count_text("Hello, world!") + print(f"Estimated tokens: {tokens}") + """ + + # Calibration constants (derived from tiktoken analysis) + CHARS_PER_TOKEN = 4.0 # Average for English text + CHARS_PER_TOKEN_CODE = 3.5 # Code is denser + CHARS_PER_TOKEN_JSON = 3.2 # JSON has more structure + + # Patterns for content type detection + CODE_PATTERN = re.compile( + r'(?:def |class |function |const |let |var |import |from |' + r'if \(|for \(|while \(|switch \(|try \{|catch \(|' + r'=>|->|\{\{|\}\}|;$)', + re.MULTILINE + ) + JSON_PATTERN = re.compile(r'^\s*[\[\{]') + URL_PATTERN = re.compile(r'https?://\S+') + UUID_PATTERN = re.compile( + r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}', + re.IGNORECASE + ) + + def __init__(self, chars_per_token: float | None = None): + """Initialize estimating counter. + + Args: + chars_per_token: Override default chars per token ratio. + If None, auto-detects based on content type. + """ + self._fixed_ratio = chars_per_token + + def count_text(self, text: str) -> int: + """Estimate token count for text. + + Args: + text: Text to count tokens for. + + Returns: + Estimated number of tokens. + """ + if not text: + return 0 + + # Use fixed ratio if provided + if self._fixed_ratio is not None: + return max(1, int(len(text) / self._fixed_ratio + 0.5)) + + # Auto-detect content type and adjust ratio + ratio = self._detect_ratio(text) + + # Apply ratio with minimum of 1 token + base_count = int(len(text) / ratio + 0.5) + + # Add overhead for special patterns + overhead = self._count_special_overhead(text) + + return max(1, base_count + overhead) + + def _detect_ratio(self, text: str) -> float: + """Detect optimal chars-per-token ratio based on content. + + Args: + text: Text to analyze. + + Returns: + Chars per token ratio. + """ + # Check for JSON + if self.JSON_PATTERN.match(text): + try: + json.loads(text) + return self.CHARS_PER_TOKEN_JSON + except (json.JSONDecodeError, ValueError): + pass + + # Check for code + code_matches = len(self.CODE_PATTERN.findall(text)) + if code_matches > len(text) / 500: # ~2 matches per KB + return self.CHARS_PER_TOKEN_CODE + + return self.CHARS_PER_TOKEN + + def _count_special_overhead(self, text: str) -> int: + """Count additional tokens for special patterns. + + URLs and UUIDs often tokenize into more tokens than + character count would suggest. + + Args: + text: Text to analyze. + + Returns: + Additional token overhead. + """ + overhead = 0 + + # URLs typically tokenize to more tokens + urls = self.URL_PATTERN.findall(text) + for url in urls: + # Each URL component adds overhead + overhead += url.count('/') + url.count('?') + url.count('&') + + # UUIDs are typically 8-10 tokens despite being 36 chars + uuids = self.UUID_PATTERN.findall(text) + overhead += len(uuids) * 2 # Each UUID adds ~2 extra tokens + + return overhead + + def count_messages(self, messages: list[dict[str, Any]]) -> int: + """Estimate tokens in chat messages. + + Uses the base class implementation with estimation-based + text counting. + + Args: + messages: List of chat messages. + + Returns: + Estimated total token count. + """ + # Use base class implementation + return super().count_messages(messages) + + def __repr__(self) -> str: + if self._fixed_ratio: + return f"EstimatingTokenCounter(chars_per_token={self._fixed_ratio})" + return "EstimatingTokenCounter(auto)" + + +class CharacterCounter(BaseTokenizer): + """Simple character-based counter. + + Uses a fixed character-to-token ratio. Useful for: + - Quick approximations + - Testing + - Models with unknown tokenization + + This is less accurate than EstimatingTokenCounter but faster. + """ + + def __init__(self, chars_per_token: float = 4.0): + """Initialize character counter. + + Args: + chars_per_token: Characters per token ratio. + """ + self.chars_per_token = chars_per_token + + def count_text(self, text: str) -> int: + """Count tokens based on character count. + + Args: + text: Text to count. + + Returns: + Estimated token count. + """ + if not text: + return 0 + return max(1, int(len(text) / self.chars_per_token + 0.5)) + + def __repr__(self) -> str: + return f"CharacterCounter(chars_per_token={self.chars_per_token})" diff --git a/headroom/tokenizers/huggingface.py b/headroom/tokenizers/huggingface.py new file mode 100644 index 000000000..3cac028f8 --- /dev/null +++ b/headroom/tokenizers/huggingface.py @@ -0,0 +1,316 @@ +"""HuggingFace tokenizer wrapper for open models. + +Supports Llama, Mistral, Falcon, and other models with HuggingFace +tokenizers. Requires the `transformers` library. +""" + +from __future__ import annotations + +import logging +from functools import lru_cache +from typing import Any + +from .base import BaseTokenizer + +logger = logging.getLogger(__name__) + + +# Model name to HuggingFace tokenizer mapping +# Maps common model names to their HuggingFace tokenizer identifiers +MODEL_TO_TOKENIZER: dict[str, str] = { + # Llama 3 family + "llama-3": "meta-llama/Meta-Llama-3-8B", + "llama-3-8b": "meta-llama/Meta-Llama-3-8B", + "llama-3-70b": "meta-llama/Meta-Llama-3-70B", + "llama-3.1-8b": "meta-llama/Llama-3.1-8B", + "llama-3.1-70b": "meta-llama/Llama-3.1-70B", + "llama-3.1-405b": "meta-llama/Llama-3.1-405B", + "llama-3.2-1b": "meta-llama/Llama-3.2-1B", + "llama-3.2-3b": "meta-llama/Llama-3.2-3B", + "llama-3.3-70b": "meta-llama/Llama-3.3-70B-Instruct", + # Llama 2 family + "llama-2": "meta-llama/Llama-2-7b-hf", + "llama-2-7b": "meta-llama/Llama-2-7b-hf", + "llama-2-13b": "meta-llama/Llama-2-13b-hf", + "llama-2-70b": "meta-llama/Llama-2-70b-hf", + # CodeLlama + "codellama": "codellama/CodeLlama-7b-hf", + "codellama-7b": "codellama/CodeLlama-7b-hf", + "codellama-13b": "codellama/CodeLlama-13b-hf", + "codellama-34b": "codellama/CodeLlama-34b-hf", + # Mistral family + "mistral": "mistralai/Mistral-7B-v0.1", + "mistral-7b": "mistralai/Mistral-7B-v0.1", + "mistral-7b-v0.2": "mistralai/Mistral-7B-Instruct-v0.2", + "mistral-7b-v0.3": "mistralai/Mistral-7B-Instruct-v0.3", + "mistral-nemo": "mistralai/Mistral-Nemo-Base-2407", + "mistral-small": "mistralai/Mistral-Small-Instruct-2409", + "mistral-large": "mistralai/Mistral-Large-Instruct-2407", + # Mixtral + "mixtral": "mistralai/Mixtral-8x7B-v0.1", + "mixtral-8x7b": "mistralai/Mixtral-8x7B-v0.1", + "mixtral-8x22b": "mistralai/Mixtral-8x22B-v0.1", + # Qwen family + "qwen": "Qwen/Qwen-7B", + "qwen-7b": "Qwen/Qwen-7B", + "qwen-14b": "Qwen/Qwen-14B", + "qwen-72b": "Qwen/Qwen-72B", + "qwen2": "Qwen/Qwen2-7B", + "qwen2-7b": "Qwen/Qwen2-7B", + "qwen2-72b": "Qwen/Qwen2-72B", + "qwen2.5": "Qwen/Qwen2.5-7B", + "qwen2.5-7b": "Qwen/Qwen2.5-7B", + "qwen2.5-72b": "Qwen/Qwen2.5-72B", + # DeepSeek + "deepseek": "deepseek-ai/deepseek-llm-7b-base", + "deepseek-7b": "deepseek-ai/deepseek-llm-7b-base", + "deepseek-67b": "deepseek-ai/deepseek-llm-67b-base", + "deepseek-coder": "deepseek-ai/deepseek-coder-6.7b-base", + "deepseek-v2": "deepseek-ai/DeepSeek-V2", + "deepseek-v3": "deepseek-ai/DeepSeek-V3", + # Yi family + "yi": "01-ai/Yi-6B", + "yi-6b": "01-ai/Yi-6B", + "yi-34b": "01-ai/Yi-34B", + "yi-1.5": "01-ai/Yi-1.5-6B", + # Phi family + "phi-2": "microsoft/phi-2", + "phi-3": "microsoft/Phi-3-mini-4k-instruct", + "phi-3-mini": "microsoft/Phi-3-mini-4k-instruct", + "phi-3-small": "microsoft/Phi-3-small-8k-instruct", + "phi-3-medium": "microsoft/Phi-3-medium-4k-instruct", + # Falcon + "falcon": "tiiuae/falcon-7b", + "falcon-7b": "tiiuae/falcon-7b", + "falcon-40b": "tiiuae/falcon-40b", + "falcon-180b": "tiiuae/falcon-180B", + # StarCoder + "starcoder": "bigcode/starcoder", + "starcoder2": "bigcode/starcoder2-15b", + "starcoder2-3b": "bigcode/starcoder2-3b", + "starcoder2-7b": "bigcode/starcoder2-7b", + "starcoder2-15b": "bigcode/starcoder2-15b", + # MPT + "mpt-7b": "mosaicml/mpt-7b", + "mpt-30b": "mosaicml/mpt-30b", + # Gemma + "gemma": "google/gemma-7b", + "gemma-2b": "google/gemma-2b", + "gemma-7b": "google/gemma-7b", + "gemma-2": "google/gemma-2-9b", + "gemma-2-9b": "google/gemma-2-9b", + "gemma-2-27b": "google/gemma-2-27b", +} + + +@lru_cache(maxsize=16) +def _load_tokenizer(tokenizer_name: str): + """Load and cache HuggingFace tokenizer. + + Args: + tokenizer_name: HuggingFace model/tokenizer name. + + Returns: + Loaded tokenizer, or None if unavailable. + """ + from transformers import AutoTokenizer + + try: + return AutoTokenizer.from_pretrained( + tokenizer_name, + trust_remote_code=True, + ) + except Exception as e: + logger.warning(f"Failed to load tokenizer {tokenizer_name}: {e}") + return None + + +def get_tokenizer_name(model: str) -> str: + """Get HuggingFace tokenizer name for a model. + + Args: + model: Model name. + + Returns: + HuggingFace tokenizer identifier. + """ + model_lower = model.lower() + + # Direct lookup + if model_lower in MODEL_TO_TOKENIZER: + return MODEL_TO_TOKENIZER[model_lower] + + # Try prefix matching + for key, value in MODEL_TO_TOKENIZER.items(): + if model_lower.startswith(key): + return value + + # Assume model name is the tokenizer name + return model + + +class HuggingFaceTokenizer(BaseTokenizer): + """Token counter using HuggingFace tokenizers. + + Supports any model with a HuggingFace tokenizer, including: + - Llama family (Llama 2, Llama 3, CodeLlama) + - Mistral family (Mistral, Mixtral) + - Qwen family + - DeepSeek family + - Phi family + - Falcon, StarCoder, MPT, Gemma, etc. + + Requires the `transformers` library: + pip install transformers + + Some models may require authentication: + huggingface-cli login + + Example: + counter = HuggingFaceTokenizer("llama-3-8b") + tokens = counter.count_text("Hello, world!") + """ + + # Overhead per message (varies by model, this is a reasonable default) + MESSAGE_OVERHEAD = 4 + REPLY_OVERHEAD = 3 + + def __init__(self, model: str): + """Initialize HuggingFace tokenizer. + + Args: + model: Model name (e.g., 'llama-3-8b', 'mistral-7b'). + """ + self.model = model + self.tokenizer_name = get_tokenizer_name(model) + self._tokenizer = None # Lazy load + + @property + def tokenizer(self): + """Lazy-load the tokenizer.""" + if self._tokenizer is None: + loaded = _load_tokenizer(self.tokenizer_name) + if loaded is not None: + self._tokenizer = loaded + else: + # Mark as unavailable + self._tokenizer = False + return self._tokenizer if self._tokenizer is not False else None + + def _use_fallback(self) -> bool: + """Check if we need to use fallback estimation.""" + return self.tokenizer is None + + def count_text(self, text: str) -> int: + """Count tokens in text. + + Falls back to estimation if tokenizer unavailable. + + Args: + text: Text to tokenize. + + Returns: + Number of tokens. + """ + if not text: + return 0 + if self._use_fallback(): + # Fall back to ~4 chars per token estimation + return max(1, int(len(text) / 4 + 0.5)) + tokens = self.tokenizer.encode(text, add_special_tokens=False) + return len(tokens) + + def count_messages(self, messages: list[dict[str, Any]]) -> int: + """Count tokens in chat messages. + + Uses the model's chat template if available, otherwise + falls back to base class implementation. + + Args: + messages: List of chat messages. + + Returns: + Total token count. + """ + if self._use_fallback(): + # Use base class implementation with estimation + return super().count_messages(messages) + + # Try to use chat template for accurate counting + if hasattr(self.tokenizer, "apply_chat_template"): + try: + # Apply chat template and count + formatted = self.tokenizer.apply_chat_template( + messages, + tokenize=True, + add_generation_prompt=True, + ) + return len(formatted) + except Exception: + # Fall back to base implementation + pass + + return super().count_messages(messages) + + def encode(self, text: str) -> list[int]: + """Encode text to token IDs. + + Args: + text: Text to encode. + + Returns: + List of token IDs. + + Raises: + NotImplementedError: If tokenizer not available. + """ + if self._use_fallback(): + raise NotImplementedError( + f"Encoding not available for {self.model} - " + f"tokenizer {self.tokenizer_name} could not be loaded" + ) + return self.tokenizer.encode(text, add_special_tokens=False) + + def decode(self, tokens: list[int]) -> str: + """Decode token IDs to text. + + Args: + tokens: List of token IDs. + + Returns: + Decoded text. + + Raises: + NotImplementedError: If tokenizer not available. + """ + if self._use_fallback(): + raise NotImplementedError( + f"Decoding not available for {self.model} - " + f"tokenizer {self.tokenizer_name} could not be loaded" + ) + return self.tokenizer.decode(tokens) + + @classmethod + def is_available(cls) -> bool: + """Check if HuggingFace tokenizers are available. + + Returns: + True if transformers is installed. + """ + try: + import transformers + return True + except ImportError: + return False + + @classmethod + def list_supported_models(cls) -> list[str]: + """List models with known tokenizer mappings. + + Returns: + List of supported model names. + """ + return list(MODEL_TO_TOKENIZER.keys()) + + def __repr__(self) -> str: + return f"HuggingFaceTokenizer(model={self.model!r}, tokenizer={self.tokenizer_name!r})" diff --git a/headroom/tokenizers/mistral.py b/headroom/tokenizers/mistral.py new file mode 100644 index 000000000..12317b84b --- /dev/null +++ b/headroom/tokenizers/mistral.py @@ -0,0 +1,244 @@ +"""Mistral tokenizer using the official mistral-common package. + +Mistral AI released their tokenizer publicly, making accurate +token counting possible without API calls. + +Requires: pip install mistral-common +""" + +from __future__ import annotations + +import logging +from functools import lru_cache +from typing import Any + +from .base import BaseTokenizer + +logger = logging.getLogger(__name__) + +# Check if mistral-common is available +try: + from mistral_common.protocol.instruct.messages import ( + AssistantMessage, + SystemMessage, + UserMessage, + ) + from mistral_common.protocol.instruct.request import ChatCompletionRequest + from mistral_common.tokens.tokenizers.mistral import MistralTokenizer as _MistralTokenizer + MISTRAL_AVAILABLE = True +except ImportError: + MISTRAL_AVAILABLE = False + _MistralTokenizer = None + + +def is_mistral_available() -> bool: + """Check if mistral-common is installed.""" + return MISTRAL_AVAILABLE + + +# Model to tokenizer version mapping +MODEL_TO_VERSION = { + # Mistral models use v3 tokenizer (tekken) + "mistral-large": "v3", + "mistral-large-latest": "v3", + "mistral-small": "v3", + "mistral-small-latest": "v3", + "ministral-8b": "v3", + "ministral-3b": "v3", + "mistral-nemo": "v3", + "pixtral-12b": "v3", + "codestral": "v3", + "codestral-latest": "v3", + # Mixtral uses v1 + "mixtral-8x7b": "v1", + "mixtral-8x22b": "v1", + "open-mixtral-8x7b": "v1", + "open-mixtral-8x22b": "v1", + # Mistral 7B uses v1 + "mistral-7b": "v1", + "open-mistral-7b": "v1", + "mistral-7b-instruct": "v1", +} + + +@lru_cache(maxsize=4) +def _get_tokenizer(version: str): + """Get and cache Mistral tokenizer by version.""" + if not MISTRAL_AVAILABLE: + raise RuntimeError( + "mistral-common is required for MistralTokenizer. " + "Install with: pip install mistral-common" + ) + + if version == "v3": + return _MistralTokenizer.v3(is_tekken=True) + elif version == "v2": + return _MistralTokenizer.v2() + else: # v1 + return _MistralTokenizer.v1() + + +def get_tokenizer_version(model: str) -> str: + """Get tokenizer version for a model.""" + model_lower = model.lower() + + # Direct lookup + if model_lower in MODEL_TO_VERSION: + return MODEL_TO_VERSION[model_lower] + + # Prefix matching + for prefix, version in [ + ("mistral-large", "v3"), + ("mistral-small", "v3"), + ("ministral", "v3"), + ("codestral", "v3"), + ("pixtral", "v3"), + ("mistral-nemo", "v3"), + ("mixtral", "v1"), + ("mistral-7b", "v1"), + ("open-mistral", "v1"), + ]: + if model_lower.startswith(prefix): + return version + + # Default to v3 for newer models + return "v3" + + +class MistralTokenizer(BaseTokenizer): + """Token counter using Mistral's official tokenizer. + + Uses mistral-common package for accurate token counting. + + Requires: pip install mistral-common + + Example: + counter = MistralTokenizer("mistral-large") + tokens = counter.count_text("Hello, world!") + """ + + MESSAGE_OVERHEAD = 4 + REPLY_OVERHEAD = 3 + + def __init__(self, model: str = "mistral-large"): + """Initialize Mistral tokenizer. + + Args: + model: Mistral model name. + """ + if not MISTRAL_AVAILABLE: + raise RuntimeError( + "mistral-common is required for MistralTokenizer. " + "Install with: pip install mistral-common" + ) + + self.model = model + self.version = get_tokenizer_version(model) + self._tokenizer = None # Lazy load + + @property + def tokenizer(self): + """Lazy-load the tokenizer (MistralTokenizer object).""" + if self._tokenizer is None: + self._tokenizer = _get_tokenizer(self.version) + return self._tokenizer + + @property + def _text_tokenizer(self): + """Get the underlying text tokenizer for encode/decode.""" + return self.tokenizer.instruct_tokenizer.tokenizer + + def count_text(self, text: str) -> int: + """Count tokens in text. + + Args: + text: Text to tokenize. + + Returns: + Number of tokens. + """ + if not text: + return 0 + tokens = self._text_tokenizer.encode(text, bos=False, eos=False) + return len(tokens) + + def count_messages(self, messages: list[dict[str, Any]]) -> int: + """Count tokens in chat messages. + + Uses Mistral's chat template for accurate counting. + + Args: + messages: List of chat messages. + + Returns: + Total token count. + """ + if not messages: + return 0 + + try: + # Convert to Mistral message format + mistral_messages = [] + for msg in messages: + role = msg.get("role", "user") + content = msg.get("content", "") + + if isinstance(content, list): + # Multi-part content - extract text + text_parts = [] + for part in content: + if isinstance(part, dict) and part.get("type") == "text": + text_parts.append(part.get("text", "")) + elif isinstance(part, str): + text_parts.append(part) + content = "\n".join(text_parts) + + if role == "user": + mistral_messages.append(UserMessage(content=content)) + elif role == "assistant": + mistral_messages.append(AssistantMessage(content=content)) + elif role == "system": + mistral_messages.append(SystemMessage(content=content)) + else: + # Tool messages etc - treat as user + mistral_messages.append(UserMessage(content=content)) + + # Encode with chat template + request = ChatCompletionRequest(messages=mistral_messages) + tokenized = self.tokenizer.encode_chat_completion(request) + return len(tokenized.tokens) + + except Exception as e: + logger.debug(f"Mistral chat encoding failed: {e}, falling back to text counting") + # Fallback to base implementation + return super().count_messages(messages) + + def encode(self, text: str) -> list[int]: + """Encode text to token IDs. + + Args: + text: Text to encode. + + Returns: + List of token IDs. + """ + return self._text_tokenizer.encode(text, bos=False, eos=False) + + def decode(self, tokens: list[int]) -> str: + """Decode token IDs to text. + + Args: + tokens: List of token IDs. + + Returns: + Decoded text. + """ + return self._text_tokenizer.decode(tokens) + + @classmethod + def is_available(cls) -> bool: + """Check if Mistral tokenizer is available.""" + return MISTRAL_AVAILABLE + + def __repr__(self) -> str: + return f"MistralTokenizer(model={self.model!r}, version={self.version!r})" diff --git a/headroom/tokenizers/registry.py b/headroom/tokenizers/registry.py new file mode 100644 index 000000000..4934868da --- /dev/null +++ b/headroom/tokenizers/registry.py @@ -0,0 +1,398 @@ +"""Tokenizer registry for universal model support. + +Provides automatic tokenizer selection based on model name with +support for multiple backends and custom tokenizers. +""" + +from __future__ import annotations + +import logging +import re +from collections.abc import Callable +from typing import TYPE_CHECKING + +from .base import TokenCounter +from .estimator import EstimatingTokenCounter + +if TYPE_CHECKING: + pass + +logger = logging.getLogger(__name__) + + +# Model pattern matching for tokenizer selection +# Order matters - more specific patterns first +MODEL_PATTERNS: list[tuple[str, str]] = [ + # OpenAI models -> tiktoken + (r"^gpt-4o", "tiktoken"), + (r"^gpt-4", "tiktoken"), + (r"^gpt-3\.5", "tiktoken"), + (r"^o1", "tiktoken"), + (r"^o3", "tiktoken"), + (r"^text-embedding", "tiktoken"), + (r"^text-davinci", "tiktoken"), + (r"^code-", "tiktoken"), + (r"^davinci", "tiktoken"), + (r"^curie", "tiktoken"), + (r"^babbage", "tiktoken"), + (r"^ada", "tiktoken"), + # Anthropic models -> estimation (Claude uses custom tokenizer) + (r"^claude-", "anthropic"), + # Llama family -> huggingface (when available) + (r"^llama", "huggingface"), + (r"^meta-llama", "huggingface"), + (r"^codellama", "huggingface"), + # Mistral family -> official mistral tokenizer + (r"^mistral", "mistral"), + (r"^mixtral", "mistral"), + (r"^codestral", "mistral"), + (r"^ministral", "mistral"), + (r"^pixtral", "mistral"), + # Google models -> estimation (Gemini uses SentencePiece) + (r"^gemini", "google"), + (r"^palm", "google"), + # Cohere models -> estimation + (r"^command", "cohere"), + # Open models commonly served via OpenAI-compatible APIs + (r"^phi-", "huggingface"), + (r"^qwen", "huggingface"), + (r"^deepseek", "huggingface"), + (r"^yi-", "huggingface"), + (r"^falcon", "huggingface"), + (r"^mpt-", "huggingface"), + (r"^starcoder", "huggingface"), + (r"^codegen", "huggingface"), +] + + +class TokenizerRegistry: + """Registry for tokenizer instances and factories. + + Supports: + - Automatic tokenizer selection based on model name + - Custom tokenizer registration + - Multiple backends (tiktoken, huggingface, estimation) + - Lazy loading of tokenizer dependencies + + Example: + # Auto-detect tokenizer + tokenizer = TokenizerRegistry.get("gpt-4o") + + # Register custom tokenizer + TokenizerRegistry.register("my-model", my_tokenizer) + + # Use specific backend + tokenizer = TokenizerRegistry.get("llama-3", backend="huggingface") + """ + + # Singleton registry instance + _instance: TokenizerRegistry | None = None + + # Registered tokenizers (model -> tokenizer instance) + _tokenizers: dict[str, TokenCounter] = {} + + # Registered factories (backend -> factory function) + _factories: dict[str, Callable[[str], TokenCounter]] = {} + + # Cache for auto-detected tokenizers + _cache: dict[str, TokenCounter] = {} + + def __new__(cls) -> TokenizerRegistry: + """Singleton pattern.""" + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._init_factories() + return cls._instance + + def _init_factories(self) -> None: + """Initialize default tokenizer factories.""" + self._factories = { + "tiktoken": self._create_tiktoken, + "huggingface": self._create_huggingface, + "anthropic": self._create_anthropic, + "google": self._create_google, + "cohere": self._create_cohere, + "mistral": self._create_mistral, + "estimation": self._create_estimation, + } + + @classmethod + def get( + cls, + model: str, + backend: str | None = None, + fallback: bool = True, + ) -> TokenCounter: + """Get tokenizer for a model. + + Args: + model: Model name (e.g., 'gpt-4o', 'claude-3-sonnet'). + backend: Force specific backend ('tiktoken', 'huggingface', etc.). + If None, auto-detects based on model name. + fallback: If True, fall back to estimation on errors. + + Returns: + TokenCounter instance for the model. + + Raises: + ValueError: If backend not found and fallback=False. + """ + registry = cls() + model_lower = model.lower() + + # Check for explicitly registered tokenizer + if model_lower in registry._tokenizers: + return registry._tokenizers[model_lower] + + # Check cache + cache_key = f"{model_lower}:{backend or 'auto'}" + if cache_key in registry._cache: + return registry._cache[cache_key] + + # Create tokenizer + try: + tokenizer = registry._create_tokenizer(model, backend) + registry._cache[cache_key] = tokenizer + return tokenizer + except Exception as e: + if fallback: + logger.warning( + f"Failed to create tokenizer for {model}: {e}. " + "Falling back to estimation." + ) + tokenizer = EstimatingTokenCounter() + registry._cache[cache_key] = tokenizer + return tokenizer + raise ValueError(f"No tokenizer available for {model}: {e}") from e + + @classmethod + def register( + cls, + model: str, + tokenizer: TokenCounter | None = None, + factory: Callable[[str], TokenCounter] | None = None, + ) -> None: + """Register a tokenizer or factory for a model. + + Args: + model: Model name to register. + tokenizer: Pre-instantiated tokenizer instance. + factory: Factory function that creates tokenizer for model. + + Raises: + ValueError: If neither tokenizer nor factory provided. + """ + registry = cls() + model_lower = model.lower() + + if tokenizer is not None: + registry._tokenizers[model_lower] = tokenizer + elif factory is not None: + registry._factories[model_lower] = factory + else: + raise ValueError("Must provide either tokenizer or factory") + + # Clear cache for this model + keys_to_remove = [k for k in registry._cache if k.startswith(model_lower)] + for key in keys_to_remove: + del registry._cache[key] + + @classmethod + def register_backend( + cls, + backend: str, + factory: Callable[[str], TokenCounter], + ) -> None: + """Register a backend factory. + + Args: + backend: Backend name. + factory: Factory function (model: str) -> TokenCounter. + """ + registry = cls() + registry._factories[backend] = factory + + @classmethod + def list_backends(cls) -> list[str]: + """List available backends.""" + registry = cls() + return list(registry._factories.keys()) + + @classmethod + def list_registered(cls) -> list[str]: + """List explicitly registered models.""" + registry = cls() + return list(registry._tokenizers.keys()) + + @classmethod + def clear_cache(cls) -> None: + """Clear the tokenizer cache.""" + registry = cls() + registry._cache.clear() + + def _create_tokenizer( + self, + model: str, + backend: str | None, + ) -> TokenCounter: + """Create tokenizer for model. + + Args: + model: Model name. + backend: Backend to use (or None for auto-detect). + + Returns: + TokenCounter instance. + """ + if backend is None: + backend = self._detect_backend(model) + + factory = self._factories.get(backend) + if factory is None: + raise ValueError(f"Unknown backend: {backend}") + + return factory(model) + + def _create_mistral(self, model: str) -> TokenCounter: + """Create Mistral tokenizer using official mistral-common.""" + try: + from .mistral import MistralTokenizer, is_mistral_available + if is_mistral_available(): + return MistralTokenizer(model) + except ImportError: + pass + + logger.warning( + "mistral-common not installed for Mistral tokenizer. " + "Install with: pip install mistral-common" + ) + return EstimatingTokenCounter() + + def _detect_backend(self, model: str) -> str: + """Detect best backend for model. + + Args: + model: Model name. + + Returns: + Backend name. + """ + model_lower = model.lower() + + for pattern, backend in MODEL_PATTERNS: + if re.match(pattern, model_lower): + return backend + + # Default to estimation for unknown models + return "estimation" + + def _create_tiktoken(self, model: str) -> TokenCounter: + """Create tiktoken-based tokenizer.""" + try: + from .tiktoken_counter import TiktokenCounter + return TiktokenCounter(model) + except ImportError: + logger.warning( + "tiktoken not installed. Install with: pip install tiktoken" + ) + return EstimatingTokenCounter() + + def _create_huggingface(self, model: str) -> TokenCounter: + """Create HuggingFace-based tokenizer.""" + try: + from .huggingface import HuggingFaceTokenizer + return HuggingFaceTokenizer(model) + except ImportError: + logger.warning( + "transformers not installed for HuggingFace tokenizer. " + "Install with: pip install transformers" + ) + return EstimatingTokenCounter() + except Exception as e: + logger.warning(f"Failed to load HuggingFace tokenizer for {model}: {e}") + return EstimatingTokenCounter() + + def _create_anthropic(self, model: str) -> TokenCounter: + """Create Anthropic tokenizer. + + Anthropic uses a custom tokenizer that's not publicly available. + We use estimation calibrated for Claude models. + """ + # Claude models use ~3.5 chars per token on average + return EstimatingTokenCounter(chars_per_token=3.5) + + def _create_google(self, model: str) -> TokenCounter: + """Create Google tokenizer. + + Gemini uses SentencePiece which isn't easily accessible. + We use estimation calibrated for Gemini models. + """ + # Gemini models use ~4 chars per token + return EstimatingTokenCounter(chars_per_token=4.0) + + def _create_cohere(self, model: str) -> TokenCounter: + """Create Cohere tokenizer. + + Cohere has its own tokenizer, we use estimation. + """ + return EstimatingTokenCounter(chars_per_token=4.0) + + def _create_estimation(self, model: str) -> TokenCounter: + """Create estimation-based tokenizer.""" + return EstimatingTokenCounter() + + +# Convenience functions +def get_tokenizer( + model: str, + backend: str | None = None, + fallback: bool = True, +) -> TokenCounter: + """Get tokenizer for a model. + + This is the main entry point for getting tokenizers. + + Args: + model: Model name (e.g., 'gpt-4o', 'claude-3-sonnet'). + backend: Force specific backend ('tiktoken', 'huggingface', etc.). + fallback: If True, fall back to estimation on errors. + + Returns: + TokenCounter instance. + + Example: + tokenizer = get_tokenizer("gpt-4o") + tokens = tokenizer.count_text("Hello, world!") + """ + return TokenizerRegistry.get(model, backend, fallback) + + +def register_tokenizer( + model: str, + tokenizer: TokenCounter | None = None, + factory: Callable[[str], TokenCounter] | None = None, +) -> None: + """Register a custom tokenizer for a model. + + Args: + model: Model name. + tokenizer: Tokenizer instance. + factory: Factory function. + + Example: + # Register instance + register_tokenizer("my-model", MyTokenizer()) + + # Register factory + register_tokenizer("my-model", factory=lambda m: MyTokenizer(m)) + """ + TokenizerRegistry.register(model, tokenizer, factory) + + +def list_supported_models() -> dict[str, str]: + """List models with known tokenizer mappings. + + Returns: + Dict mapping model pattern to backend. + """ + return {pattern: backend for pattern, backend in MODEL_PATTERNS} diff --git a/headroom/tokenizers/tiktoken_counter.py b/headroom/tokenizers/tiktoken_counter.py new file mode 100644 index 000000000..498e84ac4 --- /dev/null +++ b/headroom/tokenizers/tiktoken_counter.py @@ -0,0 +1,247 @@ +"""Tiktoken-based token counter for OpenAI models. + +Tiktoken is OpenAI's fast BPE tokenizer used by GPT models. +It supports multiple encodings: +- cl100k_base: GPT-4, GPT-3.5-turbo, text-embedding-ada-002 +- o200k_base: GPT-4o, GPT-4o-mini +- p50k_base: Codex models, text-davinci-002/003 +- r50k_base: GPT-3 models (davinci, curie, etc.) +""" + +from __future__ import annotations + +from functools import lru_cache +from typing import Any + +from .base import BaseTokenizer + +# Model to encoding mapping +MODEL_TO_ENCODING = { + # GPT-4o family (o200k_base) + "gpt-4o": "o200k_base", + "gpt-4o-mini": "o200k_base", + "gpt-4o-2024-05-13": "o200k_base", + "gpt-4o-2024-08-06": "o200k_base", + "gpt-4o-2024-11-20": "o200k_base", + "gpt-4o-mini-2024-07-18": "o200k_base", + # o1 reasoning models (o200k_base) + "o1": "o200k_base", + "o1-mini": "o200k_base", + "o1-preview": "o200k_base", + "o3-mini": "o200k_base", + # GPT-4 family (cl100k_base) + "gpt-4": "cl100k_base", + "gpt-4-turbo": "cl100k_base", + "gpt-4-turbo-preview": "cl100k_base", + "gpt-4-0314": "cl100k_base", + "gpt-4-0613": "cl100k_base", + "gpt-4-32k": "cl100k_base", + "gpt-4-32k-0314": "cl100k_base", + "gpt-4-32k-0613": "cl100k_base", + "gpt-4-1106-preview": "cl100k_base", + "gpt-4-0125-preview": "cl100k_base", + "gpt-4-turbo-2024-04-09": "cl100k_base", + # GPT-3.5 family (cl100k_base) + "gpt-3.5-turbo": "cl100k_base", + "gpt-3.5-turbo-0301": "cl100k_base", + "gpt-3.5-turbo-0613": "cl100k_base", + "gpt-3.5-turbo-1106": "cl100k_base", + "gpt-3.5-turbo-0125": "cl100k_base", + "gpt-3.5-turbo-16k": "cl100k_base", + "gpt-3.5-turbo-16k-0613": "cl100k_base", + "gpt-3.5-turbo-instruct": "cl100k_base", + # Embeddings (cl100k_base) + "text-embedding-ada-002": "cl100k_base", + "text-embedding-3-small": "cl100k_base", + "text-embedding-3-large": "cl100k_base", + # Codex (p50k_base) + "code-davinci-002": "p50k_base", + "code-davinci-001": "p50k_base", + "code-cushman-002": "p50k_base", + "code-cushman-001": "p50k_base", + # Legacy GPT-3 (r50k_base) + "text-davinci-003": "p50k_base", + "text-davinci-002": "p50k_base", + "text-davinci-001": "r50k_base", + "text-curie-001": "r50k_base", + "text-babbage-001": "r50k_base", + "text-ada-001": "r50k_base", + "davinci": "r50k_base", + "curie": "r50k_base", + "babbage": "r50k_base", + "ada": "r50k_base", +} + +# Default encoding for unknown models +DEFAULT_ENCODING = "cl100k_base" + + +@lru_cache(maxsize=8) +def _get_encoding(encoding_name: str): + """Get tiktoken encoding, cached for performance.""" + import tiktoken + return tiktoken.get_encoding(encoding_name) + + +def get_encoding_for_model(model: str) -> str: + """Get the tiktoken encoding name for a model. + + Args: + model: Model name (e.g., 'gpt-4o', 'gpt-3.5-turbo'). + + Returns: + Encoding name (e.g., 'o200k_base', 'cl100k_base'). + """ + # Direct lookup + if model in MODEL_TO_ENCODING: + return MODEL_TO_ENCODING[model] + + # Try prefix matching for versioned models + for prefix in ["gpt-4o", "gpt-4-turbo", "gpt-4", "gpt-3.5", "o1", "o3"]: + if model.startswith(prefix): + # Find any model with this prefix + for known_model, encoding in MODEL_TO_ENCODING.items(): + if known_model.startswith(prefix): + return encoding + + return DEFAULT_ENCODING + + +class TiktokenCounter(BaseTokenizer): + """Token counter using tiktoken (OpenAI's tokenizer). + + This is the most accurate tokenizer for OpenAI models and provides + a good approximation for many other models that use similar BPE + tokenization. + + Example: + counter = TiktokenCounter("gpt-4o") + tokens = counter.count_text("Hello, world!") + print(f"Token count: {tokens}") + """ + + # OpenAI-specific message overhead + MESSAGE_OVERHEAD = 3 + REPLY_OVERHEAD = 3 + + def __init__(self, model: str = "gpt-4o"): + """Initialize tiktoken counter. + + Args: + model: Model name to determine encoding. + Defaults to 'gpt-4o' (o200k_base encoding). + """ + self.model = model + self.encoding_name = get_encoding_for_model(model) + self._encoding = None # Lazy load + + @property + def encoding(self): + """Lazy-load the encoding.""" + if self._encoding is None: + self._encoding = _get_encoding(self.encoding_name) + return self._encoding + + def count_text(self, text: str) -> int: + """Count tokens in text using tiktoken. + + Args: + text: Text to tokenize. + + Returns: + Number of tokens. + """ + if not text: + return 0 + return len(self.encoding.encode(text)) + + def count_messages(self, messages: list[dict[str, Any]]) -> int: + """Count tokens in messages using OpenAI's exact formula. + + This matches OpenAI's token counting for chat completions. + + Args: + messages: List of chat messages. + + Returns: + Total token count. + """ + total = 0 + + for message in messages: + # Every message has overhead for role and formatting + total += self.MESSAGE_OVERHEAD + + for key, value in message.items(): + if value is None: + continue + + if key == "content": + if isinstance(value, str): + total += self.count_text(value) + elif isinstance(value, list): + # Multi-part content + for part in value: + if isinstance(part, dict): + if part.get("type") == "text": + total += self.count_text(part.get("text", "")) + elif part.get("type") == "image_url": + # Image tokens vary by detail level + detail = part.get("image_url", {}).get("detail", "auto") + if detail == "low": + total += 85 + else: + total += 170 # Base for high detail + else: + total += self.count_text(str(part)) + elif isinstance(part, str): + total += self.count_text(part) + elif key == "role": + total += self.count_text(value) + elif key == "name": + total += self.count_text(value) + total += 1 # Name adds 1 token + elif key == "tool_calls": + for tool_call in value: + total += 3 # Tool call overhead + if "function" in tool_call: + func = tool_call["function"] + total += self.count_text(func.get("name", "")) + total += self.count_text(func.get("arguments", "")) + if "id" in tool_call: + total += self.count_text(tool_call["id"]) + elif key == "tool_call_id": + total += self.count_text(value) + elif key == "function_call": + total += self.count_text(value.get("name", "")) + total += self.count_text(value.get("arguments", "")) + + # Every reply is primed with assistant + total += self.REPLY_OVERHEAD + + return total + + def encode(self, text: str) -> list[int]: + """Encode text to token IDs. + + Args: + text: Text to encode. + + Returns: + List of token IDs. + """ + return self.encoding.encode(text) + + def decode(self, tokens: list[int]) -> str: + """Decode token IDs to text. + + Args: + tokens: List of token IDs. + + Returns: + Decoded text. + """ + return self.encoding.decode(tokens) + + def __repr__(self) -> str: + return f"TiktokenCounter(model={self.model!r}, encoding={self.encoding_name!r})" diff --git a/headroom/transforms/pipeline.py b/headroom/transforms/pipeline.py index 6ffaa9623..59b2b871f 100644 --- a/headroom/transforms/pipeline.py +++ b/headroom/transforms/pipeline.py @@ -2,14 +2,13 @@ from __future__ import annotations -from typing import Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Any from ..config import ( CacheAlignerConfig, DiffArtifact, HeadroomConfig, RollingWindowConfig, - SmartCrusherConfig, ToolCrusherConfig, TransformDiff, TransformResult, diff --git a/headroom/transforms/smart_crusher.py b/headroom/transforms/smart_crusher.py index fda4e8e14..dcdeec7ce 100644 --- a/headroom/transforms/smart_crusher.py +++ b/headroom/transforms/smart_crusher.py @@ -34,11 +34,10 @@ import statistics from collections import Counter from dataclasses import dataclass, field from enum import Enum -from typing import TYPE_CHECKING, Any +from typing import Any from ..config import RelevanceScorerConfig, TransformResult -from ..relevance import BM25Scorer, RelevanceScorer, create_scorer - +from ..relevance import RelevanceScorer, create_scorer # Legacy patterns for backwards compatibility (extract_query_anchors) _UUID_PATTERN = re.compile( diff --git a/headroom/transforms/tool_crusher.py b/headroom/transforms/tool_crusher.py index 44c980dfa..02d16cd53 100644 --- a/headroom/transforms/tool_crusher.py +++ b/headroom/transforms/tool_crusher.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json from typing import Any from ..config import ToolCrusherConfig, TransformResult diff --git a/pyproject.toml b/pyproject.toml index ca84580e9..d0ea3100a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,39 +4,67 @@ build-backend = "hatchling.build" [project] name = "headroom" -version = "0.1.0" -description = "A safe, deterministic Context Budget Controller for LLM APIs" +version = "0.2.0" +description = "The Context Optimization Layer for LLM Applications - Cut costs by 50-90%" readme = "README.md" -license = "MIT" +license = "Apache-2.0" requires-python = ">=3.10" authors = [ - { name = "Headroom Team" } + { name = "Headroom Contributors" } +] +maintainers = [ + { name = "Headroom Contributors" } ] keywords = [ "llm", "openai", + "anthropic", + "claude", + "gpt", "context", "token", "optimization", + "compression", "caching", + "proxy", + "ai", + "machine-learning", ] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Topic :: Scientific/Engineering :: Artificial Intelligence", "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", ] dependencies = [ "tiktoken>=0.5.0", "pydantic>=2.0.0", - "jinja2>=3.0.0", ] [project.optional-dependencies] +# Semantic relevance scoring with embeddings +relevance = [ + "sentence-transformers>=2.2.0", + "numpy>=1.24.0", +] +# Proxy server +proxy = [ + "fastapi>=0.100.0", + "uvicorn>=0.23.0", + "httpx>=0.24.0", +] +# Report generation +reports = [ + "jinja2>=3.0.0", +] +# Development dependencies dev = [ "pytest>=7.0.0", "pytest-cov>=4.0.0", @@ -44,16 +72,36 @@ dev = [ "ruff>=0.1.0", "mypy>=1.0.0", "openai>=1.0.0", + "anthropic>=0.18.0", ] +# All optional dependencies +all = [ + "headroom[relevance,proxy,reports]", +] + +[project.scripts] +headroom = "headroom.cli:main" [project.urls] Homepage = "https://github.com/headroom-sdk/headroom" -Documentation = "https://github.com/headroom-sdk/headroom#readme" +Documentation = "https://headroom.dev/docs" Repository = "https://github.com/headroom-sdk/headroom" +Issues = "https://github.com/headroom-sdk/headroom/issues" +Changelog = "https://github.com/headroom-sdk/headroom/blob/main/CHANGELOG.md" [tool.hatch.build.targets.wheel] packages = ["headroom"] +[tool.hatch.build.targets.sdist] +include = [ + "/headroom", + "/tests", + "/LICENSE", + "/NOTICE", + "/README.md", + "/CHANGELOG.md", +] + [tool.ruff] target-version = "py310" line-length = 100 @@ -71,19 +119,43 @@ select = [ ignore = [ "E501", # line too long (handled by formatter) "B008", # do not perform function calls in argument defaults + "B905", # zip without strict parameter ] [tool.ruff.lint.isort] known-first-party = ["headroom"] +[tool.ruff.format] +quote-style = "double" +indent-style = "space" + [tool.mypy] python_version = "3.10" warn_return_any = true warn_unused_configs = true disallow_untyped_defs = true +ignore_missing_imports = true [tool.pytest.ini_options] testpaths = ["tests"] python_files = ["test_*.py"] python_functions = ["test_*"] addopts = "-v --tb=short" +asyncio_mode = "auto" + +[tool.coverage.run] +source = ["headroom"] +branch = true +omit = [ + "headroom/cli.py", + "*/tests/*", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise NotImplementedError", + "if TYPE_CHECKING:", + "if __name__ == .__main__.:", +] diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 000000000..24683a9f6 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,260 @@ +"""Tests for the model registry and capabilities database.""" + +from __future__ import annotations + +import pytest +from datetime import date + +from headroom.models import ( + ModelRegistry, + ModelInfo, + get_model_info, + list_models, + register_model, +) + + +class TestModelInfo: + """Tests for ModelInfo dataclass.""" + + def test_default_values(self): + """Test default values.""" + info = ModelInfo(name="test", provider="test-provider") + assert info.context_window == 128000 + assert info.max_output_tokens == 4096 + assert info.supports_tools is True + assert info.supports_vision is False + assert info.supports_streaming is True + + def test_custom_values(self): + """Test custom values.""" + info = ModelInfo( + name="custom-model", + provider="custom", + context_window=32000, + max_output_tokens=8192, + supports_tools=False, + supports_vision=True, + input_cost_per_1m=1.5, + output_cost_per_1m=3.0, + ) + assert info.context_window == 32000 + assert info.max_output_tokens == 8192 + assert info.supports_tools is False + assert info.supports_vision is True + assert info.input_cost_per_1m == 1.5 + + def test_frozen(self): + """Test that ModelInfo is frozen (immutable).""" + info = ModelInfo(name="test", provider="test") + with pytest.raises(AttributeError): + info.name = "changed" + + +class TestModelRegistry: + """Tests for ModelRegistry.""" + + def test_get_openai_model(self): + """Test getting OpenAI model info.""" + info = ModelRegistry.get("gpt-4o") + assert info is not None + assert info.provider == "openai" + assert info.context_window == 128000 + + def test_get_anthropic_model(self): + """Test getting Anthropic model info.""" + info = ModelRegistry.get("claude-3-5-sonnet-20241022") + assert info is not None + assert info.provider == "anthropic" + assert info.context_window == 200000 + + def test_get_google_model(self): + """Test getting Google model info.""" + info = ModelRegistry.get("gemini-1.5-pro") + assert info is not None + assert info.provider == "google" + assert info.context_window == 2000000 # 2M! + + def test_get_by_alias(self): + """Test getting model by alias.""" + info = ModelRegistry.get("gpt-4o-2024-11-20") + assert info is not None + assert info.name == "gpt-4o" + + def test_get_unknown_model(self): + """Test getting unknown model returns None.""" + info = ModelRegistry.get("unknown-model-xyz") + assert info is None + + def test_get_prefix_matching(self): + """Test prefix matching for versioned models.""" + info = ModelRegistry.get("gpt-4o-new-version") + assert info is not None + assert info.name == "gpt-4o" + + def test_register_custom_model(self): + """Test registering custom model.""" + info = ModelRegistry.register( + "my-custom-model", + provider="custom", + context_window=64000, + supports_vision=True, + ) + assert info.name == "my-custom-model" + assert info.provider == "custom" + assert info.context_window == 64000 + + # Should be retrievable + retrieved = ModelRegistry.get("my-custom-model") + assert retrieved is not None + assert retrieved.context_window == 64000 + + def test_list_models_all(self): + """Test listing all models.""" + models = ModelRegistry.list_models() + assert len(models) > 0 + + def test_list_models_by_provider(self): + """Test listing models by provider.""" + openai_models = ModelRegistry.list_models(provider="openai") + assert len(openai_models) > 0 + assert all(m.provider == "openai" for m in openai_models) + + def test_list_models_with_tools(self): + """Test listing models with tool support.""" + models = ModelRegistry.list_models(supports_tools=True) + assert len(models) > 0 + assert all(m.supports_tools for m in models) + + def test_list_models_with_vision(self): + """Test listing models with vision support.""" + models = ModelRegistry.list_models(supports_vision=True) + assert len(models) > 0 + assert all(m.supports_vision for m in models) + + def test_list_models_min_context(self): + """Test listing models with minimum context.""" + models = ModelRegistry.list_models(min_context=1000000) + assert len(models) > 0 + assert all(m.context_window >= 1000000 for m in models) + + def test_list_providers(self): + """Test listing all providers.""" + providers = ModelRegistry.list_providers() + assert "openai" in providers + assert "anthropic" in providers + assert "google" in providers + + def test_get_context_limit(self): + """Test getting context limit.""" + limit = ModelRegistry.get_context_limit("gpt-4o") + assert limit == 128000 + + def test_get_context_limit_unknown(self): + """Test getting context limit for unknown model.""" + limit = ModelRegistry.get_context_limit("unknown", default=32000) + assert limit == 32000 + + def test_estimate_cost(self): + """Test cost estimation.""" + cost = ModelRegistry.estimate_cost( + model="gpt-4o", + input_tokens=1000000, + output_tokens=500000, + ) + assert cost is not None + # GPT-4o: $2.50/1M input + $10.00/1M output * 0.5 = $2.50 + $5.00 = $7.50 + assert abs(cost - 7.50) < 0.01 + + def test_estimate_cost_with_cache(self): + """Test cost estimation with cached tokens.""" + cost = ModelRegistry.estimate_cost( + model="gpt-4o", + input_tokens=1000000, + output_tokens=0, + cached_tokens=500000, # Half cached + ) + assert cost is not None + # 500K regular at $2.50/1M + 500K cached at $1.25/1M + # = $1.25 + $0.625 = $1.875 + assert abs(cost - 1.875) < 0.01 + + def test_estimate_cost_unknown_model(self): + """Test cost estimation for unknown model.""" + cost = ModelRegistry.estimate_cost( + model="unknown-model", + input_tokens=1000, + output_tokens=500, + ) + assert cost is None + + +class TestConvenienceFunctions: + """Tests for convenience functions.""" + + def test_get_model_info(self): + """Test get_model_info function.""" + info = get_model_info("gpt-4o") + assert info is not None + assert info.name == "gpt-4o" + + def test_list_models(self): + """Test list_models function.""" + models = list_models(provider="anthropic") + assert len(models) > 0 + + def test_register_model(self): + """Test register_model function.""" + info = register_model( + "test-function-model", + provider="test", + context_window=16000, + ) + assert info.name == "test-function-model" + + +class TestBuiltInModels: + """Tests for built-in model data.""" + + def test_gpt4o_info(self): + """Test GPT-4o model info.""" + info = get_model_info("gpt-4o") + assert info.provider == "openai" + assert info.context_window == 128000 + assert info.supports_tools is True + assert info.supports_vision is True + assert info.input_cost_per_1m == 2.50 + assert info.output_cost_per_1m == 10.00 + + def test_o1_info(self): + """Test o1 model info.""" + info = get_model_info("o1") + assert info.provider == "openai" + assert info.context_window == 200000 # 200K context + assert info.max_output_tokens == 100000 # 100K output + + def test_claude_info(self): + """Test Claude model info.""" + info = get_model_info("claude-3-5-sonnet-20241022") + assert info.provider == "anthropic" + assert info.context_window == 200000 + assert info.cached_input_cost_per_1m == 0.30 # 90% cache discount + + def test_gemini_info(self): + """Test Gemini model info.""" + info = get_model_info("gemini-1.5-pro") + assert info.provider == "google" + assert info.context_window == 2000000 # 2M tokens! + + def test_llama_info(self): + """Test Llama model info.""" + info = get_model_info("llama-3.1-8b") + assert info.provider == "meta" + assert info.context_window == 128000 + assert info.tokenizer_backend == "huggingface" + + def test_mistral_info(self): + """Test Mistral model info.""" + info = get_model_info("mistral-large") + assert info.provider == "mistral" + assert info.supports_tools is True diff --git a/tests/test_providers/test_cohere.py b/tests/test_providers/test_cohere.py new file mode 100644 index 000000000..b460e962c --- /dev/null +++ b/tests/test_providers/test_cohere.py @@ -0,0 +1,124 @@ +"""Tests for Cohere provider.""" + +from __future__ import annotations + +import pytest + +from headroom.providers import CohereProvider + + +class TestCohereProvider: + """Tests for CohereProvider.""" + + @pytest.fixture + def provider(self): + """Create Cohere provider without client (estimation mode).""" + return CohereProvider() + + def test_name(self, provider): + """Test provider name.""" + assert provider.name == "cohere" + + def test_supports_command_models(self, provider): + """Test support for Command models.""" + assert provider.supports_model("command-r-plus") is True + assert provider.supports_model("command-r") is True + assert provider.supports_model("command-a") is True + assert provider.supports_model("command") is True + + def test_not_supports_other_models(self, provider): + """Test non-support for other models.""" + assert provider.supports_model("gpt-4o") is False + assert provider.supports_model("claude-3") is False + assert provider.supports_model("gemini-2.0") is False + + def test_get_token_counter(self, provider): + """Test getting token counter.""" + counter = provider.get_token_counter("command-r-plus") + assert counter is not None + count = counter.count_text("Hello, world!") + assert count > 0 + + def test_get_context_limit_command_a(self, provider): + """Test context limit for Command A (256K).""" + limit = provider.get_context_limit("command-a") + assert limit == 256000 + + def test_get_context_limit_command_r_plus(self, provider): + """Test context limit for Command R+.""" + limit = provider.get_context_limit("command-r-plus") + assert limit == 128000 + + def test_get_context_limit_command_r(self, provider): + """Test context limit for Command R.""" + limit = provider.get_context_limit("command-r") + assert limit == 128000 + + def test_get_context_limit_legacy_command(self, provider): + """Test context limit for legacy Command.""" + limit = provider.get_context_limit("command") + assert limit == 4096 + + def test_estimate_cost_command_r_plus(self, provider): + """Test cost estimation for Command R+.""" + cost = provider.estimate_cost( + input_tokens=1000000, + output_tokens=500000, + model="command-r-plus", + ) + assert cost is not None + # 1M input * $2.50/1M + 0.5M output * $10.00/1M = $2.50 + $5.00 = $7.50 + assert abs(cost - 7.50) < 0.01 + + def test_estimate_cost_command_r(self, provider): + """Test cost estimation for Command R.""" + cost = provider.estimate_cost( + input_tokens=1000000, + output_tokens=500000, + model="command-r", + ) + assert cost is not None + # 1M input * $0.15/1M + 0.5M output * $0.60/1M = $0.15 + $0.30 = $0.45 + assert abs(cost - 0.45) < 0.01 + + def test_estimate_cost_unknown_model(self, provider): + """Test cost estimation returns None for unknown model.""" + cost = provider.estimate_cost( + input_tokens=1000, + output_tokens=500, + model="unknown-model", + ) + assert cost is None + + +class TestCohereTokenCounter: + """Tests for CohereTokenCounter.""" + + @pytest.fixture + def counter(self): + """Create token counter without client.""" + provider = CohereProvider() + return provider.get_token_counter("command-r-plus") + + def test_count_text_empty(self, counter): + """Test counting empty text.""" + assert counter.count_text("") == 0 + + def test_count_text_simple(self, counter): + """Test counting simple text.""" + count = counter.count_text("Hello, world!") + assert count > 0 + assert count < 20 # Should be a few tokens + + def test_count_messages(self, counter): + """Test counting messages.""" + messages = [ + {"role": "user", "content": "Hello!"}, + {"role": "assistant", "content": "Hi there!"}, + ] + count = counter.count_messages(messages) + assert count > 0 + + def test_count_messages_empty(self, counter): + """Test counting empty messages.""" + assert counter.count_messages([]) == 0 diff --git a/tests/test_providers/test_universal.py b/tests/test_providers/test_universal.py new file mode 100644 index 000000000..9b1256ba2 --- /dev/null +++ b/tests/test_providers/test_universal.py @@ -0,0 +1,293 @@ +"""Tests for universal provider support. + +Tests OpenAICompatibleProvider, GoogleProvider, and LiteLLMProvider. +""" + +from __future__ import annotations + +import pytest + + +def _transformers_available() -> bool: + """Check if transformers is available.""" + try: + import transformers # noqa: F401 + return True + except ImportError: + return False + +from headroom.providers import ( + OpenAICompatibleProvider, + ModelCapabilities, + GoogleProvider, + create_ollama_provider, + create_together_provider, + create_groq_provider, + create_vllm_provider, + create_lmstudio_provider, + is_litellm_available, +) + + +class TestOpenAICompatibleProvider: + """Tests for OpenAICompatibleProvider.""" + + def test_init_default(self): + """Test initialization with defaults.""" + provider = OpenAICompatibleProvider() + assert provider.name == "openai_compatible" + assert provider.base_url is None + + def test_init_with_config(self): + """Test initialization with configuration.""" + provider = OpenAICompatibleProvider( + name="custom", + base_url="http://localhost:8080/v1", + api_key="test-key", + ) + assert provider.name == "custom" + assert provider.base_url == "http://localhost:8080/v1" + assert provider.api_key == "test-key" + + def test_supports_any_model(self): + """Test that provider supports any model.""" + provider = OpenAICompatibleProvider() + assert provider.supports_model("any-model") is True + assert provider.supports_model("llama-3") is True + assert provider.supports_model("custom-finetuned") is True + + @pytest.mark.skipif( + not _transformers_available(), + reason="transformers not installed - needed for HuggingFace tokenizer" + ) + def test_get_token_counter(self): + """Test getting token counter.""" + provider = OpenAICompatibleProvider() + counter = provider.get_token_counter("llama-3-8b") + assert counter is not None + # Should be able to count tokens + count = counter.count_text("Hello, world!") + assert count > 0 + + def test_get_context_limit_known_model(self): + """Test context limit for known models.""" + provider = OpenAICompatibleProvider() + # Llama 3.1 has 128K context + limit = provider.get_context_limit("llama-3.1-8b") + assert limit == 128000 + + def test_get_context_limit_unknown_model(self): + """Test context limit for unknown models (defaults to 128K).""" + provider = OpenAICompatibleProvider() + limit = provider.get_context_limit("unknown-model") + assert limit == 128000 + + def test_register_model(self): + """Test registering a custom model.""" + provider = OpenAICompatibleProvider() + provider.register_model( + "my-model", + context_window=64000, + max_output_tokens=8192, + input_cost_per_1m=1.0, + output_cost_per_1m=2.0, + ) + assert provider.get_context_limit("my-model") == 64000 + + def test_estimate_cost_registered_model(self): + """Test cost estimation for registered model.""" + provider = OpenAICompatibleProvider() + provider.register_model( + "priced-model", + input_cost_per_1m=1.0, + output_cost_per_1m=2.0, + ) + cost = provider.estimate_cost( + input_tokens=1000000, + output_tokens=500000, + model="priced-model", + ) + assert cost == 2.0 # 1.0 + 1.0 + + def test_estimate_cost_unknown_model(self): + """Test cost estimation returns None for unknown model.""" + provider = OpenAICompatibleProvider() + cost = provider.estimate_cost( + input_tokens=1000, + output_tokens=500, + model="unknown-model", + ) + assert cost is None + + +class TestModelCapabilities: + """Tests for ModelCapabilities dataclass.""" + + def test_default_values(self): + """Test default capability values.""" + caps = ModelCapabilities(model="test-model") + assert caps.context_window == 128000 + assert caps.max_output_tokens == 4096 + assert caps.supports_tools is True + assert caps.supports_vision is False + assert caps.supports_streaming is True + + def test_custom_values(self): + """Test custom capability values.""" + caps = ModelCapabilities( + model="custom-model", + context_window=32000, + max_output_tokens=16384, + supports_tools=False, + supports_vision=True, + input_cost_per_1m=0.5, + output_cost_per_1m=1.5, + ) + assert caps.context_window == 32000 + assert caps.max_output_tokens == 16384 + assert caps.supports_tools is False + assert caps.supports_vision is True + assert caps.input_cost_per_1m == 0.5 + assert caps.output_cost_per_1m == 1.5 + + +class TestGoogleProvider: + """Tests for GoogleProvider.""" + + @pytest.fixture + def provider(self): + """Create Google provider.""" + return GoogleProvider() + + def test_name(self, provider): + """Test provider name.""" + assert provider.name == "google" + + def test_supports_gemini_models(self, provider): + """Test support for Gemini models.""" + assert provider.supports_model("gemini-2.0-flash") is True + assert provider.supports_model("gemini-1.5-pro") is True + assert provider.supports_model("gemini-1.5-flash") is True + + def test_not_supports_other_models(self, provider): + """Test non-support for other models.""" + assert provider.supports_model("gpt-4o") is False + assert provider.supports_model("claude-3") is False + + def test_get_token_counter(self, provider): + """Test getting token counter.""" + counter = provider.get_token_counter("gemini-2.0-flash") + assert counter is not None + count = counter.count_text("Hello, world!") + assert count > 0 + + def test_get_context_limit_gemini_2(self, provider): + """Test context limit for Gemini 2.0.""" + limit = provider.get_context_limit("gemini-2.0-flash") + assert limit == 1000000 # 1M tokens + + def test_get_context_limit_gemini_1_5_pro(self, provider): + """Test context limit for Gemini 1.5 Pro (2M!).""" + limit = provider.get_context_limit("gemini-1.5-pro") + assert limit == 2000000 # 2M tokens! + + def test_estimate_cost(self, provider): + """Test cost estimation.""" + cost = provider.estimate_cost( + input_tokens=1000000, + output_tokens=500000, + model="gemini-2.0-flash", + ) + assert cost is not None + # 1M input * $0.10 + 0.5M output * $0.40 = $0.10 + $0.20 = $0.30 + assert abs(cost - 0.30) < 0.01 + + def test_openai_compatible_url(self): + """Test OpenAI-compatible URL.""" + url = GoogleProvider.get_openai_compatible_url("test-key") + assert "generativelanguage.googleapis.com" in url + + +class TestProviderFactoryFunctions: + """Tests for provider factory functions.""" + + def test_create_ollama_provider(self): + """Test creating Ollama provider.""" + provider = create_ollama_provider() + assert provider.name == "ollama" + assert provider.base_url == "http://localhost:11434/v1" + + def test_create_ollama_provider_custom_url(self): + """Test creating Ollama provider with custom URL.""" + provider = create_ollama_provider("http://192.168.1.100:11434/v1") + assert provider.base_url == "http://192.168.1.100:11434/v1" + + def test_create_together_provider(self): + """Test creating Together provider.""" + provider = create_together_provider() + assert provider.name == "together" + assert "together.xyz" in provider.base_url + + def test_create_groq_provider(self): + """Test creating Groq provider.""" + provider = create_groq_provider() + assert provider.name == "groq" + assert "groq.com" in provider.base_url + + def test_create_vllm_provider(self): + """Test creating vLLM provider.""" + provider = create_vllm_provider("http://localhost:8000/v1") + assert provider.name == "vllm" + assert provider.base_url == "http://localhost:8000/v1" + + def test_create_lmstudio_provider(self): + """Test creating LM Studio provider.""" + provider = create_lmstudio_provider() + assert provider.name == "lmstudio" + assert provider.base_url == "http://localhost:1234/v1" + + +class TestLiteLLMProvider: + """Tests for LiteLLM provider.""" + + def test_is_litellm_available(self): + """Test checking LiteLLM availability.""" + result = is_litellm_available() + assert isinstance(result, bool) + + @pytest.mark.skipif( + not is_litellm_available(), + reason="LiteLLM not installed", + ) + def test_create_litellm_provider(self): + """Test creating LiteLLM provider.""" + from headroom.providers import create_litellm_provider + + provider = create_litellm_provider() + assert provider.name == "litellm" + + @pytest.mark.skipif( + not is_litellm_available(), + reason="LiteLLM not installed", + ) + def test_litellm_supports_any_model(self): + """Test LiteLLM supports any model.""" + from headroom.providers import create_litellm_provider + + provider = create_litellm_provider() + assert provider.supports_model("gpt-4o") is True + assert provider.supports_model("claude-3-sonnet") is True + assert provider.supports_model("any-model") is True + + @pytest.mark.skipif( + not is_litellm_available(), + reason="LiteLLM not installed", + ) + def test_litellm_list_providers(self): + """Test listing LiteLLM providers.""" + from headroom.providers import LiteLLMProvider + + providers = LiteLLMProvider.list_supported_providers() + assert "openai" in providers + assert "anthropic" in providers + assert "ollama" in providers diff --git a/tests/test_tokenizers.py b/tests/test_tokenizers.py new file mode 100644 index 000000000..f955632e3 --- /dev/null +++ b/tests/test_tokenizers.py @@ -0,0 +1,443 @@ +"""Tests for the pluggable tokenizer system.""" + +from __future__ import annotations + +import pytest + +from headroom.tokenizers import ( + TokenizerRegistry, + get_tokenizer, + register_tokenizer, + list_supported_models, + TiktokenCounter, + EstimatingTokenCounter, + CharacterCounter, + TokenCounter, + BaseTokenizer, + is_mistral_tokenizer_available, + get_mistral_tokenizer, +) + + +class TestTiktokenCounter: + """Tests for TiktokenCounter.""" + + def test_init_default_model(self): + """Test initialization with default model.""" + counter = TiktokenCounter() + assert counter.model == "gpt-4o" + assert counter.encoding_name == "o200k_base" + + def test_init_gpt4_model(self): + """Test initialization with GPT-4.""" + counter = TiktokenCounter("gpt-4") + assert counter.model == "gpt-4" + assert counter.encoding_name == "cl100k_base" + + def test_count_text_empty(self): + """Test counting empty text.""" + counter = TiktokenCounter() + assert counter.count_text("") == 0 + + def test_count_text_simple(self): + """Test counting simple text.""" + counter = TiktokenCounter() + count = counter.count_text("Hello, world!") + assert count > 0 + assert count < 10 # Should be a few tokens + + def test_count_text_unicode(self): + """Test counting text with unicode.""" + counter = TiktokenCounter() + count = counter.count_text("Hello, 世界!") + assert count > 0 + + def test_count_messages_single(self): + """Test counting single message.""" + counter = TiktokenCounter() + messages = [{"role": "user", "content": "Hello!"}] + count = counter.count_messages(messages) + assert count > 0 + + def test_count_messages_with_tool_calls(self): + """Test counting messages with tool calls.""" + counter = TiktokenCounter() + messages = [ + {"role": "user", "content": "Search for Python"}, + { + "role": "assistant", + "tool_calls": [{ + "id": "call_123", + "type": "function", + "function": { + "name": "search", + "arguments": '{"query": "Python"}', + }, + }], + }, + { + "role": "tool", + "tool_call_id": "call_123", + "content": "Results...", + }, + ] + count = counter.count_messages(messages) + assert count > 0 + + def test_encode_decode_roundtrip(self): + """Test encode/decode roundtrip.""" + counter = TiktokenCounter() + text = "Hello, world!" + tokens = counter.encode(text) + decoded = counter.decode(tokens) + assert decoded == text + + def test_repr(self): + """Test string representation.""" + counter = TiktokenCounter("gpt-4o") + assert "TiktokenCounter" in repr(counter) + assert "gpt-4o" in repr(counter) + + +class TestEstimatingTokenCounter: + """Tests for EstimatingTokenCounter.""" + + def test_init_default(self): + """Test initialization with defaults.""" + counter = EstimatingTokenCounter() + assert counter._fixed_ratio is None + + def test_init_fixed_ratio(self): + """Test initialization with fixed ratio.""" + counter = EstimatingTokenCounter(chars_per_token=3.5) + assert counter._fixed_ratio == 3.5 + + def test_count_text_empty(self): + """Test counting empty text.""" + counter = EstimatingTokenCounter() + assert counter.count_text("") == 0 + + def test_count_text_simple(self): + """Test counting simple text.""" + counter = EstimatingTokenCounter() + text = "Hello, world!" + count = counter.count_text(text) + assert count > 0 + # Rough estimate: 13 chars / 4 chars per token ≈ 3-4 tokens + assert 2 <= count <= 6 + + def test_count_text_fixed_ratio(self): + """Test counting with fixed ratio.""" + counter = EstimatingTokenCounter(chars_per_token=5.0) + text = "x" * 50 # 50 chars + count = counter.count_text(text) + assert count == 10 # 50 / 5 = 10 + + def test_count_text_minimum_one(self): + """Test minimum of 1 token.""" + counter = EstimatingTokenCounter() + assert counter.count_text("x") >= 1 + + def test_count_messages(self): + """Test counting messages.""" + counter = EstimatingTokenCounter() + messages = [ + {"role": "user", "content": "Hello!"}, + {"role": "assistant", "content": "Hi there!"}, + ] + count = counter.count_messages(messages) + assert count > 0 + + def test_json_detection(self): + """Test JSON content detection.""" + counter = EstimatingTokenCounter() + json_text = '{"name": "test", "value": 123}' + # Should use JSON ratio + count = counter.count_text(json_text) + assert count > 0 + + def test_code_detection(self): + """Test code content detection.""" + counter = EstimatingTokenCounter() + code_text = """ +def hello(): + return "Hello, world!" +""" + count = counter.count_text(code_text) + assert count > 0 + + def test_repr(self): + """Test string representation.""" + counter = EstimatingTokenCounter() + assert "EstimatingTokenCounter" in repr(counter) + + +class TestCharacterCounter: + """Tests for CharacterCounter.""" + + def test_init_default(self): + """Test initialization with default ratio.""" + counter = CharacterCounter() + assert counter.chars_per_token == 4.0 + + def test_init_custom_ratio(self): + """Test initialization with custom ratio.""" + counter = CharacterCounter(chars_per_token=3.5) + assert counter.chars_per_token == 3.5 + + def test_count_text(self): + """Test counting text.""" + counter = CharacterCounter(chars_per_token=4.0) + text = "x" * 40 # 40 chars + count = counter.count_text(text) + assert count == 10 # 40 / 4 = 10 + + def test_count_text_empty(self): + """Test counting empty text.""" + counter = CharacterCounter() + assert counter.count_text("") == 0 + + +class TestTokenizerRegistry: + """Tests for TokenizerRegistry.""" + + def test_get_openai_model(self): + """Test getting tokenizer for OpenAI model.""" + tokenizer = get_tokenizer("gpt-4o") + assert isinstance(tokenizer, TiktokenCounter) + + def test_get_anthropic_model(self): + """Test getting tokenizer for Anthropic model.""" + tokenizer = get_tokenizer("claude-3-sonnet") + assert isinstance(tokenizer, EstimatingTokenCounter) + + def test_get_unknown_model_fallback(self): + """Test fallback for unknown model.""" + tokenizer = get_tokenizer("unknown-model-xyz") + assert isinstance(tokenizer, EstimatingTokenCounter) + + def test_get_with_specific_backend(self): + """Test forcing specific backend.""" + tokenizer = get_tokenizer("any-model", backend="estimation") + assert isinstance(tokenizer, EstimatingTokenCounter) + + def test_register_custom_tokenizer(self): + """Test registering custom tokenizer.""" + custom = EstimatingTokenCounter(chars_per_token=3.0) + register_tokenizer("my-custom-model", tokenizer=custom) + retrieved = get_tokenizer("my-custom-model") + assert retrieved is custom + + def test_list_supported_models(self): + """Test listing supported models.""" + models = list_supported_models() + assert isinstance(models, dict) + assert "gpt-4o" in str(models) or "^gpt-4o" in str(models) + + def test_clear_cache(self): + """Test clearing tokenizer cache.""" + # Get a tokenizer to populate cache + get_tokenizer("gpt-4o") + # Clear cache + TokenizerRegistry.clear_cache() + # Should still work after clearing + tokenizer = get_tokenizer("gpt-4o") + assert tokenizer is not None + + +class TestTokenCounterProtocol: + """Tests for TokenCounter protocol.""" + + def test_tiktoken_implements_protocol(self): + """Test TiktokenCounter implements protocol.""" + counter = TiktokenCounter() + assert isinstance(counter, TokenCounter) + + def test_estimating_implements_protocol(self): + """Test EstimatingTokenCounter implements protocol.""" + counter = EstimatingTokenCounter() + assert isinstance(counter, TokenCounter) + + def test_character_implements_protocol(self): + """Test CharacterCounter implements protocol.""" + counter = CharacterCounter() + assert isinstance(counter, TokenCounter) + + +class TestBaseTokenizer: + """Tests for BaseTokenizer base class.""" + + def test_message_overhead_constant(self): + """Test message overhead constant.""" + assert BaseTokenizer.MESSAGE_OVERHEAD == 4 + + def test_reply_overhead_constant(self): + """Test reply overhead constant.""" + assert BaseTokenizer.REPLY_OVERHEAD == 3 + + +class TestMistralTokenizer: + """Tests for Mistral tokenizer using official mistral-common.""" + + def test_is_available(self): + """Test availability check.""" + result = is_mistral_tokenizer_available() + assert isinstance(result, bool) + + @pytest.mark.skipif( + not is_mistral_tokenizer_available(), + reason="mistral-common not installed", + ) + def test_get_mistral_tokenizer_class(self): + """Test getting MistralTokenizer class.""" + MistralTokenizer = get_mistral_tokenizer() + assert MistralTokenizer is not None + assert hasattr(MistralTokenizer, "count_text") + + @pytest.mark.skipif( + not is_mistral_tokenizer_available(), + reason="mistral-common not installed", + ) + def test_init_default_model(self): + """Test initialization with default model.""" + MistralTokenizer = get_mistral_tokenizer() + counter = MistralTokenizer() + assert counter.model == "mistral-large" + assert counter.version == "v3" + + @pytest.mark.skipif( + not is_mistral_tokenizer_available(), + reason="mistral-common not installed", + ) + def test_init_mixtral_model(self): + """Test initialization with Mixtral model (uses v1).""" + MistralTokenizer = get_mistral_tokenizer() + counter = MistralTokenizer("mixtral-8x7b") + assert counter.version == "v1" + + @pytest.mark.skipif( + not is_mistral_tokenizer_available(), + reason="mistral-common not installed", + ) + def test_count_text_empty(self): + """Test counting empty text.""" + MistralTokenizer = get_mistral_tokenizer() + counter = MistralTokenizer() + assert counter.count_text("") == 0 + + @pytest.mark.skipif( + not is_mistral_tokenizer_available(), + reason="mistral-common not installed", + ) + def test_count_text_simple(self): + """Test counting simple text.""" + MistralTokenizer = get_mistral_tokenizer() + counter = MistralTokenizer() + count = counter.count_text("Hello, world!") + assert count > 0 + assert count < 10 + + @pytest.mark.skipif( + not is_mistral_tokenizer_available(), + reason="mistral-common not installed", + ) + def test_count_text_unicode(self): + """Test counting text with unicode.""" + MistralTokenizer = get_mistral_tokenizer() + counter = MistralTokenizer() + count = counter.count_text("Hello, 世界!") + assert count > 0 + + @pytest.mark.skipif( + not is_mistral_tokenizer_available(), + reason="mistral-common not installed", + ) + def test_count_messages(self): + """Test counting messages.""" + MistralTokenizer = get_mistral_tokenizer() + counter = MistralTokenizer() + messages = [ + {"role": "user", "content": "Hello!"}, + {"role": "assistant", "content": "Hi there!"}, + ] + count = counter.count_messages(messages) + assert count > 0 + + @pytest.mark.skipif( + not is_mistral_tokenizer_available(), + reason="mistral-common not installed", + ) + def test_count_messages_with_system(self): + """Test counting messages with system prompt.""" + MistralTokenizer = get_mistral_tokenizer() + counter = MistralTokenizer() + messages = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Hello!"}, + ] + count = counter.count_messages(messages) + assert count > 0 + + @pytest.mark.skipif( + not is_mistral_tokenizer_available(), + reason="mistral-common not installed", + ) + def test_encode_decode_roundtrip(self): + """Test encode/decode roundtrip.""" + MistralTokenizer = get_mistral_tokenizer() + counter = MistralTokenizer() + text = "Hello, world!" + tokens = counter.encode(text) + decoded = counter.decode(tokens) + assert decoded == text + + @pytest.mark.skipif( + not is_mistral_tokenizer_available(), + reason="mistral-common not installed", + ) + def test_implements_protocol(self): + """Test MistralTokenizer implements TokenCounter protocol.""" + MistralTokenizer = get_mistral_tokenizer() + counter = MistralTokenizer() + assert isinstance(counter, TokenCounter) + + @pytest.mark.skipif( + not is_mistral_tokenizer_available(), + reason="mistral-common not installed", + ) + def test_repr(self): + """Test string representation.""" + MistralTokenizer = get_mistral_tokenizer() + counter = MistralTokenizer("mistral-large") + assert "MistralTokenizer" in repr(counter) + assert "mistral-large" in repr(counter) + + @pytest.mark.skipif( + not is_mistral_tokenizer_available(), + reason="mistral-common not installed", + ) + def test_registry_returns_mistral_for_mistral_models(self): + """Test registry returns Mistral tokenizer for Mistral models.""" + tokenizer = get_tokenizer("mistral-large") + MistralTokenizer = get_mistral_tokenizer() + assert isinstance(tokenizer, MistralTokenizer) + + @pytest.mark.skipif( + not is_mistral_tokenizer_available(), + reason="mistral-common not installed", + ) + def test_registry_returns_mistral_for_mixtral(self): + """Test registry returns Mistral tokenizer for Mixtral models.""" + tokenizer = get_tokenizer("mixtral-8x7b") + MistralTokenizer = get_mistral_tokenizer() + assert isinstance(tokenizer, MistralTokenizer) + + @pytest.mark.skipif( + not is_mistral_tokenizer_available(), + reason="mistral-common not installed", + ) + def test_registry_returns_mistral_for_codestral(self): + """Test registry returns Mistral tokenizer for Codestral models.""" + tokenizer = get_tokenizer("codestral") + MistralTokenizer = get_mistral_tokenizer() + assert isinstance(tokenizer, MistralTokenizer)