refactor(providers): a provider is a vendor and a key, and the table is gone
The dropdown was listing "connections" — a row per adapter + model + key — which made choosing a model an act of configuration, put a key icon and a bin inside a menu item, and showed five accounts nobody had created. A provider is now a vendor Officer curates. Every one is listed, set up or not, because a vendor you have no key for is exactly what you need to see in order to add one. The model is chosen per generation, from that provider's entry in the catalogue, beside it. With the model off the row, all that remained of a "provider" was the key for OpenAI — and Officer already stores keys: one active value per purpose, 0600 inside a 0700 directory, outside the backup tarball, created on first use so nothing needs provisioning. So `atelier_providers` is dropped, along with `generations.provider_id` that pointed into it. `provider` and `model` stay as text on each generation, which were always the historical fact rather than a pointer to configuration. `getSecret`, never `getKey`: this is a foreign credential, and getKey mints a random value for a purpose it cannot find — which for someone else's API key means authenticating with something invented. Resolution follows: a request names a model, and the catalogue says whose it is, rather than a database being asked which stored row served it. A model nobody in the catalogue claims is still accepted — the catalogue is advisory, and a model released today should work before we ship a file that mentions it. Two routes replace five: PUT and DELETE on /providers/:name/key. Deleting a key that came from the environment is refused rather than faked — it belongs to the install, not to this screen.
This commit is contained in:
@@ -55,18 +55,16 @@ export const generations = pgTable('atelier_generations',
|
||||
status: text('status').notNull().default('queued').$type<GenerationStatus>(),
|
||||
|
||||
/**
|
||||
* The adapter and model, as text, denormalised on purpose.
|
||||
* The provider and model, as text, and now the only record of either.
|
||||
*
|
||||
* `providerId` below points at the configured provider, but that row can be renamed, repointed at
|
||||
* another model, or deleted — and none of that may change what an existing image was made with.
|
||||
* The row is a pointer to configuration; these two are the historical fact.
|
||||
* There was a `provider_id` beside these, pointing at a configured provider row. Both the column
|
||||
* and the table it referenced are gone: a provider is a curated vendor plus a key in the secret
|
||||
* store, so there is no row to point at. These two were always the historical fact — what this
|
||||
* image was actually made with, immune to configuration changing afterwards — and they still are.
|
||||
*/
|
||||
provider: text('provider').notNull(),
|
||||
model: text('model').notNull(),
|
||||
|
||||
/** The provider row this came from, when there was one. Null for rows made before providers existed. */
|
||||
providerId: uuid('provider_id'),
|
||||
|
||||
prompt: text('prompt').notNull(),
|
||||
|
||||
/** Whatever else the caller asked for — size, quality, background. Provider-shaped, so opaque here. */
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
// stay in the `atelier` database, untouched. See PLUGIN.md.
|
||||
export * from './generations';
|
||||
export * from './collections';
|
||||
export * from './providers';
|
||||
|
||||
// The connection itself, so callers do not reach past the package for it.
|
||||
export { db, waitForDatabase } from './db';
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './schema';
|
||||
export * from './queries';
|
||||
@@ -1,100 +0,0 @@
|
||||
import { and, asc, eq, ne } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { providers } from './schema';
|
||||
|
||||
export type Provider = typeof providers.$inferSelect;
|
||||
export type NewProvider = typeof providers.$inferInsert;
|
||||
|
||||
export async function listProviders(): Promise<Provider[]> {
|
||||
return db.select().from(providers).orderBy(asc(providers.name));
|
||||
}
|
||||
|
||||
export async function getProvider(id: string): Promise<Provider | undefined> {
|
||||
const [row] = await db.select().from(providers).where(eq(providers.id, id)).limit(1);
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function getProviderByName(name: string): Promise<Provider | undefined> {
|
||||
const [row] = await db.select().from(providers).where(eq(providers.name, name)).limit(1);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which provider serves a request, in the order a caller would expect.
|
||||
*
|
||||
* 1. the model, if exactly one enabled provider offers it
|
||||
* 2. the model, preferring the default when several do — two keys for the same model is an
|
||||
* ordinary thing and the tie has to break somewhere stable
|
||||
* 3. the default provider, for a model nothing is configured for
|
||||
*
|
||||
* Step 3 is deliberate rather than a 404. Vendors ship models faster than rows get added, and the
|
||||
* request still names a model the adapter will happily pass along — refusing it would make Atelier
|
||||
* the reason a brand-new model cannot be used.
|
||||
*/
|
||||
export async function resolveProviderForModel(model: string | undefined): Promise<Provider | undefined> {
|
||||
if (model) {
|
||||
const candidates = await db
|
||||
.select()
|
||||
.from(providers)
|
||||
.where(and(eq(providers.model, model), eq(providers.enabled, true)))
|
||||
.orderBy(asc(providers.createdAt));
|
||||
|
||||
const match = candidates.find((row) => row.isDefault) ?? candidates[0];
|
||||
if (match) return match;
|
||||
}
|
||||
|
||||
return defaultProvider();
|
||||
}
|
||||
|
||||
export async function defaultProvider(): Promise<Provider | undefined> {
|
||||
const [byFlag] = await db
|
||||
.select()
|
||||
.from(providers)
|
||||
.where(and(eq(providers.isDefault, true), eq(providers.enabled, true)))
|
||||
.limit(1);
|
||||
if (byFlag) return byFlag;
|
||||
|
||||
// No flag set — the oldest enabled row, so a single-provider install needs no configuration at all.
|
||||
const [oldest] = await db
|
||||
.select()
|
||||
.from(providers)
|
||||
.where(eq(providers.enabled, true))
|
||||
.orderBy(asc(providers.createdAt))
|
||||
.limit(1);
|
||||
return oldest;
|
||||
}
|
||||
|
||||
export async function createProvider(values: NewProvider): Promise<Provider> {
|
||||
const [row] = await db.insert(providers).values(values).returning();
|
||||
if (row?.isDefault) await clearOtherDefaults(row.id);
|
||||
return row!;
|
||||
}
|
||||
|
||||
export async function updateProvider(id: string, values: Partial<NewProvider>): Promise<Provider | undefined> {
|
||||
const [row] = await db
|
||||
.update(providers)
|
||||
.set({ ...values, updatedAt: new Date() })
|
||||
.where(eq(providers.id, id))
|
||||
.returning();
|
||||
|
||||
if (row?.isDefault) await clearOtherDefaults(id);
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function deleteProvider(id: string): Promise<void> {
|
||||
await db.delete(providers).where(eq(providers.id, id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Exactly one default.
|
||||
*
|
||||
* Enforced by clearing the others on write rather than by a partial unique index, because the write
|
||||
* that sets a new default would otherwise have to clear the old one first — two statements racing to
|
||||
* leave zero defaults instead of two.
|
||||
*/
|
||||
async function clearOtherDefaults(keepId: string): Promise<void> {
|
||||
await db
|
||||
.update(providers)
|
||||
.set({ isDefault: false })
|
||||
.where(and(eq(providers.isDefault, true), ne(providers.id, keepId)));
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { pgTable, uuid, text, boolean, timestamp, index } from 'drizzle-orm/pg-core';
|
||||
|
||||
/**
|
||||
* A configured connection to a model: adapter + model + credentials.
|
||||
*
|
||||
* ── Why this is a row and the adapter is not ──
|
||||
*
|
||||
* "OpenAI" is not a thing you can generate with. "OpenAI's gpt-image-1, with this key" is. The two
|
||||
* differ in how often they change: an ADAPTER is code that learns a vendor's HTTP API and is written
|
||||
* once; a PROVIDER is a decision — which model, whose key, is it on — and you make several of those
|
||||
* per adapter and change them at runtime.
|
||||
*
|
||||
* So `openai + gpt-image-1` and `openai + dall-e-3` are two rows sharing one adapter and possibly one
|
||||
* key. The previous design keyed configuration on the adapter name, which made that unsayable: there
|
||||
* was exactly one OpenAI, and its model was a property of it rather than the point of it.
|
||||
*
|
||||
* Nothing here constrains `model` against the adapter. Vendors ship models faster than a list in this
|
||||
* repo is updated, and refusing a new one on release day is a worse failure than passing it through
|
||||
* and letting the vendor answer.
|
||||
*/
|
||||
export const providers = pgTable('atelier_providers',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
|
||||
/** What a human calls it — "OpenAI · gpt-image-1". Unique, so it can be named in a request. */
|
||||
name: text('name').notNull().unique(),
|
||||
|
||||
/** Which adapter drives it. Text, not an enum: adding an adapter must not be a schema change. */
|
||||
adapter: text('adapter').notNull(),
|
||||
|
||||
model: text('model').notNull(),
|
||||
|
||||
/** AES-256-GCM via servers/crypto.ts. Never leaves the server and never appears in a response. */
|
||||
apiKeyEncrypted: text('api_key_encrypted'),
|
||||
|
||||
enabled: boolean('enabled').notNull().default(true),
|
||||
|
||||
/**
|
||||
* The one to use when a request names no provider and no model, or names a model nothing serves.
|
||||
*
|
||||
* A column rather than "the oldest row" because it is a decision the owner makes and should be
|
||||
* able to change without deleting and recreating rows. Exactly one row should carry it; the
|
||||
* queries below enforce that by clearing the others when one is set.
|
||||
*/
|
||||
isDefault: boolean('is_default').notNull().default(false),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
// Resolving a request by model is the hot path: every generation does it once.
|
||||
index('atelier_providers_model_idx').on(table.model, table.enabled),
|
||||
],
|
||||
);
|
||||
@@ -1,10 +1,10 @@
|
||||
// What drizzle-kit creates, and the runtime's view of the tables — in the SEPARATE `atelier` database,
|
||||
// not officer_db. See PLUGIN.md for why this plugin keeps its own database rather than folding into
|
||||
// officer_db's <appname>_-prefixed convention: both `collections` and `provider_settings` had rows on
|
||||
// the machine this was extracted from, and losing someone's generated images to a schema decision is
|
||||
// not a trade to make unattended.
|
||||
// What drizzle-kit creates, and the runtime's view of the tables.
|
||||
//
|
||||
// `provider_settings` is deliberately not exported here — see ./index.ts.
|
||||
// Two tables. `atelier_providers` is gone: a provider is a vendor Officer curates plus a key in the
|
||||
// platform's secret store, so there is no provider state in Postgres to keep — see
|
||||
// sidecar/providers/credentials.ts. The five rows that table held had been minted by a migration, one
|
||||
// per model in a hard-coded list, and read as five accounts nobody had configured.
|
||||
//
|
||||
// Everything is `atelier_`-prefixed and lives in officer_db, exactly like every other plugin.
|
||||
export * from './generations/schema';
|
||||
export * from './collections/schema';
|
||||
export * from './providers/schema';
|
||||
|
||||
Reference in New Issue
Block a user