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:
@@ -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'
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user