From 363b8a6fcbd768eb80655cf21b37fc58b47a85ba Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:17:19 -0700 Subject: [PATCH] fix(desktop): custom endpoints pin an API mode and keep /v1/models alias metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings > Custom Endpoints assumed Chat Completions: the form, its types, the update payload and _write_custom_endpoint carried no api_mode, so a Responses-only (or Anthropic-compatible) host validated fine on /models and then 404'd on every POST /chat/completions. Validation also flattened each /v1/models row to a bare id, so a reasoning alias like gpt-5.6-sol-high (canonical_model + reasoning_effort) was saved as a literal upstream model. - Desktop form: API Mode segmented control (Auto-detect / Chat Completions / Responses API / Anthropic Messages — the same set `hermes model` offers); threaded through toPayload, hydrated from GET read-back. - CustomEndpointUpdate.api_mode (Literal) persisted as providers..api_mode, the key the CLI writes and the runtime reads; None (older UI) leaves a hand-written mode alone, "" clears it. GET rows report api_mode. - validate returns model_details (id / canonical_model / reasoning_effort) next to the unchanged string[] models; _parse_model_ids is now a projection of _parse_model_entries. - Save keeps the alias metadata in providers..models and, when the picked default is an alias, persists the canonical model and pins its effort under agent.reasoning_overrides (the resolve_reasoning_config chokepoint). Fixes #93622 Supersedes #69824 (@SacrEllfarch), #82148 (@JackLee992), #93693 (@fangliquanflq) --- .../custom-endpoints-settings.test.tsx | 58 ++++++++++++- .../settings/custom-endpoints-settings.tsx | 48 ++++++++++- apps/desktop/src/types/hermes.ts | 17 ++++ hermes_cli/web_models.py | 11 +++ hermes_cli/web_routers/config_env.py | 70 ++++++++++++++-- hermes_cli/web_server_profiles.py | 32 +++++-- tests/hermes_cli/test_web_server.py | 84 +++++++++++++++++++ website/docs/user-guide/desktop.md | 1 + 8 files changed, 303 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/app/settings/custom-endpoints-settings.test.tsx b/apps/desktop/src/app/settings/custom-endpoints-settings.test.tsx index 8aa6f4b577..d144ea3dee 100644 --- a/apps/desktop/src/app/settings/custom-endpoints-settings.test.tsx +++ b/apps/desktop/src/app/settings/custom-endpoints-settings.test.tsx @@ -6,6 +6,7 @@ import type { CustomEndpointsResponse } from '@/types/hermes' const getCustomEndpoints = vi.fn() const saveCustomEndpoint = vi.fn() +const validateCustomEndpoint = vi.fn() const notify = vi.fn() const notifyError = vi.fn() const triggerHaptic = vi.fn() @@ -16,7 +17,7 @@ vi.mock('@/hermes', async importOriginal => ({ deleteCustomEndpoint: vi.fn(), getCustomEndpoints: (...args: unknown[]) => getCustomEndpoints(...args), saveCustomEndpoint: (...args: unknown[]) => saveCustomEndpoint(...args), - validateCustomEndpoint: vi.fn() + validateCustomEndpoint: (...args: unknown[]) => validateCustomEndpoint(...args) })) vi.mock('./profile-scope', () => ({ ActiveProfileNote: () => null })) vi.mock('@/lib/haptics', () => ({ triggerHaptic: (...args: unknown[]) => triggerHaptic(...args) })) @@ -54,6 +55,61 @@ afterEach(() => { }) describe('CustomEndpointsSettings', () => { + it('sends the chosen API mode and discovered alias metadata on Save (#93622)', async () => { + getCustomEndpoints.mockResolvedValue(emptyResponse) + validateCustomEndpoint.mockResolvedValue({ + message: '', + model_details: [ + { id: 'gpt-5.6-sol' }, + { canonical_model: 'gpt-5.6-sol', id: 'gpt-5.6-sol-high', reasoning_effort: 'high' } + ], + models: ['gpt-5.6-sol', 'gpt-5.6-sol-high'], + ok: true, + reachable: true + }) + saveCustomEndpoint.mockResolvedValue(savedResponse) + const { CustomEndpointsSettings } = await import('./custom-endpoints-settings') + + render() + + await screen.findByText('No custom endpoints') + fireEvent.change(screen.getByPlaceholderText('Axet Proxy'), { target: { value: 'Responses gateway' } }) + fireEvent.change(screen.getByPlaceholderText('http://127.0.0.1:8081/v1'), { + target: { value: 'https://responses-gateway.example.com/v1' } + }) + fireEvent.click(screen.getByRole('button', { name: 'Responses API' })) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Test' })) + }) + fireEvent.change(screen.getByPlaceholderText('gpt-5.4'), { target: { value: 'gpt-5.6-sol-high' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + expect(validateCustomEndpoint).toHaveBeenCalledWith(expect.objectContaining({ api_mode: 'codex_responses' })) + expect(saveCustomEndpoint).toHaveBeenCalledWith( + expect.objectContaining({ + api_mode: 'codex_responses', + model: 'gpt-5.6-sol-high', + model_details: expect.arrayContaining([ + expect.objectContaining({ canonical_model: 'gpt-5.6-sol', id: 'gpt-5.6-sol-high', reasoning_effort: 'high' }) + ]), + models: ['gpt-5.6-sol', 'gpt-5.6-sol-high'] + }) + ) + }) + + it('hydrates the API mode from a saved endpoint', async () => { + getCustomEndpoints.mockResolvedValue({ + ...savedResponse, + endpoints: [{ ...savedResponse.endpoints[0], api_mode: 'anthropic_messages' }] + }) + const { CustomEndpointsSettings } = await import('./custom-endpoints-settings') + + render() + + await screen.findByText('Profile A') + expect(screen.getByRole('button', { name: 'Anthropic Messages' }).getAttribute('aria-pressed')).toBe('true') + }) + it('drops a pending save completion after its profile-scoped view unmounts', async () => { let resolveSave!: (value: CustomEndpointsResponse) => void saveCustomEndpoint.mockReturnValue(new Promise(resolve => (resolveSave = resolve))) diff --git a/apps/desktop/src/app/settings/custom-endpoints-settings.tsx b/apps/desktop/src/app/settings/custom-endpoints-settings.tsx index 6259c73f42..2fb04a9ade 100644 --- a/apps/desktop/src/app/settings/custom-endpoints-settings.tsx +++ b/apps/desktop/src/app/settings/custom-endpoints-settings.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { Input } from '@/components/ui/input' +import { SegmentedControl } from '@/components/ui/segmented-control' import { activateCustomEndpoint, deleteCustomEndpoint, @@ -16,7 +17,12 @@ import { Check, Globe, Loader2, Plus, Save, Trash2, Zap } from '@/lib/icons' import { cn } from '@/lib/utils' import { confirm } from '@/store/confirm' import { notify, notifyError } from '@/store/notifications' -import type { CustomEndpoint, CustomEndpointUpdate } from '@/types/hermes' +import type { + CustomEndpoint, + CustomEndpointApiMode, + CustomEndpointModelDetail, + CustomEndpointUpdate +} from '@/types/hermes' import { EmptyState, Pill, SectionHeading, SettingsContent, SettingsSkeleton } from './primitives' import { ActiveProfileNote } from './profile-scope' @@ -28,6 +34,7 @@ interface CustomEndpointsSettingsProps { interface EndpointForm { apiKey: string + apiMode: CustomEndpointApiMode baseUrl: string contextLength: string discoverModels: boolean @@ -37,8 +44,18 @@ interface EndpointForm { name: string } +// Same choices as `hermes model`'s custom-provider setup; '' = runtime auto-detect. +// This panel is not internationalized — keep the literals it has. +const API_MODE_OPTIONS: readonly { id: CustomEndpointApiMode; label: string }[] = [ + { id: '', label: 'Auto-detect' }, + { id: 'chat_completions', label: 'Chat Completions' }, + { id: 'codex_responses', label: 'Responses API' }, + { id: 'anthropic_messages', label: 'Anthropic Messages' } +] + const EMPTY_FORM: EndpointForm = { apiKey: '', + apiMode: '', baseUrl: '', contextLength: '', discoverModels: true, @@ -51,6 +68,7 @@ const EMPTY_FORM: EndpointForm = { function formFromEndpoint(endpoint: CustomEndpoint): EndpointForm { return { apiKey: '', + apiMode: endpoint.api_mode ?? '', baseUrl: endpoint.base_url, contextLength: endpoint.context_length ? String(endpoint.context_length) : '', discoverModels: endpoint.discover_models, @@ -61,7 +79,11 @@ function formFromEndpoint(endpoint: CustomEndpoint): EndpointForm { } } -function toPayload(form: EndpointForm, models?: string[]): CustomEndpointUpdate { +function toPayload( + form: EndpointForm, + models?: string[], + modelDetails?: CustomEndpointModelDetail[] +): CustomEndpointUpdate { const contextLength = Number.parseInt(form.contextLength, 10) return { @@ -70,10 +92,12 @@ function toPayload(form: EndpointForm, models?: string[]): CustomEndpointUpdate base_url: form.baseUrl.trim(), model: form.model.trim(), api_key: form.apiKey.trim() || undefined, + api_mode: form.apiMode, context_length: Number.isFinite(contextLength) && contextLength > 0 ? contextLength : undefined, discover_models: form.discoverModels, make_default: form.makeDefault, - models: models?.length ? models : undefined + models: models?.length ? models : undefined, + model_details: modelDetails?.length ? modelDetails : undefined } } @@ -88,6 +112,9 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C const [endpoints, setEndpoints] = useState([]) const [form, setForm] = useState(EMPTY_FORM) const [discoveredModels, setDiscoveredModels] = useState([]) + // Alias metadata from the last Test; the backend resolves a picked alias to its + // canonical model + reasoning effort on Save (#93622). + const [discoveredDetails, setDiscoveredDetails] = useState([]) async function refresh() { const data = await getCustomEndpoints() @@ -137,7 +164,7 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C async function handleSave() { try { setSaving(true) - const response = await saveCustomEndpoint(toPayload(form, discoveredModels)) + const response = await saveCustomEndpoint(toPayload(form, discoveredModels, discoveredDetails)) if (!mounted.current) { return @@ -179,6 +206,7 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C } setDiscoveredModels(response.models) + setDiscoveredDetails(response.model_details ?? []) if (response.ok) { if (!form.model && response.models[0]) { @@ -256,6 +284,7 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C if (form.id === endpoint.id) { setForm(EMPTY_FORM) setDiscoveredModels([]) + setDiscoveredDetails([]) } onConfigSaved?.() @@ -293,6 +322,7 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C onClick={() => { setForm(formFromEndpoint(endpoint)) setDiscoveredModels(endpoint.models) + setDiscoveredDetails([]) }} type="button" > @@ -377,6 +407,15 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C value={form.baseUrl} /> +
+ API Mode + setForm(current => ({ ...current, apiMode }))} + options={API_MODE_OPTIONS} + value={form.apiMode} + /> +