feat(admin): choose the drafting model from Settings (#223)

The model was going to be an environment variable, which meant a redeploy to change it. It is now an admin setting, so it can be changed from the Settings page like the cart expiry and the greeting.

A dropdown validated on the server, not a free-text field. The API only rejects an unknown model at the point of use, so a typo would be stored happily and then fail on every submission, surfacing as drafts quietly not appearing rather than as an error anybody could act on. The PUT refuses anything outside the offered set, and getSettings falls back rather than handing on a value that is no longer offered — drafting with the default beats drafting with a model the API will refuse.

One catalogue rather than two lists. The dropdown needs the models, costMicros needs their rates, and the price shown beside a model in Admin has to be the price it is actually billed at, which it cannot be if the two are maintained separately. Rates were confirmed against the pricing page rather than recalled: Sonnet 5 $2/$10, Opus 5 $5/$25, Haiku 4.5 $1/$5 per million tokens. The unknown-model fallback is deliberately the most expensive rate and never zero, because a budget that reads as unspent however much was spent is the one failure a spend guard cannot have.

Adding a third setting type pushed getSettings past the cognitive complexity limit, so the per-type resolution moved out into one small function each — the same shape the definitions block above it already argues for.

The exhaustive assertion in the GET test gained the new field rather than being loosened. It exists to catch a setting silently vanishing from the response, and that is worth more than not having to touch it here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-31 19:43:40 -05:00
co-authored by Claude Opus 5
parent 2a30a69653
commit c8fb610c70
7 changed files with 264 additions and 16 deletions
@@ -29,7 +29,8 @@ describe('GET /api/admin/settings', () => {
verifyTokenHours: 24,
passwordResetHours: 1,
greetingFormat: 'Hi {{firstName}},',
greetingFallback: 'Hi,'
greetingFallback: 'Hi,',
draftingModel: 'claude-sonnet-5'
});
});
});
@@ -100,4 +101,22 @@ describe('PUT /api/admin/settings', () => {
expect(res.status).toBe(400);
expect((await getSettings()).cartExpiryHours).toBe(6);
});
// #223. A model name outside the offered set is refused rather than stored.
// Stored, it would be accepted here and then fail on every submission,
// surfacing only as drafts quietly not appearing.
it('refuses a drafting model it does not offer', async () => {
const res = await request(app)
.put('/api/admin/settings')
.send({ draftingModel: 'claude-sonnet-5-typo' });
expect(res.status).toBe(400);
expect(res.body.error).toContain('draftingModel');
});
it('accepts a drafting model it does offer', async () => {
const res = await request(app).put('/api/admin/settings').send({ draftingModel: 'claude-opus-5' });
expect(res.status).toBe(200);
expect((await getSettings()).draftingModel).toBe('claude-opus-5');
});
});
+58
View File
@@ -0,0 +1,58 @@
import {
costMicros,
DRAFTING_MODELS,
DEFAULT_DRAFTING_MODEL,
isDraftingModel
} from '../../src/intake/models';
// Rates confirmed against the pricing page on 2026-08-31: Sonnet 5 is $2 per
// million input tokens and $10 per million output.
describe('costMicros', () => {
it('prices a million input tokens at two dollars', () => {
expect(costMicros('claude-sonnet-5', 1_000_000, 0)).toBe(2_000_000);
});
it('prices a million output tokens at ten dollars', () => {
expect(costMicros('claude-sonnet-5', 0, 1_000_000)).toBe(10_000_000);
});
it('adds both halves', () => {
expect(costMicros('claude-sonnet-5', 1_000_000, 1_000_000)).toBe(12_000_000);
});
it('rounds to whole micros rather than carrying a fraction', () => {
expect(Number.isInteger(costMicros('claude-sonnet-5', 1, 1))).toBe(true);
});
// An unknown model must not silently price at zero, which would make a budget
// ceiling read as unspent however much was actually used.
it('falls back to a non-zero rate for an unrecognised model', () => {
expect(costMicros('some-future-model', 1_000_000, 0)).toBeGreaterThan(0);
});
it('charges nothing for nothing', () => {
expect(costMicros('claude-sonnet-5', 0, 0)).toBe(0);
});
});
describe('the model catalogue', () => {
it('offers the default as one of its choices', () => {
expect(DRAFTING_MODELS.map((m) => m.id)).toContain(DEFAULT_DRAFTING_MODEL);
});
// The settings dropdown and the price table are the same list precisely so
// they cannot drift. If a model were ever priced by the fallback, the figure
// shown next to it in Admin would not be the figure it is billed at.
it('prices every model it offers', () => {
for (const model of DRAFTING_MODELS) {
expect(model.inputRate).toBeGreaterThan(0);
expect(model.outputRate).toBeGreaterThan(0);
}
});
it('recognises exactly the models it offers', () => {
expect(isDraftingModel(DEFAULT_DRAFTING_MODEL)).toBe(true);
expect(isDraftingModel('claude-sonnet-5-typo')).toBe(false);
expect(isDraftingModel('')).toBe(false);
});
});