diff --git a/apps/hub/src/config/models.ts b/apps/hub/src/config/models.ts index a3861eca..56db6473 100644 --- a/apps/hub/src/config/models.ts +++ b/apps/hub/src/config/models.ts @@ -24,8 +24,6 @@ type CommonEntry = { plugin: string; label: string; hint: string; - // Whether a person points it at their own base URL. - needsBaseUrl: boolean; baseURL: string; // The adapter quirks every offering of this provider carries. quirks?: Readonly>; @@ -66,7 +64,6 @@ export const MODEL_PROVIDERS: readonly ShippedEntry[] = [ kind: "api_key", label: "OpenAI", hint: "An OpenAI API key.", - needsBaseUrl: false, baseURL: "https://api.openai.com/v1", models: [ { id: "gpt-6-astra", label: "GPT-6 Astra" }, @@ -84,7 +81,6 @@ export const MODEL_PROVIDERS: readonly ShippedEntry[] = [ kind: "api_key", label: "Anthropic", hint: "An Anthropic API key.", - needsBaseUrl: false, baseURL: "https://api.anthropic.com", models: [ { id: "claude-fable-5-1", label: "Claude Fable 5.1" }, @@ -106,7 +102,6 @@ export const MODEL_PROVIDERS: readonly ShippedEntry[] = [ kind: "api_key", label: "Google", hint: "A Gemini API key.", - needsBaseUrl: false, baseURL: "https://generativelanguage.googleapis.com", models: [ { id: "gemini-4-argon", label: "Gemini 4 Argon" }, @@ -121,7 +116,6 @@ export const MODEL_PROVIDERS: readonly ShippedEntry[] = [ quirks: { reasoning: { maxEffort: "xhigh" } }, label: "xAI API", hint: "An xAI API key, from console.x.ai.", - needsBaseUrl: false, baseURL: "https://api.x.ai/v1", models: [ { id: "grok-4.7", label: "Grok 4.7" }, @@ -140,7 +134,6 @@ export const MODEL_PROVIDERS: readonly ShippedEntry[] = [ kind: "login", label: "ChatGPT", hint: "Your ChatGPT subscription, signed in through OpenAI.", - needsBaseUrl: false, baseURL: "https://chatgpt.com/backend-api", models: [ { id: "gpt-6.1-sol", label: "gpt-6.1-sol" }, @@ -160,7 +153,6 @@ export const MODEL_PROVIDERS: readonly ShippedEntry[] = [ kind: "login", label: "xAI", hint: "Your xAI subscription, signed in through xAI.", - needsBaseUrl: false, baseURL: "https://cli-chat-proxy.grok.com/v1", models: [ { id: "grok-4.7", label: "Grok 4.7" }, @@ -190,23 +182,13 @@ export function modelProvider( return directory.find((entry) => entry.id === id); } -/** - * The shipped entry a catalog provider row is a connection of: the one at its plugin and fixed base - * URL, else the entry of that plugin a person points at their own base URL. - */ +/** The shipped entry a catalog provider row is a connection of: the one at its plugin and base URL. */ export function shippedOfRow( row: Readonly<{ plugin: string; baseURL: string | undefined }>, ): ShippedEntry | undefined { - const ofPlugin = MODEL_PROVIDERS.filter( - (entry) => entry.plugin === row.plugin, + return MODEL_PROVIDERS.find( + (entry) => entry.plugin === row.plugin && entry.baseURL === row.baseURL, ); - if (row.baseURL !== undefined) { - const exact = ofPlugin.find( - (entry) => !entry.needsBaseUrl && entry.baseURL === row.baseURL, - ); - if (exact) return exact; - } - return ofPlugin.find((entry) => entry.needsBaseUrl); } /** The custom provider running on a catalog provider row, else the shipped entry it connects. */ diff --git a/apps/hub/src/settings/custom-providers.ts b/apps/hub/src/settings/custom-providers.ts index 1d8da76f..a0cc0a84 100644 --- a/apps/hub/src/settings/custom-providers.ts +++ b/apps/hub/src/settings/custom-providers.ts @@ -67,7 +67,6 @@ export async function modelDirectory( keyless: entry.keyless, label: entry.name, hint: CUSTOM_APIS[CustomApi.assert(row.plugin)], - needsBaseUrl: true, baseURL: row.baseURL, models, }, diff --git a/apps/hub/src/settings/routes.ts b/apps/hub/src/settings/routes.ts index ca1af376..1c07efd1 100644 --- a/apps/hub/src/settings/routes.ts +++ b/apps/hub/src/settings/routes.ts @@ -311,7 +311,6 @@ export function createSettingsRoutes( kind: entry.kind, label: entry.label, hint: entry.hint, - needsBaseUrl: entry.needsBaseUrl, baseURL: entry.baseURL, models: [...entry.models], // A stored row name is the catalog's unique key, not something a person chose. @@ -378,15 +377,14 @@ export function createSettingsRoutes( const input = ProviderTest(await c.req.json().catch(() => null)); if (input instanceof type.errors) throw new ProjectError(400, "invalid_provider_test"); - const typed = input.baseURL?.trim(); - const entry = shippedOfRow({ plugin: input.plugin, baseURL: typed }); + const baseURL = input.baseURL?.trim(); + const entry = shippedOfRow({ plugin: input.plugin, baseURL }); if (!entry && !CustomApi.allows(input.plugin)) throw new ProjectError(400, "unknown_provider"); if (entry?.kind === "login") throw new ProjectError(400, "provider_signs_in"); if (entry && !("secret" in input)) throw new ProjectError(400, "secret_required"); - const baseURL = entry && !entry.needsBaseUrl ? entry.baseURL : typed; if (!baseURL) throw new ProjectError(400, "base_url_required"); return c.json({ status: await probeKey( diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts index 5519c1bc..db60e02b 100644 --- a/apps/web/src/config.ts +++ b/apps/web/src/config.ts @@ -864,7 +864,69 @@ export const wording = { provider: "Provider", baseUrl: "Base URL", key: "API key", - keyless: `For servers that need no API key; ${productName} sends a placeholder.`, + keyless: "Keyless", + keylessAbout: "About Keyless", + keylessHint: `For servers that need no API key; ${productName} sends a placeholder.`, + custom: { + tile: "Custom endpoint", + tileHint: "Your own server, on the API it speaks.", + addTitle: "Add a custom endpoint", + addLead: "Connect your own server. It is saved once the test passes.", + name: "Name", + api: "API type", + apiLead: "Pick the API your server speaks.", + apiPaths: { + "openai-compatible": "/v1/chat/completions", + "openai-responses": "/v1/responses", + anthropic: "/v1/messages", + }, + apiTips: { + "openai-compatible": + "OpenAI's chat format. vLLM, Ollama, LM Studio, llama.cpp and most hosted servers use it.", + "openai-responses": + "OpenAI's newer Responses format. OpenAI and a few servers that copy it use it.", + anthropic: + "Anthropic's Messages format. Anthropic and servers that copy it use it.", + }, + tips: { + name: `What this server is called in ${productName}.`, + api: "The request format your server understands.", + baseUrl: "The address requests go to, usually ending in /v1.", + key: "The key your server asks for. Kept encrypted and never shown again.", + modelId: "The exact model name your server expects.", + modelName: "How the model is shown. Empty shows its model ID.", + contextWindow: "How many tokens the model can read at once.", + maxOutputTokens: + "The most tokens it writes in one reply. Below the context window.", + }, + about: (label: string) => `About ${label}`, + models: "Models", + modelCount: (count: number) => + count === 1 ? "1 model" : `${count} models`, + duplicateIds: "Each model needs its own model ID.", + modelId: "Model ID", + modelName: "Name", + optional: "Optional", + contextWindow: "Context window", + maxOutputTokens: "Output cap", + addModel: "+ Add model", + removeModel: (id: string) => `Remove ${id}`, + keyAgain: "A new API type or base URL needs its key again, or Keyless.", + editTitle: (label: string) => `Edit ${label}`, + editLead: + "Change its name or connection. A new API type, base URL or key is tested before it is saved.", + options: (label: string) => `Options for ${label}`, + edit: "Edit", + remove: "Remove…", + confirmRemove: "Yes, remove it", + back: "Back", + save: "Save", + saving: "Saving…", + savedState: "Saved", + failedState: "Not saved", + saved: (label: string) => `${label} saved`, + removed: (label: string) => `${label} removed`, + }, test: "Test connection", testing: "Testing…", saved: (label: string) => `${label} connected`, diff --git a/apps/web/src/pages/custom-providers.tsx b/apps/web/src/pages/custom-providers.tsx new file mode 100644 index 00000000..14721ee1 --- /dev/null +++ b/apps/web/src/pages/custom-providers.tsx @@ -0,0 +1,1010 @@ +/** + * A person's own endpoints, in the row language of Settings: adding and editing one's connection + * in a dialog of rows, and its models under its row in Providers. + * + * The connection is a name, the API it speaks, a base URL, and a key or Keyless. The hub keeps the + * key on the base URL it was entered for, so a new API type, base URL or key is a new connection + * and is tested before it is saved, like any other key; a keyless server is tested with no key at + * all. The hub needs one model to add a provider, so the add dialog asks for the first; the rest + * are managed under the row. The dialog shell and card picker here are the add dialog's too. + */ +import { wording } from "../config.ts"; +import { Button as UiButton, Switch } from "@corbits/react-ui"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@corbits/react-ui/ui/dialog"; +import { + Menu, + MenuContent, + MenuItem, + MenuSeparator, + MenuTrigger, +} from "@corbits/react-ui/ui/menu"; +import { InfoTooltip } from "@corbits/react-ui/ui/tooltip"; +import * as Tooltip from "@radix-ui/react-tooltip"; +import { useQueryClient } from "@tanstack/react-query"; +import { ChevronDown, Ellipsis, X } from "lucide-react"; +import { useRef, useState, type KeyboardEvent, type ReactNode } from "react"; +import { toast } from "sonner"; +import type { + CustomApi, + CustomModel, + CustomProviderCreate, + ModelCatalog, + ModelProvider, +} from "@corbits-builder/schemas/models"; +import { Banner, Button, QuietState } from "../components.jsx"; +import { keys } from "../queries/keys.ts"; +import { productClient } from "../api/transport.ts"; +import { workspaceTenantId } from "../api/auth.ts"; +import { ProviderLogo } from "./provider-logos.tsx"; + +const copy = wording.providers; +const tips = copy.custom.tips; + +type ModelDraft = { + id: string; + label: string; + contextWindow: string; + maxOutputTokens: string; +}; + +type CustomDraft = { + name: string; + api: CustomApi; + baseURL: string; + secret: string; + keyless: boolean; + model: ModelDraft; +}; + +type Apis = ModelCatalog["customApis"]; + +type SaveState = "saving" | "saved" | "failed"; + +// A model listed under its provider; a fixed one is saved, so its id is no longer edited. +type ListedModel = ModelDraft & { fixed: boolean }; + +const BLANK_MODEL: ModelDraft = { + id: "", + label: "", + contextWindow: "", + maxOutputTokens: "", +}; + +const BLANK_DRAFT: CustomDraft = { + name: "", + api: "openai-compatible", + baseURL: "", + secret: "", + keyless: false, + model: BLANK_MODEL, +}; + +// The fields of a model after its id, each a column under the list's heads. +const MODEL_FIELDS = [ + { key: "label", label: copy.custom.modelName, tip: tips.modelName }, + { + key: "contextWindow", + label: copy.custom.contextWindow, + tip: tips.contextWindow, + }, + { + key: "maxOutputTokens", + label: copy.custom.maxOutputTokens, + tip: tips.maxOutputTokens, + }, +] as const satisfies readonly { + key: keyof ModelDraft; + label: string; + tip: string; +}[]; + +const SUBMIT_LABELS = { + test: { idle: copy.test, busy: copy.testing }, + save: { idle: copy.custom.save, busy: copy.custom.saving }, +} as const; + +const SAVE_STATES = { + saving: { tone: "loading", label: copy.custom.saving }, + saved: { tone: "success", label: copy.custom.savedState }, + failed: { tone: "error", label: copy.custom.failedState }, +} as const satisfies Record; + +const STEP: Partial> = { + ArrowRight: 1, + ArrowDown: 1, + ArrowLeft: -1, + ArrowUp: -1, +}; + +export function message(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause); +} + +/** Title, lead, the fields, the last error, and Cancel or Back beside the dialog's action. */ +export function ProviderDialog(props: { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description: string; + error: string | null; + busy: boolean; + ready: boolean; + /** Whether submitting tests a connection first. */ + tests: boolean; + onSubmit: () => void; + /** Shown in place of Cancel when the dialog was reached from another view of itself. */ + onBack?: (() => void) | undefined; + /** Fields laid out as Settings rows rather than stacked. */ + rows?: boolean; + /** Opens with focus on the dialog itself, so no field opens with its text selected. */ + focusDialog?: boolean; + children: ReactNode; +}) { + const { busy, error, onBack, onOpenChange } = props; + return ( + + { + event.preventDefault(); + (event.currentTarget as HTMLElement).focus(); + }, + } + : {})} + > + + {props.title} + {props.description} + +
{ + event.preventDefault(); + props.onSubmit(); + }} + > + + {props.children} + {error ? : null} + + + + + +
+
+
+ ); +} + +/** + * One card per choice, as a radio group: arrow keys move the choice, Tab leaves the group. A + * choice with a `tip` explains itself on hover or focus. + */ +export function ProviderPicker({ + providers, + chosenId, + disabled, + labelledBy, + className = "provider-cards", + onChoose, +}: { + providers: readonly (Pick & { + tip?: string; + })[]; + chosenId: string | undefined; + disabled: boolean; + labelledBy: string; + className?: string; + onChoose: (id: string) => void; +}) { + const cards = useRef<(HTMLButtonElement | null)[]>([]); + const move = (event: KeyboardEvent, index: number) => { + const step = STEP[event.key]; + let target: number | undefined; + if (event.key === "Home") target = 0; + else if (event.key === "End") target = providers.length - 1; + else if (step !== undefined) + target = (index + step + providers.length) % providers.length; + const provider = target === undefined ? undefined : providers[target]; + if (target === undefined || !provider) return; + event.preventDefault(); + onChoose(provider.id); + cards.current[target]?.focus(); + }; + return ( +
+ {providers.map((provider, index) => { + const checked = provider.id === chosenId; + const card = ( + + ); + return provider.tip ? ( + + {card} + + ) : ( + card + ); + })} +
+ ); +} + +/** The library's info-tooltip surface around any trigger; the library only offers it on its glyph. */ +function Tip({ label, children }: { label: string; children: ReactNode }) { + return ( + + + {children} + + + {label} + + + + + + ); +} + +/** A Settings row: its label with a tooltip, an optional hint, and its control on the right. */ +function FieldRow({ + label, + tip, + htmlFor, + labelId, + hint, + children, +}: { + label: string; + tip: string; + htmlFor?: string; + /** Names a group the row heads, when it labels no single control. */ + labelId?: string; + hint?: string | null; + children?: ReactNode; +}) { + return ( +
+
+ + {htmlFor ? ( + + ) : ( + + )} + + + {hint ? {hint} : null} +
+ {children} +
+ ); +} + +function keylessOf(provider: ModelProvider): boolean { + return provider.connections.some((connection) => connection.keyless); +} + +function draftOf(provider: ModelProvider): CustomDraft { + return { + name: provider.label, + api: provider.plugin as CustomApi, + baseURL: provider.baseURL, + secret: "", + keyless: keylessOf(provider), + model: BLANK_MODEL, + }; +} + +function modelDraftOf(model: ModelProvider["models"][number]): ModelDraft { + const label = model.label; + return { + id: model.id, + // An unnamed custom model drafts with an empty label; it shows by its id. + label: label !== undefined ? label : "", + contextWindow: "contextWindow" in model ? String(model.contextWindow) : "", + maxOutputTokens: + "maxOutputTokens" in model ? String(model.maxOutputTokens) : "", + }; +} + +function tokens(value: string): number | null { + const count = Number(value); + return Number.isInteger(count) && count > 0 ? count : null; +} + +function duplicateIds(models: readonly ModelDraft[]): boolean { + const ids = models.map((model) => model.id.trim()).filter(Boolean); + return new Set(ids).size !== ids.length; +} + +/** The models drafted, once every id is set and distinct and each output cap is below its window. */ +function parsedModels(models: readonly ModelDraft[]): CustomModel[] | null { + if (models.length === 0 || duplicateIds(models)) return null; + const parsed: CustomModel[] = []; + for (const model of models) { + const id = model.id.trim(); + const label = model.label.trim(); + const contextWindow = tokens(model.contextWindow); + const maxOutputTokens = tokens(model.maxOutputTokens); + if (!id || !contextWindow || !maxOutputTokens) return null; + if (maxOutputTokens >= contextWindow) return null; + parsed.push({ + id, + ...(label ? { label } : {}), + contextWindow, + maxOutputTokens, + }); + } + return parsed; +} + +/** Whether saving makes a new connection: always when adding, else on a new API, URL or key. */ +function reconnects(draft: CustomDraft, original?: ModelProvider): boolean { + return ( + !original || + draft.api !== original.plugin || + draft.baseURL.trim() !== original.baseURL || + draft.keyless !== keylessOf(original) || + draft.secret.trim().length > 0 + ); +} + +function endpointOf(draft: CustomDraft): CustomProviderCreate["connection"] { + const endpoint = { api: draft.api, baseURL: draft.baseURL.trim() }; + return draft.keyless + ? { ...endpoint, keyless: true } + : { ...endpoint, secret: draft.secret.trim() }; +} + +async function testConnection(draft: CustomDraft): Promise { + const { api, ...connection } = endpointOf(draft); + const { status } = await productClient().testProvider( + await workspaceTenantId(), + { plugin: api, ...connection }, + ); + if (status !== "connected") throw new Error(copy.failed[status]); +} + +/** + * One custom provider's connection form: its rows, whether it can be submitted, whether + * submitting tests a new connection first, and `save`, which throws a failed test's reason. + */ +export function useCustomProviderForm(apis: Apis, original?: ModelProvider) { + const [draft, setDraft] = useState(() => + original ? draftOf(original) : BLANK_DRAFT, + ); + const tests = reconnects(draft, original); + const keyed = draft.keyless || draft.secret.trim().length > 0; + return { + name: draft.name.trim(), + tests, + ready: + draft.name.trim().length > 0 && + draft.baseURL.trim().length > 0 && + (!tests || keyed) && + (original !== undefined || parsedModels([draft.model]) !== null), + reset: () => setDraft(BLANK_DRAFT), + rows: (disabled: boolean) => ( + <> + + {original ? null : ( + setDraft({ ...draft, model })} + /> + )} + + ), + save: async (): Promise => { + if (tests) await testConnection(draft); + const tenantId = await workspaceTenantId(); + const client = productClient(); + const name = draft.name.trim(); + if (!original) { + await client.createCustomProvider(tenantId, { + name, + models: parsedModels([draft.model])!, + connection: endpointOf(draft), + }); + return; + } + await client.updateCustomProvider(tenantId, original.id, { + name, + models: parsedModels(original.models.map(modelDraftOf))!, + ...(tests ? { connection: endpointOf(draft) } : {}), + }); + }, + }; +} + +function ConnectionRows({ + draft, + apis, + disabled, + original, + onChange, +}: { + draft: CustomDraft; + apis: Apis; + disabled: boolean; + original: ModelProvider | undefined; + onChange: (draft: CustomDraft) => void; +}) { + const keyAgain = + original !== undefined && + (draft.api !== original.plugin || + draft.baseURL.trim() !== original.baseURL); + return ( + <> + + onChange({ ...draft, name: event.target.value })} + /> + + + ({ + ...api, + hint: copy.custom.apiPaths[api.id].replaceAll("/", "/\u200b"), + tip: copy.custom.apiTips[api.id], + }))} + chosenId={draft.api} + disabled={disabled} + labelledBy="custom-api-label" + className="api-options" + onChoose={(api) => onChange({ ...draft, api: api as CustomApi })} + /> + + + onChange({ ...draft, baseURL: event.target.value }) + } + /> + + {draft.keyless ? null : ( + + + onChange({ ...draft, secret: event.target.value }) + } + /> + + )} + + + onChange({ ...draft, keyless, secret: "" }) + } + /> + + + ); +} + +/** The column heads of a model list, each with its tooltip, and a save state when there is one. */ +function ModelHeader({ + duplicate, + removable = false, + children, +}: { + duplicate: boolean; + /** Whether the rows under it end in a remove button, which the heads leave room for. */ + removable?: boolean; + children?: ReactNode; +}) { + return ( +
+
+ + {copy.custom.modelId} + + + {duplicate ? ( + {copy.custom.duplicateIds} + ) : ( + children + )} +
+ + {MODEL_FIELDS.map((field) => ( + + {field.label} + + + ))} + {removable ? ( + +
+ ); +} + +/** One model as a row of quiet fields; its id is a field until it is saved. */ +function ModelRow({ + model, + editableId, + disabled, + onChange, + onCommit, + children, +}: { + model: ModelDraft; + editableId: boolean; + disabled: boolean; + onChange: (model: ModelDraft) => void; + onCommit?: () => void; + children?: ReactNode; +}) { + const blur = onCommit ? { onBlur: onCommit } : {}; + return ( +
+
+ {editableId ? ( + onChange({ ...model, id: event.target.value })} + {...blur} + /> + ) : ( + {model.id} + )} +
+ + {MODEL_FIELDS.map((field) => ( + + onChange({ ...model, [field.key]: event.target.value }) + } + {...blur} + /> + ))} + {children} + +
+ ); +} + +/** The one model a provider is added with, laid out as the list under its row will show it. */ +function FirstModelRows({ + model, + disabled, + onChange, +}: { + model: ModelDraft; + disabled: boolean; + onChange: (model: ModelDraft) => void; +}) { + return ( + <> + + + + ); +} + +/** + * A custom provider's row in Providers: its options menu, and its models listed under it on + * request, each edited in place and saved once it is complete. + */ +export function CustomProviderRow({ + provider, + apis, + onChanged, + onError, +}: { + provider: ModelProvider; + apis: Apis; + onChanged: () => void; + onError: (message: string | null) => void; +}) { + const [open, setOpen] = useState(false); + const listId = `custom-models-${provider.id}`; + return ( + <> +
+
+ {provider.label} + {provider.hint} +
+ + + + +
+ {open ? ( +
+ +
+ ) : null} + + ); +} + +function CustomProviderModels({ + provider, + onChanged, +}: { + provider: ModelProvider; + onChanged: () => void; +}) { + const queryClient = useQueryClient(); + const [models, setModels] = useState(() => + provider.models.map((model) => ({ ...modelDraftOf(model), fixed: true })), + ); + const [saveState, setSaveState] = useState(null); + const [error, setError] = useState(null); + const saved = useRef(JSON.stringify(parsedModels(models))); + + // Saves once the list is complete and differs from what the hub holds; a half-typed model waits. + const commit = async (next: readonly ListedModel[]) => { + const parsed = parsedModels(next); + const snapshot = JSON.stringify(parsed); + if (!parsed || snapshot === saved.current) return; + setSaveState("saving"); + setError(null); + try { + await productClient().updateCustomProvider( + await workspaceTenantId(), + provider.id, + { name: provider.label, models: parsed }, + ); + saved.current = snapshot; + setModels((current) => + current.map((model) => ({ + ...model, + fixed: model.fixed || parsed.some(({ id }) => id === model.id.trim()), + })), + ); + setSaveState("saved"); + await queryClient.invalidateQueries({ queryKey: keys.modelCatalog }); + onChanged(); + } catch (cause) { + setSaveState("failed"); + setError(message(cause)); + } + }; + + return ( + <> + + {saveState ? ( + + {SAVE_STATES[saveState].label} + + ) : null} + + {models.map((model, index) => ( + + setModels( + models.map((entry, at) => + at === index ? { ...next, fixed: model.fixed } : entry, + ), + ) + } + onCommit={() => void commit(models)} + > + { + const rest = models.filter((_, at) => at !== index); + setModels(rest); + void commit(rest); + }} + > + + + ))} +
+ +
+ {error ? : null} + + ); +} + +/** One custom provider's options menu: edit its connection, or remove it. */ +function CustomProviderActions({ + provider, + apis, + onChanged, + onError, +}: { + provider: ModelProvider; + apis: Apis; + onChanged: () => void; + onError: (message: string | null) => void; +}) { + const queryClient = useQueryClient(); + const [editing, setEditing] = useState(false); + const [removing, setRemoving] = useState(false); + // Removing takes two clicks, both in the menu, as deleting a project does. + const [confirming, setConfirming] = useState(false); + + const remove = async () => { + setRemoving(true); + onError(null); + try { + await productClient().removeCustomProvider( + await workspaceTenantId(), + provider.id, + ); + toast.success(copy.custom.removed(provider.label)); + await queryClient.invalidateQueries({ queryKey: keys.modelCatalog }); + onChanged(); + } catch (cause) { + onError(message(cause)); + } finally { + setRemoving(false); + } + }; + + return ( + <> + { + if (!open) setConfirming(false); + }} + > + + + + + + setEditing(true)}> + {copy.custom.edit} + + + {confirming ? ( + void remove()} + > + {copy.custom.confirmRemove} + + ) : ( + { + event.preventDefault(); + setConfirming(true); + }} + > + {copy.custom.remove} + + )} + + + {editing ? ( + setEditing(false)} + onSaved={onChanged} + /> + ) : null} + + ); +} + +function EditCustomProviderDialog({ + provider, + apis, + onClose, + onSaved, +}: { + provider: ModelProvider; + apis: Apis; + onClose: () => void; + onSaved: () => void; +}) { + const queryClient = useQueryClient(); + const form = useCustomProviderForm(apis, provider); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const save = async () => { + if (!form.ready || busy) return; + setBusy(true); + setError(null); + try { + await form.save(); + toast.success(copy.custom.saved(form.name)); + await queryClient.invalidateQueries({ queryKey: keys.modelCatalog }); + onSaved(); + onClose(); + } catch (cause) { + setError(message(cause)); + } finally { + setBusy(false); + } + }; + + return ( + (open || busy ? undefined : onClose())} + title={copy.custom.editTitle(provider.label)} + description={copy.custom.editLead} + error={error} + busy={busy} + ready={form.ready} + tests={form.tests} + onSubmit={() => void save()} + rows + focusDialog + > + {form.rows(busy)} + + ); +} diff --git a/apps/web/src/pages/profiles.tsx b/apps/web/src/pages/profiles.tsx index 50e6e41d..6b745221 100644 --- a/apps/web/src/pages/profiles.tsx +++ b/apps/web/src/pages/profiles.tsx @@ -57,8 +57,12 @@ export function connected(catalog: ModelCatalog, provider: string): boolean { return ( catalog.providers .find((entry) => entry.id === provider) - ?.connections.some((connection) => connection.status === "connected") ?? - false + ?.connections.some( + (connection) => + connection.status === "connected" || + // A custom endpoint is never probed on read, so its key being set is what is known. + (connection.status === undefined && connection.credentialSet), + ) ?? false ); } @@ -434,7 +438,7 @@ function ProfileEditor({ {provider?.models.map((model) => ( ))} diff --git a/apps/web/src/pages/provider-logos.tsx b/apps/web/src/pages/provider-logos.tsx index 372dc5a9..7ef827af 100644 --- a/apps/web/src/pages/provider-logos.tsx +++ b/apps/web/src/pages/provider-logos.tsx @@ -13,6 +13,9 @@ const XAI = const MARKS: Readonly> = { openai: OPENAI, codex: OPENAI, + // A custom endpoint's API type, shown with the mark of the API it speaks. + "openai-compatible": OPENAI, + "openai-responses": OPENAI, anthropic: "M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z", google: diff --git a/apps/web/src/pages/providers.tsx b/apps/web/src/pages/providers.tsx index f6cf3632..d0f7af61 100644 --- a/apps/web/src/pages/providers.tsx +++ b/apps/web/src/pages/providers.tsx @@ -4,24 +4,16 @@ * Which providers exist, what they are called and what they need comes from the hub's * config through `/settings/models`. A subscription provider is connected by logging in: the hub * runs the sign-in, this page opens its authorize URL and waits for the hub to finish. Adding an - * API-key provider is: pick it, paste its key (and base URL), test it with one real model-list - * call, and only a key that passes is saved. Keys live in form state until they are handed over, - * and nothing here renders a key or token back. + * API-key provider is: pick it, paste its key, test it with one real model-list call, and only a + * key that passes is saved. A custom endpoint is added from the same dialog and + * sits in the same list (custom-providers.tsx). Keys live in form state until they are handed + * over, and nothing here renders a key or token back. */ import { wording } from "../config.ts"; import { EmptyState, Input, Section } from "@corbits/react-ui"; -import { - Dialog, - DialogBody, - DialogContent, - DialogDescription, - DialogFieldLabel, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@corbits/react-ui/ui/dialog"; +import { DialogFieldLabel } from "@corbits/react-ui/ui/dialog"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useRef, useState, type KeyboardEvent } from "react"; +import { useState } from "react"; import { toast } from "sonner"; import type { ModelProvider, @@ -33,10 +25,19 @@ import { keys } from "../queries/keys.ts"; import { productClient } from "../api/transport.ts"; import { workspaceTenantId } from "../api/auth.ts"; import { setCredential } from "../api/credentials.ts"; -import { ProviderLogo } from "./provider-logos.tsx"; +import { + CustomProviderRow, + ProviderDialog, + ProviderPicker, + message, + useCustomProviderForm, +} from "./custom-providers.tsx"; const copy = wording.providers; +// The picker's tile for a person's own endpoint; no catalog provider has this id. +const CUSTOM_ENDPOINT = "custom-endpoint"; + const POLL_MS = 1000; // The hub's login holds the provider's loopback port for five minutes. const LOGIN_DEADLINE_MS = 5 * 60 * 1000; @@ -48,10 +49,6 @@ const STATUS_TONE = { unconfigured: "warning", } as const satisfies Record; -function message(cause: unknown): string { - return cause instanceof Error ? cause.message : String(cause); -} - export function useModelCatalog() { return useQuery({ queryKey: keys.modelCatalog, @@ -157,10 +154,12 @@ export function SubscriptionLogins({ export function ProvidersSection({ onChanged }: { onChanged: () => void }) { const catalog = useModelCatalog(); const [adding, setAdding] = useState(false); + const [error, setError] = useState(null); const providers = catalog.data?.providers ?? []; + const customApis = catalog.data?.customApis; const hasLogins = providers.some((provider) => provider.kind === "login"); const connected = providers - .filter((provider) => provider.kind === "api_key") + .filter((provider) => provider.kind !== "login") .flatMap((provider) => provider.connections.map((connection) => ({ provider, connection })), ); @@ -178,22 +177,33 @@ export function ProvidersSection({ onChanged }: { onChanged: () => void }) { } /> ) : null} - {connected.map(({ provider, connection }) => ( -
-
- {connection.name} - {connection.name === provider.label ? null : ( - {provider.label} - )} - {connection.note ? {connection.note} : null} + {connected.map(({ provider, connection }) => + provider.kind === "custom" && customApis ? ( + + ) : provider.kind === "custom" ? null : ( +
+
+ {connection.name} + {connection.name === provider.label ? null : ( + {provider.label} + )} + {connection.note ? {connection.note} : null} +
+ {connection.status ? ( + + {copy.status[connection.status]} + + ) : null}
- {connection.status ? ( - - {copy.status[connection.status]} - - ) : null} -
- ))} + ), + )} + {error ? : null} ); const addAction = @@ -268,39 +278,66 @@ export function AddProviderDialog({ const providers = (catalog.data?.providers ?? []).filter( (provider) => provider.kind === "api_key", ); + const customApis = catalog.data?.customApis; + const apis = customApis !== undefined ? customApis : []; const [chosenId, setChosenId] = useState(null); - const [baseUrl, setBaseUrl] = useState(""); const [secret, setSecret] = useState(""); + const customForm = useCustomProviderForm(apis); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const custom = providerId === undefined && chosenId === CUSTOM_ENDPOINT; const chosen: ModelProvider | undefined = providers.find((provider) => provider.id === (providerId ?? chosenId)) ?? providers[0]; - const ready = - chosen !== undefined && - secret.trim().length > 0 && - (!chosen.needsBaseUrl || baseUrl.trim().length > 0); + const keyReady = chosen !== undefined && secret.trim().length > 0; + const ready = custom ? customForm.ready : keyReady; + const picker = [ + ...providers, + ...(providerId === undefined && apis.length > 0 + ? [ + { + id: CUSTOM_ENDPOINT, + label: copy.custom.tile, + hint: copy.custom.tileHint, + }, + ] + : []), + ]; const close = (next: boolean) => { if (busy) return; if (!next) { setSecret(""); - setBaseUrl(""); + customForm.reset(); setError(null); } onOpenChange(next); }; + const saved = async (label: string) => { + setSecret(""); + customForm.reset(); + toast.success(copy.saved(label)); + await queryClient.invalidateQueries({ queryKey: keys.modelCatalog }); + onConnected(); + onOpenChange(false); + }; + const testAndSave = async () => { if (!ready || busy) return; setBusy(true); setError(null); try { + if (custom) { + await customForm.save(); + await saved(customForm.name); + return; + } + if (!keyReady) return; const tenantId = await workspaceTenantId(); - const endpoint = chosen.needsBaseUrl ? baseUrl.trim() : chosen.baseURL; const { status } = await productClient().testProvider(tenantId, { plugin: chosen.plugin, - baseURL: endpoint, + baseURL: chosen.baseURL, secret: secret.trim(), }); if (status !== "connected") { @@ -310,15 +347,10 @@ export function AddProviderDialog({ await setCredential({ plugin: chosen.plugin, secret: secret.trim(), - baseURL: endpoint, + baseURL: chosen.baseURL, label: chosen.label, }); - setSecret(""); - setBaseUrl(""); - toast.success(copy.saved(chosen.label)); - await queryClient.invalidateQueries({ queryKey: keys.modelCatalog }); - onConnected(); - onOpenChange(false); + await saved(chosen.label); } catch (cause) { setError(message(cause)); } finally { @@ -326,170 +358,73 @@ export function AddProviderDialog({ } }; - return ( - - - - - {providerId && chosen - ? copy.connectOne(chosen.label) - : copy.dialogTitle} - - - {providerId ? copy.keyLead : copy.dialogLead} - - -
{ - event.preventDefault(); - void testAndSave(); - }} - > - - {catalog.error ? ( - - ) : null} - {providerId ? null : ( -
- - {copy.provider} - - { - setChosenId(id); - setError(null); - }} - /> -
- )} - {chosen?.needsBaseUrl ? ( -
- - {copy.baseUrl} - - setBaseUrl(event.target.value)} - /> -
- ) : null} -
- - {copy.key} - - setSecret(event.target.value)} - /> -
- {error ? : null} -
- - - - -
-
-
- ); -} + let dialogTitle: string; + if (custom) dialogTitle = copy.custom.addTitle; + else if (providerId && chosen) dialogTitle = copy.connectOne(chosen.label); + else dialogTitle = copy.dialogTitle; -const STEP: Partial> = { - ArrowRight: 1, - ArrowDown: 1, - ArrowLeft: -1, - ArrowUp: -1, -}; + let dialogDescription: string; + if (custom) dialogDescription = copy.custom.addLead; + else if (providerId) dialogDescription = copy.keyLead; + else dialogDescription = copy.dialogLead; -/** One card per provider, as a radio group: arrow keys move the choice, Tab leaves the group. */ -function ProviderPicker({ - providers, - chosenId, - disabled, - onChoose, -}: { - providers: readonly ModelProvider[]; - chosenId: string | undefined; - disabled: boolean; - onChoose: (id: string) => void; -}) { - const cards = useRef<(HTMLButtonElement | null)[]>([]); - const move = (event: KeyboardEvent, index: number) => { - const step = STEP[event.key]; - const target = - event.key === "Home" - ? 0 - : event.key === "End" - ? providers.length - 1 - : step === undefined - ? undefined - : (index + step + providers.length) % providers.length; - const provider = target === undefined ? undefined : providers[target]; - if (target === undefined || !provider) return; - event.preventDefault(); - onChoose(provider.id); - cards.current[target]?.focus(); - }; return ( -
void testAndSave()} + onBack={ + custom + ? () => { + setChosenId(null); + setError(null); + } + : undefined + } + rows={custom} > - {providers.map((provider, index) => { - const checked = provider.id === chosenId; - return ( - - ); - })} -
+ /> +
+ )} + {custom ? ( + customForm.rows(busy) + ) : ( +
+ {copy.key} + setSecret(event.target.value)} + /> +
+ )} + ); } diff --git a/apps/web/src/pages/settings-layout.css b/apps/web/src/pages/settings-layout.css index 63bac83e..44f86153 100644 --- a/apps/web/src/pages/settings-layout.css +++ b/apps/web/src/pages/settings-layout.css @@ -73,7 +73,8 @@ gap: var(--space-1); } -.settings-page .row { +.settings-page .row, +.settings-rows .row { display: flex; align-items: center; justify-content: space-between; @@ -82,14 +83,16 @@ font-size: 14px; } -.settings-page .row .k { +.settings-page .row .k, +.settings-rows .row .k { display: flex; flex-direction: column; gap: 1px; min-width: 0; } -.settings-page .row .k b { +.settings-page .row .k b, +.settings-rows .row .k b { display: inline-flex; align-items: center; gap: var(--space-2); @@ -97,13 +100,15 @@ font-size: 13.5px; } -.settings-page .row .k span { +.settings-page .row .k span, +.settings-rows .row .k span { font-size: 12px; color: var(--muted-foreground); font-family: var(--font-mono); } -.settings-page .row .v { +.settings-page .row .v, +.settings-rows .row .v { font-family: var(--font-mono); font-size: 12px; color: var(--muted-foreground); @@ -116,7 +121,8 @@ max-width: min(420px, 100%); } -.settings-page input.field { +.settings-page input.field, +.settings-rows input.field { display: block; font-family: var(--font-mono); font-size: 12.5px; @@ -130,7 +136,8 @@ outline: none; } -.settings-page input.field:focus { +.settings-page input.field:focus, +.settings-rows input.field:focus { border-color: var(--primary); } @@ -160,11 +167,13 @@ padding: var(--space-4) 0 var(--space-12); } - .settings-page .row { + .settings-page .row, + .settings-rows .row { flex-wrap: wrap; } - .settings-page .row .v { + .settings-page .row .v, + .settings-rows .row .v { flex-shrink: 1; justify-content: flex-start; } @@ -174,7 +183,8 @@ } } -.settings-page .quiet-state { +.settings-page .quiet-state, +.settings-rows .quiet-state { display: inline-flex; align-items: center; gap: var(--space-2); @@ -206,6 +216,76 @@ } } +/* Settings rows in a dialog: the body's own gap would double the rows' spacing, and rows sit a + little closer so a whole form fits without scrolling. */ +.provider-form.settings-rows { + gap: 0; +} + +.provider-form.settings-rows .row { + padding: var(--space-2) 0; +} + +/* A custom endpoint's API types: three compact choices on one line, the path each one calls. */ +.api-options { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-2); + padding-bottom: var(--space-1); +} + +.api-options .provider-hint { + font-family: var(--font-mono); + font-size: 12px; +} + +/* A custom provider's models, listed under its row. */ +.custom-models { + padding-left: var(--space-4); +} + +.settings-page input.field.token-field, +.settings-rows input.field.token-field, +.token-field { + width: 104px; +} + +.token-head { + display: inline-flex; + align-items: center; + gap: var(--space-1); + font-family: var(--font-sans); +} + +.token-remove { + width: 2.25rem; + flex: none; +} + +.models-toggle { + display: inline-flex; + align-items: center; + gap: var(--space-1); +} + +@media (max-width: 560px) { + .settings-page input.field.token-field, + .settings-rows input.field.token-field, + .token-field { + width: 88px; + } + + .custom-models { + padding-left: 0; + } +} + +@media (max-width: 520px) { + .api-options { + grid-template-columns: minmax(0, 1fr); + } +} + .settings-page .about-subhead { margin: var(--space-2) 0 0; font-size: 13.5px; diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 31f1b9af..67ad9338 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1394,6 +1394,13 @@ select.quiet-control { padding: 4px; } +/* The form sits between the dialog's header and its edges, so the body scrolls and the footer stays. */ +.provider-dialog-form { + display: flex; + flex-direction: column; + min-height: 0; +} + .provider-field { display: flex; flex-direction: column; diff --git a/packages/schemas/src/models.ts b/packages/schemas/src/models.ts index 3568bd1a..f2e03446 100644 --- a/packages/schemas/src/models.ts +++ b/packages/schemas/src/models.ts @@ -95,7 +95,6 @@ export const ModelProvider = type({ kind: "'api_key' | 'login' | 'custom'", label: name, hint: name, - needsBaseUrl: "boolean", baseURL: "string", models: type({ "+": "reject", id: name, label: name }) .or(CustomModel)