Skip to content

Commit fc07695

Browse files
committed
fix: close the gaps a red-team pass found in spend exclusions
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
1 parent 3f0e4f6 commit fc07695

11 files changed

Lines changed: 253 additions & 107 deletions

backend/api/routers/cost_excluded_experiments.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from sqlalchemy.exc import IntegrityError, ProgrammingError
1616
from sqlalchemy.ext.asyncio import AsyncSession
1717

18-
from auth import AuthContext, can_manage_api_keys, require_admin
18+
from auth import AuthContext, require_admin
1919
from auth.permissions import require_operator_org
2020
from oddish.db import (
2121
CostExcludedExperimentModel,
@@ -55,11 +55,6 @@ def _response(row: CostExcludedExperimentModel) -> CostExcludedExperimentRespons
5555

5656
def _require_manage(auth: AuthContext) -> None:
5757
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-
)
6358

6459

6560
def _unavailable(exc: ProgrammingError) -> HTTPException:
@@ -102,6 +97,18 @@ async def _resolve_experiment(session: AsyncSession, ref: str) -> ExperimentMode
10297
return matches[0]
10398

10499

100+
def _reject_collection(experiment: ExperimentModel) -> None:
101+
if getattr(experiment, "is_collection", False):
102+
raise HTTPException(
103+
status_code=400,
104+
detail=(
105+
"that experiment is a collection: it homes no trials of its "
106+
"own, so excluding it would exclude nothing. Exclude the "
107+
"experiments that actually ran the work."
108+
),
109+
)
110+
111+
105112
@router.get("", response_model=list[CostExcludedExperimentResponse])
106113
async def list_cost_excluded_experiments(
107114
auth: Annotated[AuthContext, Depends(require_admin)],
@@ -133,6 +140,7 @@ async def add_cost_excluded_experiment(
133140
try:
134141
async with get_session() as session:
135142
experiment = await _resolve_experiment(session, ref)
143+
_reject_collection(experiment)
136144
existing = await session.scalars(
137145
select(CostExcludedExperimentModel).where(
138146
CostExcludedExperimentModel.experiment_id == experiment.id

backend/api/routers/cost_excluded_models.py

Lines changed: 80 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,18 @@
1-
"""Admin API for excluding whole models from cost accounting.
2-
3-
Operator-only and deployment-wide: a model on this list stops counting on the
4-
admin cost dashboards and against quotas everywhere, for every org, and
5-
retroactively -- the reason its spend isn't real (sponsored capacity, a free
6-
preview tier) is a property of the model, not of when it ran.
7-
"""
8-
91
from __future__ import annotations
102

113
from typing import Annotated
124

135
from fastapi import APIRouter, Depends, HTTPException
146
from pydantic import BaseModel
15-
from sqlalchemy import select
7+
from sqlalchemy import distinct, or_, select
168
from sqlalchemy.exc import IntegrityError, ProgrammingError
9+
from sqlalchemy.ext.asyncio import AsyncSession
1710

18-
from auth import AuthContext, can_manage_api_keys, require_admin
11+
from auth import AuthContext, require_admin
1912
from auth.permissions import require_operator_org
13+
from oddish.config import normalize_model_id
2014
from oddish.core.cost_exclusions import canonical_excluded_model
21-
from oddish.db import CostExcludedModelModel, get_session, utcnow
15+
from oddish.db import CostExcludedModelModel, TrialModel, get_session, utcnow
2216
from pg_errors import is_undefined_table_error
2317

2418
router = APIRouter(prefix="/admin/cost-excluded-models", tags=["Admin"])
@@ -47,23 +41,52 @@ def _response(row: CostExcludedModelModel) -> CostExcludedModelResponse:
4741
)
4842

4943

50-
def _require_manage(auth: AuthContext) -> None:
51-
require_operator_org(auth)
52-
if not can_manage_api_keys(auth):
53-
raise HTTPException(
54-
status_code=403,
55-
detail="Only organization admins may edit the cost-exclusion list",
56-
)
44+
def _unavailable(exc: ProgrammingError) -> HTTPException:
45+
if not is_undefined_table_error(exc):
46+
raise exc
47+
return HTTPException(
48+
503,
49+
"Cost exclusions are not available yet (schema is still migrating). "
50+
"Try again shortly.",
51+
)
5752

5853

59-
def _unavailable(exc: ProgrammingError) -> HTTPException:
60-
if is_undefined_table_error(exc):
61-
return HTTPException(
62-
503,
63-
"Cost exclusions are not available yet (schema is still "
64-
"migrating). Try again shortly.",
54+
async def _resolve_models(session: AsyncSession, ref: str) -> list[str]:
55+
"""Every spelling of ``ref`` that ``trials.model`` actually stores.
56+
57+
Exclusion matches ``trials.model`` verbatim, but that value is written by
58+
the agent-aware ``normalize_trial_model``, which re-routes ids: an operator
59+
who types ``kimi-k2`` has trials stored under ``moonshot/kimi-k2``, and
60+
``claude-sonnet-4-5`` lands as a Bedrock id. Storing what they typed would
61+
register a row that silently matches nothing. So resolve against the values
62+
on disk and store those, and return every match -- one model can legitimately
63+
have two stored spellings (bare ``grok-free-preview`` and the routed
64+
``xai/grok-free-preview``), and excluding only one would leave half the
65+
spend counting.
66+
"""
67+
canonical = canonical_excluded_model(ref)
68+
rows = await session.scalars(
69+
select(distinct(TrialModel.model)).where(
70+
TrialModel.model.isnot(None),
71+
or_(
72+
TrialModel.model == ref,
73+
TrialModel.model == canonical,
74+
TrialModel.model.ilike(f"%/{canonical}"),
75+
),
6576
)
66-
raise exc
77+
)
78+
stored = [m for m in rows if m]
79+
# The ILIKE can over-match on a suffix (``a/b-c`` for ``b-c``); confirm in
80+
# Python against the same normalization the writer used.
81+
return sorted(
82+
{
83+
m
84+
for m in stored
85+
if m == ref
86+
or normalize_model_id(m) == canonical
87+
or (normalize_model_id(m) or "").endswith(f"/{canonical}")
88+
}
89+
)
6790

6891

6992
@router.get("", response_model=list[CostExcludedModelResponse])
@@ -83,43 +106,52 @@ async def list_cost_excluded_models(
83106
raise _unavailable(exc)
84107

85108

86-
@router.post("", response_model=CostExcludedModelResponse)
109+
@router.post("", response_model=list[CostExcludedModelResponse])
87110
async def add_cost_excluded_model(
88111
request: CreateCostExcludedModelRequest,
89112
auth: Annotated[AuthContext, Depends(require_admin)],
90-
) -> CostExcludedModelResponse:
91-
_require_manage(auth)
92-
93-
# Store the canonical spelling so the live UNIQUE index collapses case and
94-
# whitespace variants, and so the stored value matches ``trials.model``
95-
# (normalized by the same function on write).
96-
model_name = canonical_excluded_model(request.model_name)
97-
if not model_name:
113+
) -> list[CostExcludedModelResponse]:
114+
require_operator_org(auth)
115+
116+
if not canonical_excluded_model(request.model_name):
98117
raise HTTPException(status_code=400, detail="model must not be empty")
99118

100119
try:
101120
async with get_session() as session:
121+
names = await _resolve_models(session, request.model_name.strip())
122+
if not names:
123+
raise HTTPException(
124+
status_code=404,
125+
detail=(
126+
"no trials have run on that model, so excluding it "
127+
"would have no effect; check the spelling against the "
128+
"model shown on a trial"
129+
),
130+
)
131+
102132
existing = await session.scalars(
103-
select(CostExcludedModelModel).where(
104-
CostExcludedModelModel.model_name == model_name
133+
select(CostExcludedModelModel.model_name).where(
134+
CostExcludedModelModel.model_name.in_(names)
105135
)
106136
)
107-
if existing.first() is not None:
137+
fresh = [name for name in names if name not in set(existing)]
138+
if not fresh:
108139
raise HTTPException(status_code=409, detail="model is already excluded")
109140

110-
row = CostExcludedModelModel(
111-
model_name=model_name,
112-
label=request.label.strip(),
113-
created_by_user_id=auth.user_id,
114-
)
115-
session.add(row)
141+
rows = [
142+
CostExcludedModelModel(
143+
model_name=name,
144+
label=request.label.strip(),
145+
created_by_user_id=auth.user_id,
146+
)
147+
for name in fresh
148+
]
149+
session.add_all(rows)
116150
try:
117151
await session.commit()
118152
except IntegrityError:
119-
raise HTTPException(
120-
status_code=409, detail="model is already excluded"
121-
)
122-
return _response(row)
153+
raise HTTPException(status_code=409, detail="model is already excluded")
154+
return [_response(row) for row in rows]
123155
except ProgrammingError as exc:
124156
raise _unavailable(exc)
125157

@@ -129,7 +161,7 @@ async def remove_cost_excluded_model(
129161
row_id: str,
130162
auth: Annotated[AuthContext, Depends(require_admin)],
131163
) -> dict:
132-
_require_manage(auth)
164+
require_operator_org(auth)
133165
try:
134166
async with get_session() as session:
135167
result = await session.scalars(

backend/carl_tools.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"RangeTableFunc",
3737
}
3838
_SQL_FORBIDDEN_COLUMNS = {
39+
"llm_key_hash",
3940
"harbor_config",
4041
"idempotency_key",
4142
"result",

backend/tests/test_cost_excluded_experiments_router.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -262,14 +262,15 @@ async def test_member_jwt_cannot_add(app, monkeypatch):
262262
app.dependency_overrides.pop(require_auth, None)
263263

264264

265-
async def test_full_api_key_cannot_add(app, monkeypatch):
266-
_install_fake_get_session(monkeypatch, FakeSession())
265+
async def test_operator_full_api_key_can_add(app, monkeypatch):
266+
# The CLI's only credential; operator-org membership is the real gate.
267+
_install_fake_get_session(monkeypatch, FakeSession(results=[[_experiment()], []]))
267268
client = _client(app, _full_api_key())
268269
try:
269270
resp = await client.post(
270271
"/admin/cost-excluded-experiments", json={"experiment": "exp_1"}
271272
)
272-
assert resp.status_code == 403
273+
assert resp.status_code == 200, resp.text
273274
finally:
274275
await client.aclose()
275276
app.dependency_overrides.pop(require_auth, None)

backend/tests/test_cost_excluded_models_router.py

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ async def scalars(self, _stmt):
4646
def add(self, obj):
4747
self.added.append(obj)
4848

49+
def add_all(self, objs):
50+
self.added.extend(objs)
51+
4952
async def commit(self):
5053
# Simulate the Python-side column defaults a real flush would apply.
5154
from oddish.db import generate_id
@@ -132,27 +135,57 @@ async def admin_client(app):
132135
app.dependency_overrides.pop(require_auth, None)
133136

134137

135-
async def test_add_canonicalizes_model(admin_client, monkeypatch):
136-
# One query: the duplicate check (miss).
137-
session = FakeSession(results=[[]])
138+
async def test_add_stores_the_spelling_trials_actually_use(admin_client, monkeypatch):
139+
# Queries: resolve against trials.model (hit), duplicate check (miss).
140+
# The operator typed a bare id; trials store the agent-routed one, and that
141+
# is what has to be stored or the exclusion matches nothing.
142+
session = FakeSession(results=[["moonshot/kimi-k2"], []])
138143
_install_fake_get_session(monkeypatch, session)
139144

140145
resp = await admin_client.post(
141146
"/admin/cost-excluded-models",
142-
json={"model_name": " XAI/Grok-4 ", "label": "sponsored"},
147+
json={"model_name": " Kimi-K2 ", "label": "sponsored"},
143148
)
144149
assert resp.status_code == 200, resp.text
145150
body = resp.json()
146-
# Stored canonicalized so it matches trials.model, which is normalized by
147-
# the same function on write.
148-
assert body["model_name"] == "xai/grok-4"
149-
assert body["label"] == "sponsored"
150-
assert session.added[0].model_name == "xai/grok-4"
151+
assert [r["model_name"] for r in body] == ["moonshot/kimi-k2"]
152+
assert body[0]["label"] == "sponsored"
153+
assert session.added[0].model_name == "moonshot/kimi-k2"
151154
assert session.committed
152155

153156

157+
async def test_add_covers_every_stored_spelling(admin_client, monkeypatch):
158+
# One model can be stored two ways; excluding one would leave half the
159+
# spend counting.
160+
session = FakeSession(
161+
results=[["grok-free-preview", "xai/grok-free-preview"], []]
162+
)
163+
_install_fake_get_session(monkeypatch, session)
164+
165+
resp = await admin_client.post(
166+
"/admin/cost-excluded-models", json={"model_name": "grok-free-preview"}
167+
)
168+
assert resp.status_code == 200, resp.text
169+
assert sorted(r["model_name"] for r in resp.json()) == [
170+
"grok-free-preview",
171+
"xai/grok-free-preview",
172+
]
173+
174+
175+
async def test_add_unknown_model_is_404(admin_client, monkeypatch):
176+
# Silently registering a row that matches nothing is the failure mode this
177+
# endpoint exists to prevent.
178+
_install_fake_get_session(monkeypatch, FakeSession(results=[[]]))
179+
resp = await admin_client.post(
180+
"/admin/cost-excluded-models", json={"model_name": "never/ran"}
181+
)
182+
assert resp.status_code == 404
183+
184+
154185
async def test_add_duplicate_is_409(admin_client, monkeypatch):
155-
_install_fake_get_session(monkeypatch, FakeSession(results=[[object()]]))
186+
_install_fake_get_session(
187+
monkeypatch, FakeSession(results=[["xai/grok-4"], ["xai/grok-4"]])
188+
)
156189
resp = await admin_client.post(
157190
"/admin/cost-excluded-models", json={"model_name": "xai/grok-4"}
158191
)
@@ -166,7 +199,7 @@ class RacingSession(FakeSession):
166199
async def commit(self):
167200
raise IntegrityError("INSERT", {}, Exception("duplicate key"))
168201

169-
_install_fake_get_session(monkeypatch, RacingSession(results=[[]]))
202+
_install_fake_get_session(monkeypatch, RacingSession(results=[["xai/grok-4"], []]))
170203
resp = await admin_client.post(
171204
"/admin/cost-excluded-models", json={"model_name": "xai/grok-4"}
172205
)
@@ -221,16 +254,16 @@ async def test_member_jwt_cannot_add(app, monkeypatch):
221254
app.dependency_overrides.pop(require_auth, None)
222255

223256

224-
async def test_full_api_key_cannot_add(app, monkeypatch):
225-
# Mutations are Clerk-admin only: a full-scope API key must not be able to
226-
# silently zero out a deployment's spend reporting.
227-
_install_fake_get_session(monkeypatch, FakeSession())
257+
async def test_operator_full_api_key_can_add(app, monkeypatch):
258+
# The CLI's only credential. `require_admin` already counts a full-scope
259+
# key as admin; the operator-org check is what keeps this privileged.
260+
_install_fake_get_session(monkeypatch, FakeSession(results=[["xai/grok-4"], []]))
228261
client = _client(app, _full_api_key())
229262
try:
230263
resp = await client.post(
231264
"/admin/cost-excluded-models", json={"model_name": "xai/grok-4"}
232265
)
233-
assert resp.status_code == 403
266+
assert resp.status_code == 200, resp.text
234267
finally:
235268
await client.aclose()
236269
app.dependency_overrides.pop(require_auth, None)

frontend/src/components/experiment-detail-view.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -777,7 +777,6 @@ function ExperimentSummaryBar({
777777
<NotRealSpendBadge
778778
excludedCostUsd={summary.excludedCostUsd}
779779
totalCostUsd={summary.costUsd}
780-
wholeSubjectExcluded={summary.experimentCostExcluded}
781780
/>
782781
)}
783782
</span>

oddish/alembic/versions/costexcl02_model_and_experiment_exclusions.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,14 @@ def upgrade() -> None:
7777
op.execute("DROP TABLE IF EXISTS cost_excluded_llm_keys")
7878
op.execute("SET LOCAL lock_timeout = '5s'")
7979
op.execute("ALTER TABLE trials DROP COLUMN IF EXISTS llm_key_hash")
80+
# All pending revisions share one transaction, so an un-reset SET LOCAL
81+
# would impose this timeout on every later migration in the same run.
82+
op.execute("SET LOCAL lock_timeout = DEFAULT")
8083

8184

8285
def downgrade() -> None:
8386
# Recreates the key feature's shape only. Neither the exclusion rows nor
8487
# the per-trial hashes are recoverable -- nothing else stored either.
85-
op.execute("SET LOCAL lock_timeout = '5s'")
86-
op.execute("ALTER TABLE trials ADD COLUMN IF NOT EXISTS llm_key_hash VARCHAR(64)")
8788
op.execute(
8889
"""
8990
CREATE TABLE IF NOT EXISTS cost_excluded_llm_keys (
@@ -106,3 +107,6 @@ def downgrade() -> None:
106107
)
107108
op.execute("DROP TABLE IF EXISTS cost_excluded_experiments")
108109
op.execute("DROP TABLE IF EXISTS cost_excluded_models")
110+
op.execute("SET LOCAL lock_timeout = '5s'")
111+
op.execute("ALTER TABLE trials ADD COLUMN IF NOT EXISTS llm_key_hash VARCHAR(64)")
112+
op.execute("SET LOCAL lock_timeout = DEFAULT")

0 commit comments

Comments
 (0)