Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
51e9134
add: design
Mar 18, 2026
0b92dc5
docs: add hierarchical budget enforcement design, PRD, and implementa…
Mar 18, 2026
9bbb3b0
docs: update version references to v0.3.1
Mar 18, 2026
b64ccbe
docs: remap all phases to v0.3.x subversions
Mar 18, 2026
c25e3bb
feat(v0.3.1): ShekelRuntime foundation + per-component budget API
Mar 18, 2026
dc5fb3d
docs(v0.3.1): update changelog, API reference, and index for ShekelRu…
Mar 18, 2026
9f3d39d
docs: clarify .node/.agent/.task registration vs enforcement
Mar 18, 2026
575a2a8
feat(v0.3.1): LangGraph node-level circuit breaking via LangGraphAdapter
Mar 18, 2026
5b5f532
docs(v0.3.1): update docs to reflect LangGraph enforcement is live in…
Mar 18, 2026
387695d
feat: distributed budgets — RedisBackend, multi-cap spec, Docker inte…
Mar 18, 2026
f340dff
chore: merge feat/distributed-budgets into release/v0.3.1
Mar 18, 2026
8912d67
chore: merge feat/hierarchical-integration into release/v0.3.1
Mar 18, 2026
44a8fd3
style: apply black formatting to test_temporal_integration.py
Mar 18, 2026
fa41fe5
fix: resolve all CodeQL security alerts and CI black failures
Mar 18, 2026
0a1387b
test: fix patch coverage gaps — protocol stubs + _ensure_script + win…
Mar 18, 2026
989d38a
feat: LangChain chain-level budget enforcement + nested budget bug fix
Mar 18, 2026
145413c
docs: v0.3.1 — highlight LangGraph/LangChain circuit breaking + distr…
Mar 18, 2026
03dbe09
fix: resolve CodeQL security alerts in PR #24
Mar 18, 2026
a2ea1ab
feat: CrewAI agent/task-level budget circuit breaking (v0.3.1)
Mar 18, 2026
8fea317
fix: document intentional exception swallowing in get_state methods
Mar 18, 2026
c4765f5
style: black format redis.py after noqa comment addition
Mar 18, 2026
3c91d4f
docs: update all docs and metadata for CrewAI agent/task enforcement
Mar 18, 2026
6aefd21
fix: repair broken mkdocs links in quickstart and crewai integration …
Mar 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 6 additions & 111 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -1,117 +1,12 @@
{
"permissions": {
"allow": [
"Bash(wc:*)",
"Bash(git:*)",
"Bash(black:*)",
"Bash(ruff check:*)",
"Bash(kill %1:*)",
"Bash(gh run:*)",
"WebFetch(domain:pypi.org)",
"Bash(brew install:*)",
"Bash(gh:*)",
"Bash(mypy:*)",
"WebFetch(domain:github.com)",
"WebFetch(domain:raw.githubusercontent.com)",
"Bash(pip install:*)",
"Bash(black .:*)",
"Bash(grep -r \"from langfuse\" /mnt/c/Users/elish/code/shekel/shekel --include=\"*.py\" 2>/dev/null | head -20)",
"Bash(python3 -c \"\nimport tomllib\nwith open\\('pyproject.toml', 'rb'\\) as f:\n data = tomllib.load\\(f\\)\n \nprint\\('all extra:', data['project']['optional-dependencies']['all']\\)\nprint\\('all-models extra:', data['project']['optional-dependencies']['all-models']\\)\nprint\\('langfuse extra:', data['project']['optional-dependencies']['langfuse']\\)\n\")",
"Bash(python -m pytest tests/integrations/ -v 2>&1 | head -150)",
"Bash(python3 -m pytest tests/integrations/ -v --tb=short 2>&1 | head -200)",
"Bash(python3 -m py_compile shekel/integrations/langfuse.py shekel/integrations/base.py shekel/integrations/registry.py shekel/integrations/async_queue.py 2>&1)",
"Bash(grep -r \"import langfuse\\\\|from langfuse\" /mnt/c/Users/elish/code/shekel/tests --include=\"*.py\" 2>/dev/null | head -5)",
"Bash(grep \"\\\\.utilization\" /mnt/c/Users/elish/code/shekel/examples/langfuse/*.py)",
"Bash(git diff:*)",
"Bash(grep -A 1 \"@property\" /mnt/c/Users/elish/code/shekel/shekel/_budget.py | grep \"def \" | sed 's/.*def //' | sed 's/\\(.*$//')",
"Bash(python -m pytest tests/test_nested_auto_capping.py tests/integrations/test_core_integration.py tests/integrations/test_adapter_pattern.py tests/test_decorator.py -v 2>&1 | tail -40)",
"Bash(python3 -m pytest tests/test_nested_auto_capping.py tests/integrations/test_core_integration.py tests/integrations/test_adapter_pattern.py tests/test_decorator.py -v 2>&1 | tail -50)",
"Bash(uv run:*)",
"Bash(find . -name \"pytest\" -o -name \"python3*\" 2>/dev/null | head -5 && cat pyproject.toml | grep -A3 \"\\\\[tool.pytest\")",
"Bash(pip3 show:*)",
"Bash(pip3 install:*)",
"Bash(/usr/bin/python3 -m pip install --user black 2>&1 | tail -3)",
"Bash(/usr/bin/python3 -c \"import ensurepip; ensurepip.bootstrap\\(\\)\" 2>&1 && /usr/bin/python3 -m pip install --user black 2>&1 | tail -3)",
"Bash(apt-get install:*)",
"Bash(sudo apt-get:*)",
"Read(//mnt/c/Users/elish/AppData/Local/Programs/**)",
"Read(//mnt/c/Users/elish/AppData/Roaming/**)",
"Read(//mnt/c/Users/elish/**)",
"Bash(export PATH=\"$HOME/.pyenv/bin:$HOME/.pyenv/shims:$PATH\" && black --version)",
"Bash(export PATH=\"$HOME/.pyenv/bin:$HOME/.pyenv/shims:$PATH\" && pyenv shell 3.11.15 && pip install --user black && black --version)",
"Bash(~/.pyenv/versions/3.11.15/bin/pip install:*)",
"Bash(PYBIN=~/.pyenv/versions/3.11.15/bin && echo \"=== black ===\" && $PYBIN/black --check . 2>&1 | tail -3 && echo \"=== isort ===\" && $PYBIN/isort --check-only . 2>&1 | tail -5 && echo \"=== ruff ===\" && $PYBIN/ruff check . 2>&1 | tail -5 && echo \"=== mypy ===\" && $PYBIN/mypy shekel/ 2>&1 | tail -5)",
"Bash(BIN=~/.pyenv/versions/3.11.15/bin && echo \"=== black ===\" && /home/elish/.local/bin/black --check . 2>&1 | tail -3)",
"Bash(BIN=~/.pyenv/versions/3.11.15/bin && echo \"=== isort ===\" && $BIN/isort --check-only . 2>&1 | tail -5 && echo \"=== ruff ===\" && $BIN/ruff check . 2>&1 | tail -5 && echo \"=== mypy ===\" && $BIN/mypy shekel/ 2>&1 | tail -5)",
"Bash(~/.pyenv/versions/3.11.15/bin/isort --check-only .)",
"Read(//mnt/c/Users/elish/code/shekel/**)",
"Bash(~/.pyenv/versions/3.11.15/bin/ruff check:*)",
"Bash(~/.pyenv/versions/3.11.15/bin/mypy shekel/)",
"Bash(~/.pyenv/versions/3.11.15/bin/isort --check-only . 2>&1; echo \"EXIT: $?\")",
"Bash(~/.pyenv/versions/3.11.15/bin/isort examples/langfuse/complete_demo.py examples/langfuse/quickstart.py tests/test_nested_auto_capping.py tests/integrations/test_adapter_pattern.py tests/integrations/test_async_queue.py && echo \"done\")",
"Bash(/home/elish/.local/bin/black --line-length 100 examples/langfuse/complete_demo.py examples/langfuse/quickstart.py tests/test_nested_auto_capping.py tests/integrations/test_adapter_pattern.py tests/integrations/test_async_queue.py && echo \"done\")",
"Bash(/home/elish/.local/bin/black --line-length 100 examples/langfuse/complete_demo.py tests/integrations/test_core_integration.py tests/test_nested_auto_capping.py 2>&1)",
"Bash(/home/elish/.local/bin/black --check .)",
"Bash(/home/elish/.local/bin/black --check . 2>&1; echo \"BLACK EXIT: $?\")",
"Bash(/home/elish/.local/bin/black --line-length 100 tests/integrations/test_adapter_pattern.py tests/integrations/test_langfuse_circuit_break.py tests/integrations/test_langfuse_cost_streaming.py tests/integrations/test_langfuse_fallback.py tests/integrations/test_langfuse_nested_mapping.py 2>&1)",
"Bash(~/.pyenv/versions/3.11.15/bin/pytest tests/ -v --cov=shekel --cov-report=term-missing --cov-fail-under=90 2>&1)",
"Bash(npm list:*)",
"WebSearch",
"Bash(which npx:*)",
"Read(//usr/bin/**)",
"Bash(npm --version)",
"Bash(npm --version && npx --version)",
"Bash(npx bmad-method:*)",
"Bash(curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash 2>&1 | tail -5)",
"Bash(export NVM_DIR=\"$HOME/.nvm\" && . \"$NVM_DIR/nvm.sh\" && nvm install 22 2>&1 | tail -5 && node --version)",
"Read(//home/elish/.nvm/**)",
"Bash(source ~/.nvm/nvm.sh)",
"Bash(nvm install:*)",
"Bash(grep -r \"v0\\\\\\\\.2\\\\\\\\.[0-9]\" /mnt/c/Users/elish/code/shekel/docs/*.md /mnt/c/Users/elish/code/shekel/docs/**/*.md | grep -v \"CHANGELOG\\\\|changelog\" | head -20)",
"Bash(cat /home/elish/.claude/projects/-mnt-c-Users-elish-code-shekel/511ceb7f-1b10-477c-a628-4f32a473aafd/tool-results/toolu_012eRcqWe5kFZnaqkfoTQNcU.txt | jq -r '.[0].text' > /mnt/c/Users/elish/code/shekel/IMPLEMENTATION_PLAN.md)",
"Bash(pyenv local:*)",
"Bash(python -m pytest tests/providers/test_provider_test_base.py -v 2>&1)",
"Bash(python -m pytest tests/ --ignore=tests/providers -x -q 2>&1)",
"Bash(python -m pytest tests/providers/test_registry.py -v 2>&1 | head -40)",
"Bash(python -m pytest tests/providers/ -v 2>&1 | tail -60)",
"Bash(python -m pytest --tb=short -q 2>&1 | tail -20)",
"Bash(python -m pytest --tb=short -q 2>&1 | tail -30)",
"Bash(python -m pytest --tb=short -q 2>&1 | tail -5)",
"Bash(isort --check-only shekel/providers/ tests/providers/ examples/cohere_adapter_template.py 2>&1)",
"Bash(isort shekel/providers/ tests/providers/ examples/cohere_adapter_template.py 2>&1 && ruff check shekel/providers/ tests/providers/ examples/cohere_adapter_template.py 2>&1)",
"Bash(mkdocs build:*)",
"Bash(black shekel/providers/ && python -m pytest tests/providers/ -q 2>&1 | tail -5)",
"Bash(echo \"=== BLACK ===\" && black --check shekel/providers/ tests/providers/ examples/cohere_adapter_template.py 2>&1 | tail -3 && echo \"=== ISORT ===\" && isort --check-only shekel/providers/ tests/providers/ examples/cohere_adapter_template.py 2>&1 | tail -3 && echo \"=== RUFF ===\" && ruff check shekel/providers/ tests/providers/ examples/cohere_adapter_template.py 2>&1 && echo \"=== MYPY ===\" && mypy shekel/ 2>&1 | tail -3)",
"Bash(isort shekel/providers/ tests/providers/ examples/cohere_adapter_template.py && black shekel/providers/ tests/providers/ examples/cohere_adapter_template.py && ruff check shekel/providers/ tests/providers/ examples/cohere_adapter_template.py 2>&1)",
"Bash(python -m pytest tests/test_call_limits.py -xvs 2>&1 | head -200)",
"Bash(python -m pytest tests/test_fallback.py -xvs 2>&1 | head -100)",
"Bash(python -m pytest tests/test_fallback.py -xvs 2>&1 | head -200)",
"Bash(python -m pytest tests/test_fallback.py -xvs 2>&1 | tail -50)",
"Bash(python -m pytest tests/test_decorator.py -xvs 2>&1 | tail -50)",
"Bash(python -m pytest tests/test_summary.py -xvs 2>&1 | tail -50)",
"Bash(python -m pytest tests/test_session_budget.py -xvs 2>&1 | tail -50)",
"Bash(python -m pytest tests/integrations/test_ollama_integration.py -xvs 2>&1 | tail -100)",
"Bash(python -m pytest tests/test_fallback.py tests/test_decorator.py tests/test_summary.py tests/test_session_budget.py tests/integrations/test_ollama_integration.py -v 2>&1 | tail -100)",
"Bash(python -m pytest tests/test_fallback.py tests/test_decorator.py tests/test_summary.py tests/test_session_budget.py tests/integrations/test_ollama_integration.py --tb=short 2>&1 | grep -E \"\\(PASSED|FAILED|ERROR|passed|failed\\)\" | tail -5)",
"Bash(python -m pytest tests/ -q --tb=no 2>&1 | tail -20)",
"Bash(python -m pytest tests/ -q --tb=line 2>&1 | tail -30)",
"Bash(python -m pytest tests/ -q --tb=no 2>&1 | tail -30)",
"Bash(python -m pytest tests/test_fallback.py::test_fallback_model_rewritten_in_kwargs -xvs 2>&1 | tail -50)",
"Bash(python -m pytest tests/test_fallback.py -xvs 2>&1 | head -150)",
"Bash(python -m pytest tests/test_fallback.py::test_fallback_model_rewritten_in_kwargs -xvs 2>&1 | grep -A 20 \"AssertionError\")",
"Bash(python -m pytest tests/test_fallback.py::test_fallback_model_rewritten_in_kwargs -xvs 2>&1 | tail -30)",
"Bash(python -m pytest tests/test_fallback.py::test_fallback_model_rewritten_in_kwargs tests/test_fallback.py::test_fallback_spent_tracks_separately -xvs 2>&1 | tail -20)",
"Bash(python -m pytest tests/test_fallback.py::test_fallback_model_rewritten_in_kwargs -xvs 2>&1 | tail -20)",
"Bash(python -m pytest tests/ -q --tb=no 2>&1 | tail -10)",
"Bash(python -m pytest tests/test_fallback.py::test_fallback_small_call_within_budget -xvs 2>&1 | grep -A 5 \"assert\")",
"Bash(GIT_EDITOR=true git merge --continue)",
"Bash(python -m ruff check . 2>&1)",
"Bash(python -m mypy shekel/ 2>&1)",
"Bash(python -m black --check . 2>&1)",
"Bash(python -m isort --check-only . 2>&1)",
"Bash(python -m pytest --cov=shekel --cov-report=term-missing 2>&1)",
"Bash(python -c \"\nimport yaml, os\nwith open\\('/mnt/c/Users/elish/code/shekel/mkdocs.yml'\\) as f:\n cfg = yaml.safe_load\\(f\\)\n\ndef extract_paths\\(nav\\):\n for item in nav:\n if isinstance\\(item, dict\\):\n for k, v in item.items\\(\\):\n if isinstance\\(v, str\\):\n yield v\n elif isinstance\\(v, list\\):\n yield from extract_paths\\(v\\)\n\nmissing = []\nfor path in extract_paths\\(cfg['nav']\\):\n full = f'/mnt/c/Users/elish/code/shekel/docs/{path}'\n if not os.path.exists\\(full\\):\n missing.append\\(path\\)\n\nif missing:\n print\\('MISSING:', missing\\)\nelse:\n print\\('All nav files exist.'\\)\n\")",
"Bash(python -m mypy shekel/ --verbose 2>&1 | grep \"_pytest\" | head -10)"
"Bash(*)",
"Read(*)",
"Edit(*)",
"Write(*)",
"WebFetch(*)",
"WebSearch(*)"
]
}
}
1 change: 0 additions & 1 deletion .python-version

This file was deleted.

69 changes: 69 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,75 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.3.1] - 2026-03-18

### Added

- **`ShekelRuntime`** (`shekel/_runtime.py`) — framework detection and adapter wiring scaffold; called automatically at `budget.__enter__()` / `__exit__()` (and async variants)
- `ShekelRuntime.register(AdapterClass)` — class-level registry for framework adapters; adapters are probed once at budget open and released at budget close
- `probe()` — activates all registered adapters; silently skips adapters that raise `ImportError` (framework not installed)
- `release()` — deactivates adapters on budget exit; suppresses cleanup exceptions to avoid masking original errors

- **`ComponentBudget`** (`shekel/_budget.py`) — lightweight dataclass for per-component cap tracking (`name`, `max_usd`, `_spent`)

- **`Budget.node(name, max_usd)`** — register an explicit USD cap for a LangGraph node; returns `self` for chaining

- **`Budget.agent(name, max_usd)`** — register an explicit USD cap for a named agent (CrewAI / OpenClaw); returns `self` for chaining

- **`Budget.task(name, max_usd)`** — register an explicit USD cap for a named task (CrewAI); returns `self` for chaining

- **`Budget.chain(name, max_usd)`** — register an explicit USD cap for a named LangChain chain; returns `self` for chaining; enforced by `LangChainRunnerAdapter`

- **5 new exception subclasses** (`shekel/exceptions.py`), all inheriting from `BudgetExceededError`:
- `NodeBudgetExceededError(node_name, spent, limit)` — raised when a LangGraph node exceeds its cap
- `AgentBudgetExceededError(agent_name, spent, limit)` — raised when an agent exceeds its cap
- `TaskBudgetExceededError(task_name, spent, limit)` — raised when a task exceeds its cap
- `SessionBudgetExceededError(agent_name, spent, limit, window=None)` — raised when a rolling-window agent session exceeds its budget
- `ChainBudgetExceededError(chain_name, spent, limit)` — raised when a LangChain chain exceeds its cap
- `BudgetConfigMismatchError` — raised by `RedisBackend` when a budget name is reused with different limits/windows

- **`budget.tree()` enhancement** — renders registered node/agent/task/chain component budgets below the children block; shows `[node]`, `[agent]`, `[task]`, `[chain]` labels with spent / limit / percentage

- **`LangGraphAdapter`** (`shekel/providers/langgraph.py`) — transparent node-level circuit breaking for LangGraph; zero user code changes required
- Patches `StateGraph.add_node()` at `budget.__enter__()` so every node — sync and async — gets a pre-execution budget gate
- Pre-execution gate: raises `NodeBudgetExceededError` before the node body runs if the explicit node cap or parent budget is exhausted
- Post-execution attribution: spend delta credited to `ComponentBudget._spent` so `budget.tree()` shows per-node costs
- Reference-counted patch: nested budgets don't double-patch; restored when the last budget context closes
- Automatically skipped (silent `ImportError`) when `langgraph` is not installed

- **`LangChainRunnerAdapter`** (`shekel/providers/langchain.py`) — transparent chain-level circuit breaking for LangChain; zero user code changes required
- Patches `Runnable._call_with_config`, `_acall_with_config`, and `RunnableSequence.invoke`/`ainvoke`
- Pre-execution gate: raises `ChainBudgetExceededError` before the chain body runs if the explicit chain cap or parent budget is exhausted
- Reference-counted patch: same nesting semantics as `LangGraphAdapter`
- Automatically skipped when `langchain_core` is not installed

- **Multi-cap temporal budget spec** — `"$5/hr + 100 calls/hr"` string DSL for simultaneous USD + call-count caps with independent rolling windows
- `_parse_cap_spec()` — parses compound spec strings into a list of `(counter, limit, window_s)` triples
- `TemporalBudget` supports `usd`, `llm_calls`, `tool_calls`, and `tokens` counters simultaneously
- All-or-nothing atomicity: if any counter would exceed its limit, no counters are incremented

- **`RedisBackend`** (`shekel/backends/redis.py`) — synchronous Redis-backed rolling-window budget backend for distributed enforcement
- Atomic all-or-nothing Lua script (single round-trip per call)
- Lazy connection with connection pool reuse
- Circuit breaker: stops calling Redis after N consecutive errors (configurable threshold + cooldown)
- Fail-closed (default) or fail-open (`on_unavailable="open"`) on backend unavailability
- `BudgetConfigMismatchError` when a budget name is reused with different limits/windows
- `on_backend_unavailable` adapter event

- **`AsyncRedisBackend`** (`shekel/backends/redis.py`) — async version of `RedisBackend` for FastAPI, async LangGraph, and other async contexts; same semantics, all public methods are coroutines

- **`on_backend_unavailable` adapter event** (`shekel/integrations/base.py`) — fires before raising `BudgetExceededError` (fail-closed) or allowing through (fail-open); payload: `budget_name`, `error`

### Fixed

- **Nested budget node/chain cap enforcement** — node caps registered on an outer `budget()` context are now correctly enforced inside inner nested budget contexts; `_find_node_cap()` and `_find_chain_cap()` walk the parent chain to locate the cap

### Technical

- **245 new TDD tests**: 45 in `tests/test_runtime.py`, 41 in `tests/test_langchain_wrappers.py`, 36 in `tests/test_langgraph_wrappers.py`, 81 in `tests/test_distributed_budgets.py` (unit) + 419-line Docker integration suite in `tests/integrations/test_redis_docker.py`
- `shekel/__version__` bumped to `0.3.1`
- `Budget.chain`, `ChainBudgetExceededError`, `BudgetConfigMismatchError`, `RedisBackend`, `AsyncRedisBackend` all exported in `shekel.__all__`

## [0.2.9] - 2026-03-15

### Added
Expand Down
Loading
Loading