From 6c9e648eead4e0eaad3d2db65079f5444ae95ee6 Mon Sep 17 00:00:00 2001 From: whit3rabbit Date: Wed, 15 Jul 2026 19:15:34 -0500 Subject: [PATCH] feat(admin-ui): provider detail form redesign Extract the provider editor into a larger single-column ProviderForm with an API-key show/hide toggle, an informational Models list (add + query-models discovery), and inline Edit of existing managed backends (not just create/delete). Providers.tsx now delegates to ProviderForm. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/tabs/providers/ProviderForm.tsx | 283 ++++++++++++++++++ .../admin-ui/src/tabs/providers/Providers.tsx | 206 ++----------- 2 files changed, 305 insertions(+), 184 deletions(-) create mode 100644 crates/proxy/admin-ui/src/tabs/providers/ProviderForm.tsx diff --git a/crates/proxy/admin-ui/src/tabs/providers/ProviderForm.tsx b/crates/proxy/admin-ui/src/tabs/providers/ProviderForm.tsx new file mode 100644 index 0000000..eb0bd96 --- /dev/null +++ b/crates/proxy/admin-ui/src/tabs/providers/ProviderForm.tsx @@ -0,0 +1,283 @@ +import { useEffect, useState } from 'react' +import { + useCreateManagedBackend, + useUpdateManagedBackend, + useDiscoverModels, +} from '../../api/queries' +import type { CatalogProvider, ManagedBackend } from '../../api/types' +import { getProviderFields, resolveDiscoveryUrl } from '../../utils/providerFields' +import { AdminButton } from '../../components/shared/Performative' + +// A provider is "local" when its default endpoint is a loopback address. +function isLocalProvider(p: CatalogProvider): boolean { + return /localhost|127\.0\.0\.1|0\.0\.0\.0/.test(p.default_base_url ?? '') +} + +function errorMessage(err: Error | null, fallback: string): string { + if (!err) return fallback + try { + const parsed = JSON.parse(err.message) + if (parsed && typeof parsed.error === 'string') return parsed.error + } catch { + /* not JSON */ + } + return err.message || fallback +} + +/** + * Create or edit a managed backend for a given provider. Rendered inside the + * provider detail Modal (large, single column). On edit, non-secret fields are + * pre-seeded and resent on save: ManagedBackendPatch has no null sentinel, so + * omitting a field would silently keep the stale value. + */ +export default function ProviderForm({ + provider, + existing, + existingCount, + onDone, +}: { + provider: CatalogProvider + existing?: ManagedBackend | null + existingCount: number + onDone?: () => void +}) { + const isEdit = !!existing + const create = useCreateManagedBackend() + const update = useUpdateManagedBackend() + const discover = useDiscoverModels() + + const initialForm = (): Record => { + if (existing) { + const seeded: Record = { name: existing.name } + for (const k of ['api_base', 'deployment', 'api_version', 'project', 'region'] as const) { + if (existing[k] != null) seeded[k] = String(existing[k]) + } + if (existing.rpm != null) seeded.rpm = String(existing.rpm) + if (existing.tpm != null) seeded.tpm = String(existing.tpm) + return seeded + } + const base: Record = { name: `${provider.id}-${existingCount + 1}` } + if (isLocalProvider(provider) && provider.default_base_url) { + base.api_base = provider.default_base_url + } + return base + } + + const [form, setForm] = useState>(initialForm) + const [shown, setShown] = useState>({}) + const [models, setModels] = useState([]) + const [modelInput, setModelInput] = useState('') + + useEffect(() => { + setForm(initialForm()) + setModels([]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [provider.id, existing?.name]) + + const fields = getProviderFields(provider) + + function set(name: string, value: string) { + setForm(p => ({ ...p, [name]: value })) + } + + function addModel(name: string) { + const m = name.trim() + if (m && !models.includes(m)) setModels(p => [...p, m]) + setModelInput('') + } + + function queryModels() { + // Local providers relax SSRF (loopback/LAN) based on provider_id. + discover.mutate( + { + source: 'custom', + url: form.api_base || provider.default_base_url, + provider_id: provider.id, + api_key: form.api_key || undefined, + }, + { + onSuccess: data => { + setModels(prev => { + const merged = new Set(prev) + for (const m of data.models) merged.add(m.id) + return [...merged] + }) + }, + }, + ) + } + + function submit() { + const payload = { + api_key: form.api_key || undefined, + api_base: form.api_base ? form.api_base.trim().replace(/\/+$/, '') : undefined, + deployment: form.deployment || undefined, + api_version: form.api_version || undefined, + project: form.project || undefined, + region: form.region || undefined, + aws_access_key_id: form.aws_access_key_id || undefined, + aws_secret_access_key: form.aws_secret_access_key || undefined, + aws_session_token: form.aws_session_token || undefined, + rpm: form.rpm ? Number(form.rpm) : undefined, + tpm: form.tpm ? Number(form.tpm) : undefined, + } + if (isEdit && existing) { + update.mutate({ name: existing.name, data: payload }, { onSuccess: () => onDone?.() }) + } else { + create.mutate( + { name: form.name, provider_id: provider.id, ...payload }, + { + onSuccess: () => { + setForm(initialForm()) + setModels([]) + onDone?.() + }, + }, + ) + } + } + + function credSet(fieldName: string): boolean { + if (!existing) return false + if (fieldName === 'api_key') return existing.api_key_set + if (fieldName === 'aws_access_key_id' || fieldName === 'aws_secret_access_key') + return existing.aws_creds_set + return false + } + + const pending = create.isPending || update.isPending + const submitError = create.error || update.error + // The name input's `pattern` attr never fires (no
/submit event), so + // validate here to match what the backend enforces before sending. + const nameValid = isEdit || /^[a-zA-Z0-9_-]+$/.test(form.name ?? '') + + return ( +
+
+ + set('name', e.target.value)} + style={{ width: '100%' }} + /> + {!nameValid && form.name && ( +
+ Name may contain only letters, numbers, hyphens, and underscores. +
+ )} +
+ + {fields.map(f => { + const isPassword = f.type === 'password' + const inputType = isPassword && !shown[f.name] ? 'password' : f.type === 'url' ? 'text' : f.type + return ( +
+ + {f.hint &&
{f.hint}
} +
+ set(f.name, e.target.value)} + style={{ flex: 1 }} + /> + {isPassword && ( + setShown(s => ({ ...s, [f.name]: !s[f.name] }))} + > + {shown[f.name] ? 'Hide' : 'Show'} + + )} +
+ {f.name === 'api_base' && (() => { + const target = resolveDiscoveryUrl(form.api_base || provider.default_base_url || '') + if (!target) return null + const unsupported = ['vertex_ai', 'gemini_native', 'bedrock_native'].includes( + provider.protocol, + ) + return ( +
+ Query models will request: {target} + {unsupported && ' — model discovery may not work for this provider.'} +
+ ) + })()} +
+ ) + })} + + {/* Models (informational): discovered or hand-added names. Not persisted + on the backend today; used to sanity-check connectivity. */} +
+ +
+ setModelInput(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault() + addModel(modelInput) + } + }} + style={{ flex: 1 }} + /> + addModel(modelInput)}> + Add Model + +
+ {models.length > 0 && ( +
+ {models.map(m => ( + setModels(prev => prev.filter(x => x !== m))}> + {m} ✕ + + ))} +
+ )} + {discover.isError && ( +
{errorMessage(discover.error, 'Failed to query models')}
+ )} +
+ + {submitError && ( +
{errorMessage(submitError, 'Failed to save backend')}
+ )} + +
+ + Query models + + + {isEdit ? 'Save' : 'Create'} + +
+
+ ) +} diff --git a/crates/proxy/admin-ui/src/tabs/providers/Providers.tsx b/crates/proxy/admin-ui/src/tabs/providers/Providers.tsx index 61a1d43..42292b9 100644 --- a/crates/proxy/admin-ui/src/tabs/providers/Providers.tsx +++ b/crates/proxy/admin-ui/src/tabs/providers/Providers.tsx @@ -1,42 +1,21 @@ -import { useEffect, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import { useCatalogProviders, useManagedBackends, - useCreateManagedBackend, useDeleteManagedBackend, useUptime, useFavorites, useToggleFavorite, - useDiscoverModels, } from '../../api/queries' import type { CatalogProvider, ManagedBackend } from '../../api/types' -import { getProviderFields, resolveDiscoveryUrl } from '../../utils/providerFields' import { groupSections } from '../../utils/providerTiers' - -// A provider is "local" when its default endpoint is a loopback address. -// Used to pre-fill the endpoint field so local-LLM users don't type it. -function isLocalProvider(p: CatalogProvider): boolean { - return /localhost|127\.0\.0\.1|0\.0\.0\.0/.test(p.default_base_url ?? '') -} - -// Backend errors arrive as `{"error":"..."}` JSON in the thrown Error's message. -// Surface the real message instead of a generic banner. -function errorMessage(err: Error | null, fallback: string): string { - if (!err) return fallback - try { - const parsed = JSON.parse(err.message) - if (parsed && typeof parsed.error === 'string') return parsed.error - } catch { - // not JSON; fall through to the raw message - } - return err.message || fallback -} import AsyncBoundary from '../../components/shared/AsyncBoundary' import ConfirmDialog from '../../components/shared/ConfirmDialog' import Modal from '../../components/shared/Modal' import StatusDot from '../../components/shared/StatusDot' import ProviderIcon from '../../components/shared/ProviderIcon' import { AdminButton, AdminSurface } from '../../components/shared/Performative' +import ProviderForm from './ProviderForm' // ── Provider Tile ────────────────────────────────────────────────────────────── @@ -99,10 +78,12 @@ function ProviderTile({ function BackendRow({ backend, healthStatus, + onEdit, onDelete, }: { backend: ManagedBackend healthStatus?: string + onEdit: () => void onDelete: () => void }) { return ( @@ -116,6 +97,9 @@ function BackendRow({ {backend.api_key_set ? 'key set' : 'no key'} {backend.rpm != null && <> · RPM {backend.rpm}} + + Edit + Delete @@ -123,165 +107,6 @@ function BackendRow({ ) } -// ── Add Backend Form (inside detail panel) ───────────────────────────────────── - -/** - * Component providing fields to configure credentials and create a new managed backend. - */ -function AddBackendForm({ - provider, - existingCount, -}: { - provider: CatalogProvider - existingCount: number -}) { - const create = useCreateManagedBackend() - const discover = useDiscoverModels() - // Local providers get their loopback endpoint pre-filled (editable); hosted ones don't, - // so we never send a redundant api_base for them. - const initialForm = (): Record => { - const base: Record = { - name: `${provider.id}-${existingCount + 1}`, - provider_id: provider.id, - } - if (isLocalProvider(provider) && provider.default_base_url) { - base.api_base = provider.default_base_url - } - return base - } - const [form, setForm] = useState>(initialForm) - - // Reset form when the provider or count changes - useEffect(() => { - setForm(initialForm()) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [provider.id, existingCount]) - - function submit() { - create.mutate( - { - name: form.name, - provider_id: provider.id, - api_key: form.api_key || undefined, - api_base: form.api_base ? form.api_base.trim().replace(/\/+$/, '') : undefined, - deployment: form.deployment || undefined, - api_version: form.api_version || undefined, - project: form.project || undefined, - region: form.region || undefined, - aws_access_key_id: form.aws_access_key_id || undefined, - aws_secret_access_key: form.aws_secret_access_key || undefined, - aws_session_token: form.aws_session_token || undefined, - rpm: form.rpm ? Number(form.rpm) : undefined, - tpm: form.tpm ? Number(form.tpm) : undefined, - }, - { - // useCreateManagedBackend invalidates ['managed-backends'], so the list refreshes itself. - // Reset the form so a second key can be added without stale field values. - onSuccess: () => setForm(initialForm()), - }, - ) - } - - function queryModels() { - // For local providers the backend relaxes SSRF (loopback/LAN) based on provider_id. - discover.mutate({ - source: 'custom', - url: form.api_base || provider.default_base_url, - provider_id: provider.id, - api_key: form.api_key || undefined, - }) - } - - const fields = getProviderFields(provider) - - return ( -
-
- - setForm((p) => ({ ...p, name: e.target.value }))} - style={{ width: '100%' }} - /> -
- {fields.map((f) => ( -
- - {f.hint &&
{f.hint}
} - setForm((p) => ({ ...p, [f.name]: e.target.value }))} - style={{ width: '100%' }} - /> - {f.name === 'api_base' && (() => { - const target = resolveDiscoveryUrl(form.api_base || provider.default_base_url || '') - if (!target) return null - // /v1/models discovery only works for OpenAI-shaped and Anthropic-native providers. - const unsupported = ['vertex_ai', 'gemini_native', 'bedrock_native'].includes( - provider.protocol, - ) - return ( -
- Query models will request: {target} - {unsupported && ' — model discovery may not work for this provider.'} -
- ) - })()} -
- ))} - {discover.isError && ( -
{errorMessage(discover.error, 'Failed to query models')}
- )} - {discover.isSuccess && ( -
- {discover.data.models.length > 0 - ? `Found ${discover.data.models.length} model(s): ${discover.data.models - .slice(0, 8) - .map((m) => m.id) - .join(', ')}${discover.data.models.length > 8 ? ', …' : ''}` - : 'No models returned by the server.'} -
- )} - {create.isError && ( -
{errorMessage(create.error, 'Failed to create backend')}
- )} -
- setForm(initialForm())} - disabled={create.isPending} - > - Reset - - - Query models - - - Create - -
-
- ) -} - // ── Provider Detail Panel (lightbox overlay) ─────────────────────────────────── /** @@ -300,6 +125,7 @@ function ProviderDetailPanel({ onClose: () => void onDeleteBackend: (b: ManagedBackend) => void }) { + const [editing, setEditing] = useState(null) const caps = provider.capabilities const capList: [string, boolean][] = [ ['chat', caps.chat_completions], @@ -311,7 +137,7 @@ function ProviderDetailPanel({ ] return ( - + {/* Capabilities */}
{capList.map(([label, active]) => ( @@ -357,13 +183,25 @@ function ProviderDetailPanel({ key={b.id} backend={b} healthStatus={healthMap.get(b.name)} + onEdit={() => setEditing(b)} onDelete={() => onDeleteBackend(b)} /> ))} - + {editing ? `Edit ${editing.name}` : 'Add credentials'} +
+ setEditing(null)} /> + {editing && ( + setEditing(null)}> + Cancel edit + + )}
)