feat(config): configurable OpenAI-compatible backend provider (#1342) (#1467)

This commit is contained in:
Steven Enamakel
2026-05-11 01:02:11 -07:00
committed by GitHub
parent 381fc9f4da
commit 532a76c180
9 changed files with 940 additions and 5 deletions
+1 -1
View File
@@ -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: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -0,0 +1,511 @@
import debug from 'debug';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
type ClientConfig,
type ModelRoute,
openhumanGetClientConfig,
openhumanUpdateModelSettings,
} from '../../../utils/tauriCommands';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const log = debug('settings:llm-provider');
const KEY_PLACEHOLDER = '••••••••••••••••';
/**
* Task-hint slots the core router understands (see
* `src/openhuman/providers/router.rs`). When the user picks a non-OpenHuman
* preset we persist a `model_routes` entry for each role so the router has a
* sensible per-task default for the chosen provider.
*/
const ROLE_HINTS = ['reasoning', 'agentic', 'coding', 'summarization'] as const;
type RoleHint = (typeof ROLE_HINTS)[number];
const ROLE_LABELS: Record<RoleHint, { label: string; help: string }> = {
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<RoleHint, string>;
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 Anthropics 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<ClientConfig | null>(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<RoleModels>(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<string>(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 (
<div>
<SettingsHeader
title="LLM Provider"
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="p-4 space-y-5">
<p className="text-sm text-stone-500 leading-relaxed">
Pick where inference runs. Any OpenAI-compatible provider (OpenAI, Anthropic, OpenRouter,
Ollama, your own gateway).
</p>
{!loaded ? (
<div className="text-sm text-stone-400 animate-pulse">Loading current settings</div>
) : (
<>
<section className="space-y-2">
<label className="block text-xs font-semibold uppercase tracking-wide text-stone-500">
Provider
</label>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{PROVIDER_PRESETS.map(preset => {
const selected = preset.id === activePreset.id;
return (
<button
key={preset.id}
type="button"
onClick={() => applyPreset(preset)}
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-left transition-colors ${
selected ? preset.tint.selected : `bg-white ${preset.tint.idle}`
}`}
aria-pressed={selected}>
<span
className={`h-2 w-2 shrink-0 rounded-full ${preset.tint.dot}`}
aria-hidden="true"
/>
<span className="text-sm font-medium text-stone-800 truncate">
{preset.label}
</span>
</button>
);
})}
</div>
<p className="text-xs text-stone-400">{activePreset.note}</p>
</section>
{isOpenHuman && (
<div className="rounded-lg border border-sage-300 bg-sage-50 p-3 flex gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-sage-700">
Congrats! Youre using the most optimized setup
</p>
<p className="mt-1 text-sm text-sage-900 leading-relaxed">
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.
</p>
</div>
</div>
)}
{!isOpenHuman && (
<div className="rounded-lg border border-primary-200 bg-primary-50 p-3">
<p className="mt-1 text-sm text-primary-900 leading-relaxed">
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.
</p>
</div>
)}
{activePreset.id === 'custom' && (
<section className="space-y-2">
<label
htmlFor="llm-api-url"
className="block text-xs font-semibold uppercase tracking-wide text-stone-500">
API URL
</label>
<input
id="llm-api-url"
type="url"
value={apiUrl}
onChange={e => {
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}
/>
<p className="text-xs text-stone-400">
Full URL of the OpenAI-compatible chat-completions endpoint for your gateway.
</p>
</section>
)}
{!isOpenHuman && (
<section className="space-y-2">
<div className="flex items-center justify-between">
<label
htmlFor="llm-api-key"
className="block text-xs font-semibold uppercase tracking-wide text-stone-500">
API Key
</label>
{client?.api_key_set && (
<button
type="button"
onClick={handleClearKey}
disabled={saving}
className="text-xs text-coral-600 hover:text-coral-700 disabled:opacity-50">
Clear stored key
</button>
)}
</div>
<input
id="llm-api-key"
type="password"
value={apiKey}
onChange={e => {
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}
/>
<p className="text-xs text-stone-400">
{client?.api_key_set ? 'A key is currently saved.' : 'No key is currently saved.'}
</p>
</section>
)}
{!isOpenHuman && (
<section className="space-y-2">
<label className="block text-xs font-semibold uppercase tracking-wide text-stone-500">
Models by Role
</label>
<p className="text-xs text-stone-400">
The core router dispatches each task to the right model. Leave a field blank to
skip routing for that role.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{ROLE_HINTS.map(hint => (
<div key={hint} className="space-y-1">
<label
htmlFor={`llm-role-${hint}`}
className="block text-xs font-medium text-stone-700">
{ROLE_LABELS[hint].label}
</label>
<input
id={`llm-role-${hint}`}
type="text"
value={roleModels[hint]}
onChange={e => {
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}
/>
</div>
))}
</div>
</section>
)}
<div className="flex items-center gap-3 pt-1">
<button
type="button"
onClick={handleSave}
disabled={saving}
className="rounded-lg bg-primary-500 px-4 py-2 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-50">
{saving ? 'Saving' : 'Save'}
</button>
{status.kind === 'ok' && (
<span className="text-sm text-sage-700">{status.message}</span>
)}
{status.kind === 'error' && (
<span className="text-sm text-coral-700">{status.message}</span>
)}
</div>
</>
)}
</div>
</div>
);
};
export default BackendProviderPanel;
@@ -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<string, unknown> = {}) {
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<ReturnType<typeof openhumanGetClientConfig>>);
}
function mockUpdateOk() {
vi.mocked(openhumanUpdateModelSettings).mockResolvedValue({
result: { config: {}, workspace_dir: '', config_path: '' },
messages: [],
} as unknown as Awaited<ReturnType<typeof openhumanUpdateModelSettings>>);
}
describe('BackendProviderPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUpdateOk();
});
it('renders all provider preset chips and the OpenHuman success banner by default', async () => {
mockClientConfig();
renderWithProviders(<BackendProviderPanel />);
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(<BackendProviderPanel />);
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(<BackendProviderPanel />);
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(<BackendProviderPanel />);
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(<BackendProviderPanel />);
// 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(<BackendProviderPanel />);
// 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(<BackendProviderPanel />);
// 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(<BackendProviderPanel />);
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(<BackendProviderPanel />);
await waitFor(() => {
expect(screen.getByText(/Failed to load current settings: offline/i)).toBeTruthy();
});
});
});
+19 -1
View File
@@ -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 = [
</svg>
),
},
{
id: 'backend-provider',
title: 'LLM Provider',
description: 'Point inference at the OpenHuman backend or any OpenAI-compatible provider',
route: 'backend-provider',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 7a2 2 0 012-2h12a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2V7zm0 10a2 2 0 012-2h12a2 2 0 012 2v0a2 2 0 01-2 2H6a2 2 0 01-2-2v0zM7 9h.01M7 17h.01"
/>
</svg>
),
},
];
const WrappedSettingsPage = ({ children }: { children: ReactNode }) => {
@@ -247,7 +264,7 @@ const Settings = () => {
element={wrapSettingsPage(
<SettingsSectionPage
title="AI & Models"
description="Local AI model setup and management."
description="Local AI model setup and backend provider configuration."
items={aiModelsSettingsItems}
/>
)}
@@ -279,6 +296,7 @@ const Settings = () => {
<Route path="tools" element={wrapSettingsPage(<ToolsPanel />)} />
{/* AI & Models leaf panels */}
<Route path="local-model" element={wrapSettingsPage(<LocalModelPanel />)} />
<Route path="backend-provider" element={wrapSettingsPage(<BackendProviderPanel />)} />
{/* Developer Options */}
<Route path="developer-options" element={wrapSettingsPage(<DeveloperOptionsPanel />)} />
<Route
@@ -0,0 +1,42 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { callCoreRpc } from '../../../services/coreRpcClient';
import { openhumanGetClientConfig } from '../config';
vi.mock('../../../services/coreRpcClient', () => ({ 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);
});
});
+33
View File
@@ -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<CommandResponse<ConfigSnapsh
return await callCoreRpc<CommandResponse<ConfigSnapshot>>({ 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<CommandResponse<ClientConfig>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await callCoreRpc<CommandResponse<ClientConfig>>({
method: 'openhuman.config_get_client_config',
});
}
export async function openhumanUpdateModelSettings(
update: ModelSettingsUpdate
): Promise<CommandResponse<ConfigSnapshot>> {
+11
View File
@@ -172,6 +172,12 @@ pub struct ModelSettingsPatch {
pub api_key: Option<String>,
pub default_model: Option<String>,
pub default_temperature: Option<f64>,
/// 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<Vec<crate::openhuman::config::ModelRouteConfig>>,
}
#[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(
+90
View File
@@ -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());
+48 -3
View File
@@ -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<String>,
/// 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<String>,
default_model: Option<String>,
default_temperature: Option<f64>,
/// 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<Vec<ModelRouteUpdate>>,
}
#[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<String, Value>) -> 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<String, Value>) -> ControllerFuture
let update = deserialize_params::<ModelSettingsUpdate>(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?)
})