From 3d97893571dcb46b02756b8a2974cf3df7579fef Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Sun, 19 Jul 2026 19:59:46 -0400 Subject: [PATCH] feat(desktop): custom endpoint settings (supersedes #42745) (#67759) * feat(desktop): add custom endpoint settings (supersedes #42745) Salvages PR #42745 (elashera:custom-endpoints-desktop), which could no longer merge cleanly against main. Re-integrated the work onto current main and reconciled the conflicts: - Settings nav: wired the new 'Custom Endpoints' provider sub-view into main's data-driven navGroups/OverlayNav layout (PR predated that refactor) and added it to PROVIDER_VIEWS. - providers-settings: kept BOTH main's LocalEndpointRow affordance and the PR's fuller CRUD panel; unified ProvidersSettingsProps to carry onClose + onConfigSaved + onMainModelChanged. - web_server: kept main's _normalize_main_model_assignment + api_key propagation AND the PR's provider base_url lookup in _apply_model_assignment_sync. - model_switch: dropped the PR's bare direct-custom-config picker block; main already implements it (source='model-config', with live model discovery). Updated the salvaged test to assert main's behavior. - Merged additive import/type blocks in hermes.ts and types/hermes.ts. Backend endpoints, i18n labels (en/ja/zh/zh-hant), and the custom-endpoints-settings.tsx panel carried over. 28 custom-endpoint tests pass. Co-authored-by: elashera * chore(contributors): map elashera's commit email Salvage of #42745 (superseded by #67759) preserves @elashera's authorship, whose corporate commit email had no contributor mapping. Adds contributors/emails/ mapping so check-attribution passes. Verified: GitHub user 'elashera' id=135239963 matches their own noreply commit email (135239963+elashera@users.noreply.github.com). --------- Co-authored-by: elashera --- .../settings/custom-endpoints-settings.tsx | 397 ++++++++++++++++++ apps/desktop/src/app/settings/index.tsx | 17 +- .../src/app/settings/providers-settings.tsx | 24 +- apps/desktop/src/hermes.ts | 43 ++ apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/ja.ts | 1 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + apps/desktop/src/types/hermes.ts | 43 ++ .../emilio.jesus.lasheras.romero@nttdata.com | 2 + hermes_cli/web_server.py | 254 +++++++++++ .../test_user_providers_model_switch.py | 27 ++ tests/hermes_cli/test_web_server.py | 95 +++++ 14 files changed, 902 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/app/settings/custom-endpoints-settings.tsx create mode 100644 contributors/emails/emilio.jesus.lasheras.romero@nttdata.com diff --git a/apps/desktop/src/app/settings/custom-endpoints-settings.tsx b/apps/desktop/src/app/settings/custom-endpoints-settings.tsx new file mode 100644 index 000000000000..d7bfb3b3702b --- /dev/null +++ b/apps/desktop/src/app/settings/custom-endpoints-settings.tsx @@ -0,0 +1,397 @@ +import { useEffect, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Input } from '@/components/ui/input' +import { + activateCustomEndpoint, + deleteCustomEndpoint, + getCustomEndpoints, + saveCustomEndpoint, + validateCustomEndpoint +} from '@/hermes' +import { triggerHaptic } from '@/lib/haptics' +import { Check, Globe, Loader2, Plus, Save, Trash2, Zap } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { notify, notifyError } from '@/store/notifications' +import type { CustomEndpoint, CustomEndpointUpdate } from '@/types/hermes' + +import { EmptyState, LoadingState, Pill, SectionHeading, SettingsContent } from './primitives' + +interface CustomEndpointsSettingsProps { + onConfigSaved?: () => void + onMainModelChanged?: (provider: string, model: string) => void +} + +interface EndpointForm { + apiKey: string + baseUrl: string + contextLength: string + discoverModels: boolean + id: string + makeDefault: boolean + model: string + name: string +} + +const EMPTY_FORM: EndpointForm = { + apiKey: '', + baseUrl: '', + contextLength: '', + discoverModels: true, + id: '', + makeDefault: true, + model: '', + name: '' +} + +function formFromEndpoint(endpoint: CustomEndpoint): EndpointForm { + return { + apiKey: '', + baseUrl: endpoint.base_url, + contextLength: endpoint.context_length ? String(endpoint.context_length) : '', + discoverModels: endpoint.discover_models, + id: endpoint.id, + makeDefault: Boolean(endpoint.is_current), + model: endpoint.model, + name: endpoint.name + } +} + +function toPayload(form: EndpointForm): CustomEndpointUpdate { + const contextLength = Number.parseInt(form.contextLength, 10) + + return { + id: form.id.trim() || undefined, + name: form.name.trim(), + base_url: form.baseUrl.trim(), + model: form.model.trim(), + api_key: form.apiKey.trim() || undefined, + context_length: Number.isFinite(contextLength) && contextLength > 0 ? contextLength : undefined, + discover_models: form.discoverModels, + make_default: form.makeDefault + } +} + +export function CustomEndpointsSettings({ + onConfigSaved, + onMainModelChanged +}: CustomEndpointsSettingsProps) { + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [testing, setTesting] = useState(false) + const [activating, setActivating] = useState(null) + const [deleting, setDeleting] = useState(null) + const [endpoints, setEndpoints] = useState([]) + const [form, setForm] = useState(EMPTY_FORM) + const [discoveredModels, setDiscoveredModels] = useState([]) + + async function refresh() { + const data = await getCustomEndpoints() + setEndpoints(data.endpoints) + } + + useEffect(() => { + let cancelled = false + + async function load() { + try { + const data = await getCustomEndpoints() + + if (cancelled) { + return + } + + setEndpoints(data.endpoints) + const current = data.endpoints.find(endpoint => endpoint.is_current) ?? data.endpoints[0] + + if (current) { + setForm(formFromEndpoint(current)) + setDiscoveredModels(current.models) + } + } catch (err) { + notifyError(err, 'Could not load custom endpoints') + } finally { + if (!cancelled) { + setLoading(false) + } + } + } + + void load() + + return () => { + cancelled = true + } + }, []) + + async function handleSave() { + try { + setSaving(true) + const response = await saveCustomEndpoint(toPayload(form)) + setEndpoints(response.endpoints) + const saved = response.endpoints.find(endpoint => endpoint.id === response.id) + + if (saved) { + setForm(formFromEndpoint(saved)) + setDiscoveredModels(saved.models) + } + + if (saved && saved.is_current) { + onMainModelChanged?.(saved.id, saved.model) + } + + triggerHaptic('success') + onConfigSaved?.() + notify({ kind: 'success', message: 'Custom endpoint saved.' }) + } catch (err) { + notifyError(err, 'Save failed') + } finally { + setSaving(false) + } + } + + async function handleValidate() { + try { + setTesting(true) + const response = await validateCustomEndpoint(toPayload(form)) + setDiscoveredModels(response.models) + + if (response.ok) { + if (!form.model && response.models[0]) { + setForm(current => ({ ...current, model: response.models[0] })) + } + + notify({ + kind: 'success', + message: response.models.length ? `Endpoint is reachable. Found ${response.models.length} models.` : 'Endpoint is reachable.' + }) + } else { + notify({ + kind: response.reachable ? 'warning' : 'error', + message: response.message || 'Endpoint validation failed.' + }) + } + } catch (err) { + notifyError(err, 'Validation failed') + } finally { + setTesting(false) + } + } + + async function handleActivate(endpoint: CustomEndpoint) { + try { + setActivating(endpoint.id) + const response = await activateCustomEndpoint(endpoint.id) + await refresh() + onConfigSaved?.() + onMainModelChanged?.(response.provider, response.model) + triggerHaptic('success') + } catch (err) { + notifyError(err, 'Activation failed') + } finally { + setActivating(null) + } + } + + async function handleDelete(endpoint: CustomEndpoint) { + if (!window.confirm(`Delete ${endpoint.name}?`)) { + return + } + + try { + setDeleting(endpoint.id) + const response = await deleteCustomEndpoint(endpoint.id) + setEndpoints(response.endpoints) + + if (form.id === endpoint.id) { + setForm(EMPTY_FORM) + setDiscoveredModels([]) + } + + onConfigSaved?.() + triggerHaptic('success') + } catch (err) { + notifyError(err, 'Delete failed') + } finally { + setDeleting(null) + } + } + + if (loading) { + return + } + + const allModelOptions = Array.from(new Set([...discoveredModels, form.model].filter(Boolean))) + const canSave = form.name.trim() && form.baseUrl.trim() && form.model.trim() + + return ( + +
+
+ +
+ {endpoints.length ? ( + endpoints.map(endpoint => ( +
+ +
+ + {endpoint.source !== 'direct-config' && ( + + )} +
+
+ )) + ) : ( + + )} +
+
+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + + +
+
+
+
+
+ ) +} diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index e81cce91e06f..f51cca30f949 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -176,6 +176,13 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set id: 'pview:keys', label: t.settings.nav.providerApiKeys, onSelect: () => openProviderView('keys') + }, + { + active: activeView === 'providers' && providerView === 'custom-endpoints', + icon: Globe, + id: 'pview:custom-endpoints', + label: t.settings.nav.providerCustomEndpoints, + onSelect: () => openProviderView('custom-endpoints') } ], gapBefore: true, @@ -281,7 +288,7 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set - + {activeView === 'config:appearance' ? ( ) : activeView === 'about' ? ( @@ -298,7 +305,13 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set onMainModelChanged={onMainModelChanged} /> ) : activeView === 'providers' ? ( - + ) : activeView === 'keys' ? ( ) : activeView === 'notifications' ? ( diff --git a/apps/desktop/src/app/settings/providers-settings.tsx b/apps/desktop/src/app/settings/providers-settings.tsx index 7b72c604fa12..7a1d65759a58 100644 --- a/apps/desktop/src/app/settings/providers-settings.tsx +++ b/apps/desktop/src/app/settings/providers-settings.tsx @@ -25,6 +25,7 @@ import { $desktopOnboarding, startManualLocalEndpoint, startManualProviderOAuth import type { EnvVarInfo, OAuthProvider } from '@/types/hermes' import { isKeyVar, ProviderKeyRows } from './credential-key-ui' +import { CustomEndpointsSettings } from './custom-endpoints-settings' import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials' import { providerGroup, providerMeta, providerPriority } from './helpers' import { LoadingState, SettingsContent } from './primitives' @@ -44,7 +45,7 @@ function GroupLabel({ children }: { children: ReactNode }) { } // Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys. -export const PROVIDER_VIEWS = ['accounts', 'keys'] as const +export const PROVIDER_VIEWS = ['accounts', 'keys', 'custom-endpoints'] as const export type ProviderView = (typeof PROVIDER_VIEWS)[number] @@ -330,7 +331,13 @@ function LocalEndpointRow({ onOpen }: { onOpen: (reason: null | string) => void ) } -export function ProvidersSettings({ onClose, onViewChange, view }: ProvidersSettingsProps) { +export function ProvidersSettings({ + onClose, + onConfigSaved, + onMainModelChanged, + onViewChange, + view +}: ProvidersSettingsProps) { const { t } = useI18n() const { rowProps, vars } = useEnvCredentials() const [oauthProviders, setOauthProviders] = useState([]) @@ -430,7 +437,7 @@ export function ProvidersSettings({ onClose, onViewChange, view }: ProvidersSett const hasOauth = oauthProviders.length > 0 // The sidebar subnav owns the Accounts/API-keys split now; with no OAuth // providers there's nothing for the "Accounts" view to show, so fall to keys. - const showApiKeys = view === 'keys' || !hasOauth + const showApiKeys = view === 'keys' || (!hasOauth && view !== 'custom-endpoints') const keyGroups = buildProviderKeyGroups(vars) @@ -483,6 +490,15 @@ export function ProvidersSettings({ onClose, onViewChange, view }: ProvidersSett ) } + if (view === 'custom-endpoints') { + return ( + + ) + } + return ( void + onConfigSaved?: () => void + onMainModelChanged?: (provider: string, model: string) => void onViewChange: (view: ProviderView) => void view: ProviderView } diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 637f9566b66c..24d28ca6af7c 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -14,6 +14,9 @@ import type { CronJobCreatePayload, CronJobUpdates, CuratorStatusResponse, + CustomEndpointsResponse, + CustomEndpointUpdate, + CustomEndpointValidationResponse, DebugShareResponse, ElevenLabsVoicesResponse, EnvVarInfo, @@ -105,6 +108,10 @@ export type { CronJobSchedule, CronJobUpdates, CuratorStatusResponse, + CustomEndpoint, + CustomEndpointsResponse, + CustomEndpointUpdate, + CustomEndpointValidationResponse, DebugShareResponse, ElevenLabsVoice, ElevenLabsVoicesResponse, @@ -619,6 +626,42 @@ export function validateProviderCredential( }) } +export function getCustomEndpoints(): Promise { + return window.hermesDesktop.api({ + path: '/api/providers/custom-endpoints' + }) +} + +export function saveCustomEndpoint(endpoint: CustomEndpointUpdate): Promise { + return window.hermesDesktop.api({ + path: '/api/providers/custom-endpoints', + method: 'POST', + body: endpoint + }) +} + +export function validateCustomEndpoint(endpoint: CustomEndpointUpdate): Promise { + return window.hermesDesktop.api({ + path: '/api/providers/custom-endpoints/validate', + method: 'POST', + body: endpoint + }) +} + +export function activateCustomEndpoint(id: string): Promise<{ ok: boolean; provider: string; model: string }> { + return window.hermesDesktop.api<{ ok: boolean; provider: string; model: string }>({ + path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}/activate`, + method: 'POST' + }) +} + +export function deleteCustomEndpoint(id: string): Promise { + return window.hermesDesktop.api({ + path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}`, + method: 'DELETE' + }) +} + export function deleteEnvVar(key: string): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ ...profileScoped(), diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 7765c0f7468f..a0e79037f875 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -314,6 +314,7 @@ export const en: Translations = { providers: 'Providers', providerAccounts: 'Accounts', providerApiKeys: 'API keys', + providerCustomEndpoints: 'Custom Endpoints', gateway: 'Gateway', apiKeys: 'Tools & Keys', keybinds: 'Keyboard Shortcuts', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index fcc27776201e..ed902c8ee8b7 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -215,6 +215,7 @@ export const ja = defineLocale({ providers: 'プロバイダー', providerAccounts: 'アカウント', providerApiKeys: 'API キー', + providerCustomEndpoints: 'カスタムエンドポイント', gateway: 'ゲートウェイ', apiKeys: 'ツールとキー', keybinds: 'キーボードショートカット', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 40f224f2b242..ae2ec69bcdac 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -272,6 +272,7 @@ export interface Translations { providers: string providerAccounts: string providerApiKeys: string + providerCustomEndpoints: string gateway: string apiKeys: string keybinds: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 4c543feb986a..c30818309a42 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -209,6 +209,7 @@ export const zhHant = defineLocale({ providers: '提供方', providerAccounts: '帳號', providerApiKeys: 'API 金鑰', + providerCustomEndpoints: '自訂端點', gateway: '閘道', apiKeys: '工具與金鑰', keybinds: '鍵盤快捷鍵', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 07c01b7395e6..253e01326c61 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -305,6 +305,7 @@ export const zh: Translations = { providers: '提供方', providerAccounts: '账号', providerApiKeys: 'API 密钥', + providerCustomEndpoints: '自定义端点', gateway: '网关', apiKeys: '工具与密钥', keybinds: '键盘快捷键', diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index dc2a71f1dffa..1028505de130 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -149,6 +149,49 @@ export interface MemoryProviderConfig { name: string } +export interface CustomEndpoint { + api_key_preview?: null | string + base_url: string + context_length?: null | number + discover_models: boolean + has_api_key: boolean + id: string + is_current?: boolean + model: string + models: string[] + name: string + source?: string +} + +export interface CustomEndpointsResponse { + current: { + base_url: string + model: string + provider: string + } + endpoints: CustomEndpoint[] + id?: string + ok?: boolean +} + +export interface CustomEndpointUpdate { + api_key?: string + base_url: string + context_length?: number + discover_models?: boolean + id?: string + make_default?: boolean + model: string + name: string +} + +export interface CustomEndpointValidationResponse { + message: string + models: string[] + ok: boolean + reachable: boolean +} + export interface MessagingEnvVarInfo { advanced: boolean description: string diff --git a/contributors/emails/emilio.jesus.lasheras.romero@nttdata.com b/contributors/emails/emilio.jesus.lasheras.romero@nttdata.com new file mode 100644 index 000000000000..fb26e8fb79d1 --- /dev/null +++ b/contributors/emails/emilio.jesus.lasheras.romero@nttdata.com @@ -0,0 +1,2 @@ +elashera +# PR #42745 salvage -> #67759 diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 4ddb16487fda..1ea775f50366 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1045,6 +1045,17 @@ class MemoryProviderSetupRequest(BaseModel): values: Dict[str, Any] = {} +class CustomEndpointUpdate(BaseModel): + id: str = "" + name: str + base_url: str + model: str + api_key: Optional[str] = None + context_length: Optional[int] = None + discover_models: bool = True + make_default: bool = False + + class MessagingPlatformUpdate(BaseModel): enabled: Optional[bool] = None env: Dict[str, str] = {} @@ -6398,9 +6409,15 @@ def _apply_model_assignment_sync( if not provider or not model: raise HTTPException(status_code=400, detail="provider and model required for main") provider, model = _normalize_main_model_assignment(provider, model) + providers_cfg = cfg.get("providers") + provider_entry = providers_cfg.get(provider) if isinstance(providers_cfg, dict) else None + if not base_url and isinstance(provider_entry, dict) and provider_entry.get("base_url"): + base_url = str(provider_entry.get("base_url") or "").strip() model_cfg = _apply_main_model_assignment( cfg.get("model", {}), provider, model, base_url, api_key ) + if isinstance(provider_entry, dict) and provider_entry.get("api_key"): + model_cfg["api_key"] = provider_entry["api_key"] cfg["model"] = model_cfg # When switching the main provider to Nous, mirror the CLI's @@ -6928,6 +6945,243 @@ def _parse_model_ids(resp: "Any") -> List[str]: return ids +def _custom_endpoint_id(raw: str, fallback: str = "custom") -> str: + slug = re.sub(r"[^A-Za-z0-9_-]+", "-", (raw or "").strip()).strip("-_").lower() + return slug or fallback + + +def _models_from_custom_endpoint_entry(entry: Dict[str, Any]) -> List[str]: + models: List[str] = [] + raw_models = entry.get("models") + if isinstance(raw_models, dict): + models.extend(str(model).strip() for model in raw_models.keys()) + elif isinstance(raw_models, list): + models.extend(str(model).strip() for model in raw_models) + + default_model = str(entry.get("model") or entry.get("default_model") or "").strip() + if default_model: + models.insert(0, default_model) + + seen: set[str] = set() + return [model for model in models if model and not (model in seen or seen.add(model))] + + +def _custom_endpoint_response(cfg: Dict[str, Any]) -> Dict[str, Any]: + model_cfg = cfg.get("model", {}) if isinstance(cfg.get("model"), dict) else {} + current_provider = str(model_cfg.get("provider", "") or "") + current_model = str(model_cfg.get("default", model_cfg.get("name", "")) or "") + current_base_url = str(model_cfg.get("base_url", "") or "") + + endpoints: List[Dict[str, Any]] = [] + providers = cfg.get("providers") + if isinstance(providers, dict): + for provider_id, raw_entry in providers.items(): + if not isinstance(raw_entry, dict): + continue + base_url = str(raw_entry.get("base_url") or raw_entry.get("url") or raw_entry.get("api") or "").strip() + if not base_url: + continue + endpoint_id = str(provider_id) + models = _models_from_custom_endpoint_entry(raw_entry) + endpoint_model = str(raw_entry.get("model") or raw_entry.get("default_model") or (models[0] if models else "")) + endpoints.append({ + "id": endpoint_id, + "name": str(raw_entry.get("name") or endpoint_id), + "base_url": base_url, + "model": endpoint_model, + "models": models, + "context_length": raw_entry.get("context_length"), + "discover_models": bool(raw_entry.get("discover_models", True)), + "has_api_key": bool(str(raw_entry.get("api_key", "") or "").strip()), + "api_key_preview": redact_key(str(raw_entry.get("api_key", "") or "")) if raw_entry.get("api_key") else None, + "is_current": endpoint_id == current_provider, + "source": "providers", + }) + + if current_provider.lower() == "custom" and current_base_url and not any(e["id"] == "custom" for e in endpoints): + endpoints.insert(0, { + "id": "custom", + "name": "Custom", + "base_url": current_base_url, + "model": current_model, + "models": [current_model] if current_model else [], + "context_length": model_cfg.get("context_length"), + "discover_models": True, + "has_api_key": bool(str(model_cfg.get("api_key", "") or "").strip()), + "api_key_preview": redact_key(str(model_cfg.get("api_key", "") or "")) if model_cfg.get("api_key") else None, + "is_current": True, + "source": "direct-config", + }) + + return { + "endpoints": endpoints, + "current": { + "provider": current_provider, + "model": current_model, + "base_url": current_base_url, + }, + } + + +def _write_custom_endpoint(cfg: Dict[str, Any], body: CustomEndpointUpdate) -> Tuple[str, Dict[str, Any]]: + endpoint_id = _custom_endpoint_id(body.id or body.name) + name = (body.name or "").strip() + base_url = (body.base_url or "").strip().rstrip("/") + model = (body.model or "").strip() + + if not name: + raise HTTPException(status_code=400, detail="name required") + if not base_url: + raise HTTPException(status_code=400, detail="base_url required") + parsed = urllib.parse.urlparse(base_url) + if not parsed.scheme or not parsed.netloc: + raise HTTPException(status_code=400, detail="base_url must include scheme and host") + if not model: + raise HTTPException(status_code=400, detail="model required") + + providers = cfg.get("providers") + if not isinstance(providers, dict): + providers = {} + existing = providers.get(endpoint_id) + if not isinstance(existing, dict): + existing = {} + + entry: Dict[str, Any] = { + "name": name, + "base_url": base_url, + "model": model, + "models": {model: {}}, + "discover_models": bool(body.discover_models), + } + if body.context_length and body.context_length > 0: + entry["context_length"] = int(body.context_length) + entry["models"][model]["context_length"] = int(body.context_length) + if body.api_key is not None and body.api_key.strip(): + entry["api_key"] = body.api_key.strip() + elif isinstance(existing.get("api_key"), str) and existing.get("api_key"): + entry["api_key"] = existing["api_key"] + + providers[endpoint_id] = entry + cfg["providers"] = providers + + if body.make_default: + cfg["model"] = _apply_main_model_assignment( + cfg.get("model", {}), endpoint_id, model, base_url + ) + if entry.get("api_key") and isinstance(cfg["model"], dict): + cfg["model"]["api_key"] = entry["api_key"] + + return endpoint_id, entry + + +@app.get("/api/providers/custom-endpoints") +def list_custom_endpoints(): + """Return configured OpenAI-compatible custom endpoints for Desktop.""" + try: + return _custom_endpoint_response(load_config()) + except Exception: + _log.exception("GET /api/providers/custom-endpoints failed") + raise HTTPException(status_code=500, detail="Failed to list custom endpoints") + + +@app.post("/api/providers/custom-endpoints") +def upsert_custom_endpoint(body: CustomEndpointUpdate): + """Create or update a v12+ ``providers`` custom endpoint entry.""" + try: + cfg = load_config() + endpoint_id, _entry = _write_custom_endpoint(cfg, body) + save_config(cfg) + response = _custom_endpoint_response(cfg) + response["ok"] = True + response["id"] = endpoint_id + return response + except HTTPException: + raise + except Exception: + _log.exception("POST /api/providers/custom-endpoints failed") + raise HTTPException(status_code=500, detail="Failed to save custom endpoint") + + +@app.post("/api/providers/custom-endpoints/{endpoint_id}/activate") +def activate_custom_endpoint(endpoint_id: str): + """Set a configured custom endpoint as the default model provider.""" + try: + cfg = load_config() + provider_key = _custom_endpoint_id(endpoint_id) + providers = cfg.get("providers") + entry = providers.get(provider_key) if isinstance(providers, dict) else None + if not isinstance(entry, dict): + raise HTTPException(status_code=404, detail="custom endpoint not found") + + models = _models_from_custom_endpoint_entry(entry) + model = str(entry.get("model") or (models[0] if models else "")).strip() + base_url = str(entry.get("base_url") or "").strip() + if not model or not base_url: + raise HTTPException(status_code=400, detail="custom endpoint is incomplete") + + model_cfg = _apply_main_model_assignment(cfg.get("model", {}), provider_key, model, base_url) + if entry.get("api_key"): + model_cfg["api_key"] = entry["api_key"] + cfg["model"] = model_cfg + save_config(cfg) + return {"ok": True, "provider": provider_key, "model": model} + except HTTPException: + raise + except Exception: + _log.exception("POST /api/providers/custom-endpoints/%s/activate failed", endpoint_id) + raise HTTPException(status_code=500, detail="Failed to activate custom endpoint") + + +@app.delete("/api/providers/custom-endpoints/{endpoint_id}") +def delete_custom_endpoint(endpoint_id: str): + """Remove a configured custom endpoint from ``providers``.""" + try: + cfg = load_config() + provider_key = _custom_endpoint_id(endpoint_id) + providers = cfg.get("providers") + if not isinstance(providers, dict) or provider_key not in providers: + raise HTTPException(status_code=404, detail="custom endpoint not found") + providers.pop(provider_key, None) + cfg["providers"] = providers + save_config(cfg) + response = _custom_endpoint_response(cfg) + response["ok"] = True + return response + except HTTPException: + raise + except Exception: + _log.exception("DELETE /api/providers/custom-endpoints/%s failed", endpoint_id) + raise HTTPException(status_code=500, detail="Failed to delete custom endpoint") + + +@app.post("/api/providers/custom-endpoints/validate") +async def validate_custom_endpoint(body: CustomEndpointUpdate): + """Probe a custom endpoint by calling its OpenAI-compatible /models URL.""" + import httpx + + base_url = (body.base_url or "").strip().rstrip("/") + if not base_url: + return {"ok": False, "reachable": True, "message": "Enter an endpoint URL first.", "models": []} + + url = base_url + "/models" + headers = {"Accept": "application/json"} + if body.api_key and body.api_key.strip(): + headers["Authorization"] = f"Bearer {body.api_key.strip()}" + + try: + with httpx.Client(timeout=httpx.Timeout(8.0)) as client: + resp = client.get(url, headers=headers) + except Exception: + return {"ok": False, "reachable": False, "message": f"Could not reach {url}.", "models": []} + + if resp.status_code in (401, 403): + return {"ok": False, "reachable": True, "message": "The endpoint rejected the API key.", "models": []} + if not resp.is_success: + return {"ok": False, "reachable": True, "message": f"Endpoint returned HTTP {resp.status_code}.", "models": []} + + return {"ok": True, "reachable": True, "message": "", "models": _parse_model_ids(resp)} + + @app.post("/api/providers/validate") async def validate_provider_credential(body: EnvVarUpdate, request: Request): """Live-probe a provider credential before it's saved. diff --git a/tests/hermes_cli/test_user_providers_model_switch.py b/tests/hermes_cli/test_user_providers_model_switch.py index 9014a7b1b190..8c743e71b900 100644 --- a/tests/hermes_cli/test_user_providers_model_switch.py +++ b/tests/hermes_cli/test_user_providers_model_switch.py @@ -477,6 +477,33 @@ def test_list_authenticated_providers_accepts_base_url_and_singular_model(monkey assert custom["total_models"] == 3 +def test_list_authenticated_providers_exposes_bare_direct_custom_config(monkeypatch): + """A direct ``model.provider=custom`` + ``model.base_url`` config has no + providers:/custom_providers entry, but it is still a valid runtime target + and must remain visible in Desktop's model picker. + """ + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {}) + + providers = list_authenticated_providers( + current_provider="custom", + current_base_url="http://172.29.176.1:8081/v1", + current_model="gpt-5.4", + user_providers={}, + custom_providers=[], + max_models=50, + ) + + custom = next((p for p in providers if p["slug"] == "custom"), None) + assert custom is not None + assert custom["is_current"] is True + # main surfaces the bare direct-config row via the "model-config" source + # (with live model discovery); the desktop CRUD view builds on that same row. + assert custom["source"] == "model-config" + assert custom["api_url"] == "http://172.29.176.1:8081/v1" + assert "gpt-5.4" in custom["models"] + + def test_list_authenticated_providers_dedupes_when_user_and_custom_overlap(monkeypatch): """When the same slug appears in both ``providers:`` dict and ``custom_providers:`` list, emit exactly one row (providers: dict wins diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 5353ef0f4ba2..54f13e808c0e 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -4188,6 +4188,101 @@ class TestWebServerEndpoints: assert model_cfg["provider"] == "openrouter" assert model_cfg.get("base_url", "") == "" + def test_custom_endpoints_list_includes_direct_custom_config(self): + """A bare model.provider=custom config should show up in Desktop even + before the user has materialized it under providers. + """ + from hermes_cli.config import save_config + + save_config({ + "model": { + "provider": "custom", + "default": "gpt-5.4", + "base_url": "http://127.0.0.1:8081/v1", + "api_key": "sk-local", + }, + "providers": {}, + }) + + resp = self.client.get("/api/providers/custom-endpoints") + + assert resp.status_code == 200 + data = resp.json() + assert data["current"] == { + "provider": "custom", + "model": "gpt-5.4", + "base_url": "http://127.0.0.1:8081/v1", + } + assert data["endpoints"][0]["id"] == "custom" + assert data["endpoints"][0]["source"] == "direct-config" + assert data["endpoints"][0]["has_api_key"] is True + + def test_custom_endpoint_upsert_persists_provider_and_sets_default(self): + """Desktop can persist an OpenAI-compatible proxy in providers and make + it the default for new chats. + """ + from hermes_cli.config import load_config + + resp = self.client.post( + "/api/providers/custom-endpoints", + json={ + "id": "axet-proxy", + "name": "Axet Proxy", + "base_url": "http://127.0.0.1:8081/v1/", + "model": "gpt-5.4", + "api_key": "sk-local", + "context_length": 262144, + "make_default": True, + }, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["ok"] is True + assert data["id"] == "axet-proxy" + endpoint = next(e for e in data["endpoints"] if e["id"] == "axet-proxy") + assert endpoint["base_url"] == "http://127.0.0.1:8081/v1" + assert endpoint["model"] == "gpt-5.4" + assert endpoint["is_current"] is True + + cfg = load_config() + assert cfg["providers"]["axet-proxy"]["base_url"] == "http://127.0.0.1:8081/v1" + assert cfg["providers"]["axet-proxy"]["models"]["gpt-5.4"]["context_length"] == 262144 + assert cfg["model"]["provider"] == "axet-proxy" + assert cfg["model"]["default"] == "gpt-5.4" + assert cfg["model"]["base_url"] == "http://127.0.0.1:8081/v1" + + def test_set_model_main_preserves_base_url_for_named_custom_provider(self): + """Selecting a named custom endpoint from the Desktop model picker + should keep its endpoint URL attached to model config. + """ + from hermes_cli.config import load_config, save_config + + save_config({ + "model": {"provider": "nous", "default": "hermes-4"}, + "providers": { + "axet-proxy": { + "name": "Axet Proxy", + "base_url": "http://127.0.0.1:8081/v1", + "api_key": "sk-local", + "model": "gpt-5.4", + "models": {"gpt-5.4": {}}, + } + }, + }) + + resp = self.client.post( + "/api/model/set", + json={"scope": "main", "provider": "axet-proxy", "model": "gpt-5.4"}, + ) + + assert resp.status_code == 200 + model_cfg = load_config()["model"] + assert model_cfg["provider"] == "axet-proxy" + assert model_cfg["default"] == "gpt-5.4" + assert model_cfg["base_url"] == "http://127.0.0.1:8081/v1" + assert model_cfg["api_key"] == "sk-local" + def test_set_model_main_gateway_failure_does_not_block_save(self, monkeypatch): """A Portal/gateway hiccup must never prevent saving the model.""" import hermes_cli.nous_subscription as ns