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.
157 lines
6.2 KiB
TypeScript
157 lines
6.2 KiB
TypeScript
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,
|
|
};
|
|
}
|