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.
227 lines
8.2 KiB
TypeScript
227 lines
8.2 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import { KeyRound, RefreshCw } from 'lucide-react';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Label } from '@/components/ui/label';
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from '@/components/ui/dialog';
|
||
import type { ProviderStatus } from '../lib/api';
|
||
import type { ProvidersManager } from './useProviders';
|
||
|
||
/**
|
||
* Provider and model, at the top of Generate.
|
||
*
|
||
* Two dropdowns and nothing else. The provider list is the curated file — one entry per vendor,
|
||
* whether or not you have a key — and the models under it are that provider's entry in the same file.
|
||
*
|
||
* What this replaced was a menu of "connections": a row per adapter+model+key, which made choosing a
|
||
* model an act of configuration, put a key icon and a delete button inside a menu item, and listed
|
||
* five accounts nobody had created. A provider is a vendor; the key is a property of it; the model is
|
||
* a choice you make each time.
|
||
*/
|
||
export const ProvidersBar = ({
|
||
manager,
|
||
model,
|
||
onModelChange,
|
||
}: {
|
||
manager: ProvidersManager;
|
||
model: string;
|
||
onModelChange: (model: string) => void;
|
||
}) => {
|
||
const [selected, setSelected] = useState('');
|
||
const [keyFor, setKeyFor] = useState<ProviderStatus | null>(null);
|
||
|
||
// Default to a provider we can actually use, once the list arrives.
|
||
useEffect(() => {
|
||
if (selected && manager.byName(selected)) return;
|
||
const next = manager.firstUsable() ?? manager.providers[0];
|
||
if (next) setSelected(next.name);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [manager.providers]);
|
||
|
||
const provider = manager.byName(selected);
|
||
const models = provider?.model_details ?? [];
|
||
|
||
// Keep the model honest: when the provider changes, or a refresh retires what was chosen, fall back
|
||
// to that provider's default rather than leaving a model the vendor no longer lists selected.
|
||
useEffect(() => {
|
||
if (!provider) return;
|
||
if (model && models.some((entry) => entry.id === model)) return;
|
||
onModelChange(provider.default_model ?? models[0]?.id ?? '');
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [provider?.name, provider?.model_details]);
|
||
|
||
return (
|
||
<div className="grid gap-3">
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Label className="grid gap-2">
|
||
<span className="text-xs text-muted-foreground">Provider</span>
|
||
<Select value={selected} onValueChange={setSelected}>
|
||
<SelectTrigger className="w-full">
|
||
<SelectValue placeholder="Choose" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{manager.providers.map((entry) => (
|
||
<SelectItem key={entry.name} value={entry.name}>
|
||
<span className="flex w-full items-center gap-2">
|
||
{entry.label}
|
||
{/* Said here rather than discovered at Generate: a vendor with no key is listed
|
||
precisely so you can add one, so the state has to be visible in the list. */}
|
||
{!entry.configured && <span className="text-xs text-destructive">no key</span>}
|
||
</span>
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</Label>
|
||
|
||
<Label className="grid gap-2">
|
||
<span className="flex items-center gap-2 text-xs text-muted-foreground">
|
||
Model
|
||
<span className="ml-auto text-muted-foreground/60">{freshness(provider?.updated_at)}</span>
|
||
</span>
|
||
<Select value={model} onValueChange={onModelChange} disabled={models.length === 0}>
|
||
<SelectTrigger className="w-full">
|
||
<SelectValue placeholder="Choose" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{models.map((entry) => (
|
||
<SelectItem key={entry.id} value={entry.id}>
|
||
{entry.label ?? entry.id}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</Label>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Button size="sm" variant="outline" onClick={() => provider && setKeyFor(provider)} disabled={!provider}>
|
||
<KeyRound className="mr-1 size-3.5" />
|
||
{provider?.configured ? 'Change key' : 'Add key'}
|
||
</Button>
|
||
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
disabled={manager.refreshing || !provider?.refreshable}
|
||
onClick={() => void manager.refreshCatalogue(selected)}
|
||
title={`Re-read ${provider?.label ?? 'this provider'}'s models from the vendor`}
|
||
>
|
||
<RefreshCw className={manager.refreshing ? 'mr-1 size-3.5 animate-spin' : 'mr-1 size-3.5'} />
|
||
Update models
|
||
</Button>
|
||
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
disabled={manager.refreshing}
|
||
onClick={() => void manager.refreshCatalogue()}
|
||
className="text-muted-foreground"
|
||
>
|
||
All providers
|
||
</Button>
|
||
|
||
{manager.error && <span className="text-xs text-destructive">{manager.error}</span>}
|
||
</div>
|
||
|
||
<KeyDialog provider={keyFor} manager={manager} onClose={() => setKeyFor(null)} />
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/** "as shipped" is a fact worth showing: these models are exactly what the plugin was released with. */
|
||
const freshness = (updatedAt?: string | null): string => {
|
||
if (!updatedAt) return 'as shipped';
|
||
const days = Math.floor((Date.now() - new Date(updatedAt).getTime()) / 86_400_000);
|
||
return days <= 0 ? 'updated today' : `${days}d old`;
|
||
};
|
||
|
||
const KeyDialog = ({
|
||
provider,
|
||
manager,
|
||
onClose,
|
||
}: {
|
||
provider: ProviderStatus | null;
|
||
manager: ProvidersManager;
|
||
onClose: () => void;
|
||
}) => {
|
||
const [apiKey, setApiKey] = useState('');
|
||
|
||
useEffect(() => {
|
||
setApiKey('');
|
||
}, [provider]);
|
||
|
||
const fromEnvironment = provider?.key_source === 'environment';
|
||
|
||
const save = async () => {
|
||
if (!provider || !apiKey.trim()) return;
|
||
if (await manager.setKey(provider.name, apiKey)) onClose();
|
||
};
|
||
|
||
return (
|
||
<Dialog open={!!provider} onOpenChange={(next) => !next && onClose()}>
|
||
<DialogContent className="sm:max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle>{provider?.label} API key</DialogTitle>
|
||
<DialogDescription>
|
||
{fromEnvironment
|
||
? `Currently taken from ${provider?.env_var} in the environment. A key saved here takes precedence.`
|
||
: 'Stored in Officer’s secret store, encrypted at rest and never sent back — so this field always starts empty.'}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{/* A form, so Enter saves. Pasting a key and pressing Enter is the strongest habit there is in
|
||
a password field, and outside a form it silently does nothing. */}
|
||
<form
|
||
className="grid gap-4"
|
||
onSubmit={(ev) => {
|
||
ev.preventDefault();
|
||
void save();
|
||
}}
|
||
>
|
||
<Input
|
||
autoFocus
|
||
type="password"
|
||
autoComplete="off"
|
||
placeholder="sk-…"
|
||
value={apiKey}
|
||
onChange={(ev) => setApiKey(ev.target.value)}
|
||
/>
|
||
|
||
<DialogFooter className="gap-2 sm:justify-between">
|
||
{provider?.configured && !fromEnvironment ? (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
className="text-destructive"
|
||
disabled={manager.busy}
|
||
onClick={() => void manager.clearKey(provider.name).then((ok) => ok && onClose())}
|
||
>
|
||
Forget key
|
||
</Button>
|
||
) : (
|
||
<span />
|
||
)}
|
||
<span className="flex gap-2">
|
||
<Button type="button" variant="ghost" onClick={onClose}>
|
||
Cancel
|
||
</Button>
|
||
<Button type="submit" disabled={manager.busy || !apiKey.trim()}>
|
||
Save
|
||
</Button>
|
||
</span>
|
||
</DialogFooter>
|
||
</form>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
};
|