diff --git a/apps/desktop/src/api/config.ts b/apps/desktop/src/api/config.ts index 0de58dbda2..c37275ab99 100644 --- a/apps/desktop/src/api/config.ts +++ b/apps/desktop/src/api/config.ts @@ -171,41 +171,51 @@ export function validateProviderCredential( }) } -export function getCustomEndpoints(): Promise { +export function getCustomEndpoints(profile?: null | string): Promise { return hermesApi({ - ...profileScoped(), + ...profileScoped(profile), path: '/api/providers/custom-endpoints' }) } -export function saveCustomEndpoint(endpoint: CustomEndpointUpdate): Promise { +export function saveCustomEndpoint( + endpoint: CustomEndpointUpdate, + profile?: null | string +): Promise { return hermesApi({ - ...profileScoped(), + ...profileScoped(profile), path: '/api/providers/custom-endpoints', method: 'POST', body: endpoint }) } -export function validateCustomEndpoint(endpoint: CustomEndpointUpdate): Promise { +export function validateCustomEndpoint( + endpoint: CustomEndpointUpdate, + profile?: null | string +): Promise { return hermesApi({ + ...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 { +export function deleteCustomEndpoint(id: string, profile?: null | string): Promise { return hermesApi({ - ...profileScoped(), + ...profileScoped(profile), path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}`, method: 'DELETE' }) 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 0898fda3fe..aeb2277d4a 100644 --- a/apps/desktop/src/app/settings/custom-endpoints-settings.test.tsx +++ b/apps/desktop/src/app/settings/custom-endpoints-settings.test.tsx @@ -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>()), 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() + + 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, diff --git a/apps/desktop/src/app/settings/custom-endpoints-settings.tsx b/apps/desktop/src/app/settings/custom-endpoints-settings.tsx index 0ee4a247fc..15374349d9 100644 --- a/apps/desktop/src/app/settings/custom-endpoints-settings.tsx +++ b/apps/desktop/src/app/settings/custom-endpoints-settings.tsx @@ -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([]) 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 + return ( + + + + + ) } const allModelOptions = Array.from(new Set([...discoveredModels, form.model].filter(Boolean))) @@ -320,7 +336,7 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C return ( - +
diff --git a/apps/desktop/src/app/settings/profile-scope.test.tsx b/apps/desktop/src/app/settings/profile-scope.test.tsx index 327a97e898..54c0b27a0e 100644 --- a/apps/desktop/src/app/settings/profile-scope.test.tsx +++ b/apps/desktop/src/app/settings/profile-scope.test.tsx @@ -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') diff --git a/apps/desktop/src/app/settings/profile-scope.tsx b/apps/desktop/src/app/settings/profile-scope.tsx index 19a9d32968..f5121da112 100644 --- a/apps/desktop/src/app/settings/profile-scope.tsx +++ b/apps/desktop/src/app/settings/profile-scope.tsx @@ -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() diff --git a/apps/desktop/src/hermes.test.ts b/apps/desktop/src/hermes.test.ts index e1c801b1fb..ee55c9b693 100644 --- a/apps/desktop/src/hermes.test.ts +++ b/apps/desktop/src/hermes.test.ts @@ -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', () => { diff --git a/website/docs/user-guide/desktop.md b/website/docs/user-guide/desktop.md index 4f5c884d93..408cd8e2ba 100644 --- a/website/docs/user-guide/desktop.md +++ b/website/docs/user-guide/desktop.md @@ -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..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.