Feature/223 drafting worker #251
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user