Repository navigation
admin model editor: price edits and added models are silently reverted at the next startup #440
Description
What happens
The admin model editor writes prices into the models table by UPDATE. At every startup db::seed_models (src/main.rs:521) re-asserts config over that same table: an unconditional ON CONFLICT(provider, model) DO UPDATE SET over every price column, followed by DELETE FROM models for every row whose (provider, model) config does not contain (src/db.rs:654-737).
So an operator's edit is real, bills correctly, and then stops existing at the next restart — with no error, no log line, and nothing reverted back. The row simply returns to the config value.
Measured on db0160f
Temp data dir and temp config (copy of config/config.example.toml), real binary, admin API only.
Price edit — PATCH /api/admin/models/9 {"input_per_m": 1.23}:
| Read | input_per_m |
|---|---|
| before the edit | 5.0 |
| after the edit, same process | 1.23 |
| after SIGTERM + restart on the same data dir | 5.0 |
Model added through the same editor — POST /api/admin/models with probe/probe-model-x:
| Read | rows for that model (API / db) |
|---|---|
| after the add | 1 / 1 |
| after restart | 0 / 0 |
Who pays, and how much
Settlement reads that row: dao::get_model_price selects the price columns FROM models (src/dao.rs:205-210), and both settle sites take the cache-write price from the same record. So the row is the price the bill uses — which makes the restart a quiet price change:
- An operator who lowers a price to follow a vendor cut bills at the lower figure until the next restart, then silently bills at the old, higher config price from that point on.
- One who raises a price to cover a vendor increase bills at the higher figure only until the restart, then undercharges.
- Either way the stated figure is the one the operator believes is in force, and the actual figure is the config's.
- Stored rows are never rewritten.
usage_recordsandtransactionskeep whatever they were settled at; the change applies only forward from the restart. There is no reconciliation and no marker saying the price moved.
A model added through the editor has a sharper version of the same problem: it is deleted at the next startup, so keys still pointing at it can no longer be priced and calls are refused before reaching upstream (gateway.rs pricing gate, the invariant db.rs:699-705 records).
Is the operator told?
Not in anything an operator reads. The admin panel's own subtitle says the opposite of the truth — ui/js/i18n.js:506, admin.models.sub: "add, delete, edit take effect immediately", with no mention of a restart. config/config.example.toml says models are defined there and upserted at startup, which hints that config is authoritative but never says an edit made in the editor is reverted. The one place that states it plainly is a source comment, src/routes/admin_models.rs:342.
That is the case this issue is about: the behaviour is deliberate in the code, documented for the reader of the source, and invisible to the person the editor is for.
Two ways out, and which one is not mine to pick
- Make the write honest — either refuse a price edit while config is authoritative, or accept it and record that it will be reverted (an expiry marker, or text beside the field). Cheap, and it keeps config as the single source of truth, which is the design the code was built around.
- Make config yield — stop re-asserting prices over rows that already exist, so the editor is durable and config only seeds an empty row. This changes who owns pricing, which is why it needs a decision rather than a patch.
I lean to the first, but the second is a real choice about where pricing authority lives, so it is the owner's call, not mine.
Boundaries
No release, no tag, no change to #437. Nothing here proposes adding cache-write prices to the shipped catalogue or altering settlement arithmetic.
Activity
argszero commentedon Oct 26, 2026
I'd like to work on this, and I'll take the visibility half of it.
What I intend to land, in one PR:
admin.models.subcurrently says edits take effect immediately and says nothing about a restart; it will say plainly thatconfig.toml's[[models]]is the authoritative source, so a price changed in the editor or a model added there is overwritten at the next startup.db::seed_modelsoverwrites a stored price with the config value, or deletes a row config does not contain, it will log a warning naming the model and the old -> new figures, so whoever reads the startup log can see what was reverted.What I will not decide here: which source wins. Option 1 and option 2 below are still open, and picking one changes who owns pricing, so that stays for the owner. This PR deliberately does not answer it, and the issue stays open after the merge with that question intact.
No arithmetic changes, no stored row rewritten, no bill moves: the change is that the operator is told the truth in the panel and the log records each override with its figures.
argszero commentedon Oct 26, 2026
The visibility half is merged as
a6588f8(PR #441), and it was checked on a real binary with a temp data dir rather than argued from the code:PATCH {"input_per_m": 1.23}and a restart, the row reads back as the config value and the restart's own startup log now says why: a warning naminganthropic/claude-opus-5andinput_per_m: 1.23 → 5.0— the operator's figure first, the config figure second.config.toml's[[models]]is the authoritative source, that an edited price and an added model are overwritten at the next startup, and that an added model's row is deleted. Both language packs carry the new text.0 rows of prices overwritten, 0 config-removed rows deletedand emits no warning, so the warning stays a signal.This issue stays open for the decision it names. Option 1 (make the write honest) is what landed: the server keeps its current precedence and simply stops hiding it. Option 2 (stop re-asserting prices over rows that already exist, so the editor is durable and config only seeds an empty row) is untouched — it changes who owns pricing, which is the owner's call. If it is chosen, the copy and the warnings added here are removed or inverted rather than kept, and the row-restore behaviour becomes the defect instead.