Skip to content

Commit c1cfda0

Browse files
committed
feat(admin): exclude models and experiments from spend, and label what'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
1 parent a406827 commit c1cfda0

40 files changed

Lines changed: 2570 additions & 729 deletions

AGENTS.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -334,12 +334,28 @@ status, queue health, worker, orphan, cost, per-user cost, and task-expansion
334334
handlers must pass `auth.org_id`; never accept an organization selector from
335335
the client. A user cost drilldown returns 404 when the requested user belongs
336336
to another org. Deployment-wide diagnostics or mutations (global queue
337-
status/health and slot topology, model concurrency, shared-channel Slack alert
338-
settings, and the global cost-excluded LLM-key list) additionally require the active org to match
337+
status/health and slot topology, model concurrency, shared-channel Slack
338+
alert settings, and the global cost-exclusion lists) additionally require the
339+
active org to match
339340
`ODDISH_OPERATOR_ORG_ID`, which fails closed when unset; the frontend discovers
340341
that capability through `GET /admin/operator-access` and hides those controls
341342
for other orgs.
342343

344+
Admin cost exclusions (`oddish/core/cost_exclusions.py`) name spend that was
345+
never really paid for, along two axes: a **model** (`cost_excluded_models`,
346+
matched against `trials.model`, global and retroactive) and an **experiment**
347+
(`cost_excluded_experiments`, matched against `trials.experiment_id` so a
348+
collection cannot launder gathered trials' cost). Both fold into
349+
`first_party_spend_filter` and the quota inflight predicates, so excluded
350+
spend leaves the cost dashboards and stops counting against caps together.
351+
It is dropped from accounting but **not** hidden: experiment, task, and trial
352+
surfaces still render the money and label it, via `excluded_cost_usd` on the
353+
experiment rollup and `cost_exclusion_reason` on `TrialResponse`. Keep the SQL
354+
predicates and the `CostExclusions` Python twin in step — a surface that
355+
labels spend differently from the way accounting drops it is worse than one
356+
that says nothing. Callers that do not pass an exclusions snapshot report
357+
`cost_exclusion_reason=None`, which means "unresolved", not "real".
358+
343359
The authenticated org-scoped cost leaderboard is served by `GET /leaderboard` in
344360
`backend/api/routers/dashboard.py`. It shares the admin cost dashboard's
345361
settled first-party spend basis and must stay in sync with its per-user rows:

DOCS.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export ODDISH_API_KEY="ok_..."
2626
- `oddish cancel` - stop in-flight task runs or task-level QA jobs
2727
- `oddish backfill-analysis` - (re)run trial analysis for a trial, task, or experiment
2828
- `oddish costs` - view billable-spend accounting (org-wide, or per-user with `--user`)
29+
- `oddish cost-exclusions` - manage the models and experiments whose spend doesn't count
2930
- `oddish pull` - download logs and artifacts
3031
- `oddish combine` - merge several experiments into a new one
3132
- `oddish collect` - gather trials from tasks/trial IDs into a shareable read-only collection
@@ -434,6 +435,51 @@ Options
434435
- `--api TEXT` - Override the API URL
435436
- `--json` - Emit the raw cost breakdown JSON
436437

438+
## Spend That Doesn't Count
439+
440+
Some spend Oddish records was never actually paid for: provider-sponsored
441+
capacity, free preview tiers, vendor credits, a bake-off someone comped.
442+
Operators mark it with `oddish cost-exclusions`, along two axes:
443+
444+
- **Models** - every trial that ever ran on the model stops counting, because
445+
the reason its spend isn't real is a property of the model.
446+
- **Experiments** - only trials the experiment ran *itself* stop counting.
447+
Trials it merely gathered from elsewhere keep counting on the experiment that
448+
actually spent the money.
449+
450+
Excluded spend is dropped from the admin cost dashboards and from quota
451+
enforcement. It is **not** hidden: experiment, task, and trial pages still show
452+
the money, marked as not real, so the two surfaces never disagree silently.
453+
454+
Both lists are deployment-wide and apply retroactively - adding an entry removes
455+
spend already recorded, and removing one restores every dollar. Operator-only on
456+
hosted Oddish (a full-scope API key belonging to the operator org); not
457+
available on a self-hosted core server.
458+
459+
```bash
460+
# What currently doesn't count
461+
oddish cost-exclusions list
462+
oddish cost-exclusions list --kind model --json
463+
464+
# Stop counting a free model, and a comped experiment
465+
oddish cost-exclusions add model xai/grok-4 --label "sponsored"
466+
oddish cost-exclusions add experiment "glm sweep" --label "comped"
467+
468+
# Put the spend back (by row id, model name, or experiment name/id)
469+
oddish cost-exclusions remove model xai/grok-4
470+
oddish cost-exclusions remove experiment exp_01j...
471+
```
472+
473+
Options
474+
475+
- `--kind TEXT` - On `list`, limit to one axis: `model` or `experiment`
476+
- `--label TEXT` - On `add`, why it doesn't count (e.g. `sponsored`)
477+
- `--api TEXT` - Override the API URL
478+
- `--json` - Emit raw JSON
479+
480+
An experiment can be named or referenced by id. Names are not unique, so an
481+
ambiguous name is rejected rather than resolved to a guess - pass the id.
482+
437483
## Download Outputs
438484

439485
Use `oddish pull` to download logs and artifacts from Oddish to local files.

backend/api/app.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,8 @@ def create_app() -> FastAPI:
245245
api_keys,
246246
byok,
247247
clerk_webhooks,
248-
cost_excluded_keys,
248+
cost_excluded_experiments,
249+
cost_excluded_models,
249250
dashboard,
250251
documents,
251252
github_linkage,
@@ -287,7 +288,8 @@ def create_app() -> FastAPI:
287288
api.include_router(public_analysis.router)
288289
api.include_router(slack.router)
289290
api.include_router(admin.router)
290-
api.include_router(cost_excluded_keys.router)
291+
api.include_router(cost_excluded_models.router)
292+
api.include_router(cost_excluded_experiments.router)
291293
api.include_router(model_display_names.router)
292294
api.include_router(tags.router)
293295
api.include_router(reports.router)
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
"""Admin API for excluding an experiment's spend from cost accounting.
2+
3+
Operator-only and deployment-wide, like the model list. Scoped to trials
4+
**homed** in the experiment, so a collection that merely gathers other
5+
experiments' trials cannot launder their cost.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from typing import Annotated
11+
12+
from fastapi import APIRouter, Depends, HTTPException
13+
from pydantic import BaseModel
14+
from sqlalchemy import select
15+
from sqlalchemy.exc import IntegrityError, ProgrammingError
16+
from sqlalchemy.ext.asyncio import AsyncSession
17+
18+
from auth import AuthContext, can_manage_api_keys, require_admin
19+
from auth.permissions import require_operator_org
20+
from oddish.db import (
21+
CostExcludedExperimentModel,
22+
ExperimentModel,
23+
get_session,
24+
utcnow,
25+
)
26+
from pg_errors import is_undefined_table_error
27+
28+
router = APIRouter(prefix="/admin/cost-excluded-experiments", tags=["Admin"])
29+
30+
31+
class CostExcludedExperimentResponse(BaseModel):
32+
id: str
33+
experiment_id: str
34+
experiment_name: str
35+
label: str
36+
created_by: str | None
37+
created_at: str
38+
39+
40+
class CreateCostExcludedExperimentRequest(BaseModel):
41+
experiment: str
42+
label: str = ""
43+
44+
45+
def _response(row: CostExcludedExperimentModel) -> CostExcludedExperimentResponse:
46+
return CostExcludedExperimentResponse(
47+
id=row.id,
48+
experiment_id=row.experiment_id,
49+
experiment_name=row.experiment_name,
50+
label=row.label,
51+
created_by=row.created_by_user_id,
52+
created_at=row.created_at.isoformat(),
53+
)
54+
55+
56+
def _require_manage(auth: AuthContext) -> None:
57+
require_operator_org(auth)
58+
if not can_manage_api_keys(auth):
59+
raise HTTPException(
60+
status_code=403,
61+
detail="Only organization admins may edit the cost-exclusion list",
62+
)
63+
64+
65+
def _unavailable(exc: ProgrammingError) -> HTTPException:
66+
if is_undefined_table_error(exc):
67+
return HTTPException(
68+
503,
69+
"Cost exclusions are not available yet (schema is still "
70+
"migrating). Try again shortly.",
71+
)
72+
raise exc
73+
74+
75+
async def _resolve_experiment(session: AsyncSession, ref: str) -> ExperimentModel:
76+
"""The experiment an operator meant, by id or by name.
77+
78+
An exact id wins, and resolves with ``include_deleted``: spend from a
79+
soft-deleted experiment still shows on cost surfaces, so it must stay
80+
excludable. Names resolve among live experiments only and must be
81+
unambiguous -- experiment names are not unique.
82+
"""
83+
by_id = await session.scalars(
84+
select(ExperimentModel)
85+
.where(ExperimentModel.id == ref)
86+
.execution_options(include_deleted=True)
87+
)
88+
experiment = by_id.first()
89+
if experiment is not None:
90+
return experiment
91+
by_name = await session.scalars(
92+
select(ExperimentModel).where(ExperimentModel.name == ref).limit(2)
93+
)
94+
matches = by_name.all()
95+
if len(matches) > 1:
96+
raise HTTPException(
97+
status_code=409,
98+
detail="experiment name is ambiguous; use the experiment id",
99+
)
100+
if not matches:
101+
raise HTTPException(status_code=404, detail="experiment not found")
102+
return matches[0]
103+
104+
105+
@router.get("", response_model=list[CostExcludedExperimentResponse])
106+
async def list_cost_excluded_experiments(
107+
auth: Annotated[AuthContext, Depends(require_admin)],
108+
) -> list[CostExcludedExperimentResponse]:
109+
require_operator_org(auth)
110+
try:
111+
async with get_session() as session:
112+
rows = await session.scalars(
113+
select(CostExcludedExperimentModel).order_by(
114+
CostExcludedExperimentModel.created_at.desc()
115+
)
116+
)
117+
return [_response(row) for row in rows]
118+
except ProgrammingError as exc:
119+
raise _unavailable(exc)
120+
121+
122+
@router.post("", response_model=CostExcludedExperimentResponse)
123+
async def add_cost_excluded_experiment(
124+
request: CreateCostExcludedExperimentRequest,
125+
auth: Annotated[AuthContext, Depends(require_admin)],
126+
) -> CostExcludedExperimentResponse:
127+
_require_manage(auth)
128+
129+
ref = request.experiment.strip()
130+
if not ref:
131+
raise HTTPException(status_code=400, detail="experiment must not be empty")
132+
133+
try:
134+
async with get_session() as session:
135+
experiment = await _resolve_experiment(session, ref)
136+
existing = await session.scalars(
137+
select(CostExcludedExperimentModel).where(
138+
CostExcludedExperimentModel.experiment_id == experiment.id
139+
)
140+
)
141+
if existing.first() is not None:
142+
raise HTTPException(
143+
status_code=409, detail="experiment is already excluded"
144+
)
145+
146+
row = CostExcludedExperimentModel(
147+
experiment_id=experiment.id,
148+
# A display snapshot: the row outlives the experiment.
149+
experiment_name=experiment.name,
150+
label=request.label.strip(),
151+
created_by_user_id=auth.user_id,
152+
)
153+
session.add(row)
154+
try:
155+
await session.commit()
156+
except IntegrityError:
157+
raise HTTPException(
158+
status_code=409, detail="experiment is already excluded"
159+
)
160+
return _response(row)
161+
except ProgrammingError as exc:
162+
raise _unavailable(exc)
163+
164+
165+
@router.delete("/{row_id}")
166+
async def remove_cost_excluded_experiment(
167+
row_id: str,
168+
auth: Annotated[AuthContext, Depends(require_admin)],
169+
) -> dict:
170+
_require_manage(auth)
171+
try:
172+
async with get_session() as session:
173+
result = await session.scalars(
174+
select(CostExcludedExperimentModel).where(
175+
CostExcludedExperimentModel.id == row_id
176+
)
177+
)
178+
row = result.first()
179+
if row is None:
180+
raise HTTPException(
181+
status_code=404, detail="cost-excluded experiment not found"
182+
)
183+
row.deleted_at = utcnow()
184+
await session.commit()
185+
except ProgrammingError as exc:
186+
raise _unavailable(exc)
187+
return {"deleted": row_id}

0 commit comments

Comments
 (0)