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} + /> +