/** * 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_CAPABILITIES, BACKEND_COMPAT, BACKEND_ID_PREFIX, BACKEND_PRESETS, BACKEND_PROTOCOLS, BackendProtocol, DEFAULT_BACKEND_CHAT_PATH, DEFAULT_BACKEND_MODELS_PATH, FAVICON_SERVICE_URL, LOCAL_BACKEND_ID, MaxTokensField, MODEL_CONTEXT_LENGTH_FIELDS } from '$lib/constants'; import type { Backend, BackendCapabilities, BackendCompat, BackendPreset } 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); } /** Icon size requested from the favicon service, shown at 16px. */ const FAVICON_SIZE = 64; /** * Favicon of a backend's root domain, used when no bundled mark matches. API * hosts rarely serve a favicon themselves, so the subdomain is dropped * (`api.z.ai` -> `z.ai`) and the icon is requested through a favicon service. * Returns null when the URL carries no usable host. */ export function backendFaviconUrl(baseUrl: string): string | null { try { const labels = new URL(baseUrl).hostname.split('.'); const root = labels.length > 2 ? labels.slice(-2).join('.') : labels.join('.'); return root ? `${FAVICON_SERVICE_URL}${root}&sz=${FAVICON_SIZE}` : null; } catch { return null; } } /** * Preset a backend was created from, matched on origin and path so a saved * backend keeps its branding. Returns undefined for edited or custom URLs. */ export function findBackendPreset(baseUrl: string): BackendPreset | undefined { const target = normalizeBaseUrl(baseUrl); if (!target) return undefined; return BACKEND_PRESETS.find((preset) => normalizeBaseUrl(preset.baseUrl) === target); } /** Origin and path of a URL, without a trailing slash; null when unparsable. */ function normalizeBaseUrl(url: string): string | null { try { const parsed = new URL(url); return `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`.toLowerCase(); } catch { return null; } } /** * Features a backend supports, derived from its protocol. A missing backend * (unknown model, early startup) gets the plain compatible defaults. */ export function getBackendCapabilities(backend?: Backend): BackendCapabilities { return ( BACKEND_CAPABILITIES[backend?.protocol ?? BackendProtocol.OPENAI] ?? BACKEND_CAPABILITIES[BackendProtocol.OPENAI] ); } /** Wire quirks for a backend: protocol defaults overridden by the backend. */ export function getBackendCompat(backend: Backend): BackendCompat { return { ...(BACKEND_COMPAT[backend.protocol] ?? BACKEND_COMPAT[BackendProtocol.OPENAI]), ...backend.compat }; } /** The built-in backend pointing at the server that serves this UI. */ export function createLocalBackend(apiKey?: string, enabled = true): Backend { return { apiKey, baseUrl: '', enabled, id: LOCAL_BACKEND_ID, name: 'Local', protocol: BackendProtocol.COMPAT }; } /** * Parse the persisted backends JSON into backend entries. */ export function parseBackendsSettings(rawBackends: unknown): Backend[] { if (!rawBackends) return []; let parsed: unknown; if (typeof rawBackends === 'string') { const trimmed = rawBackends.trim(); if (!trimmed) return []; try { parsed = JSON.parse(trimmed); } catch (error) { console.warn('[backends] Failed to parse backends JSON, ignoring value:', error); return []; } } else { parsed = rawBackends; } if (!Array.isArray(parsed)) return []; return parsed.flatMap((entry, index) => { const backend = parseBackendEntry(entry, index); return backend ? [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; const raw = entry as Record; const baseUrl = typeof raw.baseUrl === 'string' ? raw.baseUrl.trim() : ''; // the local backend is built in and never persisted if (!baseUrl || raw.id === LOCAL_BACKEND_ID) return null; const protocol = BACKEND_PROTOCOLS.includes(raw.protocol as BackendProtocol) ? (raw.protocol as BackendProtocol) : BackendProtocol.OPENAI; const id = typeof raw.id === 'string' && raw.id.trim() ? raw.id.trim() : `${BACKEND_ID_PREFIX}-${index + 1}`; const name = typeof raw.name === 'string' && raw.name.trim() ? raw.name.trim() : baseUrl; const apiKey = typeof raw.apiKey === 'string' && raw.apiKey.trim() ? raw.apiKey.trim() : undefined; return { apiKey, baseUrl, chatPath: parseOptionalPath(raw.chatPath), compat: parseBackendCompat(raw.compat, protocol), enabled: raw.enabled !== false, headers: parseBackendHeaders(raw.headers), id, modelsPath: parseOptionalPath(raw.modelsPath), name, protocol }; } // only keep the override keys the protocol understands, so a stale persisted // value can never inject an unknown field into a request function parseBackendCompat( raw: unknown, protocol: BackendProtocol ): Partial | undefined { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined; const entry = raw as Record; const defaults = BACKEND_COMPAT[protocol] ?? BACKEND_COMPAT.openai; const overrides: Partial = {}; if ( entry.maxTokensField === MaxTokensField.COMPLETION || entry.maxTokensField === MaxTokensField.CHAT_COMPLETION ) { overrides.maxTokensField = entry.maxTokensField; } if (typeof entry.supportsUsageInStreaming === 'boolean') { overrides.supportsUsageInStreaming = entry.supportsUsageInStreaming; } // drop a no-op override so an unmodified backend stays undefined const isDefault = (overrides.maxTokensField === undefined || overrides.maxTokensField === defaults.maxTokensField) && (overrides.supportsUsageInStreaming === undefined || overrides.supportsUsageInStreaming === defaults.supportsUsageInStreaming); return isDefault ? undefined : overrides; } function parseBackendHeaders(raw: unknown): Record | undefined { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined; const entries = Object.entries(raw as Record) .filter(([, value]) => typeof value === 'string' && value.trim() !== '') .map(([key, value]) => [key.trim(), (value as string).trim()] as const); return entries.length > 0 ? Object.fromEntries(entries) : undefined; } function parseOptionalPath(raw: unknown): string | undefined { return typeof raw === 'string' && raw.trim() ? raw.trim() : undefined; } /** * Read a model's context size out of one `/v1/models` entry. Providers use * different field names, and OpenRouter nests the authoritative value under * `top_provider`, so try the flat fields first and the nested one after. */ export function readModelContextLength(value: unknown): number | undefined { if (!value || typeof value !== 'object') return undefined; const entry = value as Record; const flat = readContextField(entry); if (flat !== undefined) return flat; const topProvider = entry.top_provider; if (topProvider && typeof topProvider === 'object') { const nested = readContextField(topProvider as Record); if (nested !== undefined) return nested; } // Hugging Face lists one entry per inference provider, and they disagree on // the budget; take the largest so the gauge does not undersell the model. const providers = entry.providers; if (Array.isArray(providers)) { const sizes = providers .map((provider) => provider && typeof provider === 'object' ? readContextField(provider as Record) : undefined ) .filter((size): size is number => size !== undefined); if (sizes.length > 0) return Math.max(...sizes); } return undefined; } function readContextField(entry: Record): number | undefined { for (const field of MODEL_CONTEXT_LENGTH_FIELDS) { const value = entry[field]; if (typeof value === 'number' && value > 0) return value; } return undefined; }