ui : add backend presets, CRUD and connection test

Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
Aleksander Grygier
2026-09-16 19:22:47 +02:00
parent 0ce4a9cef9
commit 8d6bc71ebd
10 changed files with 283 additions and 29 deletions
@@ -1,10 +1,83 @@
import type { BackendProtocol } from '$lib/types';
import type { BackendPreset, BackendProtocol } from '$lib/types';
/** Id of the built-in backend that points at the server serving this UI. */
export const LOCAL_BACKEND_ID = 'local';
/** Version sent with the Anthropic Messages API. */
export const ANTHROPIC_API_VERSION = '2023-06-01';
/** Prefix for generated ids of user-added backends. */
export const BACKEND_ID_PREFIX = 'backend';
/** Protocols a configured backend can speak, in display order. */
export const BACKEND_PROTOCOLS: readonly BackendProtocol[] = ['llama.cpp', 'openai', 'anthropic'];
/** Prefix for generated ids of user-added backends. */
export const BACKEND_ID_PREFIX = 'backend';
/** Chat completions path used when a backend does not override it. */
export const DEFAULT_BACKEND_CHAT_PATH = '/v1/chat/completions';
/** Models listing path used when a backend does not override it. */
export const DEFAULT_BACKEND_MODELS_PATH = '/v1/models';
/** Id of the built-in backend that points at the server serving this UI. */
export const LOCAL_BACKEND_ID = 'local';
/**
* Ready-made endpoints offered when adding a backend. `custom` intentionally
* carries no URL so the user starts from an empty form.
*/
export const BACKEND_PRESETS: readonly BackendPreset[] = [
{
baseUrl: 'https://openrouter.ai/api',
id: 'openrouter',
name: 'OpenRouter',
protocol: 'openai'
},
{
baseUrl: 'https://api.together.xyz',
id: 'together',
name: 'Together',
protocol: 'openai'
},
{
baseUrl: 'https://router.huggingface.co',
id: 'huggingface',
name: 'Hugging Face',
protocol: 'openai'
},
{
baseUrl: 'https://api.deepseek.com',
id: 'deepseek',
name: 'DeepSeek',
protocol: 'openai'
},
{
baseUrl: 'https://api.moonshot.ai',
id: 'kimi',
name: 'Kimi',
protocol: 'openai'
},
{
baseUrl: 'https://api.openai.com',
id: 'openai',
name: 'OpenAI',
protocol: 'openai'
},
{
baseUrl: 'https://api.anthropic.com',
chatPath: '/v1/messages',
id: 'anthropic',
name: 'Anthropic',
protocol: 'anthropic'
},
{
baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
chatPath: '/chat/completions',
id: 'google',
modelsPath: '/models',
name: 'Google',
protocol: 'openai'
},
{
baseUrl: '',
id: 'custom',
name: 'Custom',
protocol: 'openai'
}
];
@@ -3,6 +3,10 @@ const MCP_SESSION_ID_VISIBLE_CHARS = 5;
/** HTTP header handling for API and MCP requests. */
export const HEADERS = {
/** Anthropic-compatible backends authenticate with this header instead of Authorization */
ANTHROPIC_API_KEY: 'x-api-key',
/** Required version header for the Anthropic Messages API */
ANTHROPIC_VERSION: 'anthropic-version',
/** Canonical casing for the Authorization header (RFC 7235) */
AUTHORIZATION: 'Authorization',
/** Bearer scheme prefix used for Authorization headers (RFC 6750) */
@@ -0,0 +1,75 @@
/**
* BackendsService - Stateless backend connectivity checks
*
* Probes a backend's models endpoint to validate its URL and credentials.
* No reactive state; consumed by the backends settings UI.
*/
import type { Backend } from '$lib/types';
import { isAbortError } from '$lib/utils/abort';
import { getAuthHeadersForBackend } from '$lib/utils/api-headers';
import { backendModelsUrl } from '$lib/utils/backend';
/** Outcome of a backend connectivity check. */
export interface BackendTestResult {
error?: string;
modelCount?: number;
ok: boolean;
status: number | null;
}
export class BackendsService {
/**
* Check that a backend answers on its models endpoint.
*
* @param backend - Backend to probe. Does not need to be registered yet.
* @param signal - Optional abort signal for a cancelled test.
*/
static async test(backend: Backend, signal?: AbortSignal): Promise<BackendTestResult> {
if (!backend.baseUrl.trim()) {
return { error: 'Backend URL is required', ok: false, status: null };
}
try {
const response = await fetch(backendModelsUrl(backend), {
headers: getAuthHeadersForBackend(backend),
signal
});
if (!response.ok) {
return { error: await describeFailure(response), ok: false, status: response.status };
}
const body = (await response.json()) as { data?: unknown };
const modelCount = Array.isArray(body?.data) ? body.data.length : 0;
return { modelCount, ok: true, status: response.status };
} catch (error) {
if (isAbortError(error)) {
return { ok: false, status: null };
}
return {
error: error instanceof Error ? error.message : String(error),
ok: false,
status: null
};
}
}
}
/** Build a human-readable message from a non-OK response. */
async function describeFailure(response: Response): Promise<string> {
const status = `${response.status} ${response.statusText}`.trim();
try {
const body = (await response.json()) as { error?: { message?: string }; message?: string };
const message = body?.error?.message ?? body?.message;
if (message) return `${status}: ${message}`;
} catch {
// non-JSON error body, fall back to the status line
}
return status;
}
+8
View File
@@ -19,6 +19,14 @@
*
*/
/**
* **BackendsService** - Backend connectivity checks
*
* Probes an external backend's models endpoint to validate its URL and
* credentials before it is saved. Stateless.
*/
export { BackendsService } from './backends.service';
/**
* **ChatService** - Chat Completions API communication layer
*
+26 -2
View File
@@ -2,7 +2,7 @@
* backendsStore - API endpoints the UI can talk to.
*
* The built-in local backend is the llama-server serving this UI. External
* backends are user-configured endpoints read from settings. The store
* backends are user-configured endpoints persisted in settings. The store
* registers the resolved list with the api-base registry, which services use
* to build request URLs.
*/
@@ -34,7 +34,11 @@ class BackendsStore {
}
get local(): Backend {
return createLocalBackend();
return createLocalBackend(settingsStore.config.apiKey?.toString().trim() || undefined);
}
addBackend(backend: Backend): void {
this.saveExternal([...this.external, backend]);
}
initialize(): void {
@@ -43,11 +47,31 @@ class BackendsStore {
setBackendsResolver(() => ({ activeId: this.activeId, backends: this.list }));
}
removeBackend(backendId: string): void {
this.saveExternal(this.external.filter((backend) => backend.id !== backendId));
if (this.activeId === backendId) {
this.activeId = LOCAL_BACKEND_ID;
}
}
setActive(backendId: string): void {
this.activeId = this.list.some((backend) => backend.id === backendId)
? backendId
: LOCAL_BACKEND_ID;
}
updateBackend(backendId: string, updates: Partial<Backend>): void {
this.saveExternal(
this.external.map((backend) =>
backend.id === backendId ? { ...backend, ...updates } : backend
)
);
}
private saveExternal(backends: Backend[]): void {
settingsStore.updateConfig(SETTINGS_KEYS.BACKENDS, JSON.stringify(backends));
}
}
export const backendsStore = new BackendsStore();
+16
View File
@@ -19,12 +19,28 @@ export interface Backend {
* Empty for the local backend, which resolves against the UI origin instead.
*/
baseUrl: string;
/** Chat completions path override, e.g. /v1/messages. */
chatPath?: string;
/** Disabled backends stay configured but are not queried. */
enabled: boolean;
/** Extra headers merged into every request to this backend. */
headers?: Record<string, string>;
/** Stable identity. The local backend id is reserved. */
id: string;
/** Models listing path override, e.g. /models. */
modelsPath?: string;
name: string;
protocol: BackendProtocol;
}
/** A ready-made backend configuration offered when adding a backend. */
export interface BackendPreset {
/** Optional help text shown under the API key field. */
apiKeyHelp?: string;
baseUrl: string;
chatPath?: string;
id: string;
modelsPath?: string;
name: string;
protocol: BackendProtocol;
}
+1 -1
View File
@@ -36,7 +36,7 @@ export type {
} from './api';
// Backend types
export type { Backend, BackendProtocol } from './backend';
export type { Backend, BackendPreset, BackendProtocol } from './backend';
// HuggingFace types
export type {
+30 -16
View File
@@ -1,20 +1,46 @@
import { getBackend } from './api-base';
import { redactValue } from './redact';
import { CORS_PROXY, HEADERS, LOCAL_BACKEND_ID } from '$lib/constants';
import { ANTHROPIC_API_VERSION, CORS_PROXY, HEADERS } from '$lib/constants';
import { MimeTypeApplication } from '$lib/enums';
import { settingsStore } from '$lib/stores/settings/index.svelte';
import type { Backend } from '$lib/types';
/**
* Get authorization headers for API requests to a backend.
* External backends carry their own key; the local backend reuses the global
* API key setting.
*/
export function getAuthHeaders(backendId?: string): Record<string, string> {
const apiKey = resolveBackendApiKey(backendId);
const backend = getBackend(backendId);
if (backend) return getAuthHeadersForBackend(backend);
// no backends resolver yet (early startup, or a non-browser call): keep the
// pre-backends behaviour and authenticate against the serving origin
const apiKey = settingsStore.config.apiKey?.toString().trim();
return apiKey ? { [HEADERS.AUTHORIZATION]: `${HEADERS.BEARER}${apiKey}` } : {};
}
/**
* Get authorization headers for a backend object, including one that is not
* registered yet (used by the connection test on the add-backend form).
* Anthropic-compatible backends authenticate with x-api-key instead of Bearer.
*/
export function getAuthHeadersForBackend(backend: Backend): Record<string, string> {
const headers: Record<string, string> = { ...(backend.headers ?? {}) };
const apiKey = backend.apiKey?.trim();
if (!apiKey) return headers;
if (backend.protocol === 'anthropic') {
headers[HEADERS.ANTHROPIC_API_KEY] = apiKey;
headers[HEADERS.ANTHROPIC_VERSION] = ANTHROPIC_API_VERSION;
} else {
headers[HEADERS.AUTHORIZATION] = `${HEADERS.BEARER}${apiKey}`;
}
return headers;
}
/**
* Get standard JSON headers with optional authorization
*/
@@ -25,18 +51,6 @@ export function getJsonHeaders(backendId?: string): Record<string, string> {
};
}
function resolveBackendApiKey(backendId?: string): string | undefined {
const backend = getBackend(backendId);
const backendKey = backend?.apiKey?.trim();
if (backendKey) return backendKey;
// an external backend without its own key must not receive the local key
if (backend && backend.id !== LOCAL_BACKEND_ID) return undefined;
return settingsStore.config.apiKey?.toString().trim() || undefined;
}
/**
* Sanitize HTTP headers by redacting sensitive values.
* Known sensitive headers (from HEADERS.REDACTED) and any extra headers
+33 -3
View File
@@ -1,17 +1,34 @@
/**
* Backend list parsing and defaults.
* Backend list parsing, defaults and endpoint URLs.
*
* External backends are persisted in settings as a JSON list. Malformed
* entries are dropped instead of throwing so a corrupted settings value can
* never break URL resolution.
*/
import { BACKEND_ID_PREFIX, BACKEND_PROTOCOLS, LOCAL_BACKEND_ID } from '$lib/constants';
import {
BACKEND_ID_PREFIX,
BACKEND_PROTOCOLS,
DEFAULT_BACKEND_CHAT_PATH,
DEFAULT_BACKEND_MODELS_PATH,
LOCAL_BACKEND_ID
} from '$lib/constants';
import type { Backend, BackendProtocol } from '$lib/types';
/** Absolute chat completions URL for a backend. */
export function backendChatUrl(backend: Backend): string {
return joinBackendUrl(backend.baseUrl, backend.chatPath ?? DEFAULT_BACKEND_CHAT_PATH);
}
/** Absolute models listing URL for a backend. */
export function backendModelsUrl(backend: Backend): string {
return joinBackendUrl(backend.baseUrl, backend.modelsPath ?? DEFAULT_BACKEND_MODELS_PATH);
}
/** The built-in backend pointing at the server that serves this UI. */
export function createLocalBackend(): Backend {
export function createLocalBackend(apiKey?: string): Backend {
return {
apiKey,
baseUrl: '',
enabled: true,
id: LOCAL_BACKEND_ID,
@@ -53,6 +70,13 @@ export function parseBackendsSettings(rawBackends: unknown): Backend[] {
});
}
function joinBackendUrl(baseUrl: string, path: string): string {
const base = baseUrl.replace(/\/+$/, '');
const suffix = path.startsWith('/') ? path : `/${path}`;
return `${base}${suffix}`;
}
function parseBackendEntry(entry: unknown, index: number): Backend | null {
if (!entry || typeof entry !== 'object') return null;
@@ -76,9 +100,11 @@ function parseBackendEntry(entry: unknown, index: number): Backend | null {
return {
apiKey,
baseUrl,
chatPath: parseOptionalPath(raw.chatPath),
enabled: raw.enabled !== false,
headers: parseBackendHeaders(raw.headers),
id,
modelsPath: parseOptionalPath(raw.modelsPath),
name,
protocol
};
@@ -93,3 +119,7 @@ function parseBackendHeaders(raw: unknown): Record<string, string> | undefined {
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
function parseOptionalPath(raw: unknown): string | undefined {
return typeof raw === 'string' && raw.trim() ? raw.trim() : undefined;
}
+12 -2
View File
@@ -9,10 +9,20 @@
// API utilities
export { apiUrl, getBackend, getBackendBaseUrl, type BackendsSnapshot } from './api-base';
export { getAuthHeaders, getJsonHeaders, sanitizeHeaders } from './api-headers';
export {
getAuthHeaders,
getAuthHeadersForBackend,
getJsonHeaders,
sanitizeHeaders
} from './api-headers';
export { ApiError, apiDelete, apiFetch, apiFetchWithParams, apiPost } from './api-fetch';
export { validateApiKey } from './api-key-validation';
export { createLocalBackend, parseBackendsSettings } from './backend';
export {
backendChatUrl,
backendModelsUrl,
createLocalBackend,
parseBackendsSettings
} from './backend';
// Attachment utilities
export { getAttachmentDisplayItems, isMcpPrompt, isMcpResource } from './attachment-display';