diff --git a/backend/src/adminSettings.ts b/backend/src/adminSettings.ts index f084e63..4dc1879 100644 --- a/backend/src/adminSettings.ts +++ b/backend/src/adminSettings.ts @@ -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['name']; export type TextSettingName = Extract['name']; +export type ChoiceSettingName = Extract['name']; -export type AdminSettings = Record & Record; +export type AdminSettings = Record & + Record & + Record; export const HOURS_SETTINGS: readonly HoursSettingName[] = DEFINITIONS.filter( (d): d is Extract => d.type === 'hours' @@ -44,24 +57,67 @@ export const TEXT_SETTINGS: readonly TextSettingName[] = DEFINITIONS.filter( (d): d is Extract => d.type === 'text' ).map(d => d.name); +export const CHOICE_SETTINGS: readonly ChoiceSettingName[] = DEFINITIONS.filter( + (d): d is Extract => 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> = { + 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 { const { rows } = await pool.query(`SELECT key, value FROM admin_settings`); const stored = new Map(rows.map(r => [r.key, r.value])); const settings = {} as Record; - 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; } diff --git a/backend/src/intake/models.ts b/backend/src/intake/models.ts new file mode 100644 index 0000000..f488caf --- /dev/null +++ b/backend/src/intake/models.ts @@ -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); +} diff --git a/backend/src/routes/adminSettings.ts b/backend/src/routes/adminSettings.ts index 68c0890..d4bd8db 100644 --- a/backend/src/routes/adminSettings.ts +++ b/backend/src/routes/adminSettings.ts @@ -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()); })); diff --git a/backend/tests/integration/adminSettings.integration.test.ts b/backend/tests/integration/adminSettings.integration.test.ts index 69df181..43d41e3 100644 --- a/backend/tests/integration/adminSettings.integration.test.ts +++ b/backend/tests/integration/adminSettings.integration.test.ts @@ -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'); + }); }); diff --git a/backend/tests/unit/draftCost.test.ts b/backend/tests/unit/draftCost.test.ts new file mode 100644 index 0000000..75de60b --- /dev/null +++ b/backend/tests/unit/draftCost.test.ts @@ -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); + }); +}); diff --git a/frontend/src/admin/Settings.tsx b/frontend/src/admin/Settings.tsx index 42ecfc2..7571967 100644 --- a/frontend/src/admin/Settings.tsx +++ b/frontend/src/admin/Settings.tsx @@ -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() { + + Item drafting + {/* 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. */} + + 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. + + +