mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-17 20:31:47 +02:00
ui : add backend presets, CRUD and connection test
Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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();
|
||||
|
||||
Vendored
+16
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user