Files
atelier/sidecar/catalogue/merge.test.ts
T
pastilhas 0de38f0b13 feat(catalogue): a curated file of providers and models, refreshed daily
The five connections in the database were never configured by anyone — a
migration minted one per model from the adapter's own list, so the table held
five decisions nobody made. What that list actually is, is a CATALOGUE: what
you can choose from, which is not the same kind of thing as what you chose.

So it is a file. `sidecar/catalogue/shipped.ts` is the curated baseline, in
git, reviewed; `$OFFICER_ROOT/data/atelier/catalogue.json` is what a refresh
writes, and it wins where it has something to say. Data dir, not the plugin
dir — a plugin directory is a clone an update replaces, which is the rule
env.ts already states for images.

Refresh runs in the sidecar, daily, and only on boot when the file is actually
stale — this process has restarted twenty times in an afternoon and should not
hit OpenAI twenty times for it.

Two sources, unequal, and the code says so. `GET /v1/models` is authoritative
and thin: ids and nothing else, so a model matching /^(gpt-image|dall-e)/
appears the day it ships without a release. Prices come off an HTML page
because there is no pricing endpoint for any plan, and that parser is written
to give up rather than guess — a guess gets written to disk and then believed.
When it gives up it says so in the log; the durable replacement is handing the
page to a model for structured output, which Officer can already do locally.

Nothing a refresh cannot establish overwrites a curated value: fields merge
one by one, not objects wholesale, so an id-only listing cannot wipe the sizes
and prices that were written by hand. Retiring is decided by the merge, which
can tell a complete listing from a request that failed halfway — and it marks
`deprecated` rather than deleting, because the gallery labels 88 historical
rows from this list.

Per-model sizes, qualities and transparency now travel with each entry, which
is what will let the form stop offering dall-e-2 a transparent background it
cannot do. The catalogue is advisory throughout: validate.ts still refuses to
enforce per-model rules, exactly as before.
2026-08-20 22:15:35 +01:00

42 lines
1.8 KiB
TypeScript

import { describe, expect, test } from 'bun:test';
import { mergeCatalogue } from './store';
import { SHIPPED_CATALOGUE } from './shipped';
import type { Catalogue } from './types';
const price = (c: Catalogue, id: string) =>
c.providers[0]!.models.find((m) => m.id === id)?.pricing as { imageOutput?: number } | undefined;
describe('catalogue merge', () => {
test('a refresh that establishes nothing changes nothing', () => {
expect(mergeCatalogue(SHIPPED_CATALOGUE, null)).toEqual(SHIPPED_CATALOGUE);
});
test('an id-only refresh never wipes curated facts', () => {
const merged = mergeCatalogue(SHIPPED_CATALOGUE, {
revision: 1,
providers: [{ name: 'openai', label: 'OpenAI', envVar: 'OPENAI_API_KEY', models: [{ id: 'gpt-image-1', kind: 'image', source: 'api' }] }],
});
const model = merged.providers[0]!.models.find((m) => m.id === 'gpt-image-1')!;
expect(model.sizes).toHaveLength(4);
expect(price(merged, 'gpt-image-1')?.imageOutput).toBe(40);
expect(model.source).toBe('api');
});
test('a model we have never shipped is added, without an invented price', () => {
const merged = mergeCatalogue(SHIPPED_CATALOGUE, {
revision: 1,
providers: [{ name: 'openai', label: 'OpenAI', envVar: 'OPENAI_API_KEY', models: [{ id: 'gpt-image-2', kind: 'image', source: 'api' }] }],
});
expect(merged.providers[0]!.models.some((m) => m.id === 'gpt-image-2')).toBe(true);
expect(price(merged, 'gpt-image-2')).toBeUndefined();
});
test('a file from another revision is ignored entirely', () => {
const merged = mergeCatalogue(SHIPPED_CATALOGUE, {
revision: 99,
providers: [{ name: 'openai', label: 'nonsense', envVar: '', models: [] }],
});
expect(merged.providers[0]!.label).toBe('OpenAI');
});
});