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 <emilio.jesus.lasheras.romero@nttdata.com>

* 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 <emilio.jesus.lasheras.romero@nttdata.com>
This commit is contained in:
Austin Pickett 2026-07-19 19:59:46 -04:00 committed by GitHub
parent 57063ad47f
commit 3d97893571
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 902 additions and 5 deletions

View file

@ -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<string | null>(null)
const [deleting, setDeleting] = useState<string | null>(null)
const [endpoints, setEndpoints] = useState<CustomEndpoint[]>([])
const [form, setForm] = useState<EndpointForm>(EMPTY_FORM)
const [discoveredModels, setDiscoveredModels] = useState<string[]>([])
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 <LoadingState label="Loading custom endpoints..." />
}
const allModelOptions = Array.from(new Set([...discoveredModels, form.model].filter(Boolean)))
const canSave = form.name.trim() && form.baseUrl.trim() && form.model.trim()
return (
<SettingsContent>
<div className="space-y-6">
<section>
<SectionHeading icon={Globe} meta={`${endpoints.length}`} title="Custom Endpoints" />
<div className="divide-y divide-border/40 rounded-md border border-border/50">
{endpoints.length ? (
endpoints.map(endpoint => (
<div className="grid gap-3 p-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center" key={endpoint.id}>
<button
className="min-w-0 text-left"
onClick={() => {
setForm(formFromEndpoint(endpoint))
setDiscoveredModels(endpoint.models)
}}
type="button"
>
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">{endpoint.name}</span>
{endpoint.is_current && (
<Pill tone="primary">
<Check className="size-3" />
Active
</Pill>
)}
{endpoint.source === 'direct-config' && <Pill>config.yaml</Pill>}
</div>
<div className="mt-1 truncate font-mono text-[0.7rem] text-muted-foreground">{endpoint.base_url}</div>
<div className="mt-1 flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>{endpoint.model}</span>
{endpoint.has_api_key && <span>{endpoint.api_key_preview ?? 'API key set'}</span>}
</div>
</button>
<div className="flex items-center gap-2 sm:justify-end">
<Button
disabled={endpoint.is_current || activating === endpoint.id}
onClick={() => void handleActivate(endpoint)}
size="sm"
variant="outline"
>
{activating === endpoint.id ? <Loader2 className="animate-spin" /> : <Zap />}
Use
</Button>
{endpoint.source !== 'direct-config' && (
<Button
className="hover:text-destructive"
disabled={deleting === endpoint.id}
onClick={() => void handleDelete(endpoint)}
size="icon-sm"
title="Delete endpoint"
variant="ghost"
>
{deleting === endpoint.id ? <Loader2 className="animate-spin" /> : <Trash2 />}
</Button>
)}
</div>
</div>
))
) : (
<EmptyState description="Add an OpenAI-compatible endpoint below." title="No custom endpoints" />
)}
</div>
</section>
<section>
<SectionHeading icon={Plus} title={form.id ? 'Edit Endpoint' : 'Add Endpoint'} />
<div className="grid gap-3 rounded-md border border-border/50 p-3">
<div className="grid gap-3 sm:grid-cols-2">
<label className="grid gap-1.5 text-xs text-muted-foreground">
Name
<Input
onChange={event => setForm(current => ({ ...current, name: event.target.value }))}
placeholder="Axet Proxy"
value={form.name}
/>
</label>
<label className="grid gap-1.5 text-xs text-muted-foreground">
Provider ID
<Input
onChange={event => setForm(current => ({ ...current, id: event.target.value }))}
placeholder="axet-proxy"
value={form.id}
/>
</label>
</div>
<label className="grid gap-1.5 text-xs text-muted-foreground">
Endpoint URL
<Input
onChange={event => setForm(current => ({ ...current, baseUrl: event.target.value }))}
placeholder="http://127.0.0.1:8081/v1"
value={form.baseUrl}
/>
</label>
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_12rem]">
<label className="grid gap-1.5 text-xs text-muted-foreground">
Default Model
<Input
list="custom-endpoint-models"
onChange={event => setForm(current => ({ ...current, model: event.target.value }))}
placeholder="gpt-5.4"
value={form.model}
/>
<datalist id="custom-endpoint-models">
{allModelOptions.map(model => (
<option key={model} value={model} />
))}
</datalist>
</label>
<label className="grid gap-1.5 text-xs text-muted-foreground">
Context
<Input
inputMode="numeric"
onChange={event => setForm(current => ({ ...current, contextLength: event.target.value }))}
placeholder="Auto"
value={form.contextLength}
/>
</label>
</div>
<label className="grid gap-1.5 text-xs text-muted-foreground">
API Key
<Input
onChange={event => setForm(current => ({ ...current, apiKey: event.target.value }))}
placeholder={form.id ? 'Leave blank to keep current key' : 'Optional'}
type="password"
value={form.apiKey}
/>
</label>
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
<label className="flex items-center gap-2">
<Checkbox
checked={form.makeDefault}
onCheckedChange={checked => setForm(current => ({ ...current, makeDefault: checked === true }))}
/>
Use for new chats
</label>
<label className="flex items-center gap-2">
<Checkbox
checked={form.discoverModels}
onCheckedChange={checked => setForm(current => ({ ...current, discoverModels: checked === true }))}
/>
Discover models
</label>
</div>
<div className="flex flex-wrap gap-2">
<Button disabled={testing || !form.baseUrl.trim()} onClick={() => void handleValidate()} variant="outline">
{testing ? <Loader2 className="animate-spin" /> : <Zap />}
Test
</Button>
<Button disabled={saving || !canSave} onClick={() => void handleSave()}>
{saving ? <Loader2 className="animate-spin" /> : <Save />}
Save
</Button>
<Button
className={cn(!form.id && 'hidden')}
onClick={() => {
setForm(EMPTY_FORM)
setDiscoveredModels([])
}}
type="button"
variant="ghost"
>
New endpoint
</Button>
</div>
</div>
</section>
</div>
</SettingsContent>
)
}

View file

@ -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
<OverlaySplitLayout>
<OverlayNav footer={navFooter} groups={navGroups} />
<OverlayMain className="px-0 pb-0">
<OverlayMain className="px-0 pb-0 pt-[calc(var(--titlebar-height)+1rem)]">
{activeView === 'config:appearance' ? (
<AppearanceSettings />
) : activeView === 'about' ? (
@ -298,7 +305,13 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
onMainModelChanged={onMainModelChanged}
/>
) : activeView === 'providers' ? (
<ProvidersSettings onClose={onClose} onViewChange={setProviderView} view={providerView} />
<ProvidersSettings
onClose={onClose}
onConfigSaved={onConfigSaved}
onMainModelChanged={onMainModelChanged}
onViewChange={setProviderView}
view={providerView}
/>
) : activeView === 'keys' ? (
<KeysSettings view={keysView} />
) : activeView === 'notifications' ? (

View file

@ -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<OAuthProvider[]>([])
@ -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 (
<CustomEndpointsSettings
onConfigSaved={onConfigSaved}
onMainModelChanged={onMainModelChanged}
/>
)
}
return (
<SettingsContent>
<OAuthPicker
@ -508,6 +524,8 @@ interface ProviderKeyGroup {
interface ProvidersSettingsProps {
onClose: () => void
onConfigSaved?: () => void
onMainModelChanged?: (provider: string, model: string) => void
onViewChange: (view: ProviderView) => void
view: ProviderView
}

View file

@ -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<CustomEndpointsResponse> {
return window.hermesDesktop.api<CustomEndpointsResponse>({
path: '/api/providers/custom-endpoints'
})
}
export function saveCustomEndpoint(endpoint: CustomEndpointUpdate): Promise<CustomEndpointsResponse> {
return window.hermesDesktop.api<CustomEndpointsResponse>({
path: '/api/providers/custom-endpoints',
method: 'POST',
body: endpoint
})
}
export function validateCustomEndpoint(endpoint: CustomEndpointUpdate): Promise<CustomEndpointValidationResponse> {
return window.hermesDesktop.api<CustomEndpointValidationResponse>({
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<CustomEndpointsResponse> {
return window.hermesDesktop.api<CustomEndpointsResponse>({
path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}`,
method: 'DELETE'
})
}
export function deleteEnvVar(key: string): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...profileScoped(),

View file

@ -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',

View file

@ -215,6 +215,7 @@ export const ja = defineLocale({
providers: 'プロバイダー',
providerAccounts: 'アカウント',
providerApiKeys: 'API キー',
providerCustomEndpoints: 'カスタムエンドポイント',
gateway: 'ゲートウェイ',
apiKeys: 'ツールとキー',
keybinds: 'キーボードショートカット',

View file

@ -272,6 +272,7 @@ export interface Translations {
providers: string
providerAccounts: string
providerApiKeys: string
providerCustomEndpoints: string
gateway: string
apiKeys: string
keybinds: string

View file

@ -209,6 +209,7 @@ export const zhHant = defineLocale({
providers: '提供方',
providerAccounts: '帳號',
providerApiKeys: 'API 金鑰',
providerCustomEndpoints: '自訂端點',
gateway: '閘道',
apiKeys: '工具與金鑰',
keybinds: '鍵盤快捷鍵',

View file

@ -305,6 +305,7 @@ export const zh: Translations = {
providers: '提供方',
providerAccounts: '账号',
providerApiKeys: 'API 密钥',
providerCustomEndpoints: '自定义端点',
gateway: '网关',
apiKeys: '工具与密钥',
keybinds: '键盘快捷键',

View file

@ -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

View file

@ -0,0 +1,2 @@
elashera
# PR #42745 salvage -> #67759

View file

@ -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.

View file

@ -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

View file

@ -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