From 8d6bc71ebd608ca05731e3dcd11f97cd5fc6c63a Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Tue, 15 Sep 2026 00:12:04 +0200 Subject: [PATCH] ui : add backend presets, CRUD and connection test Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash --- .../ui/src/lib/constants/backend.constants.ts | 83 +++++++++++++++++-- .../ui/src/lib/constants/headers.constants.ts | 4 + tools/ui/src/lib/services/backends.service.ts | 75 +++++++++++++++++ tools/ui/src/lib/services/index.ts | 8 ++ tools/ui/src/lib/stores/backends.svelte.ts | 28 ++++++- tools/ui/src/lib/types/backend.d.ts | 16 ++++ tools/ui/src/lib/types/index.ts | 2 +- tools/ui/src/lib/utils/api-headers.ts | 46 ++++++---- tools/ui/src/lib/utils/backend.ts | 36 +++++++- tools/ui/src/lib/utils/index.ts | 14 +++- 10 files changed, 283 insertions(+), 29 deletions(-) create mode 100644 tools/ui/src/lib/services/backends.service.ts diff --git a/tools/ui/src/lib/constants/backend.constants.ts b/tools/ui/src/lib/constants/backend.constants.ts index 10f2d6dc19..bdf79a5939 100644 --- a/tools/ui/src/lib/constants/backend.constants.ts +++ b/tools/ui/src/lib/constants/backend.constants.ts @@ -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' + } +]; diff --git a/tools/ui/src/lib/constants/headers.constants.ts b/tools/ui/src/lib/constants/headers.constants.ts index d477fc8783..b717477d02 100644 --- a/tools/ui/src/lib/constants/headers.constants.ts +++ b/tools/ui/src/lib/constants/headers.constants.ts @@ -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) */ diff --git a/tools/ui/src/lib/services/backends.service.ts b/tools/ui/src/lib/services/backends.service.ts new file mode 100644 index 0000000000..140839d20b --- /dev/null +++ b/tools/ui/src/lib/services/backends.service.ts @@ -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 { + 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 { + 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; +} diff --git a/tools/ui/src/lib/services/index.ts b/tools/ui/src/lib/services/index.ts index 5f2c3ed342..09a5ffc56e 100644 --- a/tools/ui/src/lib/services/index.ts +++ b/tools/ui/src/lib/services/index.ts @@ -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 * diff --git a/tools/ui/src/lib/stores/backends.svelte.ts b/tools/ui/src/lib/stores/backends.svelte.ts index 989bc8e609..47bfd5b092 100644 --- a/tools/ui/src/lib/stores/backends.svelte.ts +++ b/tools/ui/src/lib/stores/backends.svelte.ts @@ -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): 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(); diff --git a/tools/ui/src/lib/types/backend.d.ts b/tools/ui/src/lib/types/backend.d.ts index 1d05752516..3ecf0d2c69 100644 --- a/tools/ui/src/lib/types/backend.d.ts +++ b/tools/ui/src/lib/types/backend.d.ts @@ -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; /** 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; } diff --git a/tools/ui/src/lib/types/index.ts b/tools/ui/src/lib/types/index.ts index 10d0d9ed2f..be5ac2bdfa 100644 --- a/tools/ui/src/lib/types/index.ts +++ b/tools/ui/src/lib/types/index.ts @@ -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 { diff --git a/tools/ui/src/lib/utils/api-headers.ts b/tools/ui/src/lib/utils/api-headers.ts index 4bb1d23911..e71710c133 100644 --- a/tools/ui/src/lib/utils/api-headers.ts +++ b/tools/ui/src/lib/utils/api-headers.ts @@ -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 { - 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 { + const headers: Record = { ...(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 { }; } -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 diff --git a/tools/ui/src/lib/utils/backend.ts b/tools/ui/src/lib/utils/backend.ts index 4771e59875..55c5589a64 100644 --- a/tools/ui/src/lib/utils/backend.ts +++ b/tools/ui/src/lib/utils/backend.ts @@ -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 | undefined { return entries.length > 0 ? Object.fromEntries(entries) : undefined; } + +function parseOptionalPath(raw: unknown): string | undefined { + return typeof raw === 'string' && raw.trim() ? raw.trim() : undefined; +} diff --git a/tools/ui/src/lib/utils/index.ts b/tools/ui/src/lib/utils/index.ts index 210a3460a0..a9b67f0100 100644 --- a/tools/ui/src/lib/utils/index.ts +++ b/tools/ui/src/lib/utils/index.ts @@ -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';