mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
* 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:
parent
57063ad47f
commit
3d97893571
14 changed files with 902 additions and 5 deletions
397
apps/desktop/src/app/settings/custom-endpoints-settings.tsx
Normal file
397
apps/desktop/src/app/settings/custom-endpoints-settings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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' ? (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -215,6 +215,7 @@ export const ja = defineLocale({
|
|||
providers: 'プロバイダー',
|
||||
providerAccounts: 'アカウント',
|
||||
providerApiKeys: 'API キー',
|
||||
providerCustomEndpoints: 'カスタムエンドポイント',
|
||||
gateway: 'ゲートウェイ',
|
||||
apiKeys: 'ツールとキー',
|
||||
keybinds: 'キーボードショートカット',
|
||||
|
|
|
|||
|
|
@ -272,6 +272,7 @@ export interface Translations {
|
|||
providers: string
|
||||
providerAccounts: string
|
||||
providerApiKeys: string
|
||||
providerCustomEndpoints: string
|
||||
gateway: string
|
||||
apiKeys: string
|
||||
keybinds: string
|
||||
|
|
|
|||
|
|
@ -209,6 +209,7 @@ export const zhHant = defineLocale({
|
|||
providers: '提供方',
|
||||
providerAccounts: '帳號',
|
||||
providerApiKeys: 'API 金鑰',
|
||||
providerCustomEndpoints: '自訂端點',
|
||||
gateway: '閘道',
|
||||
apiKeys: '工具與金鑰',
|
||||
keybinds: '鍵盤快捷鍵',
|
||||
|
|
|
|||
|
|
@ -305,6 +305,7 @@ export const zh: Translations = {
|
|||
providers: '提供方',
|
||||
providerAccounts: '账号',
|
||||
providerApiKeys: 'API 密钥',
|
||||
providerCustomEndpoints: '自定义端点',
|
||||
gateway: '网关',
|
||||
apiKeys: '工具与密钥',
|
||||
keybinds: '键盘快捷键',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
elashera
|
||||
# PR #42745 salvage -> #67759
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue