manifest, thin proxy router, and the sidecar backend
ported from the standalone officer-atelier service: providers (adapter-shaped, openai the only adapter today), the generation queue, collections, and the database layer — kept on its own Postgres database rather than folded into officer_db, since both collections and provider_settings had rows on the machine this was extracted from. what changed rather than just moved: - images are served from an authenticated GET /files/:filename instead of an unauthenticated PUBLIC_URL-prefixed static route - the OpenAI-key bootstrap fallback reads the secret store (getSecret) before .env, per-adapter - provider_settings (the one-row-per-adapter shape providers replaced) did not come along — it has no callers left anywhere in the server code, only a migration script that also did not move ping/echo (smoke-test routes with no caller) did not move either; the sidecar's own /_health covers liveness. not yet wired into the platform: sidecar/index.ts exists but nothing mounts it, and web/ has not been written, so this is not yet a discoverable plugin.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
export * from './schema';
|
||||
export * from './queries';
|
||||
@@ -0,0 +1,100 @@
|
||||
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)));
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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(
|
||||
'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('providers_model_idx').on(table.model, table.enabled),
|
||||
],
|
||||
);
|
||||
Reference in New Issue
Block a user