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-09-01 08:32:40 -05:00
co-authored by Claude Opus 5
parent 96af5a571d
commit d27dcae62b
7 changed files with 264 additions and 16 deletions
+70 -14
View File
@@ -1,4 +1,5 @@
import { pool } from './db';
import { DEFAULT_DRAFTING_MODEL, DRAFTING_MODELS, isDraftingModel } from './intake/models';
/**
* Every admin-configurable setting, in one table.
@@ -24,7 +25,16 @@ const DEFINITIONS = [
{ key: 'verify_token_hours', name: 'verifyTokenHours', type: 'hours', fallback: 24 },
{ key: 'password_reset_hours', name: 'passwordResetHours', type: 'hours', fallback: 1 },
{ key: 'greeting_format', name: 'greetingFormat', type: 'text', fallback: 'Hi {{firstName}},' },
{ key: 'greeting_fallback', name: 'greetingFallback', type: 'text', fallback: 'Hi,' }
{ key: 'greeting_fallback', name: 'greetingFallback', type: 'text', fallback: 'Hi,' },
// A 'choice' rather than a 'text', so a mistyped model name is refused at the
// edge instead of stored. It would otherwise fail on every submission and
// show up only as drafts quietly not appearing (#223).
{
key: 'drafting_model',
name: 'draftingModel',
type: 'choice',
fallback: DEFAULT_DRAFTING_MODEL
}
] as const;
type Definition = (typeof DEFINITIONS)[number];
@@ -33,8 +43,11 @@ export type SettingName = Definition['name'];
export type HoursSettingName = Extract<Definition, { type: 'hours' }>['name'];
export type TextSettingName = Extract<Definition, { type: 'text' }>['name'];
export type ChoiceSettingName = Extract<Definition, { type: 'choice' }>['name'];
export type AdminSettings = Record<HoursSettingName, number> & Record<TextSettingName, string>;
export type AdminSettings = Record<HoursSettingName, number> &
Record<TextSettingName, string> &
Record<ChoiceSettingName, string>;
export const HOURS_SETTINGS: readonly HoursSettingName[] = DEFINITIONS.filter(
(d): d is Extract<Definition, { type: 'hours' }> => d.type === 'hours'
@@ -44,24 +57,67 @@ export const TEXT_SETTINGS: readonly TextSettingName[] = DEFINITIONS.filter(
(d): d is Extract<Definition, { type: 'text' }> => d.type === 'text'
).map(d => d.name);
export const CHOICE_SETTINGS: readonly ChoiceSettingName[] = DEFINITIONS.filter(
(d): d is Extract<Definition, { type: 'choice' }> => d.type === 'choice'
).map(d => d.name);
/**
* The values each choice setting will accept, for the route to validate against
* and the admin UI to offer. Derived from the model catalogue rather than
* restated, so the dropdown cannot come to disagree with what is billable.
*/
export const CHOICE_OPTIONS: Readonly<Record<ChoiceSettingName, readonly string[]>> = {
draftingModel: DRAFTING_MODELS.map(m => m.id)
};
export function isValidChoice(name: ChoiceSettingName, value: string): boolean {
return name === 'draftingModel' ? isDraftingModel(value) : false;
}
// One reader per type, at module level rather than branched inline. The same
// reasoning as the definitions above: getSettings should read as "look each one
// up and resolve it", and a third type was enough to push the inline version
// past the complexity limit.
// An empty format would render every greeting as nothing at all, which reads as
// a bug in the email rather than a setting someone cleared.
function resolveText(raw: string | undefined, fallback: string): string {
return raw !== undefined && raw.trim() !== '' ? raw : fallback;
}
// A row that is present but unparseable falls back rather than yielding NaN,
// which would otherwise reach Date arithmetic and mint a token with an Invalid
// Date expiry that no query could ever match.
function resolveHours(raw: string | undefined, fallback: number): number {
const parsed = parseFloat(raw ?? '');
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
// A stored value that is no longer offered — a model retired since it was
// chosen — falls back rather than being handed on. Drafting with the default
// beats drafting with a model the API will refuse.
function resolveChoice(
name: ChoiceSettingName,
raw: string | undefined,
fallback: string
): string {
return raw !== undefined && isValidChoice(name, raw) ? raw : fallback;
}
export async function getSettings(): Promise<AdminSettings> {
const { rows } = await pool.query<SettingRow>(`SELECT key, value FROM admin_settings`);
const stored = new Map<string, string>(rows.map(r => [r.key, r.value]));
const settings = {} as Record<SettingName, number | string>;
for (const { key, name, type, fallback } of DEFINITIONS) {
const raw = stored.get(key);
if (type === 'text') {
// An empty format would render every greeting as nothing at all, which
// reads as a bug in the email rather than a setting someone cleared.
settings[name] = raw !== undefined && raw.trim() !== '' ? raw : fallback;
continue;
for (const definition of DEFINITIONS) {
const raw = stored.get(definition.key);
if (definition.type === 'choice') {
settings[definition.name] = resolveChoice(definition.name, raw, definition.fallback);
} else if (definition.type === 'text') {
settings[definition.name] = resolveText(raw, definition.fallback);
} else {
settings[definition.name] = resolveHours(raw, definition.fallback);
}
const parsed = parseFloat(raw ?? '');
// A row that is present but unparseable falls back rather than yielding
// NaN, which would otherwise reach Date arithmetic and mint a token with an
// Invalid Date expiry that no query could ever match.
settings[name] = Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
return settings as AdminSettings;
}