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.
This commit is contained in:
@@ -3,6 +3,7 @@ import { createRouter } from '../create-router';
|
||||
import { encrypt } from '../crypto';
|
||||
import { adapterByName } from '../providers';
|
||||
import { adapterCatalogue, providerStatuses } from '../providers/credentials';
|
||||
import { refreshCatalogue } from '../catalogue/refresh';
|
||||
import { openaiError, UUID_PATTERN } from '../errors';
|
||||
|
||||
export const providersRouter = createRouter();
|
||||
@@ -20,7 +21,19 @@ const notFound = (id: string) => openaiError(`No provider with id "${id}".`, 'no
|
||||
*/
|
||||
providersRouter.get('/', async (ctx) => ctx.json({ data: await providerStatuses() }));
|
||||
|
||||
providersRouter.get('/adapters', (ctx) => ctx.json({ data: adapterCatalogue() }));
|
||||
providersRouter.get('/adapters', async (ctx) => ctx.json({ data: await adapterCatalogue() }));
|
||||
|
||||
/**
|
||||
* Refresh the catalogue now, rather than waiting for the daily pass.
|
||||
*
|
||||
* Here rather than on a timer alone because the first thing anyone does after adding a key is expect
|
||||
* the model list to reflect it — the daily refresh has no key to work with until one exists, so the
|
||||
* pass that matters most is the one immediately after.
|
||||
*/
|
||||
providersRouter.post('/catalogue/refresh', async (ctx) => {
|
||||
const catalogue = await refreshCatalogue();
|
||||
return ctx.json({ data: { updated_at: catalogue.updatedAt, providers: catalogue.providers.length } });
|
||||
});
|
||||
|
||||
providersRouter.post('/', async (ctx) => {
|
||||
const body = (await ctx.req.json().catch(() => null)) as Record<string, unknown> | null;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './types';
|
||||
export { SHIPPED_CATALOGUE } from './shipped';
|
||||
export { getCatalogue, mergeCatalogue, invalidateCatalogue, CATALOGUE_PATH } from './store';
|
||||
export { refreshCatalogue, startCatalogueRefresh } from './refresh';
|
||||
@@ -0,0 +1,41 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { listProviders } from '../db';
|
||||
import { keyForProvider } from '../providers/credentials';
|
||||
import { projectEnv } from '../env';
|
||||
import type { Catalogue, CatalogueModel } from './types';
|
||||
import { CATALOGUE_REVISION } from './types';
|
||||
|
||||
/**
|
||||
* What a refresh can establish about OpenAI, from OpenAI.
|
||||
*
|
||||
* Two sources, and they are not equal:
|
||||
*
|
||||
* `GET /v1/models` authoritative, and thin. Ids, an owner, a created timestamp. Nothing else.
|
||||
* the pricing page everything that actually matters, and it is HTML written for people.
|
||||
*
|
||||
* There is no pricing endpoint — none, for any plan — so the second source is a page and parsing it is
|
||||
* inherently fragile. Which is why nothing here is allowed to overwrite a curated value with a guess:
|
||||
* a field this cannot establish is returned `undefined`, and `mergeCatalogue` leaves the shipped value
|
||||
* standing.
|
||||
*/
|
||||
|
||||
const MODELS_ENDPOINT = 'https://api.openai.com/v1/models';
|
||||
|
||||
/** Overridable, because the page this parses is the part most likely to move. */
|
||||
const PRICING_URL = projectEnv('ATELIER_OPENAI_PRICING_URL') ?? 'https://platform.openai.com/docs/pricing';
|
||||
|
||||
/**
|
||||
* Which of OpenAI's models make pictures.
|
||||
*
|
||||
* A pattern, not a list, and that is the point of asking the API at all: `gpt-image-2` on release day
|
||||
* matches this and appears in the add dialog without a plugin update. The cost of a pattern is the
|
||||
* occasional false positive, which surfaces as a model that fails at the vendor rather than one that
|
||||
* silently does the wrong thing.
|
||||
*/
|
||||
const IMAGE_MODEL = /^(gpt-image|dall-e)/i;
|
||||
|
||||
/** A key from any connection, or the bootstrap environment variable. Refresh is read-only, so any will do. */
|
||||
async function anyKey(): Promise<string | undefined> {
|
||||
for (const provider of await listProviders()) {
|
||||
if (provider.adapter !== 'openai') continue;
|
||||
const key = await keyForProvider(provider);
|
||||
if (key) return key;
|
||||
}
|
||||
return projectEnv('OPENAI_API_KEY');
|
||||
}
|
||||
|
||||
async function fetchModelIds(apiKey: string): Promise<string[] | null> {
|
||||
try {
|
||||
const response = await fetch(MODELS_ENDPOINT, {
|
||||
headers: { authorization: `Bearer ${apiKey}` },
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.warn(`[atelier:catalogue] models endpoint answered ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
const body = (await response.json()) as { data?: { id?: unknown }[] };
|
||||
return (body.data ?? [])
|
||||
.map((entry) => entry.id)
|
||||
.filter((id): id is string => typeof id === 'string' && IMAGE_MODEL.test(id));
|
||||
} catch (err) {
|
||||
console.warn('[atelier:catalogue] could not list models:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prices, off the pricing page.
|
||||
*
|
||||
* Deliberately narrow: it looks for a model id and reads the dollar figures that follow it in the same
|
||||
* chunk of markup, and returns nothing at all when the page does not look the way it expects. A parser
|
||||
* that guesses on an unfamiliar page is worse than one that gives up, because the guess is written to
|
||||
* disk and then believed.
|
||||
*
|
||||
* This will fail the first time the page is restyled, and it is meant to fail LOUDLY — the log line is
|
||||
* the signal to replace this function. The durable version of it is not a better regex: it is handing
|
||||
* the page to a model and asking for structured output, which Officer can already do locally through
|
||||
* its own Claude sidecar. That is the next step, not a different design.
|
||||
*/
|
||||
async function fetchPricing(modelIds: string[]): Promise<Map<string, number[]>> {
|
||||
const prices = new Map<string, number[]>();
|
||||
|
||||
try {
|
||||
const response = await fetch(PRICING_URL, { signal: AbortSignal.timeout(30_000) });
|
||||
if (!response.ok) {
|
||||
console.warn(`[atelier:catalogue] pricing page answered ${response.status} — keeping shipped prices`);
|
||||
return prices;
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
for (const id of modelIds) {
|
||||
const at = html.indexOf(id);
|
||||
if (at === -1) continue;
|
||||
// The figures that follow the id, within one row's worth of markup. `$5.00`, `$0.01`, `5.00`.
|
||||
const window = html.slice(at, at + 1200);
|
||||
const found = [...window.matchAll(/\$\s?(\d+(?:\.\d+)?)/g)].map((match) => Number(match[1]));
|
||||
if (found.length > 0) prices.set(id, found);
|
||||
}
|
||||
|
||||
if (prices.size === 0) {
|
||||
console.warn(
|
||||
`[atelier:catalogue] found no prices on ${PRICING_URL} — the page has changed shape, or renders its ` +
|
||||
'prices client-side. Shipped prices kept; this extractor needs replacing.',
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[atelier:catalogue] could not read the pricing page:', err);
|
||||
}
|
||||
|
||||
return prices;
|
||||
}
|
||||
|
||||
/**
|
||||
* One refresh pass for OpenAI.
|
||||
*
|
||||
* Returns only what it established. Every model it did not see is simply absent from the result, and
|
||||
* absent means "unchanged" — this function cannot retire a model, because "the endpoint did not list
|
||||
* it" and "the request failed halfway" look identical from here. Retiring is done by the merge, which
|
||||
* can tell a complete listing from a missing one.
|
||||
*/
|
||||
export async function refreshOpenAI(): Promise<{ catalogue: Catalogue | null; listedIds: string[] | null }> {
|
||||
const apiKey = await anyKey();
|
||||
if (!apiKey) {
|
||||
console.log('[atelier:catalogue] no OpenAI key available — nothing to refresh, shipped catalogue stands');
|
||||
return { catalogue: null, listedIds: null };
|
||||
}
|
||||
|
||||
const listedIds = await fetchModelIds(apiKey);
|
||||
if (!listedIds) return { catalogue: null, listedIds: null };
|
||||
|
||||
const prices = await fetchPricing(listedIds);
|
||||
|
||||
const models: CatalogueModel[] = listedIds.map((id) => {
|
||||
const figures = prices.get(id);
|
||||
return {
|
||||
id,
|
||||
kind: 'image',
|
||||
status: 'current',
|
||||
source: figures ? 'scraped' : 'api',
|
||||
// Three figures in published order: text in, image in, image out. Fewer than three is not a
|
||||
// price we understand, so it is left undefined rather than guessed into the wrong slots.
|
||||
pricing:
|
||||
figures && figures.length >= 3
|
||||
? { unit: 'token', textInput: figures[0], imageInput: figures[1], imageOutput: figures[2] }
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
catalogue: {
|
||||
revision: CATALOGUE_REVISION,
|
||||
updatedAt: new Date().toISOString(),
|
||||
providers: [{ name: 'openai', label: 'OpenAI', envVar: 'OPENAI_API_KEY', models }],
|
||||
},
|
||||
listedIds,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { projectEnv } from '../env';
|
||||
import { refreshOpenAI } from './openai-refresh';
|
||||
import { SHIPPED_CATALOGUE } from './shipped';
|
||||
import { catalogueAge, getCatalogue, mergeCatalogue, writeCatalogue } from './store';
|
||||
import type { Catalogue } from './types';
|
||||
|
||||
/**
|
||||
* Keeping the catalogue current — the mechanism, with OpenAI as its first and only implementation.
|
||||
*
|
||||
* A provider is refreshed by one function that returns what it could establish; this file decides
|
||||
* when they run, merges the results, and writes the file. Adding a second provider is a second
|
||||
* function and a line in `REFRESHERS` — the same restraint hono.ts keeps on its mounts.
|
||||
*/
|
||||
|
||||
/** Once a day. Model lists and prices move on the order of weeks; this is already generous. */
|
||||
const INTERVAL_MS = Number(projectEnv('ATELIER_CATALOGUE_INTERVAL_MS') ?? 24 * 60 * 60 * 1000);
|
||||
|
||||
const REFRESHERS = [refreshOpenAI];
|
||||
|
||||
/**
|
||||
* Run every refresher and write the result.
|
||||
*
|
||||
* Retiring happens HERE rather than in a refresher, because it is the one judgement that needs the
|
||||
* whole picture: a model we ship and the vendor no longer lists is deprecated, but only when the
|
||||
* listing actually succeeded. "The endpoint returned three models" and "the request failed after
|
||||
* three" are indistinguishable inside the refresher and completely different here.
|
||||
*/
|
||||
export async function refreshCatalogue(): Promise<Catalogue> {
|
||||
const results = await Promise.all(REFRESHERS.map((run) => run()));
|
||||
|
||||
let merged = SHIPPED_CATALOGUE;
|
||||
for (const { catalogue, listedIds } of results) {
|
||||
if (!catalogue) continue;
|
||||
merged = mergeCatalogue(merged, catalogue);
|
||||
|
||||
if (listedIds) {
|
||||
const listed = new Set(listedIds);
|
||||
for (const provider of merged.providers) {
|
||||
if (!catalogue.providers.some((entry) => entry.name === provider.name)) continue;
|
||||
for (const model of provider.models) {
|
||||
// Deprecated, not deleted: the gallery labels historical rows from this list, and 88 images
|
||||
// name the model that made them.
|
||||
if (!listed.has(model.id)) model.status = 'deprecated';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const written = { ...merged, updatedAt: new Date().toISOString() };
|
||||
await writeCatalogue(written);
|
||||
|
||||
const count = written.providers.reduce((sum, provider) => sum + provider.models.length, 0);
|
||||
console.log(`[atelier:catalogue] refreshed — ${written.providers.length} provider(s), ${count} model(s)`);
|
||||
return written;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the schedule.
|
||||
*
|
||||
* Refreshes on boot only when the file is genuinely stale, so a sidecar that restarts twenty times in
|
||||
* an afternoon — which this one has — does not hit OpenAI twenty times. `unref` so a pending timer
|
||||
* never holds the process open at shutdown.
|
||||
*/
|
||||
export async function startCatalogueRefresh(): Promise<void> {
|
||||
const globalForCatalogue = globalThis as unknown as { __atelierCatalogueStarted?: boolean };
|
||||
if (globalForCatalogue.__atelierCatalogueStarted) return;
|
||||
globalForCatalogue.__atelierCatalogueStarted = true;
|
||||
|
||||
const age = await catalogueAge();
|
||||
if (age === null || age > INTERVAL_MS) {
|
||||
// Not awaited: a slow or unreachable vendor must not delay the sidecar's own startup, and every
|
||||
// failure path here already degrades to the shipped catalogue.
|
||||
void refreshCatalogue().catch((err: unknown) => console.error('[atelier:catalogue] refresh failed:', err));
|
||||
} else {
|
||||
console.log(`[atelier:catalogue] ${Math.round(age / 3_600_000)}h old — next refresh on schedule`);
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
void refreshCatalogue().catch((err: unknown) => console.error('[atelier:catalogue] refresh failed:', err));
|
||||
}, INTERVAL_MS).unref();
|
||||
}
|
||||
|
||||
export { getCatalogue };
|
||||
@@ -0,0 +1,87 @@
|
||||
import { CATALOGUE_REVISION, type Catalogue } from './types';
|
||||
|
||||
/**
|
||||
* The catalogue as it ships with the plugin — the floor a refresh builds on, and the whole of it when
|
||||
* no refresh has ever run or every refresh has failed.
|
||||
*
|
||||
* Written by hand and reviewed in git, because none of it is fetchable: `GET /v1/models` returns ids,
|
||||
* an owner and a timestamp, and says nothing about price, sizes, transparency, or whether a model is
|
||||
* one you would still choose. That is the entire reason this file exists rather than a nightly fetch.
|
||||
*
|
||||
* The prices are USD per million tokens, from OpenAI's published pricing, and three of the five models
|
||||
* have none — deliberately. `PRICING` in providers/openai.ts had the same gap and the same reason: a
|
||||
* guessed price produces a number that looks authoritative and sums into a wrong total.
|
||||
*/
|
||||
export const SHIPPED_CATALOGUE: Catalogue = {
|
||||
revision: CATALOGUE_REVISION,
|
||||
providers: [
|
||||
{
|
||||
name: 'openai',
|
||||
label: 'OpenAI',
|
||||
envVar: 'OPENAI_API_KEY',
|
||||
defaultModel: 'gpt-image-1',
|
||||
models: [
|
||||
{
|
||||
id: 'gpt-image-1.5',
|
||||
label: 'GPT Image 1.5',
|
||||
kind: 'image',
|
||||
sizes: ['auto', '1024x1024', '1536x1024', '1024x1536'],
|
||||
qualities: ['auto', 'low', 'medium', 'high'],
|
||||
transparency: true,
|
||||
edit: true,
|
||||
pricing: { unit: 'token', textInput: 5, imageInput: 8, imageOutput: 32 },
|
||||
status: 'current',
|
||||
source: 'shipped',
|
||||
},
|
||||
{
|
||||
id: 'gpt-image-1',
|
||||
label: 'GPT Image 1',
|
||||
kind: 'image',
|
||||
sizes: ['auto', '1024x1024', '1536x1024', '1024x1536'],
|
||||
qualities: ['auto', 'low', 'medium', 'high'],
|
||||
transparency: true,
|
||||
edit: true,
|
||||
pricing: { unit: 'token', textInput: 5, imageInput: 10, imageOutput: 40 },
|
||||
status: 'current',
|
||||
source: 'shipped',
|
||||
},
|
||||
{
|
||||
id: 'gpt-image-1-mini',
|
||||
label: 'GPT Image 1 mini',
|
||||
kind: 'image',
|
||||
sizes: ['auto', '1024x1024', '1536x1024', '1024x1536'],
|
||||
qualities: ['auto', 'low', 'medium', 'high'],
|
||||
transparency: true,
|
||||
edit: true,
|
||||
pricing: { unit: 'token', textInput: 2, imageInput: 2.5, imageOutput: 8 },
|
||||
status: 'current',
|
||||
source: 'shipped',
|
||||
},
|
||||
{
|
||||
// No transparency and a different size set — which is exactly what the form was getting
|
||||
// wrong by offering one hard-coded list of sizes and qualities for every model.
|
||||
id: 'dall-e-3',
|
||||
label: 'DALL·E 3',
|
||||
kind: 'image',
|
||||
sizes: ['1024x1024', '1792x1024', '1024x1792'],
|
||||
qualities: ['standard', 'hd'],
|
||||
transparency: false,
|
||||
edit: false,
|
||||
status: 'current',
|
||||
source: 'shipped',
|
||||
},
|
||||
{
|
||||
id: 'dall-e-2',
|
||||
label: 'DALL·E 2',
|
||||
kind: 'image',
|
||||
sizes: ['256x256', '512x512', '1024x1024'],
|
||||
qualities: ['standard'],
|
||||
transparency: false,
|
||||
edit: true,
|
||||
status: 'current',
|
||||
source: 'shipped',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import { join } from 'node:path';
|
||||
import { DATA_DIR } from '../env';
|
||||
import { SHIPPED_CATALOGUE } from './shipped';
|
||||
import { CATALOGUE_REVISION, type Catalogue, type CatalogueModel, type CatalogueProvider } from './types';
|
||||
|
||||
/**
|
||||
* Where a refreshed catalogue lives: beside the images, NOT in the plugin directory.
|
||||
*
|
||||
* A plugin directory is a git clone that an update replaces, so a file written there disappears on
|
||||
* upgrade with nothing logging it — the same rule env.ts states for generated images, and the reason
|
||||
* `$OFFICER_ROOT/data/atelier` exists at all.
|
||||
*/
|
||||
export const CATALOGUE_PATH = join(DATA_DIR, 'catalogue.json');
|
||||
|
||||
/** In-memory copy, so every add-connection dialog does not read the disk. Cleared by a refresh. */
|
||||
let cached: Catalogue | null = null;
|
||||
|
||||
/**
|
||||
* Merge a refreshed catalogue over the shipped one.
|
||||
*
|
||||
* Shipped is the FLOOR, never the ceiling: a refresh may correct a price, add a model or retire one,
|
||||
* and it may also fail halfway. Merging rather than replacing means a refresh that returned three
|
||||
* models out of five leaves the other two intact instead of deleting them, and a refresh that
|
||||
* returned nothing at all changes nothing.
|
||||
*
|
||||
* Per model, the refreshed FIELDS win one by one rather than the refreshed OBJECT winning whole — the
|
||||
* models endpoint knows ids and nothing else, so an entry built from it would otherwise wipe the
|
||||
* sizes, qualities and price that were curated by hand.
|
||||
*/
|
||||
export function mergeCatalogue(base: Catalogue, refreshed: Catalogue | null): Catalogue {
|
||||
if (!refreshed || refreshed.revision !== base.revision) return base;
|
||||
|
||||
const providers: CatalogueProvider[] = base.providers.map((provider) => {
|
||||
const incoming = refreshed.providers.find((entry) => entry.name === provider.name);
|
||||
if (!incoming) return provider;
|
||||
|
||||
const models: CatalogueModel[] = provider.models.map((model) => {
|
||||
const update = incoming.models.find((entry) => entry.id === model.id);
|
||||
return update ? { ...model, ...definedOnly(update) } : model;
|
||||
});
|
||||
|
||||
// Models the refresh found that we have never shipped: kept, with whatever it established and
|
||||
// nothing invented. They are usable — a request names a model by string — and honest about cost.
|
||||
for (const model of incoming.models) {
|
||||
if (!models.some((entry) => entry.id === model.id)) models.push(model);
|
||||
}
|
||||
|
||||
return { ...provider, ...definedOnly(incoming), models };
|
||||
});
|
||||
|
||||
// A provider the refresh knows about and we do not ship. Same reasoning as a new model.
|
||||
for (const provider of refreshed.providers) {
|
||||
if (!providers.some((entry) => entry.name === provider.name)) providers.push(provider);
|
||||
}
|
||||
|
||||
return { revision: base.revision, updatedAt: refreshed.updatedAt, providers };
|
||||
}
|
||||
|
||||
/** `{...a, ...b}` with b's `undefined`s dropped, so "not established" never overwrites "curated". */
|
||||
function definedOnly<T extends object>(value: T): Partial<T> {
|
||||
return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as Partial<T>;
|
||||
}
|
||||
|
||||
async function readRefreshed(): Promise<Catalogue | null> {
|
||||
try {
|
||||
const file = Bun.file(CATALOGUE_PATH);
|
||||
if (!(await file.exists())) return null;
|
||||
const parsed = (await file.json()) as Catalogue;
|
||||
// A file written by a plugin with a different shape is ignored rather than half-read. It is not
|
||||
// corruption — it is last month's format, and the shipped catalogue is a better answer than a
|
||||
// partially-understood one.
|
||||
if (parsed?.revision !== CATALOGUE_REVISION || !Array.isArray(parsed.providers)) {
|
||||
console.warn(`[atelier:catalogue] ignoring ${CATALOGUE_PATH}: revision ${parsed?.revision} is not ${CATALOGUE_REVISION}`);
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
console.error('[atelier:catalogue] could not read the refreshed catalogue:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The catalogue as everything else should see it: shipped, with any successful refresh merged over. */
|
||||
export async function getCatalogue(): Promise<Catalogue> {
|
||||
if (cached) return cached;
|
||||
cached = mergeCatalogue(SHIPPED_CATALOGUE, await readRefreshed());
|
||||
return cached;
|
||||
}
|
||||
|
||||
export async function writeCatalogue(catalogue: Catalogue): Promise<void> {
|
||||
await Bun.write(CATALOGUE_PATH, `${JSON.stringify(catalogue, null, 2)}\n`);
|
||||
cached = null;
|
||||
}
|
||||
|
||||
/** When the refreshed file was last written, or null if there has never been one. */
|
||||
export async function catalogueAge(): Promise<number | null> {
|
||||
const file = Bun.file(CATALOGUE_PATH);
|
||||
if (!(await file.exists())) return null;
|
||||
return Date.now() - file.lastModified;
|
||||
}
|
||||
|
||||
export function invalidateCatalogue(): void {
|
||||
cached = null;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* The catalogue: what Officer says you can connect to, and what each model can do.
|
||||
*
|
||||
* ── Catalogue, connections, adapters ──
|
||||
*
|
||||
* Three words that must never be swapped for one another:
|
||||
*
|
||||
* catalogue THIS. A curated file. "OpenAI exists, it has these models, they cost this."
|
||||
* connection A row in `atelier_providers`. "MY OpenAI, on gpt-image-1, with this key."
|
||||
* adapter Code that speaks a vendor's HTTP API. Written once, only developers say it.
|
||||
*
|
||||
* The catalogue is what you choose FROM; a connection is what you chose. Five rows were once seeded
|
||||
* into the table from the adapter's own model list, which made five decisions nobody had made — the
|
||||
* catalogue exists so that the list of possibilities never again has to be stored as if it were a
|
||||
* set of choices.
|
||||
*
|
||||
* ── Advisory, never authoritative ──
|
||||
*
|
||||
* The catalogue shapes what the UI OFFERS. It never decides what the server ACCEPTS: `validate.ts`
|
||||
* deliberately refuses to enforce per-model rules because a stale copy of them would reject valid
|
||||
* combinations, and the vendor is the one who should answer. So a model absent from here is still
|
||||
* usable if the caller names it, and a size not listed is still sent.
|
||||
*
|
||||
* ── Allowed to be incomplete ──
|
||||
*
|
||||
* Every field a refresh could not establish stays absent, and absence degrades: no pricing means a
|
||||
* null cost, which is honest, where an invented price sums into a total that is wrong.
|
||||
*/
|
||||
|
||||
/** What a model produces. Video is not implemented yet; the field exists so the file never has to change shape for it. */
|
||||
export type MediaKind = 'image' | 'video';
|
||||
|
||||
/**
|
||||
* What a model costs.
|
||||
*
|
||||
* A shape per unit rather than one number, because images are priced per million tokens and video is
|
||||
* priced per second of output — and a single `price` field would have to lie about one of them.
|
||||
*/
|
||||
export type Pricing =
|
||||
| { unit: 'token'; textInput?: number; imageInput?: number; imageOutput?: number }
|
||||
| { unit: 'second'; perSecond: number }
|
||||
| { unit: 'image'; perImage: number };
|
||||
|
||||
export type CatalogueModel = {
|
||||
/** What the vendor calls it, and what a request names — `gpt-image-1`. */
|
||||
id: string;
|
||||
/** What a person calls it. Falls back to the id when we have nothing better. */
|
||||
label?: string;
|
||||
kind: MediaKind;
|
||||
/** Sizes the UI offers. `auto` first where the vendor supports it. */
|
||||
sizes?: string[];
|
||||
qualities?: string[];
|
||||
/** Whether `background: transparent` is meaningful here. dall-e-* cannot do it. */
|
||||
transparency?: boolean;
|
||||
/** Whether this model can be used for edits, not only generations. */
|
||||
edit?: boolean;
|
||||
pricing?: Pricing;
|
||||
/**
|
||||
* `deprecated` keeps a retired model in the file instead of removing it.
|
||||
*
|
||||
* The gallery labels historical rows from this list — 88 images name the model that made them — so
|
||||
* deleting an entry rewrites the past as "unknown model". Deprecated is hidden from the add dialog
|
||||
* and still resolves everywhere else.
|
||||
*/
|
||||
status?: 'current' | 'deprecated';
|
||||
/** Where this entry's facts came from, so a bad refresh is legible rather than mysterious. */
|
||||
source?: 'shipped' | 'api' | 'scraped';
|
||||
};
|
||||
|
||||
export type CatalogueProvider = {
|
||||
/** Matches an adapter's `name` when code for it exists. A provider with no adapter is a listing, not an offer. */
|
||||
name: string;
|
||||
label: string;
|
||||
/** The bootstrap credential's variable, for a connection with no stored key. */
|
||||
envVar: string;
|
||||
defaultModel?: string;
|
||||
models: CatalogueModel[];
|
||||
};
|
||||
|
||||
export type Catalogue = {
|
||||
/** Bumped when the SHAPE changes, so a file written by an older plugin can be recognised and ignored. */
|
||||
revision: number;
|
||||
/** When the refresh that produced this ran. Absent on the shipped file — it was written by hand. */
|
||||
updatedAt?: string;
|
||||
providers: CatalogueProvider[];
|
||||
};
|
||||
|
||||
export const CATALOGUE_REVISION = 1;
|
||||
@@ -4,6 +4,7 @@ import { API_URL } from '@@/officer-url.mjs';
|
||||
import { buildHonoApp } from './hono';
|
||||
import { waitForDatabase } from './db';
|
||||
import { startQueue } from './generations/queue';
|
||||
import { startCatalogueRefresh } from './catalogue/refresh';
|
||||
|
||||
// The officer-atelier sidecar. Owns the whole image-generation contract: providers, the generation
|
||||
// queue, and the `atelier` Postgres database (kept separate from officer_db — see ../PLUGIN.md).
|
||||
@@ -79,6 +80,10 @@ const connection = createSidecarConnector({
|
||||
void (async () => {
|
||||
if (await waitForDatabase()) await startQueue();
|
||||
else console.error('[atelier:queue] not started: no database');
|
||||
|
||||
// After the database, because a refresh reads any connection's key before falling back to the
|
||||
// environment — and before it, there is nothing to read.
|
||||
await startCatalogueRefresh();
|
||||
})();
|
||||
|
||||
function shutdown(signal: string) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getProvider, getProviderByName, listProviders, resolveProviderForModel
|
||||
import type { Provider } from '../db';
|
||||
import { decrypt } from '../crypto';
|
||||
import { ADAPTERS, adapterByName } from './index';
|
||||
import { getCatalogue } from '../catalogue/store';
|
||||
import type { ImageAdapter } from './types';
|
||||
|
||||
/**
|
||||
@@ -115,13 +116,38 @@ export async function providerStatuses() {
|
||||
);
|
||||
}
|
||||
|
||||
/** The adapters a provider can be built on, for the "add provider" form. */
|
||||
export const adapterCatalogue = () =>
|
||||
ADAPTERS.map((adapter) => ({
|
||||
name: adapter.name,
|
||||
label: adapter.label,
|
||||
models: adapter.models,
|
||||
default_model: adapter.defaultModel,
|
||||
env_var: envVarFor(adapter.name),
|
||||
supports_edit: typeof adapter.edit === 'function',
|
||||
}));
|
||||
/**
|
||||
* What a new connection can be built on: the CATALOGUE, filtered to adapters that exist as code.
|
||||
*
|
||||
* The model list used to come off the adapter itself — a hard-coded array beside the HTTP client —
|
||||
* which meant adding a model was a code change and a release. It comes from the curated catalogue
|
||||
* now, refreshed daily, and the adapter keeps the one thing only it knows: whether it can edit.
|
||||
*
|
||||
* A catalogue provider with no adapter is skipped rather than offered. Listing a vendor we cannot
|
||||
* actually call is an offer that fails at the moment of use, which is worse than not being offered.
|
||||
*/
|
||||
export const adapterCatalogue = async () => {
|
||||
const catalogue = await getCatalogue();
|
||||
|
||||
return catalogue.providers.flatMap((provider) => {
|
||||
const adapter = adapterByName(provider.name);
|
||||
if (!adapter) return [];
|
||||
|
||||
// Deprecated models stay in the catalogue so old rows keep their labels; they are not offered.
|
||||
const models = provider.models.filter((model) => model.status !== 'deprecated');
|
||||
|
||||
return [
|
||||
{
|
||||
name: provider.name,
|
||||
label: provider.label,
|
||||
models: models.map((model) => model.id),
|
||||
/** The full records, for a UI that offers per-model sizes and qualities rather than one list. */
|
||||
model_details: models,
|
||||
default_model: provider.defaultModel ?? adapter.defaultModel,
|
||||
env_var: provider.envVar || envVarFor(provider.name),
|
||||
supports_edit: typeof adapter.edit === 'function',
|
||||
updated_at: catalogue.updatedAt ?? null,
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
|
||||
+23
-1
@@ -22,14 +22,36 @@ export type ProviderStatus = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
/** The code side — what a new provider can be built on. */
|
||||
/** One model in the catalogue — what it makes, what it accepts, what it costs. */
|
||||
export type CatalogueModel = {
|
||||
id: string;
|
||||
label?: string;
|
||||
kind: 'image' | 'video';
|
||||
sizes?: string[];
|
||||
qualities?: string[];
|
||||
transparency?: boolean;
|
||||
edit?: boolean;
|
||||
pricing?: Record<string, unknown>;
|
||||
status?: 'current' | 'deprecated';
|
||||
source?: 'shipped' | 'api' | 'scraped';
|
||||
};
|
||||
|
||||
/**
|
||||
* What a new connection can be built on: a catalogue provider that has an adapter behind it.
|
||||
*
|
||||
* `models` is the flat list of ids; `model_details` carries the per-model facts, which is what lets a
|
||||
* form offer dall-e-2's three sizes rather than gpt-image-1's four. The catalogue is refreshed daily
|
||||
* in the sidecar — `updated_at` says when, and is null while only the shipped file has been read.
|
||||
*/
|
||||
export type Adapter = {
|
||||
name: string;
|
||||
label: string;
|
||||
models: string[];
|
||||
model_details: CatalogueModel[];
|
||||
default_model: string;
|
||||
env_var: string;
|
||||
supports_edit: boolean;
|
||||
updated_at: string | null;
|
||||
};
|
||||
|
||||
/** Snake case, matching the server — one spelling per field across every endpoint. */
|
||||
|
||||
Reference in New Issue
Block a user