fix(desktop): honor Applies-to on Custom Endpoints

The page only showed a read-only active-profile note, so endpoint
saves followed the left-rail Bot instead of the Settings chips
Accounts and API keys already share.
This commit is contained in:
xxxigm
2026-09-20 22:33:34 +07:00
committed by Austin Pickett
parent 8ba5fe9c16
commit 050ea53aba
7 changed files with 136 additions and 38 deletions

View File

@@ -171,41 +171,51 @@ export function validateProviderCredential(
})
}
export function getCustomEndpoints(): Promise<CustomEndpointsResponse> {
export function getCustomEndpoints(profile?: null | string): Promise<CustomEndpointsResponse> {
return hermesApi<CustomEndpointsResponse>({
...profileScoped(),
...profileScoped(profile),
path: '/api/providers/custom-endpoints'
})
}
export function saveCustomEndpoint(endpoint: CustomEndpointUpdate): Promise<CustomEndpointsResponse> {
export function saveCustomEndpoint(
endpoint: CustomEndpointUpdate,
profile?: null | string
): Promise<CustomEndpointsResponse> {
return hermesApi<CustomEndpointsResponse>({
...profileScoped(),
...profileScoped(profile),
path: '/api/providers/custom-endpoints',
method: 'POST',
body: endpoint
})
}
export function validateCustomEndpoint(endpoint: CustomEndpointUpdate): Promise<CustomEndpointValidationResponse> {
export function validateCustomEndpoint(
endpoint: CustomEndpointUpdate,
profile?: null | string
): Promise<CustomEndpointValidationResponse> {
return hermesApi<CustomEndpointValidationResponse>({
...profileScoped(profile),
path: '/api/providers/custom-endpoints/validate',
method: 'POST',
body: endpoint
})
}
export function activateCustomEndpoint(id: string): Promise<{ ok: boolean; provider: string; model: string }> {
export function activateCustomEndpoint(
id: string,
profile?: null | string
): Promise<{ ok: boolean; provider: string; model: string }> {
return hermesApi<{ ok: boolean; provider: string; model: string }>({
...profileScoped(),
...profileScoped(profile),
path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}/activate`,
method: 'POST'
})
}
export function deleteCustomEndpoint(id: string): Promise<CustomEndpointsResponse> {
export function deleteCustomEndpoint(id: string, profile?: null | string): Promise<CustomEndpointsResponse> {
return hermesApi<CustomEndpointsResponse>({
...profileScoped(),
...profileScoped(profile),
path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}`,
method: 'DELETE'
})

View File

@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { atom } from 'nanostores'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { CustomEndpointsResponse } from '@/types/hermes'
@@ -11,15 +12,24 @@ const notify = vi.fn()
const notifyError = vi.fn()
const triggerHaptic = vi.fn()
vi.mock('@/store/profile', () => ({
$activeGatewayProfile: atom('default'),
$profiles: atom([]),
refreshProfiles: async () => {},
normalizeProfileKey: (p: string | null) => p || 'default',
profileLabel: (p: { display_name?: string; name: string }) => p.display_name || p.name
}))
vi.mock('@/hermes', async importOriginal => ({
...(await importOriginal<Record<string, unknown>>()),
activateCustomEndpoint: vi.fn(),
deleteCustomEndpoint: vi.fn(),
getCustomEndpoints: (...args: unknown[]) => getCustomEndpoints(...args),
getProfiles: async () => ({ profiles: (await import('@/store/profile')).$profiles.get() }),
saveCustomEndpoint: (...args: unknown[]) => saveCustomEndpoint(...args),
setApiRequestProfile: vi.fn(),
validateCustomEndpoint: (...args: unknown[]) => validateCustomEndpoint(...args)
}))
vi.mock('./profile-scope', () => ({ ActiveProfileNote: () => null }))
vi.mock('@/lib/haptics', () => ({ triggerHaptic: (...args: unknown[]) => triggerHaptic(...args) }))
vi.mock('@/store/notifications', () => ({
notify: (...args: unknown[]) => notify(...args),
@@ -49,9 +59,19 @@ const savedResponse: CustomEndpointsResponse = {
ok: true
}
afterEach(() => {
beforeEach(async () => {
const { $activeGatewayProfile, $profiles } = await import('@/store/profile')
const { $settingsScopeOverride } = await import('@/store/settings-scope')
$activeGatewayProfile.set('default')
$settingsScopeOverride.set(null)
$profiles.set([])
})
afterEach(async () => {
cleanup()
vi.clearAllMocks()
const { $settingsScopeOverride } = await import('@/store/settings-scope')
$settingsScopeOverride.set(null)
})
describe('CustomEndpointsSettings', () => {
@@ -85,7 +105,10 @@ describe('CustomEndpointsSettings', () => {
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(validateCustomEndpoint).toHaveBeenCalledWith(
expect.objectContaining({ api_mode: 'codex_responses' }),
undefined
)
expect(notify).toHaveBeenCalledWith({
kind: 'success',
message: 'Endpoint is reachable (Responses API route served). Found 2 models.'
@@ -98,10 +121,46 @@ describe('CustomEndpointsSettings', () => {
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']
})
}),
undefined
)
})
it('loads and saves endpoints for the Settings Applies-to profile, not only the active bot', async () => {
const { $activeGatewayProfile, $profiles } = await import('@/store/profile')
const { $settingsScopeOverride } = await import('@/store/settings-scope')
$activeGatewayProfile.set('carousel-director')
$settingsScopeOverride.set('content-studio')
$profiles.set(
['carousel-director', 'content-studio'].map(name => ({
name,
has_env: false,
is_default: false,
model: null,
path: '',
provider: null,
skill_count: 0
}))
)
getCustomEndpoints.mockResolvedValue(emptyResponse)
saveCustomEndpoint.mockResolvedValue(savedResponse)
const { CustomEndpointsSettings } = await import('./custom-endpoints-settings')
render(<CustomEndpointsSettings />)
await waitFor(() => expect(getCustomEndpoints).toHaveBeenCalledWith('content-studio'))
expect(screen.getByText('Applies to')).toBeTruthy()
fireEvent.change(screen.getByPlaceholderText('Axet Proxy'), { target: { value: 'Studio gateway' } })
fireEvent.change(screen.getByPlaceholderText('http://127.0.0.1:8081/v1'), {
target: { value: 'https://studio.example.com/v1' }
})
fireEvent.change(screen.getByPlaceholderText('gpt-5.4'), { target: { value: 'studio-model' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(saveCustomEndpoint).toHaveBeenCalledWith(expect.objectContaining({ name: 'Studio gateway' }), 'content-studio')
})
it('hydrates the API mode from a saved endpoint', async () => {
getCustomEndpoints.mockResolvedValue({
...savedResponse,

View File

@@ -1,3 +1,4 @@
import { useStore } from '@nanostores/react'
import { useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
@@ -17,6 +18,7 @@ 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 { $settingsRequestProfile } from '@/store/settings-scope'
import type {
CustomEndpoint,
CustomEndpointApiMode,
@@ -25,7 +27,7 @@ import type {
} from '@/types/hermes'
import { EmptyState, Pill, SectionHeading, SettingsContent, SettingsSkeleton } from './primitives'
import { ActiveProfileNote } from './profile-scope'
import { SettingsProfileScope } from './profile-scope'
interface CustomEndpointsSettingsProps {
onConfigSaved?: () => void
@@ -103,6 +105,10 @@ function toPayload(
export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: CustomEndpointsSettingsProps) {
const { t } = useI18n()
// Shared settings "Applies to" scope: read/write this profile's endpoints,
// not whichever Bot is active in the left rail. Undefined follows the
// active profile (request-shaped — never pass null, which retargets primary).
const scopeProfile = useStore($settingsRequestProfile)
const mounted = useRef(false)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
@@ -117,7 +123,7 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
const [discoveredDetails, setDiscoveredDetails] = useState<CustomEndpointModelDetail[]>([])
async function refresh() {
const data = await getCustomEndpoints()
const data = await getCustomEndpoints(scopeProfile)
if (mounted.current) {
setEndpoints(data.endpoints)
@@ -128,10 +134,15 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
useEffect(() => {
let cancelled = false
mounted.current = true
setLoading(true)
setForm(EMPTY_FORM)
setDiscoveredModels([])
setDiscoveredDetails([])
setEndpoints([])
async function load() {
try {
const data = await getCustomEndpoints()
const data = await getCustomEndpoints(scopeProfile)
if (cancelled) {
return
@@ -159,12 +170,12 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
cancelled = true
mounted.current = false
}
}, [])
}, [scopeProfile])
async function handleSave() {
try {
setSaving(true)
const response = await saveCustomEndpoint(toPayload(form, discoveredModels, discoveredDetails))
const response = await saveCustomEndpoint(toPayload(form, discoveredModels, discoveredDetails), scopeProfile)
if (!mounted.current) {
return
@@ -199,7 +210,7 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
async function handleValidate() {
try {
setTesting(true)
const response = await validateCustomEndpoint(toPayload(form))
const response = await validateCustomEndpoint(toPayload(form), scopeProfile)
if (!mounted.current) {
return
@@ -250,7 +261,7 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
async function handleActivate(endpoint: CustomEndpoint) {
try {
setActivating(endpoint.id)
const response = await activateCustomEndpoint(endpoint.id)
const response = await activateCustomEndpoint(endpoint.id, scopeProfile)
if (!mounted.current) {
return
@@ -284,7 +295,7 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
try {
setDeleting(endpoint.id)
const response = await deleteCustomEndpoint(endpoint.id)
const response = await deleteCustomEndpoint(endpoint.id, scopeProfile)
if (!mounted.current) {
return
@@ -312,7 +323,12 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
}
if (loading) {
return <SettingsSkeleton sections={[{ heading: true, rows: 3 }]} />
return (
<SettingsContent>
<SettingsProfileScope className="mb-5" />
<SettingsSkeleton sections={[{ heading: true, rows: 3 }]} />
</SettingsContent>
)
}
const allModelOptions = Array.from(new Set([...discoveredModels, form.model].filter(Boolean)))
@@ -320,7 +336,7 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
return (
<SettingsContent>
<ActiveProfileNote className="mb-5" />
<SettingsProfileScope className="mb-5" />
<div className="space-y-6">
<section>
<SectionHeading icon={Globe} meta={`${endpoints.length}`} page title={t.settings.customEndpoints.title} />

View File

@@ -139,9 +139,9 @@ describe('SettingsProfileScope', () => {
})
})
// Custom Endpoints / Local Models send unscoped requests, so they always edit
// the ACTIVE profile; the note must say which one — and stay silent for
// single-profile users, like the selector.
// Local Models sends unscoped requests, so it always edits the ACTIVE profile;
// the note must say which one — and stay silent for single-profile users, like
// the selector.
describe('ActiveProfileNote', () => {
it('names the active profile (by its chip label) only with two or more profiles', () => {
$activeGatewayProfile.set('setup')

View File

@@ -44,11 +44,11 @@ export function ScopeChip({ active, label, onSelect }: { active: boolean; label:
}
/** Shared "Applies to" profile selector for the config-backed settings pages
* (Model, Workspace, Safety, Memory & Context, Voice, Tools & Keys) and the
* Messaging overlay. Backed by one nanostore ($settingsScopeOverride) so the
* selection persists across pages. Hidden with fewer than two profiles, so
* single-profile users never see it and every request keeps its unscoped
* default shape. */
* (Model, Workspace, Safety, Memory & Context, Voice, Tools & Keys), Custom
* Endpoints, and the Messaging overlay. Backed by one nanostore
* ($settingsScopeOverride) so the selection persists across pages. Hidden with
* fewer than two profiles, so single-profile users never see it and every
* request keeps its unscoped default shape. */
export function SettingsProfileScope({ className }: { className?: string }) {
const { t } = useI18n()
const scope = t.settings.profileScope
@@ -108,9 +108,9 @@ export function SettingsProfileScope({ className }: { className?: string }) {
)
}
/** Read-only note for Providers pages whose requests carry no scope and so
* always edit the app's ACTIVE profile (Custom Endpoints, Local Models) — the
* reporter's "which profile am I editing?" gap. Same string as the selector's
/** Read-only note for Providers pages whose requests carry no settings-scope
* override and so always edit the app's ACTIVE profile (Local Models — the
* managed llama.cpp runtime is machine-scoped). Same string as the selector's
* note above; hidden with fewer than two profiles like the selector itself. */
export function ActiveProfileNote({ className }: { className?: string }) {
const { t } = useI18n()

View File

@@ -11,6 +11,7 @@ import {
deleteSession,
getAllSessionMessages,
getCronJobs,
getCustomEndpoints,
getGlobalModelInfo,
getGlobalModelOptions,
getHermesConfig,
@@ -765,6 +766,18 @@ describe('Hermes REST helpers', () => {
})
)
})
it('scopes custom endpoint reads to the requested settings profile', async () => {
await getCustomEndpoints('content-studio')
expect(api).toHaveBeenCalledWith(
expect.objectContaining({
path: '/api/providers/custom-endpoints',
profile: 'content-studio',
priority: 'foreground'
})
)
})
})
describe('pluginSocket', () => {

View File

@@ -194,7 +194,7 @@ When `hermes gui` runs inside WSL2 with `/dev/dxg` present and Mesa's `d3d12_dri
Manage providers, models, tools, and credentials from a real UI instead of editing YAML. First-run onboarding gets you to your first message in seconds. The settings panes cover providers/keys, model selection, toolset configuration, MCP servers, the gateway, and session management.
- **Providers settings pane** — a dedicated place to manage inference providers, with an Accounts / API-keys UX for signing in and storing credentials per provider. Accounts and API keys share the Settings **Applies to** selection: credential reads and edits, OAuth account removal, and sign-in launched here target the selected profile, not the active chat profile. The sign-in flow keeps that target through credential saving and model selection. Changing **Applies to** discards unsaved credential drafts. Closing sign-in cancels polling and ignores late results; a credential write already sent may still finish in its original profile. Externally managed CLI credentials use their own CLI and are not covered by this profile selector. Its **Local Models** view installs and manages an on-device llama.cpp runtime — see [Local Models](./local-models.md).
- **Providers settings pane** — a dedicated place to manage inference providers, with an Accounts / API-keys UX for signing in and storing credentials per provider. Accounts, API keys, and Custom Endpoints share the Settings **Applies to** selection: credential reads and edits, OAuth account removal, custom-endpoint save/test, and sign-in launched here target the selected profile, not the active chat profile. The sign-in flow keeps that target through credential saving and model selection. Changing **Applies to** discards unsaved credential drafts. Closing sign-in cancels polling and ignores late results; a credential write already sent may still finish in its original profile. Externally managed CLI credentials use their own CLI and are not covered by this profile selector. Its **Local Models** view installs and manages an on-device llama.cpp runtime — see [Local Models](./local-models.md).
- **Every provider and model in the menus** — the GUI surfaces the full provider list and every model that `hermes model` knows about, so you pick from the same catalog the CLI sees rather than a curated subset.
- **Custom endpoints with an API mode** — **Settings → Providers → Custom Endpoints** has an **API Mode** selector (**Auto-detect**, **Chat Completions**, **Responses API**, **Anthropic Messages**) — the same choice `hermes model` offers for a custom provider. It is saved as `providers.<id>.api_mode` in `config.yaml`, so a Responses-only or Anthropic-compatible host is no longer called on `/chat/completions`. **Test** checks the transport you will actually use, not just `/v1/models`: it sends a one-token request to the pinned mode's route (or to the mode Auto-detect resolves to) and fails with the transport named when the host does not serve it. **Test** also keeps the alias metadata a gateway advertises in `/v1/models` (`canonical_model`, `reasoning_effort`): picking an alias such as `gpt-5.6-sol-high` saves the canonical model and pins its effort under `agent.reasoning_overrides`.
- **xAI Grok OAuth** — Grok is a first-class OAuth provider in the launcher; sign in through the browser flow like the other OAuth providers.
@@ -211,7 +211,7 @@ First-run onboarding has been redesigned on a unified overlay design system, and
#### Per-profile settings: the "Applies to" scope
When you have two or more [profiles](./profiles.md), the config-backed settings pages — **Model, Workspace, Safety, Memory & Context, Voice, Chat, Advanced, and Tools & Keys** — and the **Messaging** overlay show a shared **Applies to** chip row at the top. It selects which profile your edits target:
When you have two or more [profiles](./profiles.md), the config-backed settings pages — **Model, Workspace, Safety, Memory & Context, Voice, Chat, Advanced, and Tools & Keys** — plus **Providers → Custom Endpoints** and the **Messaging** overlay show a shared **Applies to** chip row at the top. It selects which profile your edits target:
- The default selection **follows the active profile**, which behaves exactly as before — edit the profile you're using.
- Pick another profile to view and edit *its* settings without switching the whole app; the selection persists as you move between settings pages.