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;
}
+57
View File
@@ -0,0 +1,57 @@
/**
* 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);
}
+17
View File
@@ -5,6 +5,9 @@ import {
updateSettings,
HOURS_SETTINGS,
TEXT_SETTINGS,
CHOICE_SETTINGS,
CHOICE_OPTIONS,
isValidChoice,
SettingName
} from '../adminSettings';
@@ -38,6 +41,20 @@ router.put('/', asyncRoute(async (req: Request, res: Response) => {
values[name] = raw;
}
// Membership is checked here rather than left to the dropdown. A value
// outside the set would be stored happily and then fail on every submission,
// surfacing only as drafts quietly not appearing (#223).
for (const name of CHOICE_SETTINGS) {
const raw = req.body[name];
if (raw === undefined) continue;
if (typeof raw !== 'string' || !isValidChoice(name, raw)) {
return res
.status(400)
.json({ error: `${name} must be one of: ${CHOICE_OPTIONS[name].join(', ')}` });
}
values[name] = raw;
}
await updateSettings(values);
res.json(await getSettings());
}));
@@ -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);
});
});
+28 -1
View File
@@ -7,7 +7,8 @@ import message from 'antd/es/message';
import Card from 'antd/es/card';
import Space from 'antd/es/space';
import Input from 'antd/es/input';
import { fetchAdminSettings, updateAdminSettings } from './adminSettingsApi';
import Select from 'antd/es/select';
import { fetchAdminSettings, updateAdminSettings, DRAFTING_MODELS } from './adminSettingsApi';
const { Title, Text } = Typography;
@@ -115,6 +116,32 @@ export default function Settings() {
</Form.Item>
</Card>
<Card>
<Title level={4}>Item drafting</Title>
{/* A dropdown rather than a text field, and validated again on the
server. A mistyped model name is accepted by the API only at the
point of use, so it would fail on every submission and show up
merely as drafts not appearing. */}
<Text type="secondary">
Which model writes the name, description and suggested price for a submitted item. The price is per
million tokens; a typical item costs a few pence. Drafts are always reviewed before anything is
published.
</Text>
<Form.Item
name="draftingModel"
label="Drafting model"
rules={[{ required: true }]}
style={{ marginTop: 16, marginBottom: 0 }}
>
<Select
options={DRAFTING_MODELS.map(m => ({
value: m.id,
label: `${m.label}${m.price}`
}))}
/>
</Form.Item>
</Card>
<Button type="primary" onClick={handleSave} loading={loading || saving}>Save</Button>
</Space>
</Form>
+14
View File
@@ -4,8 +4,22 @@ export interface AdminSettings {
passwordResetHours: number;
greetingFormat: string;
greetingFallback: string;
draftingModel: string;
}
/**
* The models the drafting worker will accept, with what each costs per million
* input/output tokens. Mirrors the backend catalogue in src/intake/models.ts,
* which is the authority — the server refuses anything outside its own list, so
* a stale entry here fails loudly on save rather than silently drafting with
* the wrong model.
*/
export const DRAFTING_MODELS = [
{ id: 'claude-sonnet-5', label: 'Claude Sonnet 5', price: '$2 / $10 per Mtok' },
{ id: 'claude-opus-5', label: 'Claude Opus 5', price: '$5 / $25 per Mtok' },
{ id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5', price: '$1 / $5 per Mtok' }
];
export async function fetchAdminSettings(): Promise<AdminSettings> {
const res = await fetch('/api/admin/settings');
if (!res.ok) throw new Error('Could not load settings');