From 532a76c180beba5bdcba8afd11f126ae66b613d5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Mon, 11 May 2026 01:02:11 -0700 Subject: [PATCH] feat(config): configurable OpenAI-compatible backend provider (#1342) (#1467) --- app/src/components/settings/SettingsHome.tsx | 2 +- .../settings/panels/BackendProviderPanel.tsx | 511 ++++++++++++++++++ .../__tests__/BackendProviderPanel.test.tsx | 185 +++++++ app/src/pages/Settings.tsx | 20 +- .../tauriCommands/__tests__/config.test.ts | 42 ++ app/src/utils/tauriCommands/config.ts | 33 ++ src/openhuman/config/ops.rs | 11 + src/openhuman/config/ops_tests.rs | 90 +++ src/openhuman/config/schemas.rs | 51 +- 9 files changed, 940 insertions(+), 5 deletions(-) create mode 100644 app/src/components/settings/panels/BackendProviderPanel.tsx create mode 100644 app/src/components/settings/panels/__tests__/BackendProviderPanel.test.tsx create mode 100644 app/src/utils/tauriCommands/__tests__/config.test.ts diff --git a/app/src/components/settings/SettingsHome.tsx b/app/src/components/settings/SettingsHome.tsx index 97b4f507d..51635f0b4 100644 --- a/app/src/components/settings/SettingsHome.tsx +++ b/app/src/components/settings/SettingsHome.tsx @@ -176,7 +176,7 @@ const SettingsHome = () => { { id: 'ai-models', title: 'AI & Models', - description: 'Local AI model setup and downloads', + description: 'Local AI model setup, downloads, and LLM provider', icon: ( = { + reasoning: { label: 'Reasoning', help: 'Deep, multi-step thinking and planning.' }, + agentic: { label: 'Agentic', help: 'Tool use, sub-agent delegation, function calling.' }, + coding: { label: 'Coding', help: 'Code generation, refactoring, and review.' }, + summarization: { label: 'Summarization', help: 'Fast, cheap summaries and short responses.' }, +}; + +type RoleModels = Record; + +const EMPTY_ROLE_MODELS: RoleModels = { reasoning: '', agentic: '', coding: '', summarization: '' }; + +interface ProviderPreset { + /** Stable identifier — also drives the preset card key. */ + id: string; + label: string; + /** OpenAI-compatible base URL ending in `/v1`. Empty = use OpenHuman default. */ + apiUrl: string; + /** Suggested single default model id; ignored for OpenHuman (router-managed). */ + suggestedModel: string; + /** + * Per-role suggested models for the core router. `null` for OpenHuman since + * its built-in router picks per task without an external model_routes table. + */ + roleModels: RoleModels | null; + /** Short hint shown beneath the preset row when this preset is active. */ + note: string; + /** + * Tailwind classes giving the card a subtle brand-aligned tint. A colour + * cue, not a brand reproduction. + */ + tint: { idle: string; selected: string; dot: string }; +} + +/** + * Curated list of OpenAI-compatible providers (#1342). The core uses + * `OpenAiCompatibleProvider` (`/chat/completions` shape); Anthropic ships an + * OpenAI-compat shim at `https://api.anthropic.com/v1` so it lives here too. + */ +const PROVIDER_PRESETS: ProviderPreset[] = [ + { + id: 'openhuman', + label: 'OpenHuman', + apiUrl: 'https://api.tinyhumans.ai/openai/v1/chat/completions', + suggestedModel: '', + roleModels: null, + note: 'Hosted OpenHuman backend — uses your signed-in session, no API key required.', + tint: { + idle: 'border-stone-200 hover:border-primary-300 hover:bg-primary-50/40', + selected: 'border-primary-500 bg-primary-100 ring-2 ring-primary-300 text-primary-900', + dot: 'bg-primary-500', + }, + }, + { + id: 'openai', + label: 'OpenAI', + apiUrl: 'https://api.openai.com/v1/chat/completions', + suggestedModel: 'gpt-5.5-2026-04-23', + roleModels: { + reasoning: 'gpt-5.5-2026-04-23', + agentic: 'gpt-5.5-2026-04-23', + coding: 'gpt-4o', + summarization: 'gpt-4o-mini', + }, + note: 'Use a key from platform.openai.com. Defaults pick gpt-5.5 for reasoning and agentic, gpt-4o for coding, gpt-4o-mini for summarization.', + tint: { + idle: 'border-stone-200 hover:border-sage-400 hover:bg-sage-50/40', + selected: 'border-sage-600 bg-sage-100 ring-2 ring-sage-300 text-sage-900', + dot: 'bg-sage-600', + }, + }, + { + id: 'anthropic', + label: 'Anthropic', + // Anthropic ships an OpenAI-compatibility shim at /v1/chat/completions + // that maps to the same Claude models — see docs.anthropic.com/en/api/openai-sdk. + apiUrl: 'https://api.anthropic.com/v1/chat/completions', + suggestedModel: 'claude-sonnet-4-6', + roleModels: { + reasoning: 'claude-opus-4-7', + agentic: 'claude-sonnet-4-6', + coding: 'claude-sonnet-4-6', + summarization: 'claude-haiku-4-5-20251001', + }, + note: 'Uses Anthropic’s OpenAI-compatibility endpoint with a key from console.anthropic.com. Defaults: Opus 4.7 reasoning, Sonnet 4.6 agentic/coding, Haiku 4.5 summarization.', + tint: { + idle: 'border-stone-200 hover:border-coral-400 hover:bg-coral-50/40', + selected: 'border-coral-600 bg-coral-100 ring-2 ring-coral-300 text-coral-900', + dot: 'bg-coral-600', + }, + }, + { + id: 'openrouter', + label: 'OpenRouter', + apiUrl: 'https://openrouter.ai/api/v1/chat/completions', + suggestedModel: 'openai/gpt-4o', + roleModels: { + reasoning: 'openai/o1', + agentic: 'anthropic/claude-sonnet-4.6', + coding: 'anthropic/claude-sonnet-4.6', + summarization: 'openai/gpt-4o-mini', + }, + note: 'One key, dozens of providers (openrouter.ai). Mix and match per role — swap to meta-llama/llama-3.3-70b-instruct, google/gemini-2.0-flash, etc.', + tint: { + idle: 'border-stone-200 hover:border-amber-400 hover:bg-amber-50/40', + selected: 'border-amber-600 bg-amber-100 ring-2 ring-amber-300 text-amber-900', + dot: 'bg-amber-500', + }, + }, + { + id: 'ollama', + label: 'Ollama (local)', + apiUrl: 'http://localhost:11434/v1/chat/completions', + suggestedModel: 'llama3.3', + roleModels: { + reasoning: 'llama3.3', + agentic: 'llama3.3', + coding: 'qwen2.5-coder', + summarization: 'llama3.2', + }, + note: 'Local Ollama runtime via its OpenAI-compatible endpoint. API key is ignored — leave blank.', + tint: { + idle: 'border-stone-200 hover:border-stone-400 hover:bg-stone-50', + selected: 'border-stone-500 bg-stone-200 ring-2 ring-stone-300 text-stone-900', + dot: 'bg-stone-500', + }, + }, + { + id: 'custom', + label: 'Custom', + apiUrl: '', + suggestedModel: '', + roleModels: { ...EMPTY_ROLE_MODELS }, + note: 'Any other endpoint that speaks the OpenAI /chat/completions shape (vLLM, LiteLLM, LM Studio, self-hosted gateways).', + tint: { + idle: 'border-stone-200 hover:border-stone-400 hover:bg-stone-50', + selected: 'border-stone-500 bg-stone-200 ring-2 ring-stone-300 text-stone-900', + dot: 'bg-stone-400', + }, + }, +]; + +function detectPreset(apiUrl: string): ProviderPreset { + const trimmed = apiUrl.trim(); + if (!trimmed) return PROVIDER_PRESETS[0]; + const match = PROVIDER_PRESETS.find(p => p.apiUrl && p.apiUrl === trimmed); + if (match) return match; + return PROVIDER_PRESETS[PROVIDER_PRESETS.length - 1]; // custom +} + +/** + * Configure the LLM provider (#1342). Defaults to the hosted OpenHuman + * backend, whose built-in router picks the best model per request. Selecting + * any other preset reveals per-role model inputs (reasoning / agentic / + * coding / summarization) that get persisted to `config.model_routes` so the + * core router obeys them. + * + * The api_key is stored on the user's machine in `config.toml`. It is sent + * over the local Tauri↔core RPC only at write time; subsequent reads return + * only `api_key_set: bool` so the secret never leaves the core process once + * persisted. + */ +const BackendProviderPanel = () => { + const { navigateBack, breadcrumbs } = useSettingsNavigation(); + const [loaded, setLoaded] = useState(false); + const [client, setClient] = useState(null); + const [apiUrl, setApiUrl] = useState(''); + const [apiKey, setApiKey] = useState(''); + const [apiKeyDirty, setApiKeyDirty] = useState(false); + // Per-field dirty flags so a failed `load()` (which leaves the inputs at + // their empty defaults) can't silently overwrite stored config when the + // user clicks Save. CodeRabbit feedback on PR #1467. + const [apiUrlDirty, setApiUrlDirty] = useState(false); + const [roleModelsDirty, setRoleModelsDirty] = useState(false); + const [roleModels, setRoleModels] = useState(EMPTY_ROLE_MODELS); + // Explicit active-preset state. We can't derive this from `apiUrl` alone + // because OpenHuman and Custom both store an empty URL — clicking Custom + // would otherwise snap back to OpenHuman on the next render. + const [activePresetId, setActivePresetId] = useState(PROVIDER_PRESETS[0].id); + const [saving, setSaving] = useState(false); + const [status, setStatus] = useState<{ kind: 'idle' | 'ok' | 'error'; message: string }>({ + kind: 'idle', + message: '', + }); + + const load = useCallback(async () => { + try { + log('[llm-provider] loading client config'); + const response = await openhumanGetClientConfig(); + const config = response.result; + setClient(config); + const persistedUrl = config.api_url ?? ''; + setApiUrl(persistedUrl); + setActivePresetId(detectPreset(persistedUrl).id); + setApiKey(''); + setApiKeyDirty(false); + setApiUrlDirty(false); + setRoleModelsDirty(false); + setLoaded(true); + } catch (err) { + log('failed to load client config: %s', err instanceof Error ? err.message : 'unknown'); + setStatus({ + kind: 'error', + message: + err instanceof Error + ? `Failed to load current settings: ${err.message}` + : 'Failed to load current settings.', + }); + setLoaded(true); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const activePreset = useMemo( + () => PROVIDER_PRESETS.find(p => p.id === activePresetId) ?? PROVIDER_PRESETS[0], + [activePresetId] + ); + const isOpenHuman = activePreset.id === 'openhuman'; + + const applyPreset = useCallback((preset: ProviderPreset) => { + setActivePresetId(preset.id); + setApiUrl(preset.apiUrl); + setApiUrlDirty(true); + // Reset role models to the preset's defaults so each switch gives a + // clean, opinionated starting point. + setRoleModels(preset.roleModels ? { ...preset.roleModels } : { ...EMPTY_ROLE_MODELS }); + setRoleModelsDirty(true); + setStatus({ kind: 'idle', message: '' }); + }, []); + + const handleSave = useCallback(async () => { + setSaving(true); + setStatus({ kind: 'idle', message: '' }); + try { + // Build model_routes from role state when a non-OpenHuman preset is + // active. Empty roles are filtered so the router doesn't dispatch to + // an empty model id. Switching back to OpenHuman sends [] so the + // built-in router takes over. We only send routes when the user has + // actually changed the provider or edited a role input — keeps a + // stale Save click after a failed `load()` from clobbering stored + // config (CodeRabbit #1467). + const routesTouched = apiUrlDirty || roleModelsDirty; + const routes: ModelRoute[] | undefined = !routesTouched + ? undefined + : isOpenHuman + ? [] + : ROLE_HINTS.flatMap(hint => { + const model = roleModels[hint].trim(); + return model ? [{ hint, model }] : []; + }); + await openhumanUpdateModelSettings({ + api_url: apiUrlDirty ? apiUrl : undefined, + api_key: apiKeyDirty ? apiKey : undefined, + model_routes: routes, + }); + setStatus({ kind: 'ok', message: 'LLM provider settings saved.' }); + await load(); + } catch (err) { + log('save failed: %s', err instanceof Error ? err.message : 'unknown'); + setStatus({ + kind: 'error', + message: + err instanceof Error ? `Failed to save: ${err.message}` : 'Failed to save settings.', + }); + } finally { + setSaving(false); + } + }, [apiKey, apiKeyDirty, apiUrl, apiUrlDirty, isOpenHuman, load, roleModels, roleModelsDirty]); + + const handleClearKey = useCallback(async () => { + setSaving(true); + setStatus({ kind: 'idle', message: '' }); + try { + await openhumanUpdateModelSettings({ api_key: '' }); + setStatus({ kind: 'ok', message: 'API key cleared.' }); + await load(); + } catch (err) { + log('clear key failed: %s', err instanceof Error ? err.message : 'unknown'); + setStatus({ + kind: 'error', + message: + err instanceof Error ? `Failed to clear key: ${err.message}` : 'Failed to clear key.', + }); + } finally { + setSaving(false); + } + }, [load]); + + return ( +
+ +
+

+ Pick where inference runs. Any OpenAI-compatible provider (OpenAI, Anthropic, OpenRouter, + Ollama, your own gateway). +

+ + {!loaded ? ( +
Loading current settings…
+ ) : ( + <> +
+ +
+ {PROVIDER_PRESETS.map(preset => { + const selected = preset.id === activePreset.id; + return ( + + ); + })} +
+

{activePreset.note}

+
+ + {isOpenHuman && ( +
+
+

+ Congrats! You’re using the most optimized setup +

+

+ OpenHuman's built-in smart router picks the best model giving you top-tier + quality at the lowest blended cost. All within your current subscription. +

+
+
+ )} + + {!isOpenHuman && ( +
+

+ Consider switching to OpenHuman as it comes with a built-in smart router that + picks the best model for each request, cutting costs and improving quality. +

+
+ )} + + {activePreset.id === 'custom' && ( +
+ + { + setApiUrl(e.target.value); + setApiUrlDirty(true); + }} + placeholder="https://your-host.example/v1/chat/completions" + className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 placeholder:text-stone-400 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200" + autoComplete="off" + spellCheck={false} + /> +

+ Full URL of the OpenAI-compatible chat-completions endpoint for your gateway. +

+
+ )} + + {!isOpenHuman && ( +
+
+ + {client?.api_key_set && ( + + )} +
+ { + setApiKey(e.target.value); + setApiKeyDirty(true); + }} + placeholder={ + client?.api_key_set ? `${KEY_PLACEHOLDER} (replace to change)` : 'sk-…' + } + className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 placeholder:text-stone-400 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200" + autoComplete="off" + spellCheck={false} + /> +

+ {client?.api_key_set ? 'A key is currently saved.' : 'No key is currently saved.'} +

+
+ )} + + {!isOpenHuman && ( +
+ +

+ The core router dispatches each task to the right model. Leave a field blank to + skip routing for that role. +

+
+ {ROLE_HINTS.map(hint => ( +
+ + { + const next = e.target.value; + setRoleModels(prev => ({ ...prev, [hint]: next })); + setRoleModelsDirty(true); + }} + placeholder={ROLE_LABELS[hint].help} + className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 placeholder:text-stone-400 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200" + autoComplete="off" + spellCheck={false} + /> +
+ ))} +
+
+ )} + +
+ + {status.kind === 'ok' && ( + {status.message} + )} + {status.kind === 'error' && ( + {status.message} + )} +
+ + )} +
+
+ ); +}; + +export default BackendProviderPanel; diff --git a/app/src/components/settings/panels/__tests__/BackendProviderPanel.test.tsx b/app/src/components/settings/panels/__tests__/BackendProviderPanel.test.tsx new file mode 100644 index 000000000..2163a8fa7 --- /dev/null +++ b/app/src/components/settings/panels/__tests__/BackendProviderPanel.test.tsx @@ -0,0 +1,185 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '../../../../test/test-utils'; +import { + openhumanGetClientConfig, + openhumanUpdateModelSettings, +} from '../../../../utils/tauriCommands'; +import BackendProviderPanel from '../BackendProviderPanel'; + +vi.mock('../../../../utils/tauriCommands', () => ({ + openhumanGetClientConfig: vi.fn(), + openhumanUpdateModelSettings: vi.fn(), +})); + +vi.mock('../../hooks/useSettingsNavigation', () => ({ + useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }), +})); + +function mockClientConfig(overrides: Record = {}) { + vi.mocked(openhumanGetClientConfig).mockResolvedValue({ + result: { + api_url: '', + default_model: '', + app_version: '0.0.0-test', + api_key_set: false, + ...overrides, + }, + messages: [], + } as unknown as Awaited>); +} + +function mockUpdateOk() { + vi.mocked(openhumanUpdateModelSettings).mockResolvedValue({ + result: { config: {}, workspace_dir: '', config_path: '' }, + messages: [], + } as unknown as Awaited>); +} + +describe('BackendProviderPanel', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUpdateOk(); + }); + + it('renders all provider preset chips and the OpenHuman success banner by default', async () => { + mockClientConfig(); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /OpenHuman/i })).toBeTruthy(); + }); + + for (const label of ['OpenHuman', 'OpenAI', 'Anthropic', 'OpenRouter', 'Ollama', 'Custom']) { + expect(screen.getByRole('button', { name: new RegExp(label, 'i') })).toBeTruthy(); + } + + // Congrats banner is visible because OpenHuman is the default + expect(screen.getByText(/Congrats! You.{1,3}re using the most optimized setup/i)).toBeTruthy(); + // URL input is hidden for OpenHuman + expect(screen.queryByLabelText(/API URL/i)).toBeNull(); + // API key field is hidden for OpenHuman + expect(screen.queryByLabelText(/API Key/i)).toBeNull(); + // No role inputs for OpenHuman + expect(screen.queryByLabelText(/Reasoning/i)).toBeNull(); + }); + + it('shows API key + role-model inputs when a non-OpenHuman preset is picked', async () => { + mockClientConfig(); + renderWithProviders(); + + const openaiChip = await screen.findByRole('button', { name: /^OpenAI$/i }); + fireEvent.click(openaiChip); + + // API key field appears + expect(await screen.findByLabelText(/API Key/i)).toBeTruthy(); + // Role inputs appear + expect(screen.getByLabelText(/Reasoning/i)).toBeTruthy(); + expect(screen.getByLabelText(/Agentic/i)).toBeTruthy(); + expect(screen.getByLabelText(/Coding/i)).toBeTruthy(); + expect(screen.getByLabelText(/Summarization/i)).toBeTruthy(); + // Congrats banner is gone + expect(screen.queryByText(/Congrats!/i)).toBeNull(); + // URL field still hidden (only shown for Custom) + expect(screen.queryByLabelText(/API URL/i)).toBeNull(); + }); + + it('reveals the URL input only when Custom is picked', async () => { + mockClientConfig(); + renderWithProviders(); + + const customChip = await screen.findByRole('button', { name: /^Custom$/i }); + fireEvent.click(customChip); + + expect(await screen.findByLabelText(/API URL/i)).toBeTruthy(); + }); + + it('Save sends model_routes derived from the selected preset and api_key when touched', async () => { + mockClientConfig(); + renderWithProviders(); + + fireEvent.click(await screen.findByRole('button', { name: /^Anthropic$/i })); + + const keyInput = await screen.findByLabelText(/API Key/i); + fireEvent.change(keyInput, { target: { value: 'sk-ant-test-123' } }); + + fireEvent.click(screen.getByRole('button', { name: /^Save$/i })); + + await waitFor(() => { + expect(openhumanUpdateModelSettings).toHaveBeenCalled(); + }); + const args = vi.mocked(openhumanUpdateModelSettings).mock.calls[0][0]; + expect(args.api_url).toBe('https://api.anthropic.com/v1/chat/completions'); + expect(args.api_key).toBe('sk-ant-test-123'); + expect(args.model_routes).toBeInstanceOf(Array); + const hints = (args.model_routes ?? []).map(r => r.hint).sort(); + expect(hints).toEqual(['agentic', 'coding', 'reasoning', 'summarization']); + }); + + it('Save sends model_routes:[] and no api_key when switching back to OpenHuman', async () => { + mockClientConfig({ api_url: 'https://api.openai.com/v1/chat/completions', api_key_set: true }); + renderWithProviders(); + + // Hydration finished — switch to OpenHuman explicitly + fireEvent.click(await screen.findByRole('button', { name: /^OpenHuman$/i })); + fireEvent.click(screen.getByRole('button', { name: /^Save$/i })); + + await waitFor(() => expect(openhumanUpdateModelSettings).toHaveBeenCalled()); + const args = vi.mocked(openhumanUpdateModelSettings).mock.calls[0][0]; + expect(args.model_routes).toEqual([]); + expect(args.api_key).toBeUndefined(); + }); + + it('omits all touched fields on Save when nothing was edited (post failed-load safety)', async () => { + mockClientConfig({ api_url: 'https://api.openai.com/v1/chat/completions', api_key_set: true }); + renderWithProviders(); + + // Just hit Save without touching anything + fireEvent.click(await screen.findByRole('button', { name: /^Save$/i })); + + await waitFor(() => expect(openhumanUpdateModelSettings).toHaveBeenCalled()); + const args = vi.mocked(openhumanUpdateModelSettings).mock.calls[0][0]; + expect(args.api_url).toBeUndefined(); + expect(args.api_key).toBeUndefined(); + expect(args.model_routes).toBeUndefined(); + }); + + it('surfaces a "Clear stored key" button only when api_key_set is true (non-OpenHuman)', async () => { + mockClientConfig({ api_url: 'https://api.openai.com/v1/chat/completions', api_key_set: true }); + renderWithProviders(); + + // Wait for the OpenAI preset to be active (api_url matches) + await waitFor(() => { + expect(screen.getByLabelText(/API Key/i)).toBeTruthy(); + }); + expect(screen.getByRole('button', { name: /Clear stored key/i })).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: /Clear stored key/i })); + await waitFor(() => expect(openhumanUpdateModelSettings).toHaveBeenCalled()); + const args = vi.mocked(openhumanUpdateModelSettings).mock.calls[0][0]; + expect(args.api_key).toBe(''); + }); + + it('shows an error status when the save RPC rejects', async () => { + mockClientConfig(); + vi.mocked(openhumanUpdateModelSettings).mockRejectedValueOnce(new Error('boom')); + renderWithProviders(); + + fireEvent.click(await screen.findByRole('button', { name: /^OpenAI$/i })); + fireEvent.click(screen.getByRole('button', { name: /^Save$/i })); + + await waitFor(() => { + expect(screen.getByText(/Failed to save: boom/i)).toBeTruthy(); + }); + }); + + it('shows a load-error status when the initial client-config fetch rejects', async () => { + vi.mocked(openhumanGetClientConfig).mockRejectedValueOnce(new Error('offline')); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText(/Failed to load current settings: offline/i)).toBeTruthy(); + }); + }); +}); diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx index 73aebca75..30fe59ac4 100644 --- a/app/src/pages/Settings.tsx +++ b/app/src/pages/Settings.tsx @@ -6,6 +6,7 @@ import AgentChatPanel from '../components/settings/panels/AgentChatPanel'; import AIPanel from '../components/settings/panels/AIPanel'; import AutocompleteDebugPanel from '../components/settings/panels/AutocompleteDebugPanel'; import AutocompletePanel from '../components/settings/panels/AutocompletePanel'; +import BackendProviderPanel from '../components/settings/panels/BackendProviderPanel'; import BillingPanel from '../components/settings/panels/BillingPanel'; import ComposioTriagePanel from '../components/settings/panels/ComposioTriagePanel'; import ConnectionsPanel from '../components/settings/panels/ConnectionsPanel'; @@ -194,6 +195,22 @@ const aiModelsSettingsItems = [ ), }, + { + id: 'backend-provider', + title: 'LLM Provider', + description: 'Point inference at the OpenHuman backend or any OpenAI-compatible provider', + route: 'backend-provider', + icon: ( + + + + ), + }, ]; const WrappedSettingsPage = ({ children }: { children: ReactNode }) => { @@ -247,7 +264,7 @@ const Settings = () => { element={wrapSettingsPage( )} @@ -279,6 +296,7 @@ const Settings = () => { )} /> {/* AI & Models leaf panels */} )} /> + )} /> {/* Developer Options */} )} /> ({ callCoreRpc: vi.fn() })); + +vi.mock('../common', () => ({ isTauri: vi.fn(() => true), CommandResponse: undefined })); + +describe('openhumanGetClientConfig', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('throws when not running inside the Tauri shell', async () => { + const { isTauri } = await import('../common'); + vi.mocked(isTauri).mockReturnValueOnce(false); + await expect(openhumanGetClientConfig()).rejects.toThrow(/Not running in Tauri/i); + }); + + it('dispatches openhuman.config_get_client_config and returns the response', async () => { + const expected = { + result: { + api_url: 'https://api.openai.com/v1/chat/completions', + default_model: 'gpt-4o', + app_version: '0.0.0-test', + api_key_set: true, + }, + messages: [], + }; + vi.mocked(callCoreRpc).mockResolvedValueOnce(expected); + + const got = await openhumanGetClientConfig(); + + expect(callCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.config_get_client_config' }); + expect(got).toEqual(expected); + }); +}); diff --git a/app/src/utils/tauriCommands/config.ts b/app/src/utils/tauriCommands/config.ts index ae48e4401..d853214c8 100644 --- a/app/src/utils/tauriCommands/config.ts +++ b/app/src/utils/tauriCommands/config.ts @@ -11,11 +11,23 @@ export interface ConfigSnapshot { config_path: string; } +export interface ModelRoute { + hint: string; + model: string; +} + export interface ModelSettingsUpdate { api_url?: string | null; api_key?: string | null; default_model?: string | null; default_temperature?: number | null; + /** + * When present, REPLACES `config.model_routes` wholesale with these + * `(hint, model)` pairs. Send `[]` to clear all routes (used when switching + * back to the OpenHuman backend whose built-in router picks per-task models + * on its own). Omit to leave existing routes untouched. + */ + model_routes?: ModelRoute[] | null; } /** @@ -109,6 +121,27 @@ export async function openhumanGetConfig(): Promise>({ method: CORE_RPC_METHODS.configGet }); } +/** + * Safe client-facing config slice. Never contains the raw api_key — only + * `api_key_set` indicates whether a custom backend key is stored. See + * `config.get_client_config` in `src/openhuman/config/schemas.rs`. + */ +export interface ClientConfig { + api_url: string | null; + default_model: string | null; + app_version: string; + api_key_set: boolean; +} + +export async function openhumanGetClientConfig(): Promise> { + if (!isTauri()) { + throw new Error('Not running in Tauri'); + } + return await callCoreRpc>({ + method: 'openhuman.config_get_client_config', + }); +} + export async function openhumanUpdateModelSettings( update: ModelSettingsUpdate ): Promise> { diff --git a/src/openhuman/config/ops.rs b/src/openhuman/config/ops.rs index a95339243..6d5b256ed 100644 --- a/src/openhuman/config/ops.rs +++ b/src/openhuman/config/ops.rs @@ -172,6 +172,12 @@ pub struct ModelSettingsPatch { pub api_key: Option, pub default_model: Option, pub default_temperature: Option, + /// When `Some`, REPLACES the entire `config.model_routes` array with the + /// supplied (hint, model) pairs. Pass `Some(vec![])` to clear all routes + /// (e.g. when switching back to the OpenHuman backend whose built-in + /// router picks per-task models on its own). Leave `None` to keep the + /// current routes untouched. + pub model_routes: Option>, } #[derive(Debug, Clone, Default)] @@ -289,6 +295,11 @@ pub async fn apply_model_settings( if let Some(temp) = update.default_temperature { config.default_temperature = temp; } + if let Some(routes) = update.model_routes { + // Full replacement — UI sends the canonical set for the active provider + // (or an empty vec when switching back to the OpenHuman in-built router). + config.model_routes = routes; + } config.save().await.map_err(|e| e.to_string())?; let snapshot = snapshot_config_json(config)?; Ok(RpcOutcome::new( diff --git a/src/openhuman/config/ops_tests.rs b/src/openhuman/config/ops_tests.rs index 6449e6956..4424d8e2f 100644 --- a/src/openhuman/config/ops_tests.rs +++ b/src/openhuman/config/ops_tests.rs @@ -223,6 +223,7 @@ async fn apply_model_settings_updates_fields_and_persists_snapshot() { api_key: None, default_model: Some("gpt-4o".into()), default_temperature: Some(0.25), + model_routes: None, }; let outcome = apply_model_settings(&mut cfg, patch).await.expect("apply"); assert_eq!(cfg.api_url.as_deref(), Some("https://api.example.test")); @@ -234,6 +235,94 @@ async fn apply_model_settings_updates_fields_and_persists_snapshot() { ); } +#[tokio::test] +async fn apply_model_settings_stores_api_key_and_clears_when_empty() { + // #1342: custom OpenAI-compatible providers — api_key must round-trip + // through `apply_model_settings` and clear when an empty string is sent. + let tmp = tempdir().unwrap(); + let mut cfg = tmp_config(&tmp); + let set = ModelSettingsPatch { + api_url: Some("https://llm.example.test/v1".into()), + api_key: Some(" sk-test-1234 ".into()), + default_model: Some("gpt-4o-mini".into()), + default_temperature: None, + model_routes: None, + }; + let _ = apply_model_settings(&mut cfg, set).await.expect("set"); + assert_eq!(cfg.api_key.as_deref(), Some("sk-test-1234")); + + let clear = ModelSettingsPatch { + api_url: None, + api_key: Some("".into()), + default_model: None, + default_temperature: None, + model_routes: None, + }; + let _ = apply_model_settings(&mut cfg, clear).await.expect("clear"); + assert!(cfg.api_key.is_none()); + // Other fields must not be disturbed by a key-only clear. + assert_eq!(cfg.api_url.as_deref(), Some("https://llm.example.test/v1")); + assert_eq!(cfg.default_model.as_deref(), Some("gpt-4o-mini")); +} + +#[tokio::test] +async fn apply_model_settings_replaces_model_routes_when_some_and_keeps_when_none() { + // #1342: switching providers writes role->model routes; switching back to + // OpenHuman sends an empty vec to wipe them. Omitting the field leaves + // existing routes alone. + use crate::openhuman::config::ModelRouteConfig; + let tmp = tempdir().unwrap(); + let mut cfg = tmp_config(&tmp); + let set_routes = ModelSettingsPatch { + api_url: None, + api_key: None, + default_model: None, + default_temperature: None, + model_routes: Some(vec![ + ModelRouteConfig { + hint: "reasoning".into(), + model: "o1".into(), + }, + ModelRouteConfig { + hint: "agentic".into(), + model: "gpt-4o".into(), + }, + ]), + }; + let _ = apply_model_settings(&mut cfg, set_routes) + .await + .expect("set"); + assert_eq!(cfg.model_routes.len(), 2); + assert_eq!(cfg.model_routes[0].hint, "reasoning"); + + // None — leave routes alone. + let touch_other = ModelSettingsPatch { + api_url: Some("https://x.test/v1".into()), + api_key: None, + default_model: None, + default_temperature: None, + model_routes: None, + }; + let _ = apply_model_settings(&mut cfg, touch_other) + .await + .expect("touch"); + assert_eq!(cfg.model_routes.len(), 2); + assert_eq!(cfg.api_url.as_deref(), Some("https://x.test/v1")); + + // Empty vec — clear. + let clear_routes = ModelSettingsPatch { + api_url: None, + api_key: None, + default_model: None, + default_temperature: None, + model_routes: Some(vec![]), + }; + let _ = apply_model_settings(&mut cfg, clear_routes) + .await + .expect("clear"); + assert!(cfg.model_routes.is_empty()); +} + #[tokio::test] async fn apply_model_settings_empty_strings_clear_optional_fields() { let tmp = tempdir().unwrap(); @@ -244,6 +333,7 @@ async fn apply_model_settings_empty_strings_clear_optional_fields() { api_key: None, default_model: Some("".into()), default_temperature: None, + model_routes: None, }; let _ = apply_model_settings(&mut cfg, patch).await.expect("apply"); assert!(cfg.api_url.is_none()); diff --git a/src/openhuman/config/schemas.rs b/src/openhuman/config/schemas.rs index dfac70c07..66b563d08 100644 --- a/src/openhuman/config/schemas.rs +++ b/src/openhuman/config/schemas.rs @@ -9,11 +9,28 @@ use crate::rpc::RpcOutcome; const DEFAULT_ONBOARDING_FLAG_NAME: &str = ".skip_onboarding"; +#[derive(Debug, Deserialize)] +struct ModelRouteUpdate { + hint: String, + model: String, +} + #[derive(Debug, Deserialize)] struct ModelSettingsUpdate { api_url: Option, + /// Optional API key for OpenAI-compatible backends. Stored verbatim in + /// `config.toml` on the user's machine — see #1342 (local-first / pluggable + /// backends). The key is never echoed back over RPC; `get_client_config` + /// only reports `api_key_set: bool`. + api_key: Option, default_model: Option, default_temperature: Option, + /// When present, REPLACES `config.model_routes` wholesale with these + /// `(hint, model)` pairs. Send `Some([])` to clear all routes (used when + /// the user switches back to the OpenHuman backend whose built-in router + /// picks per-task models on its own). Omit to leave existing routes + /// untouched. + model_routes: Option>, } #[derive(Debug, Deserialize)] @@ -302,14 +319,21 @@ pub fn schemas(function: &str) -> ControllerSchema { comment: "OpenHuman core version.", required: true, }, + FieldSchema { + name: "api_key_set", + ty: TypeSchema::Bool, + comment: "True when a custom backend api_key is stored locally. The key itself is never returned over RPC.", + required: true, + }, ], }, "update_model_settings" => ControllerSchema { namespace: "config", function: "update_model_settings", - description: "Update model and backend connection settings.", + description: "Update model and backend connection settings, including a custom OpenAI-compatible backend (api_url + api_key).", inputs: vec![ - optional_string("api_url", "Backend API URL."), + optional_string("api_url", "Backend API URL. Set to a non-default URL together with `api_key` to point inference at any OpenAI-compatible provider."), + optional_string("api_key", "Optional API key for the configured backend. Pass an empty string to clear a previously stored key."), optional_string("default_model", "Default model id."), FieldSchema { name: "default_temperature", @@ -317,6 +341,12 @@ pub fn schemas(function: &str) -> ControllerSchema { comment: "Default model temperature.", required: false, }, + FieldSchema { + name: "model_routes", + ty: TypeSchema::Option(Box::new(TypeSchema::Json)), + comment: "Optional list of {hint, model} pairs mapping task hints (reasoning, agentic, coding, summarization) to provider-specific model ids. Replaces config.model_routes wholesale; send [] to clear (e.g. when switching back to the OpenHuman built-in router).", + required: false, + }, ], outputs: vec![json_output("snapshot", "Updated config snapshot.")], }, @@ -735,11 +765,17 @@ fn handle_get_client_config(_params: Map) -> ControllerFuture { let config = config_rpc::load_config_with_timeout().await?; let app_version = std::env::var("OPENHUMAN_APP_VERSION").unwrap_or_else(|_| "unknown".to_string()); + let api_key_set = config + .api_key + .as_deref() + .map(|k| !k.trim().is_empty()) + .unwrap_or(false); to_json(RpcOutcome::new( serde_json::json!({ "api_url": config.api_url, "default_model": config.default_model, "app_version": app_version, + "api_key_set": api_key_set, }), vec!["client config read".to_string()], )) @@ -751,9 +787,18 @@ fn handle_update_model_settings(params: Map) -> ControllerFuture let update = deserialize_params::(params)?; let patch = config_rpc::ModelSettingsPatch { api_url: update.api_url, - api_key: None, + api_key: update.api_key, default_model: update.default_model, default_temperature: update.default_temperature, + model_routes: update.model_routes.map(|routes| { + routes + .into_iter() + .map(|r| crate::openhuman::config::ModelRouteConfig { + hint: r.hint, + model: r.model, + }) + .collect() + }), }; to_json(config_rpc::load_and_apply_model_settings(patch).await?) })