Files
redefined-designs/backend/tests/unit/draftCost.test.ts
bermudalambandClaude Opus 5 d27dcae62b 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>
2026-09-01 08:32:40 -05:00

59 lines
2.1 KiB
TypeScript

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);
});
});