Files
redefined-designs/backend/src/intake/models.ts
T
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

58 lines
2.4 KiB
TypeScript

/**
* The models that may draft a listing, and what each one costs.
*
* One catalogue rather than two lists. The Admin settings 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 list and the rates are maintained separately.
*
* Rates are dollars per million tokens, confirmed against the pricing page on
* 2026-08-31 rather than recalled. Worth checking again when a model is added:
* an increase to $3/$15 had been scheduled for 2026-09-01 and was cancelled,
* with Sonnet's $2/$10 made permanent.
*/
export interface DraftingModel {
id: string;
/** Shown in the Admin dropdown. */
label: string;
/** Dollars per million input tokens. */
inputRate: number;
/** Dollars per million output tokens. */
outputRate: number;
}
export const DRAFTING_MODELS: readonly DraftingModel[] = [
{ id: 'claude-sonnet-5', label: 'Claude Sonnet 5', inputRate: 2, outputRate: 10 },
{ id: 'claude-opus-5', label: 'Claude Opus 5', inputRate: 5, outputRate: 25 },
{ id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5', inputRate: 1, outputRate: 5 }
];
/**
* Sonnet, not Opus. The task is writing a description from a photograph rather
* than reasoning, and this runs once per submission on a route a stranger with
* a link can trigger. Opus costs two and a half times as much per item.
*/
export const DEFAULT_DRAFTING_MODEL = 'claude-sonnet-5';
export function isDraftingModel(id: string): boolean {
return DRAFTING_MODELS.some((model) => model.id === id);
}
/**
* Deliberately not zero. An unrecognised model pricing at nothing would make a
* budget read as unspent however much was really spent, which is the one
* failure a spend guard must not have. Set to the most expensive rate here, so
* an unknown model errs towards over- rather than under-reporting.
*/
const FALLBACK_RATE = { inputRate: 5, outputRate: 25 };
/**
* Whole micros, so a cost never carries a floating-point fraction into the
* database. Rates are per million tokens and a micro is a millionth of a
* dollar, so the two cancel and the arithmetic is just tokens times rate.
*/
export function costMicros(model: string, inputTokens: number, outputTokens: number): number {
const rate = DRAFTING_MODELS.find((m) => m.id === model) ?? FALLBACK_RATE;
return Math.round(inputTokens * rate.inputRate + outputTokens * rate.outputRate);
}