Skip to content

Commit ea7b429

Browse files
authored
Merge pull request #32 from Floe-Labs/release/py-0.2.0-groq-pricing-crewai-hardstop
release: py 0.2.0 / js 0.2.1 — Groq pricing, reliable CrewAI hard-stop, gated publishing
2 parents 398c3de + 58d902e commit ea7b429

20 files changed

Lines changed: 1126 additions & 121 deletions

.github/workflows/ci.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,10 @@ jobs:
6262
python-version: ${{ matrix.python-version }}
6363
- run: python -m pip install --upgrade pip
6464
# Install the framework extras so the adapter handler tests actually run
65-
# (instead of skipping). langchain-core covers the LangChain handler tests.
66-
- run: pip install -e ".[dev,langchain]"
65+
# (instead of skipping). langchain-core covers the LangChain handler
66+
# tests; litellm covers the callback-swallowing regression tests (the
67+
# CrewAI adapter tests stub crewai itself, so the heavy extra stays out).
68+
- run: pip install -e ".[dev,langchain,litellm]"
6769
- name: Pytest
6870
run: pytest -q
6971

.github/workflows/publish-npm.yml

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,34 @@
11
name: Publish to npm
22

3+
# Runs after the full CI workflow succeeds on main (not on the raw push), so a
4+
# red lint/cost-map-sync/python job blocks an npm release too. The old js/**
5+
# path filter is gone; the version gate below already makes no-op runs cheap.
36
on:
4-
push:
7+
workflow_run:
8+
workflows: [CI]
9+
types: [completed]
510
branches: [main]
6-
# The npm package lives in js/; only publish when it (or this workflow) changes.
7-
paths:
8-
- 'js/**'
9-
- '.github/workflows/publish-npm.yml'
1011
workflow_dispatch:
1112

1213
concurrency:
1314
group: publish-npm-${{ github.ref }}
1415
cancel-in-progress: false
1516

1617
permissions:
17-
contents: read
18+
contents: write # push the release tag; npm auth stays the token secret
1819

1920
jobs:
2021
publish:
21-
if: github.ref == 'refs/heads/main'
22+
# workflow_run's `branches` filter matches the HEAD branch NAME of the
23+
# triggering run — a fork PR from a branch named "main" would match it. The
24+
# event/head_repository checks restrict publishing to push-triggered CI on
25+
# this repo's own main.
26+
if: >-
27+
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') ||
28+
(github.event_name == 'workflow_run' &&
29+
github.event.workflow_run.conclusion == 'success' &&
30+
github.event.workflow_run.event == 'push' &&
31+
github.event.workflow_run.head_repository.full_name == github.repository)
2232
runs-on: ubuntu-latest
2333
timeout-minutes: 15
2434
defaults:
@@ -28,6 +38,9 @@ jobs:
2838
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
2939
with:
3040
persist-credentials: false
41+
# workflow_run checks out the default branch by default; pin to the
42+
# exact commit CI validated.
43+
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
3144

3245
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
3346
with:
@@ -69,3 +82,16 @@ jobs:
6982
run: npm publish --access public
7083
env:
7184
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
85+
86+
- name: Tag the release
87+
# Maps the published artifact back to its exact commit ("js-" prefix:
88+
# the PyPI package versions independently and tags "py-v<version>").
89+
if: steps.check.outputs.should_publish == 'true'
90+
env:
91+
GH_TOKEN: ${{ github.token }}
92+
PKG_VERSION: ${{ steps.check.outputs.version }}
93+
run: |
94+
gh api "repos/${{ github.repository }}/git/refs" \
95+
-f ref="refs/tags/js-v$PKG_VERSION" \
96+
-f sha="$(git rev-parse HEAD)" \
97+
|| echo "::warning::tag js-v$PKG_VERSION already exists or could not be created"

.github/workflows/publish.yml

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
name: Publish to PyPI
22

3+
# Runs after the full CI workflow (lint, python matrix, cost-map-sync, js)
4+
# succeeds on main — not on the raw push. npm 0.2.0 once shipped while main CI
5+
# was red on cost-map-sync; gating on workflow_run closes that hole.
36
on:
4-
push:
7+
workflow_run:
8+
workflows: [CI]
9+
types: [completed]
510
branches: [main]
611
workflow_dispatch:
712

@@ -10,17 +15,29 @@ concurrency:
1015
cancel-in-progress: false
1116

1217
permissions:
13-
contents: read
18+
contents: write # push the release tag; PyPI auth stays the token secret
1419

1520
jobs:
1621
publish:
17-
if: github.ref == 'refs/heads/main'
22+
# workflow_run's `branches` filter matches the HEAD branch NAME of the
23+
# triggering run — a fork PR from a branch named "main" would match it. The
24+
# event/head_repository checks restrict publishing to push-triggered CI on
25+
# this repo's own main.
26+
if: >-
27+
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') ||
28+
(github.event_name == 'workflow_run' &&
29+
github.event.workflow_run.conclusion == 'success' &&
30+
github.event.workflow_run.event == 'push' &&
31+
github.event.workflow_run.head_repository.full_name == github.repository)
1832
runs-on: ubuntu-latest
1933
timeout-minutes: 15
2034
steps:
2135
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
2236
with:
2337
persist-credentials: false
38+
# workflow_run checks out the default branch by default; pin to the
39+
# exact commit CI validated.
40+
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
2441

2542
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
2643
with:
@@ -77,3 +94,16 @@ jobs:
7794
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
7895
with:
7996
password: ${{ secrets.PYPI_API_TOKEN }}
97+
98+
- name: Tag the release
99+
# Maps the published artifact back to its exact commit ("py-" prefix:
100+
# the npm package versions independently and tags "js-v<version>").
101+
if: steps.check.outputs.should_publish == 'true'
102+
env:
103+
GH_TOKEN: ${{ github.token }}
104+
PKG_VERSION: ${{ steps.meta.outputs.version }}
105+
run: |
106+
gh api "repos/${{ github.repository }}/git/refs" \
107+
-f ref="refs/tags/py-v$PKG_VERSION" \
108+
-f sha="$(git rev-parse HEAD)" \
109+
|| echo "::warning::tag py-v$PKG_VERSION already exists or could not be created"
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
name: Version Guard
2+
3+
# Publishing is version-gated on main, so a source change merged without a
4+
# version bump silently never ships — that is exactly how PyPI fell 3.5 weeks
5+
# behind the repo. Fail the PR instead, unless the change is deliberately
6+
# unreleased (apply the skip-version-guard label).
7+
#
8+
# Lives in its own workflow (not ci.yml) so the labeled/unlabeled trigger types
9+
# — needed for the skip label to re-evaluate without a manual re-run — don't
10+
# re-run and cancel the whole CI matrix on every label change.
11+
on:
12+
pull_request:
13+
types: [opened, synchronize, reopened, labeled, unlabeled]
14+
15+
concurrency:
16+
group: version-guard-${{ github.ref }}
17+
cancel-in-progress: true
18+
19+
permissions:
20+
contents: read
21+
22+
jobs:
23+
version-guard:
24+
if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-version-guard') }}
25+
runs-on: ubuntu-latest
26+
timeout-minutes: 5
27+
steps:
28+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
29+
with:
30+
persist-credentials: false
31+
# The checkout is refs/pull/N/merge — a synthetic merge commit whose
32+
# first parent is the CURRENT base tip. Depth 2 makes HEAD^1
33+
# resolvable; comparing against it (rather than the event's
34+
# pull_request.base.sha snapshot, which goes stale as the base moves)
35+
# means a release merged to main after the PR opened still counts.
36+
fetch-depth: 2
37+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
38+
with:
39+
python-version: '3.13'
40+
- name: Require a version bump when package source changes
41+
run: |
42+
set -euo pipefail
43+
changed=$(git diff --name-only HEAD^1 HEAD)
44+
fail=0
45+
46+
# pyproject.toml is included: dependency/extras changes alter the
47+
# shipped artifact just like src changes do.
48+
if echo "$changed" | grep -q '^\(src/floe_guard/\|pyproject\.toml\)'; then
49+
old=$(git show "HEAD^1:pyproject.toml" | python -c "import sys,tomllib;print(tomllib.loads(sys.stdin.read())['project']['version'])")
50+
new=$(python -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
51+
if [ "$old" = "$new" ]; then
52+
echo "::error::Python package source or packaging metadata changed but pyproject.toml version is still $new — bump it (or label the PR skip-version-guard)."
53+
fail=1
54+
fi
55+
fi
56+
57+
if echo "$changed" | grep -q '^js/\(src/\|package\.json\)'; then
58+
old=$(git show "HEAD^1:js/package.json" | python -c "import sys,json;print(json.loads(sys.stdin.read())['version'])")
59+
new=$(python -c "import json;print(json.load(open('js/package.json'))['version'])")
60+
if [ "$old" = "$new" ]; then
61+
echo "::error::js package source or packaging metadata changed but js/package.json version is still $new — bump it (or label the PR skip-version-guard)."
62+
fail=1
63+
fi
64+
fi
65+
66+
exit "$fail"

CHANGELOG.md

Lines changed: 85 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,83 @@ both packages adhere to [Semantic Versioning](https://semver.org/).
1010

1111
## Unreleased
1212

13-
## js 0.2.0 — 2026-07-08
13+
## py 0.2.0 / js 0.2.1 — 2026-07-10
14+
15+
Everything the repo grew between the 0.1.0 uploads and this release ships here —
16+
the earlier revision of this changelog misattributed several of these features
17+
to the py 0.1.0 entry; that entry now reflects what the released artifact
18+
actually contained.
19+
20+
### Added (py)
21+
22+
- **Concurrency-safe enforcement**: atomic `reserve()` / `settle()` /
23+
`release()` with a lock-guarded running total, closing the
24+
check-then-record race that let parallel callers blow the ceiling
25+
(issue #18). `check()` / `record()` are unchanged for sequential use.
26+
- **Context-aware budgeting**: `BudgetGuard.advisory()` returning a
27+
`BudgetAdvisory` (`near_limit`, `used_bps`, `remaining_usd`, totals), with a
28+
`near_limit_bps` constructor threshold (default 8000 = 80%).
29+
- **Adapters**: LangChain (`budget_guard_callback_handler`), OpenAI
30+
(`guarded_completion` / `guarded_acompletion`), and Anthropic (same pair,
31+
with cache-token metering) — each behind an optional extra
32+
(`[langchain]`, `[openai]`, `[anthropic]`).
33+
- **Hosted budget read**: `hosted_remaining_usd()` (GET
34+
`/v1/agents/credit-remaining`, opt-in via `FLOE_API_KEY`, host override via
35+
`FLOE_API_BASE_URL`), `HostedEnforcementError`, and package-root export of
36+
`hosted_enforcement_available()`. This is the package's only network call
37+
and never runs unless you set the key.
38+
39+
### Added (py + js)
40+
41+
- **Groq pricing**: curated Groq models vendored in the cost map —
42+
`llama-3.1-8b-instant`, `llama-3.3-70b-versatile`,
43+
`meta-llama/llama-4-scout-17b-16e-instruct`, `qwen/qwen3-32b` (new for py;
44+
these four already shipped in js 0.2.0), plus the current production lineup
45+
`openai/gpt-oss-120b`, `openai/gpt-oss-20b`, `openai/gpt-oss-safeguard-20b`
46+
(new for both packages). Kept as an explicit allowlist
47+
(`scripts/update-cost-map.mjs`) so generic multi-provider names stay
48+
fail-closed instead of under-metering.
49+
- **Smarter model-id resolution** (both packages, identical logic): lookup
50+
candidates are tried most-specific-first — the raw id, the id with a known
51+
`openai/` / `anthropic/` / `groq/` first segment stripped (so
52+
`groq/qwen/qwen3-32b` and ChatGroq's `qwen/qwen3-32b` hit the same entry),
53+
then the bare last segment; each also with a trailing dated-snapshot suffix
54+
removed, so an unlisted snapshot like `claude-opus-4-8-<date>` prices at its
55+
alias entry instead of failing closed. Unknown provider prefixes are
56+
deliberately not bridged.
57+
- Cost-map refresh adds `claude-sonnet-5` (both packages; the py package also
58+
gains `claude-fable-5`, which already shipped in js 0.2.0 —
59+
`claude-opus-4-8` and `claude-sonnet-4-6` shipped in the 0.1.0 maps) and
60+
warns when a curated Groq model disappears upstream instead of silently
61+
dropping it.
62+
63+
### Fixed (py)
64+
65+
- **CrewAI / LiteLLM-callback silent footgun**: LiteLLM runs custom-logger
66+
hooks inside `except Exception`, so the callback's enforcement raise
67+
(pre-call `BudgetExceeded`, fail-closed `UnpriceableModelError`) could be
68+
swallowed and a crew kept running unmetered with no visible signal. The
69+
callback now records the violation on its `tripped` attribute and logs it at
70+
ERROR level on the `floe_guard` logger, and
71+
`budget_guarded_llm` returns a `crewai.LLM` subclass that re-raises
72+
`tripped` and runs `check()` in the call path — outside LiteLLM — so the
73+
crew hard-stops at the next call. `guard_crew` now returns the registered
74+
callback (previously `None`) and reuses an existing registration for the
75+
same guard.
76+
77+
### Changed (py + js)
78+
79+
- Price lookup order is now most-specific-first (raw id before stripped
80+
forms) for both `price_overrides` and the cost map. Previously the bare name
81+
was tried before the raw id.
82+
83+
## js 0.2.0 — published 2026-07-10
1484

1585
### Added
1686

87+
- Curated Groq cost-map entries: `llama-3.1-8b-instant`,
88+
`llama-3.3-70b-versatile`, `meta-llama/llama-4-scout-17b-16e-instruct`,
89+
`qwen/qwen3-32b`, plus `claude-fable-5`.
1790
- **Vercel AI SDK v5 support.** The middleware now works with both `ai@4`
1891
(`LanguageModelV1Middleware`, `promptTokens`/`completionTokens`) and `ai@5`
1992
(`LanguageModelV2Middleware`, `inputTokens`/`outputTokens`) from a single
@@ -30,18 +103,18 @@ both packages adhere to [Semantic Versioning](https://semver.org/).
30103

31104
## py 0.1.0 / js 0.1.0 — 2026-06
32105

33-
Initial public release.
106+
Initial public releases (PyPI 2026-06-15, npm 2026-06-16).
34107

35-
- `BudgetGuard` with `check()` / `record()`, concurrency-safe
36-
`reserve()` / `settle()` / `release()`, and `advisory()` for context-aware
37-
budgeting (taper before the hard-stop).
38-
- Offline pricing from a vendored LiteLLM cost map; unpriceable models fail
39-
closed (`UnpriceableModelError`) with manual `price_overrides` as the
40-
escape hatch.
41-
- Python adapters: CrewAI, LiteLLM, LangChain, OpenAI, Anthropic — each behind
42-
an optional extra; the core stays dependency-free.
108+
- `BudgetGuard` with `check()` / `record()` (sequential; the atomic
109+
reservation API landed in py 0.2.0).
110+
- Offline pricing from a vendored LiteLLM cost map covering OpenAI and
111+
Anthropic; unpriceable models fail closed (`UnpriceableModelError`) with
112+
manual `price_overrides` as the escape hatch.
113+
- Python adapters: CrewAI and LiteLLM, behind optional extras; the core stays
114+
dependency-free. (The LangChain, OpenAI, and Anthropic adapters landed in
115+
py 0.2.0.)
116+
- Hosted-Floe hook as a stub (`hosted_enforcement_available()` under
117+
`floe_guard.hosted`); this release performs no network calls of any kind.
43118
- TypeScript package (`js/`) with Vercel AI SDK middleware
44119
(`budgetGuardMiddleware`), verified against `ai@4`.
45-
- Optional hosted-Floe budget read (`hosted_remaining_usd()`) via
46-
`FLOE_API_KEY` — the only network call in the package, opt-in.
47120
- No runtime telemetry of any kind.

README.md

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,23 @@ guard = BudgetGuard(
103103
# or, set fail_closed=False to warn-and-skip for models you accept un-metered.
104104
```
105105

106+
### What the bundled map prices
107+
108+
The vendored map deliberately covers **OpenAI, Anthropic, and a curated set of
109+
Groq models** (the allowlist lives in
110+
[`scripts/update-cost-map.mjs`](scripts/update-cost-map.mjs)) — not all of
111+
LiteLLM's upstream list. Generic open-weights names (`qwen3-32b`,
112+
`gpt-oss-120b`) are served by many vendors at very different prices, so
113+
resolving them at one vendor's rate would under-meter a spend guard; they stay
114+
unpriceable unless you scope them (`groq/…`) or pass a manual price.
115+
116+
Model ids resolve flexibly: provider-prefixed forms work
117+
(`openai/gpt-4o`, `groq/qwen/qwen3-32b` and the ChatGroq `qwen/qwen3-32b` both
118+
hit the same entry), and a dated snapshot the map doesn't list yet
119+
(`claude-opus-4-8-<date>`) prices at its alias entry instead of failing closed.
120+
Everything else — Gemini, Mistral, Cohere, Ollama, Bedrock, self-hosted —
121+
needs `price_overrides` (or `fail_closed=False` to accept it un-metered).
122+
106123
## Context-aware budgeting
107124

108125
The hard-stop is the guarantee; `advisory()` is the *upside*. Read it before a
@@ -142,17 +159,26 @@ pip install floe-guard[crewai]
142159
```
143160

144161
```python
145-
from crewai import Crew
162+
from crewai import Agent, Crew
146163
from floe_guard import BudgetGuard
147-
from floe_guard.integrations.crewai import guard_crew
164+
from floe_guard.integrations.crewai import budget_guarded_llm
148165

149166
guard = BudgetGuard(limit_usd=1.00)
150-
guard_crew(guard) # one line — enforces across the whole crew
151-
Crew(agents=[...], tasks=[...]).kickoff()
167+
llm = budget_guarded_llm(guard, "gpt-4o") # meters AND hard-stops
168+
Crew(agents=[Agent(..., llm=llm)], tasks=[...]).kickoff()
152169
```
153170

154-
CrewAI runs on LiteLLM, so one callback caps every agent and task under a single
155-
budget.
171+
CrewAI runs on LiteLLM, so one callback meters every agent and task under a
172+
single budget. Use `budget_guarded_llm` (not just `guard_crew`) to get the hard
173+
stop: LiteLLM can swallow exceptions raised inside its callbacks (verified on
174+
litellm 1.91.x), so a callback alone may keep the crew running past a
175+
violation. `budget_guarded_llm` also enforces in the LLM call path — where a
176+
raise reliably reaches CrewAI — re-raising any violation the callback recorded
177+
before the next call runs. `guard_crew(guard)` remains available for metering
178+
existing crews; check the returned callback's `tripped` attribute (and the
179+
`floe_guard` logger's ERROR output) if you use it alone. A recorded violation
180+
latches for the life of the callback — after remediating (say, adding a price
181+
override), call `callback.reset()` or build a fresh guard.
156182

157183
### LiteLLM
158184

@@ -169,7 +195,12 @@ response = guarded_completion(guard, model="gpt-4o", messages=[...])
169195
```
170196

171197
Prefer the LiteLLM-native callback? Register `budget_guard_callback(guard)` on
172-
`litellm.callbacks`.
198+
`litellm.callbacks` — but know its limit: LiteLLM runs callbacks inside
199+
`except Exception`, so the callback's enforcement raise can be swallowed and
200+
your loop keeps going. The callback records any violation on its `tripped`
201+
attribute and logs it at ERROR level; consult `tripped` in your own loop, or
202+
use `guarded_completion` (which enforces at the call site) for the guaranteed
203+
stop. Wrapper enforcement is tested against litellm 1.91.x.
173204

174205
### LangChain
175206

0 commit comments

Comments
 (0)