Repository navigation
Usage metering, entitlements, and evaluator BYOK #370
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
jlamoreaux
wants to merge
29
commits into
main
Choose a base branch
from
claude/paywall-strategy-open-source-si1z2h
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
29 commits
Select commit
Hold shift + click to select a range
7d7c6cb
Scope hosted monetization, usage metering, and evaluator BYOK
claude 9f47587
Give the evaluator layer a project and a billing subject
claude b8ee983
Test that every evaluation names its payer
claude a853ff4
Fix review findings on the evaluator billing context
claude 359f694
Name who pays for each cost sample
claude 0be3c71
Correct claims the attribution comments got wrong
claude 27a6fcc
Erase cost owner ids, and bill the resolve that skips the suite
claude 02faa1d
Keep the usage aggregate out of reach of project deletion
claude ada38d7
Stop the usage aggregate being refundable, and add the entitlements seam
claude 7f527a9
Add the usage meter, keyed on who the limit is actually for
claude 67e9acc
Fix two meter bugs, and put the LLM evaluator behind a provider seam
claude aa7b2cc
Let a project bring its own key, without letting it choose the endpoint
claude 71b4242
Enforce allowances observe-only, and close the BYOK exfiltration paths
claude f79256b
Show people their usage, and let an agent ask for it
claude a123296
Document what shipped, and correct what it made untrue
claude 6bc5d41
Close the org hole where there is no actor, and keep measuring past a…
claude e23ba3f
Raise the coverage ratchet to match what the suite now covers
claude 66fc0a4
Merge origin/main: adopt changelog.d/ fragments, keep both doc voices
claude aed79f7
Address the review: fail open at the meter, and one host filter that …
claude 11952fd
Correct the research doc's signup status, which now understates the e…
claude 80a25b3
Merge origin/main: the closed-beta SSO gate (#371)
claude e7c7ec3
Close two documentation gaps this PR left open
claude 5d95056
Act on the review: a broken retry of my own, and two silent failures
claude 1c23bf9
Fix the lint failure I pushed, and stop masking exit codes
claude 7e10990
Only mark a usage notice sent when one was actually sent
claude 14ce81c
Record the webhook-secret weakness properly, having first declined it…
claude 12e49e7
Merge remote-tracking branch 'origin/main' into claude/paywall-strate…
claude f9466d8
Reject the non-global IPv6 ranges the shared host filter still took
claude b98ccb3
Match only the two registered NAT64 prefixes, not the /32 around them
claude File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,162 @@ | ||
| ### Added | ||
|
|
||
| - **Bring your own model for the merge gate.** The `llm` evaluator has only ever | ||
| been able to call the instance's Workers AI binding, so the reviewer model was | ||
| the operator's choice and the tokens were the operator's bill. A project can | ||
| now run that gate on its own account: the operator lists named providers in a | ||
| new `LLM_PROVIDERS` variable (`anthropic`, or anything OpenAI-compatible), the | ||
| project stores its key in the same encrypted per-project secret store deploys | ||
| already use, and `.stratum/policy.yaml` names the provider it wants: | ||
|
|
||
| ```yaml | ||
| evaluators: | ||
| - type: llm | ||
| provider: anthropic | ||
| model: claude-sonnet-4-5 | ||
| threshold: 0.7 | ||
| ``` | ||
|
|
||
| Self-hosting, this is yours to configure and costs you nothing to ignore: | ||
| with `LLM_PROVIDERS` unset — the default — the binding is the only option and | ||
| every path below is inert. | ||
|
|
||
| Three things are worth knowing before you switch. **A policy may never name a | ||
| URL**, only select a provider the operator configured, because a base URL | ||
| chosen by repository content turns the Worker into a request-forgery | ||
| primitive pointed at a host of the author's choosing — the `llm` entry is a | ||
| whitelist of `provider`, `model`, `threshold` and `maxDiffChars`, and | ||
| `model` is required whenever `provider` is set. **Everything fails closed**: | ||
| an unconfigured provider name, a missing or undecryptable key, an instance | ||
| with no `DEPLOY_SECRET_KEY`, or a provider that answers with a redirect fails | ||
| the gate with a reason saying which, and never quietly falls back to the | ||
| operator's binding. And **any project writer can spend the owner's credit** by | ||
| opening changes; the hourly evaluation ceiling still applies, and your own key | ||
| does not lift it. Rotating `DEPLOY_SECRET_KEY` — already documented as making | ||
| every stored deploy secret undecryptable — now also blocks the gate on BYOK | ||
| projects until each re-enters its key. The threat model is | ||
| `docs/adr/008-llm-provider-byok-threat-model.md`. | ||
|
|
||
| - **See what you are spending.** A new `/settings/usage` page shows this | ||
| account's consumption against its allowances for the current period, what is | ||
| left, and when it resets; `GET /api/users/me/usage` and a nineteenth MCP tool, | ||
| `stratum_get_usage`, return the same figures, so an agent that can see 12% of | ||
| its allowance left can batch its changes or ask its human instead of | ||
| discovering the wall by hitting it. Crossing 80% of a meter raises a banner and | ||
| sends one email per crossing. Spend on a project's own provider key is reported | ||
| separately and never counted against an allowance. The billing surface is | ||
| **read-only everywhere** — no endpoint and no tool can raise a limit, buy | ||
| capacity, or store a provider key, for the same reason agent tokens cannot | ||
| submit review verdicts. Self-hosted, the page reads "unlimited" and no banner | ||
| or email is ever sent. | ||
|
|
||
| - **Metered allowances — inert unless you connect a billing service.** Stratum | ||
| can now check usage against a plan: an entitlements seam behind | ||
| `BILLING_SERVICE_URL` + `BILLING_SERVICE_SECRET`, a `UsageMeter` Durable Object | ||
| holding the monthly counters and a bucketed sliding `evaluations_per_hour` | ||
| window, and checks at the LLM evaluator, the deploy consumer, the request | ||
| limiter and private-project creation. **With those two variables unset — every | ||
| self-hosted deployment — none of it does anything**: every allowance reads as | ||
| unlimited, the meter binding is never touched, and nothing can be refused. Even | ||
| configured, every decision is observe-only (recorded, then admitted) unless | ||
| `ENTITLEMENTS_ENFORCE=1`. Plan definitions and payment live outside this | ||
| repository. One design note that outlives the billing service: a limit is | ||
| checked against the **person who ran the evaluation**, not the project's owner, | ||
| so an allowance follows you across your own and your organizations' projects, | ||
| and creating another organization does not hand you a fresh one. | ||
|
|
||
| ### Changed | ||
|
|
||
| - **Every cost record now names who pays for it, and LLM token counts are the | ||
| real ones.** `cost_records` gains `owner_id`, `owner_type` and `source` | ||
| (`platform` or `byok`), and a new `usage_periods` table keeps the owner-scoped | ||
| monthly totals those roll up into — migrations 048 and 049, applied like any | ||
| other. Attribution is **always on**, self-hosted included, because it is a | ||
| ledger rather than a paywall: expect a small number of additional D1 writes per | ||
| evaluated change, and a `USAGE_METER` Durable Object binding in `wrangler.toml` | ||
| that is created on deploy and never contacted unless a billing service is | ||
| configured. Token accounting stopped guessing at the same time: Workers AI | ||
| reports `prompt_tokens`/`completion_tokens` and those are now recorded as-is, | ||
| where every LLM cost record used to be a `~4 chars/token` estimate. The | ||
| estimate survives only as a per-response fallback for a provider that omits the | ||
| counts, and a record built that way still says `estimated`. | ||
|
|
||
| ### Fixed | ||
|
|
||
| - **A policy file is bounded, and an unusable `llm` entry says why.** | ||
| `evaluators:` now takes at most 16 entries, the cap `deploys:` has always had | ||
| and for the same reason — one merge must not amplify into an unbounded number | ||
| of external calls. An entry that names a provider must also name a `model`, | ||
| since the default is a Workers AI model id that fails every call against any | ||
| other provider. The merge-blocking reason now names the provider (or the field) | ||
| that made the entry unusable instead of only counting entries. A verdict | ||
| truncated at the provider's token cap records the tokens the provider reported | ||
| for it, rather than recording a charged call as free. | ||
| - **A deploy refusal can no longer overwrite a deployment that is running.** The | ||
| paths that write a terminal status without claiming the row first — a failed | ||
| guard, a superseded commit, and now an exhausted allowance — run before | ||
| `claimDeployment`, and `running` is not a terminal status, so a redelivery of | ||
| the same message could land `failed` on a row another delivery was mid-deploy | ||
| on and clear its lease. Those writes are now refused against a claimed row, | ||
| and the `deployment.failed` event they emit is withheld when the write does | ||
| not land: an event announcing a failure for a deploy that is about to succeed | ||
| is the one a webhook consumer acts on. | ||
| - **The commented `LLM_PROVIDERS` example carries the API version path.** The | ||
| provider appends `/messages` (or `/chat/completions`) to `baseUrl`, so the | ||
| `https://api.anthropic.com` shown in `wrangler.toml` would have 404'd on every | ||
| call; it is `https://api.anthropic.com/v1` now. A `baseUrl` is also stored | ||
| normalized, so a trailing `#` or `?` — both of which pass the "no fragment, no | ||
| query" check — cannot swallow the path the provider appends. | ||
|
|
||
| ### Security | ||
|
|
||
| - **A project secret can no longer escape through an error message.** A stored | ||
| value containing CR, LF or NUL made the outbound provider request's header | ||
| construction throw a `TypeError` that quotes the offending value; that message | ||
| became an evaluation reason, which is persisted on the change and rendered — | ||
| world-readably, for a public project. Both ends are closed: the store rejects | ||
| control characters in a value, and the provider builds its headers where a | ||
| failure maps to a constant that interpolates nothing. | ||
| - **The LLM provider request no longer follows redirects.** The allowlist | ||
| validates the host a request is *sent* to; a 3xx from an allowlisted host used | ||
| to move the prompt (the diff and the policy) to an unvalidated one, re-sending | ||
| the body on 307/308 with the project's `x-api-key` attached, which the Fetch | ||
| spec does not strip cross-origin. A redirect is now a failed evaluation. | ||
| - **Credential stripping covers the whole policy, recursively.** Fields whose | ||
| name says "credential" were removed only from the top level of each evaluator | ||
| entry, so one written at the top of `.stratum/policy.yaml`, or nested inside an | ||
| entry of an unmodelled type, reached the review model and the body POSTed to a | ||
| policy-supplied webhook URL. The word list also missed common names — | ||
| `auth`, plurals such as `tokens` and `apiKeys`, `pwd`, `pat`, `hmac`, `sig` — | ||
| while keeping innocuous ones like `keystone`. | ||
| - **One host filter, not two — which also closed a pre-existing SSRF hole in the | ||
| webhook evaluator.** The LLM provider allowlist had its own copy of the | ||
| private-address check, and it had drifted: CGNAT (`100.64/10`), the | ||
| `.internal`/`.local` suffixes (GCP's metadata endpoint is | ||
| `metadata.google.internal`), everything in `fe80::/10` not spelled `fe80`, and | ||
| IPv4-compatible IPv6 (`[::127.0.0.1]`, which the URL parser rewrites to | ||
| `[::7f00:1]`) all got through. Both callers now share one filter, which | ||
| expands an IPv6 literal rather than matching its spelling. The webhook | ||
| evaluator's URL check — which has shipped for far longer, and accepts a URL | ||
| straight from `.stratum/policy.yaml` — had the last two gaps too, so a | ||
| `webhook` entry pointed at `[::127.0.0.1]` or at `feb0::…` used to reach the | ||
| loopback and link-local addresses it was written to refuse. Both are refused | ||
| now. The filter also refuses the non-global ranges that are neither RFC 1918 | ||
| nor obviously local — IPv4 multicast, `240.0.0.0/4` (nominally reserved, and | ||
| used for real internal addressing inside more than one cloud provider), the | ||
| RFC 2544 benchmark block, `192.0.0.0/24` and the TEST-NETs; IPv6 multicast | ||
| (`ff02::1` is all-nodes on the local link), site-local, and `2001:db8::/32`. | ||
| The bare-label rule moved into that shared filter as well: `metadata` is | ||
| the metadata endpoint's short name on both AWS and GCP, DNS is what makes it | ||
| an address, and the webhook check had the rule while the provider allowlist | ||
| did not. | ||
| - **A policy declaring two `llm` entries is refused.** The BYOK provider was | ||
| resolved from the first entry while an evaluator was built for every entry, so | ||
| `[{llm}, {llm, provider: …}]` silently ran twice on the operator's Workers AI | ||
| bill — the fail-open this work exists to prevent. Both the parser and the | ||
| provider resolution now refuse rather than choosing one. | ||
| - The shared outbound-host filter now rejects the non-global IPv6 ranges it was | ||
| still accepting: discard-only (`100::/64`), IETF protocol assignments | ||
| (`2001::/23`, which covers benchmarking and Teredo), 6to4 (`2002::/16`) and | ||
| the NAT64 prefixes (`64:ff9b::/96`, `64:ff9b:1::/48`). The IPv4 half already | ||
| refused its equivalents, so webhook delivery and BYOK provider URLs were | ||
| applying two different rules through one filter. | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Document the paid-organization pooling exception consistently.
The acting user is the default enforcement subject. A positively identified pooled organization becomes the enforcement subject for its projects.
changelog.d/370-metered-allowances-byok.md#L63-L65: add the pooled-organization exception to the allowance-keying description.docs/CURRENT_CAPABILITIES.md#L117-L119: add the same exception to the capability description.📍 Affects 2 files
changelog.d/370-metered-allowances-byok.md#L63-L65(this comment)docs/CURRENT_CAPABILITIES.md#L117-L119🤖 Prompt for AI Agents