Skip to content

feat(admin): exclude models and experiments from spend, and label what's left - #1271

Open
charlesyhuang wants to merge 7 commits into
stagingfrom
claude/admin-spend-exclusion-a5mcpf
Open

feat(admin): exclude models and experiments from spend, and label what's left#1271
charlesyhuang wants to merge 7 commits into
stagingfrom
claude/admin-spend-exclusion-a5mcpf

Conversation

@charlesyhuang

@charlesyhuang charlesyhuang commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Some spend Oddish records was never actually paid for: provider-sponsored capacity, free preview tiers, vendor credits, a bake-off someone comped. Operators can now declare it along two axes, from the admin panel or the CLI.

This replaces the cost-excluded LLM-key list, which keyed off a hash nobody could read back and only covered the "we have a free key" case. It supersedes #1132 (experiment exclusions), which had gone stale against staging.

The two axes

  • Model (cost_excluded_models) — global and retroactive. Every trial that ever ran on it stops counting, because the reason its spend isn't real is a property of the model, not of when it ran.
  • Experiment (cost_excluded_experiments) — scoped to trials homed in it (trials.experiment_id), so a collection that merely gathers other experiments' trials can't launder their cost. A gathered trial keeps counting wherever it was really spent. Excluding a collection is rejected outright: it homes no trials, so it would exclude nothing while claiming everything was free.

Both fold into first_party_spend_filter, the quota inflight predicates, and the quota cancellation sweep, so excluded spend leaves the admin cost dashboards, stops counting against caps, and is not killed when a cap trips — removing an entry restores every dollar to all three.

Dropped from accounting, deliberately not hidden

Experiment, task, and trial surfaces still price the work that ran, and now mark it as not real. Without that, a reader reconciling an experiment tile against the admin dashboard finds a gap with no explanation.

  • The experiment rollup splits its existing (agent, model, billed, owned) groups on the exclusion predicate to report excluded_cost_usd / owned_excluded_cost_usd, plus experiment_cost_excluded. Only the owned tile may claim the whole figure is comped; the all-member Cost tile covers gathered trials that are still counted elsewhere.
  • Trials carry cost_exclusion_reason ("model" / "experiment"), threaded through build_trial_response via a per-request snapshot. Null means real spend or "this caller didn't resolve exclusions" — absence is not a claim the money was real.

Matching the spelling trials actually store

trials.model is written by the agent-aware normalize_trial_model, which re-routes ids: kimi-k2 is stored as moonshot/kimi-k2, Claude models as Bedrock ids, fireworks/glm-5.2 as glm-5p2. Storing what an operator typed would register a row matching nothing, with no feedback and no way to discover the stored spelling.

So POST resolves the typed value against the values on disk, registers every match (one model can legitimately have two stored spellings — bare grok-free-preview and routed xai/grok-free-preview — and excluding one would leave half the spend counting), and 404s when nothing matches. The SQL predicate and its CostExclusions Python twin both compare trials.model verbatim, so the query and the label can't disagree about which rows are excluded.

CLI

oddish cost-exclusions list [--kind model|experiment] [--json]
oddish cost-exclusions add model kimi-k2 --label "sponsored"
oddish cost-exclusions add experiment "glm sweep" --label "comped"
oddish cost-exclusions remove model kimi-k2

remove accepts the row id, the model name, or an experiment name/id, canonicalizes model spellings, and rejects ambiguous experiment names rather than guessing. Mutations are gated on operator-org membership; require_admin already counts a full-scope API key as admin, which is the CLI's only credential.

The key list is deleted

Nobody was using it, so this removes everything that only existed to feed it: core/llm_key_fingerprint.py and its test, the ORM field, the settlement-time and local-runner stamps, and the now-dead byok_env plumbing they were the only readers of. BYOK's own key_hint is a different feature and is untouched.

trials.llm_key_hash is deliberately not dropped here. Both deploy workflows run migrations before the code deploy (AGENTS.md: "the backend can hard-require new schema on its hot paths"). That is the expand pattern — safe for ADD, wrong for DROP. Dropping it would leave the still-serving previous release mapping a column that no longer exists, failing every select(TrialModel) for the length of the image build. The column is inert either way, so it costs one release and the window disappears. Follow-up PR to drop it, once this is rolled out. Its entry stays in Carl's SQL deny-list meanwhile, which is the only guard where code runs ahead of schema.

One subtlety at the claim site in trial_handler: the conditional UPDATE there was never about the hash — its rowcount is the "this attempt is still ours" check that aborts a stale or reassigned worker. It stays an UPDATE with the same predicate so the check remains atomic, now writing updated_at (which onupdate was already writing on every claim, so no new writes).

Reviewer notes

Three read-only red-team passes ran over this; their findings are in commit fc07695. The two that were regressions from earlier commits in this PR — the quota sweep cancelling excluded trials, and model exclusions silently matching nothing — both now have tests, since the coverage that would have caught the first was itself rewritten around the deleted feature.

test_quota_enforcement.py::test_org_quota_cancels_every_users_active_trials built its preserved-task case on an excluded key, so it was reworked to a plain second task; test_cost_exclusions.py carries the exclusion-specific coverage now.

Comments and docstrings are stripped from the files this PR introduces, per request. Pre-existing prose in shared modules (cost_basis, experiment_cost, quotas, helpers) documents other features and is left alone.

Testing

  • 31 backend router tests (both axes: auth gating, model resolution, unknown-model 404, collection rejection, ambiguity, duplicate races, soft delete), 13 CLI tests, and DB-backed test_cost_exclusions.py covering both axes dropping from get_cost_breakdown_core and sum_org_cost_usd, removal re-including, inflight reservations skipping excluded spend, and the cancellation sweep sparing it.
  • ruff check at the same 41 findings as the base branch, pnpm tsc --noEmit clean, prettier applied.
  • Each commit was diffed against the branch tip it sits on — identical failures (4, all DB-dependent in this environment) and identical ruff counts, verified by stashing and re-running.

🤖 Generated with Claude Code


Generated by Claude Code

…t's left

Some spend Oddish records was never actually paid for: provider-sponsored
capacity, free preview tiers, vendor credits, a bake-off someone comped.
Operators can now declare it along two axes, from the admin panel or the CLI.

Replaces the LLM-key exclusion list, which keyed off a hash nobody could read
back and only covered the "we have a free key" case. The two axes people
actually reach for:

* Model (`cost_excluded_models`) -- global and retroactive. Every trial that
  ever ran on it stops counting, because the reason its spend isn't real is a
  property of the model.
* Experiment (`cost_excluded_experiments`) -- scoped to trials homed in it, so
  a collection that merely gathers other experiments' trials can't launder
  their cost.

Both fold into `first_party_spend_filter` and the quota inflight predicates,
so excluded spend leaves the admin cost dashboards and stops counting against
caps together, and re-including it restores every dollar.

Dropped from accounting, but deliberately not hidden. Experiment, task, and
trial surfaces still price the work that ran and now mark it as not real, so a
reader reconciling an experiment tile against the admin dashboard finds an
explanation instead of a silent gap. The experiment rollup splits its existing
groups on the exclusion predicate to report `excluded_cost_usd`; trials carry
`cost_exclusion_reason`, which is null both for real spend and for callers
that never resolved exclusions -- absence is not a claim that the money was
real.

`core/cost_exclusions.py` owns the SQL predicates and the `CostExclusions`
Python twin used to label already-loaded rows; keep the two in step.

`trials.llm_key_hash` is left in place and is now write-only -- dropping a
column off the hot trials table is a separate, riskier change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zmdrcCqnkVM6hWjhPu2T9
@vercel

vercel Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
oddish-app Ready Ready Preview Aug 17, 2026 10:01pm

@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Oddish preview

Commit: ff67b43014d19e27832febf77d99b62fda579f12

Surface Link Target
Frontend https://pr-1271.oddish.app Vercel preview for ff67b43
Backend oddish-pr-1271 oddish-pr-1271
Database project odbgzcdlhlikvyxwvwdn project odbgzcdlhlikvyxwvwdn

Vercel deployment URL: https://oddish-l8qkvtu8r.oddish.app

Plan:

  • Frontend deploy: true
  • Backend deploy: false
  • Migrations: false

This comment is updated by the PR Preview workflow.

The key list is gone from the code paths; take out what only existed to
feed it. `trials.llm_key_hash` had exactly one reader — the exclusion
probe — so with the probe gone the column, the fingerprinting module, and
both stamping sites are dead weight.

Removes `core/llm_key_fingerprint.py` and its test, the `llm_key_hash`
column and ORM field, the settlement-time stamp in `trial_handler`, the
local-runner stamp, and the `carl_tools` field allowlist entry. BYOK's
own `key_hint` is a different feature and is untouched.

`costexcl02` now drops the column alongside the table, under a short
`lock_timeout` — dropping a column is catalog-only in Postgres but still
takes a brief ACCESS EXCLUSIVE lock on a hot table, and failing fast beats
queueing behind a long read with every writer stacked up behind us.

One subtlety at the claim site: the conditional UPDATE there was never
really about the hash. Its rowcount is the "this attempt is still ours"
check that aborts a stale or reassigned worker before it does any
expensive work. Kept as an UPDATE with the same predicate so the check
stays atomic against a concurrent finish, now writing `updated_at`, since
an UPDATE needs a SET.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zmdrcCqnkVM6hWjhPu2T9
Four of these are real defects, two of them regressions from the two
commits above.

Quota sweep no longer cancels excluded trials. `_active_trial_predicates`
lost the key-exclusion filter when that feature was deleted and gained
nothing in its place, so `cancel_trials_if_quota_reached` would kill every
active trial in an excluded experiment — trials that contributed nothing
to the number that tripped the cap and reserve nothing against it, so
killing them frees no headroom. Adds both filters and a test, since the
test that used to cover this was rewritten around the deleted feature.

Model exclusions no longer silently no-op. `trials.model` is written by
the agent-aware `normalize_trial_model`, which re-routes ids — `kimi-k2`
is stored as `moonshot/kimi-k2`, Claude models as Bedrock ids — so storing
what the operator typed registered a row that matched nothing, with no
feedback. The endpoint now resolves the typed spelling against the values
trials actually store, registers every match (one model can have two
stored spellings, and excluding one would leave half the spend counting),
and 404s when nothing matches. The SQL predicate and its Python twin now
both compare `trials.model` verbatim, so they can no longer disagree about
which rows are excluded.

The CLI could never write. Mutations gated on `can_manage_api_keys`, which
rejects API-key auth by design because it guards key management itself —
so every `oddish cost-exclusions add` was a guaranteed 403 telling the
operator to use the credential they were already using. Operator-org
membership is the gate; `require_admin` already counts a full-scope key as
admin.

The experiment Cost tile claimed gathered spend was comped. It covers
member trials including gathered ones, but was passed the whole-experiment
excluded flag, which by design only excludes homed trials. Excluding a
collection is now rejected outright for the same reason: it homes no
trials, so it would exclude nothing while claiming everything was free.

Also: migration resets `SET LOCAL lock_timeout` (all pending revisions
share one transaction, so it leaked into later ones) and touches `trials`
last on downgrade as it already did on upgrade; restores the `llm_key_hash`
entry in Carl's SQL deny-list, which is inert once the column is gone but
is the only guard where code runs ahead of schema; CLI emits JSON on every
error branch and resolves non-canonical model spellings on remove.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zmdrcCqnkVM6hWjhPu2T9
Both deploy workflows run migrations before the code deploy — AGENTS.md
states the ordering explicitly, because the backend can hard-require new
schema on its hot paths. That is the expand pattern: safe for ADD, wrong
for DROP. Dropping the column here leaves the still-serving previous
release mapping a column that no longer exists, so every
`select(TrialModel)` fails for the length of the Modal image build, and
in-flight trials on old containers cannot persist their results.

The column is inert either way — nothing reads or writes it after the
commits above — so keeping it costs one release and removes the window
entirely. The drop belongs in a follow-up migration, once a release that
never touches the column is fully rolled out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zmdrcCqnkVM6hWjhPu2T9
@cursor

cursor Bot commented Aug 16, 2026

Copy link
Copy Markdown

Bugbot is paused — on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.

Removes comments and docstrings from the files this PR introduces, and
takes out what the red-team pass found was dead or duplicated. Net -613
lines across 27 files.

Dead code removed: the `byok_env` plumbing through `_settle_trial_metering`
and `store_trial_results`, whose only reader was the deleted key-hash
stamp; the unused `exclusions` parameter on `build_task_status_response`,
which no caller passed because `task_detail` rebuilds the trial list
itself; and `CostExclusions.__bool__`.

Deduplicated: the four Next.js proxy routes now use the app's shared
`proxyBackendJson` / `proxyJsonRequest` instead of reimplementing them —
83 lines down to 10 — which also restores request-id propagation, upstream
server-timing, and abort-signal forwarding, and drops the bespoke
`{error, details}` envelope in favour of the backend payload the rest of
the app already forwards. The two routers share `unavailable` and
`soft_delete`; their genuinely different halves (model resolution against
`trials.model` returning a list, experiment id-or-name resolution
rejecting collections) stay separate.

Pre-existing prose in shared modules is untouched — the accounting
rationale in `cost_basis`, `experiment_cost`, `quotas` and `helpers`
documents other features and this PR has no business deleting it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zmdrcCqnkVM6hWjhPu2T9
Retitles the admin card to "Remove Spend Tracking" and replaces the
paragraph under each heading with a single plain sentence. Same rename and
trim in DOCS.md, plus a pointer to where the card lives in the dashboard.

The two behaviours worth knowing are kept as a short note under the
options, because both are things an operator hits in practice: `add model`
matches the spelling trials actually store, and a model nothing has run on
is rejected rather than saved as an entry that matches nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zmdrcCqnkVM6hWjhPu2T9
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants