mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
This commit is contained in:
@@ -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 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<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! You’re 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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?)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user