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.
181 lines
7.3 KiB
TypeScript
181 lines
7.3 KiB
TypeScript
import { getHeaders, useClient } from 'hooks/useClient';
|
|
|
|
// Every call the panels make to this plugin's own sidecar, through `/api/atelier`.
|
|
//
|
|
// `useClient('/api/atelier')` gives typed verbs for JSON; `fileUrl` below is the one thing a client
|
|
// call cannot do — put a bearer token on an `<img src>`.
|
|
|
|
const ATELIER_API = '/api/atelier';
|
|
|
|
/**
|
|
* A provider: a vendor Officer curates, and whether we hold a key for it.
|
|
*
|
|
* Every provider in the catalogue is listed, set up or not — a vendor you have no key for is exactly
|
|
* what you need to see in order to add one. There is no "configured provider" as a separate thing;
|
|
* `configured` is a fact about the key, and the model is chosen per generation.
|
|
*/
|
|
export type ProviderStatus = {
|
|
name: string;
|
|
label: string;
|
|
configured: boolean;
|
|
key_source: 'store' | 'environment' | null;
|
|
env_var: string;
|
|
/** False when the catalogue lists a vendor this build has no adapter for. */
|
|
usable: boolean;
|
|
supports_edit: boolean;
|
|
refreshable: boolean;
|
|
default_model: string | null;
|
|
models: string[];
|
|
model_details: CatalogueModel[];
|
|
updated_at: string | null;
|
|
};
|
|
|
|
/** 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';
|
|
};
|
|
|
|
/** Snake case, matching the server — one spelling per field across every endpoint. */
|
|
export type GenerationRow = {
|
|
id: string;
|
|
request_id: string;
|
|
index: number;
|
|
operation: 'generate' | 'edit';
|
|
status: 'queued' | 'running' | 'succeeded' | 'failed';
|
|
provider: string;
|
|
model: string;
|
|
prompt: string;
|
|
options: Record<string, unknown> | null;
|
|
input_images: string[] | null;
|
|
mask_image: string | null;
|
|
image_path: string | null;
|
|
url: string | null;
|
|
width: number | null;
|
|
height: number | null;
|
|
has_alpha: boolean | null;
|
|
opaque: boolean | null;
|
|
cost_usd: string | null;
|
|
duration_ms: number | null;
|
|
error: string | null;
|
|
created_at: string;
|
|
};
|
|
|
|
export type JobResponse = {
|
|
id?: string;
|
|
status?: string;
|
|
created?: number;
|
|
data?: { url: string; revised_prompt?: string; b64_json?: string }[];
|
|
atelier?: {
|
|
id: string;
|
|
status: 'queued' | 'running' | 'succeeded' | 'failed' | 'partial';
|
|
cost_usd: string | null;
|
|
duration_ms: number;
|
|
images: { id: string; status: string; url: string | null; error: string | null }[];
|
|
};
|
|
error?: { message: string; param?: string | null };
|
|
};
|
|
|
|
export type Collection = {
|
|
id: string;
|
|
name: string;
|
|
description: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
};
|
|
|
|
export type CollectionSummary = Collection & { item_count: number; cover_image_path: string | null; cover_url: string | null };
|
|
export type CollectionDetail = Collection & { images: GenerationRow[] };
|
|
|
|
/**
|
|
* `row.url` (e.g. `/files/<name>`) with this session's bearer token appended, for an `<img src>`.
|
|
*
|
|
* Browsers cannot set an `Authorization` header on an `<img>` request, so the token travels in the
|
|
* query string instead — the same escape hatch `FileViewer/file-types.ts` and the music plugin's
|
|
* `MusicPlayerHost` already use for exactly this reason (`permission-gate.ts` and `user-middleware.ts`
|
|
* both read `?token=` as well as the header).
|
|
*/
|
|
export function fileUrl(path: string | null): string | null {
|
|
if (!path) return null;
|
|
const token = getHeaders()['Authorization']?.replace('Bearer ', '') ?? '';
|
|
return `${ATELIER_API}${path}?token=${encodeURIComponent(token)}`;
|
|
}
|
|
|
|
export function useAtelierClient() {
|
|
const client = useClient(ATELIER_API);
|
|
|
|
return {
|
|
...client,
|
|
|
|
listProviders: () => client.get<{ data: ProviderStatus[] }>('providers').then((r) => r.data),
|
|
|
|
/** Save a provider's key. Goes to the platform's secret store; nothing ever reads it back. */
|
|
setProviderKey: (name: string, apiKey: string) =>
|
|
client.put<{ data: ProviderStatus[] }>(`providers/${name}/key`, { apiKey }).then((r) => r.data),
|
|
/** Forget a stored key. Refused for a key that came from the environment — that one is the install's. */
|
|
clearProviderKey: (name: string) =>
|
|
client.delete<{ data: ProviderStatus[] }>(`providers/${name}/key`).then((r) => r.data),
|
|
|
|
/**
|
|
* Re-read one provider's models and prices from the vendor, or every provider when omitted.
|
|
*
|
|
* The only thing that updates the catalogue — there is no schedule. It runs a live fetch, so it is
|
|
* as slow as the vendor is and the caller should show that it is working.
|
|
*/
|
|
refreshCatalogue: (provider?: string) =>
|
|
client
|
|
.post<{ data: { updated_at: string | null; providers: ProviderStatus[]; changed: boolean } }>(
|
|
`providers/catalogue/refresh${provider ? `?provider=${encodeURIComponent(provider)}` : ''}`,
|
|
{},
|
|
)
|
|
.then((r) => r.data),
|
|
|
|
listGenerations: (limit = 60) => client.get<{ data: GenerationRow[] }>(`images?limit=${limit}`).then((r) => r.data),
|
|
|
|
startGeneration: (body: Record<string, unknown>) => client.post<{ id: string }>('images/generations', body),
|
|
|
|
/** Long-polls up to `waitSeconds` server-side, so the panel does not run its own retry loop. */
|
|
awaitGeneration: (id: string, waitSeconds = 30) => client.get<JobResponse>(`images/generations/${id}?wait=${waitSeconds}`),
|
|
|
|
/** A provider row is adapter + model; the key is set separately, and never comes back. */
|
|
createProvider: (adapter: string, model: string) =>
|
|
client.post<{ data: ProviderStatus[] }>('providers', { adapter, model }).then((r) => r.data),
|
|
updateProvider: (id: string, values: Record<string, unknown>) =>
|
|
client.patch<{ data: ProviderStatus[] }>(`providers/${id}`, values).then((r) => r.data),
|
|
deleteProvider: (id: string) => client.delete<{ data: ProviderStatus[] }>(`providers/${id}`).then((r) => r.data),
|
|
|
|
listCollections: () => client.get<{ data: CollectionSummary[] }>('collections').then((r) => r.data),
|
|
getCollection: (id: string) => client.get<{ data: CollectionDetail }>(`collections/${id}`).then((r) => r.data),
|
|
createCollection: (name: string) =>
|
|
client.post<{ data: Collection }>('collections', { name }).then((r) => r.data),
|
|
renameCollection: (id: string, name: string) =>
|
|
client.patch<{ data: Collection }>(`collections/${id}`, { name }).then((r) => r.data),
|
|
/**
|
|
* `deleteImages` is the difference between filing something wrongly and regretting the whole
|
|
* afternoon — the server keeps the images unless it is asked, by name, not to.
|
|
*/
|
|
deleteCollection: (id: string, deleteImages = false) =>
|
|
client
|
|
.delete<{ data: { id: string; deleted: boolean; deletedImages: number } }>(
|
|
`collections/${id}${deleteImages ? '?images=delete' : ''}`,
|
|
)
|
|
.then((r) => r.data),
|
|
addToCollection: (id: string, generationIds: string[]) =>
|
|
client
|
|
.post<{ data: { added: number; images: GenerationRow[] } }>(`collections/${id}/items`, { generationIds })
|
|
.then((r) => r.data),
|
|
removeFromCollection: (id: string, generationId: string) =>
|
|
client.delete<{ data: { images: GenerationRow[] } }>(`collections/${id}/items/${generationId}`).then((r) => r.data),
|
|
};
|
|
}
|
|
|
|
export type UseAtelierClientType = ReturnType<typeof useAtelierClient>;
|