Fix provider model testing and add MCP coming soon placeholder (#2570)

This commit is contained in:
Steven Enamakel
2026-05-24 10:30:03 -07:00
committed by GitHub
parent d52abe5524
commit 59c1687dd4
26 changed files with 842 additions and 81 deletions
+219 -73
View File
@@ -30,6 +30,7 @@ import {
saveAISettings,
setCloudProviderKey,
setOpenAICompatEndpointKey,
testProviderModel,
} from '../../../services/api/aiSettingsApi';
import {
creditsApi,
@@ -209,6 +210,14 @@ function maskKeyLabel(hasKey: boolean): string {
return hasKey ? '•••• configured' : 'Not configured';
}
function slugifyCustomProviderName(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
/**
* Default auth style for a slug. Built-in slugs map to their known styles;
* everything else (custom + third-party slugs the user types in) defaults
@@ -1671,6 +1680,12 @@ function humanizeModelId(id: string): string {
return id.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
function appendTemperatureToProviderString(provider: string, temperature: number | null): string {
if (temperature == null || !Number.isFinite(temperature)) return provider;
const rounded = Math.round(temperature * 100) / 100;
return `${provider}@${String(rounded)}`;
}
const CustomRoutingDialog = ({
workload,
initial,
@@ -1710,6 +1725,11 @@ const CustomRoutingDialog = ({
const [cloudModelsLoading, setCloudModelsLoading] = useState(false);
const [cloudModelsError, setCloudModelsError] = useState<string | null>(null);
const [modelsKey, setModelsKey] = useState(0);
const [testBusy, setTestBusy] = useState(false);
const [testReply, setTestReply] = useState<string | null>(null);
const [testError, setTestError] = useState<string | null>(null);
const [testStartedAt, setTestStartedAt] = useState<string | null>(null);
const testRequestIdRef = useRef(0);
// Optional temperature override for this workload. `null` = use provider/global default;
// a finite number means "send `temperature: X` upstream for this workload only".
const [temperature, setTemperature] = useState<number | null>(
@@ -1762,6 +1782,28 @@ const CustomRoutingDialog = ({
}, [selectedSlug, modelsKey]);
const canSave = source !== null && model.trim().length > 0;
const canTest = canSave && !cloudModelsLoading;
const resetTestState = () => {
testRequestIdRef.current += 1;
setTestReply(null);
setTestError(null);
setTestStartedAt(null);
setTestBusy(false);
};
const currentProviderString =
source == null
? null
: source.kind === 'cloud'
? appendTemperatureToProviderString(
`${source.providerSlug}:${model.trim()}`,
temperature == null || !Number.isFinite(temperature) ? null : temperature
)
: appendTemperatureToProviderString(
`ollama:${model.trim()}`,
temperature == null || !Number.isFinite(temperature) ? null : temperature
);
const handleSave = () => {
if (!source || !canSave) return;
@@ -1778,6 +1820,28 @@ const CustomRoutingDialog = ({
}
};
const handleTest = async () => {
if (!currentProviderString || !canTest) return;
const requestId = testRequestIdRef.current + 1;
testRequestIdRef.current = requestId;
setTestBusy(true);
setTestReply(null);
setTestError(null);
setTestStartedAt(new Date().toLocaleTimeString());
try {
const result = await testProviderModel(workload.id, currentProviderString, 'Hello world');
if (testRequestIdRef.current !== requestId) return;
setTestReply(result.reply);
} catch (err) {
if (testRequestIdRef.current !== requestId) return;
setTestError(err instanceof Error ? err.message : String(err));
} finally {
if (testRequestIdRef.current === requestId) {
setTestBusy(false);
}
}
};
const noProviders = customCloud.length === 0 && !localAvailable;
return (
@@ -1830,6 +1894,7 @@ const CustomRoutingDialog = ({
const colonIdx = e.target.value.indexOf(':');
const kind = e.target.value.slice(0, colonIdx);
const slug = e.target.value.slice(colonIdx + 1);
resetTestState();
if (kind === 'local') {
setSource({ kind: 'local' });
setModel(localModels[0]?.id ?? '');
@@ -1855,7 +1920,10 @@ const CustomRoutingDialog = ({
{source?.kind === 'local' ? (
<select
value={model}
onChange={e => setModel(e.target.value)}
onChange={e => {
resetTestState();
setModel(e.target.value);
}}
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
{localModels.map(m => (
<option key={m.id} value={m.id}>
@@ -1888,7 +1956,10 @@ const CustomRoutingDialog = ({
<input
type="text"
value={model}
onChange={e => setModel(e.target.value)}
onChange={e => {
resetTestState();
setModel(e.target.value);
}}
placeholder={selectedCloud ? `${selectedCloud.slug} model id` : 'model-id'}
className="w-full rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm font-mono text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
@@ -1896,7 +1967,10 @@ const CustomRoutingDialog = ({
) : cloudModels.length > 0 ? (
<select
value={model}
onChange={e => setModel(e.target.value)}
onChange={e => {
resetTestState();
setModel(e.target.value);
}}
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
{!model && <option value="">Select a model</option>}
{/* Keep existing value selectable even if the provider no longer lists it */}
@@ -1913,7 +1987,10 @@ const CustomRoutingDialog = ({
<input
type="text"
value={model}
onChange={e => setModel(e.target.value)}
onChange={e => {
resetTestState();
setModel(e.target.value);
}}
placeholder={selectedCloud ? `${selectedCloud.slug} model id` : 'model-id'}
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm font-mono text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
@@ -1928,7 +2005,10 @@ const CustomRoutingDialog = ({
<input
type="checkbox"
checked={temperature != null}
onChange={e => setTemperature(e.target.checked ? 0.7 : null)}
onChange={e => {
resetTestState();
setTemperature(e.target.checked ? 0.7 : null);
}}
className="h-3.5 w-3.5 rounded border-stone-300 dark:border-neutral-700 text-primary-500 focus:ring-primary-500"
/>
Temperature override
@@ -1948,7 +2028,10 @@ const CustomRoutingDialog = ({
max={2}
step={0.05}
value={temperature}
onChange={e => setTemperature(Number(e.target.value))}
onChange={e => {
resetTestState();
setTemperature(Number(e.target.value));
}}
className="flex-1 accent-primary-500"
/>
<input
@@ -1960,7 +2043,10 @@ const CustomRoutingDialog = ({
value={temperature}
onChange={e => {
const v = Number(e.target.value);
if (Number.isFinite(v)) setTemperature(Math.max(0, Math.min(2, v)));
if (Number.isFinite(v)) {
resetTestState();
setTemperature(Math.max(0, Math.min(2, v)));
}
}}
className="w-16 rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1 text-xs font-mono text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
@@ -1970,6 +2056,51 @@ const CustomRoutingDialog = ({
Lower = more deterministic. Leave unchecked to use the provider default.
</p>
</div>
{(testBusy || testReply || testError || testStartedAt) && (
<div
role={testError ? 'alert' : 'status'}
className={`rounded-lg border px-3 py-2 text-xs ${
testError
? 'border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 text-coral-700 dark:text-coral-300'
: testBusy
? 'border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 text-amber-800 dark:text-amber-200'
: 'border-sage-200 dark:border-sage-500/30 bg-sage-50 dark:bg-sage-500/10 text-sage-800 dark:text-sage-200'
}`}>
<div className="font-semibold">
{testError ? 'Test failed' : testBusy ? 'Testing model…' : 'Model response'}
</div>
<div className="mt-1 space-y-1">
<div className="font-mono text-[11px] text-current/80">
Provider: {currentProviderString ?? '—'}
</div>
<div className="font-mono text-[11px] text-current/80">Prompt: Hello world</div>
{testStartedAt && (
<div className="font-mono text-[11px] text-current/80">
Started: {testStartedAt}
</div>
)}
</div>
{testBusy ? (
<div className="mt-2 rounded-md border border-current/15 bg-white/50 px-3 py-2 text-[12px] dark:bg-black/10">
Waiting for response from the selected model
</div>
) : testError ? (
<div className="mt-2 rounded-md border border-current/15 bg-white/50 px-3 py-2 font-mono text-[11px] whitespace-pre-wrap break-words dark:bg-black/10">
{testError}
</div>
) : (
<div className="mt-3 space-y-1.5">
<div className="text-[11px] font-semibold uppercase tracking-wide text-current/80">
Response
</div>
<div className="rounded-md border border-current/15 bg-white/70 px-3 py-3 text-[13px] leading-relaxed text-stone-900 whitespace-pre-wrap break-words dark:bg-black/10 dark:text-neutral-100">
{testReply}
</div>
</div>
)}
</div>
)}
</div>
)}
@@ -1980,6 +2111,13 @@ const CustomRoutingDialog = ({
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-4 py-2 text-sm font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60">
{t('common.cancel')}
</button>
<button
type="button"
onClick={() => void handleTest()}
disabled={!canTest || testBusy}
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-4 py-2 text-sm font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 disabled:cursor-not-allowed disabled:opacity-50">
{testBusy ? 'Testing…' : 'Test'}
</button>
<button
type="button"
onClick={handleSave}
@@ -2141,6 +2279,13 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
runtime_enabled: true,
opt_in_confirmed: true,
});
} else if (isLocalRuntime && slug === 'lmstudio') {
await openhumanUpdateLocalAiSettings({
base_url: endpoint,
provider: 'lm_studio',
runtime_enabled: true,
opt_in_confirmed: true,
});
}
if (slug !== 'openhuman') {
@@ -2728,21 +2873,33 @@ const CloudProviderEditor = ({
onClearKey: (slug: string) => Promise<void> | void;
}) => {
const { t } = useT();
const defaultSlug: string =
initial?.slug ??
(['openai', 'anthropic', 'openrouter', 'orcarouter', 'custom'] as const).find(
s => !existingSlugs.includes(s)
) ??
'custom';
const [slug, setSlug] = useState<string>(defaultSlug);
const [label, setLabel] = useState<string>(
initial?.label ?? BUILTIN_PROVIDER_META[defaultSlug]?.label ?? defaultSlug
);
const [endpoint, setEndpoint] = useState(initial?.endpoint ?? defaultEndpointFor(defaultSlug));
const [label, setLabel] = useState<string>(initial?.label ?? '');
const [endpoint, setEndpoint] = useState(initial?.endpoint ?? '');
const [apiKey, setApiKey] = useState('');
const [saving, setSaving] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const isOpenHuman = slug === 'openhuman';
const slug = initial?.slug ?? slugifyCustomProviderName(label);
const hasReservedSlugCollision =
!initial &&
[
'cloud',
'openhuman',
'pid',
'openai',
'anthropic',
'openrouter',
'orcarouter',
'custom',
'ollama',
'lmstudio',
].includes(slug);
const slugError = !slug
? 'Enter a provider name to generate a slug.'
: existingSlugs.includes(slug)
? 'That provider name is already in use.'
: hasReservedSlugCollision
? 'Choose a different provider name.'
: null;
const hasExistingKey = (initial?.maskedKey ?? '').startsWith('••••');
return (
@@ -2761,74 +2918,60 @@ const CloudProviderEditor = ({
</div>
<div className="space-y-3 px-4 py-3">
<div>
<label className="text-[10px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
Provider slug
</label>
<select
value={slug}
onChange={e => {
const next = e.target.value;
setSlug(next);
setLabel(BUILTIN_PROVIDER_META[next]?.label ?? next);
if (!initial) {
setEndpoint(defaultEndpointFor(next));
}
}}
disabled={!!initial}
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 disabled:opacity-60 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200">
{(['openai', 'anthropic', 'openrouter', 'orcarouter', 'custom'] as const)
.filter(s => s === slug || !existingSlugs.includes(s))
.map(s => (
<option key={s} value={s}>
{BUILTIN_PROVIDER_META[s]?.label ?? s}
</option>
))}
</select>
</div>
<div>
<label className="text-[10px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
Display label
<label
htmlFor="cloud-provider-name"
className="text-[10px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
Name
</label>
<input
id="cloud-provider-name"
value={label}
onChange={e => setLabel(e.target.value)}
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 dark:placeholder:text-neutral-500 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200"
placeholder="My Provider"
/>
<div className="mt-1 text-[11px] text-stone-500 dark:text-neutral-400">
Slug:{' '}
<span className="font-mono text-stone-700 dark:text-neutral-200">{slug || '—'}</span>
</div>
{slugError ? (
<div className="mt-1 text-[11px] text-coral-600 dark:text-coral-300">{slugError}</div>
) : null}
</div>
<div>
<label className="text-[10px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
Endpoint
<label
htmlFor="cloud-provider-openai-url"
className="text-[10px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
OpenAI URL
</label>
<input
id="cloud-provider-openai-url"
value={endpoint}
onChange={e => setEndpoint(e.target.value)}
disabled={isOpenHuman}
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 font-mono text-xs text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 dark:placeholder:text-neutral-500 disabled:opacity-60 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200"
placeholder="https://api.example.com/v1"
placeholder="https://api.openai.com/v1"
/>
</div>
<div>
<label className="flex items-center justify-between text-[10px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
<span>API key</span>
{hasExistingKey && (
<button
onClick={() => void onClearKey(slug)}
className="text-[10px] font-medium normal-case text-coral-600 dark:text-coral-300 hover:text-coral-700 dark:text-coral-300">
{t('settings.ai.clearStoredKey')}
</button>
)}
</label>
<input
aria-label="API key"
type="password"
value={apiKey}
onChange={e => setApiKey(e.target.value)}
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 font-mono text-xs text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 dark:placeholder:text-neutral-500 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200"
placeholder={hasExistingKey ? 'Leave blank to keep existing key' : 'sk-...'}
/>
</div>
{!isOpenHuman && (
<div>
<label className="flex items-center justify-between text-[10px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
<span>API key</span>
{hasExistingKey && (
<button
onClick={() => void onClearKey(slug)}
className="text-[10px] font-medium normal-case text-coral-600 dark:text-coral-300 hover:text-coral-700 dark:text-coral-300">
{t('settings.ai.clearStoredKey')}
</button>
)}
</label>
<input
type="password"
value={apiKey}
onChange={e => setApiKey(e.target.value)}
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 font-mono text-xs text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 dark:placeholder:text-neutral-500 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200"
placeholder={hasExistingKey ? 'Leave blank to keep existing key' : 'sk-...'}
/>
</div>
)}
{submitError ? <ProviderSetupErrorNotice error={submitError} /> : null}
</div>
<div className="flex items-center justify-end gap-2 border-t border-stone-200 dark:border-neutral-800 px-4 py-3">
@@ -2843,13 +2986,16 @@ const CloudProviderEditor = ({
setSaving(true);
setSubmitError(null);
try {
if (slugError) {
throw new Error(slugError);
}
await onSubmit(
{
id: initial?.id ?? '',
slug,
label: label.trim() || slug,
endpoint: endpoint.trim(),
authStyle: initial?.authStyle ?? authStyleForSlug(slug),
authStyle: initial?.authStyle ?? 'bearer',
maskedKey: maskKeyLabel(hasExistingKey || apiKey.length > 0),
},
apiKey.trim()
@@ -2868,7 +3014,7 @@ const CloudProviderEditor = ({
setSaving(false);
}
}}
disabled={saving || !endpoint.trim()}
disabled={saving || !endpoint.trim() || Boolean(slugError)}
className="rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-600 disabled:opacity-50">
{saving
? t('settings.ai.saving')
@@ -11,6 +11,7 @@ import {
saveAISettings,
setCloudProviderKey,
setOpenAICompatEndpointKey,
testProviderModel,
} from '../../../../services/api/aiSettingsApi';
import { creditsApi } from '../../../../services/api/creditsApi';
import { renderWithProviders } from '../../../../test/test-utils';
@@ -41,6 +42,7 @@ vi.mock('../../../../services/api/aiSettingsApi', () => ({
saveAISettings: vi.fn(),
loadLocalProviderSnapshot: vi.fn(),
setOpenAICompatEndpointKey: vi.fn(),
testProviderModel: vi.fn(),
clearOpenAICompatEndpointKey: vi.fn().mockResolvedValue(undefined),
setCloudProviderKey: vi.fn(),
clearCloudProviderKey: vi.fn().mockResolvedValue(undefined),
@@ -206,6 +208,7 @@ describe('AIPanel', () => {
vi.mocked(setOpenAICompatEndpointKey).mockResolvedValue(undefined);
vi.mocked(clearOpenAICompatEndpointKey).mockResolvedValue(undefined);
vi.mocked(setCloudProviderKey).mockResolvedValue(undefined);
vi.mocked(testProviderModel).mockResolvedValue({ reply: 'Hello from the selected model.' });
vi.mocked(listProviderModels).mockResolvedValue([]);
vi.mocked(connectOpenRouterViaOAuth).mockResolvedValue('sk-or-oauth');
vi.mocked(openhumanHeartbeatSettingsGet).mockResolvedValue({
@@ -474,6 +477,8 @@ describe('AIPanel', () => {
// The full CloudProviderEditor should appear (has "Add cloud provider" heading).
await waitFor(() => expect(screen.getByText(/Add cloud provider/i)).toBeInTheDocument());
expect(screen.getByLabelText(/^Name$/i)).toBeInTheDocument();
expect(screen.getByLabelText(/OpenAI URL/i)).toBeInTheDocument();
// The simple ProviderKeyDialog should NOT appear.
expect(screen.queryByRole('dialog', { name: /Connect Custom/i })).not.toBeInTheDocument();
});
@@ -629,18 +634,52 @@ describe('AIPanel', () => {
fireEvent.click(screen.getByRole('switch', { name: /Connect Custom/i }));
await waitFor(() => expect(screen.getByText(/Add cloud provider/i)).toBeInTheDocument());
fireEvent.change(screen.getByLabelText(/^Name$/i), { target: { value: 'Team Gateway' } });
fireEvent.change(screen.getByLabelText(/OpenAI URL/i), {
target: { value: 'https://api.openai.com/v1' },
});
fireEvent.change(screen.getByPlaceholderText('sk-...'), { target: { value: 'sk-test-key' } });
fireEvent.click(screen.getByRole('button', { name: /Add provider/i }));
const alert = await screen.findByRole('alert');
expect(
within(alert).getByText(
'Could not reach OpenAI: Provider teapot says no. Try another endpoint.'
'Could not reach Team Gateway: Provider teapot says no. Try another endpoint.'
)
).toBeInTheDocument();
expect(within(alert).getByText('Technical details')).toBeInTheDocument();
expect(within(alert).getByText(/provider returned 418/)).toBeInTheDocument();
expect(screen.queryByRole('switch', { name: /Disconnect OpenAI/i })).not.toBeInTheDocument();
expect(
screen.queryByRole('switch', { name: /Disconnect Team Gateway/i })
).not.toBeInTheDocument();
});
it('derives the custom provider slug from the entered name', async () => {
vi.mocked(loadAISettings).mockResolvedValue({ ...baseSettings, cloudProviders: [] });
renderWithProviders(<AIPanel />);
await waitFor(() =>
expect(screen.getByRole('switch', { name: /Connect Custom/i })).toBeInTheDocument()
);
fireEvent.click(screen.getByRole('switch', { name: /Connect Custom/i }));
await waitFor(() => expect(screen.getByText(/Add cloud provider/i)).toBeInTheDocument());
fireEvent.change(screen.getByLabelText(/^Name$/i), { target: { value: 'My Team Gateway' } });
expect(screen.getByText(/Slug:/i)).toHaveTextContent('Slug: my-team-gateway');
fireEvent.change(screen.getByLabelText(/OpenAI URL/i), {
target: { value: 'https://gateway.example.com/v1' },
});
fireEvent.change(screen.getByPlaceholderText('sk-...'), { target: { value: 'sk-team-key' } });
fireEvent.click(screen.getByRole('button', { name: /Add provider/i }));
await waitFor(() =>
expect(vi.mocked(setCloudProviderKey)).toHaveBeenCalledWith('my-team-gateway', 'sk-team-key')
);
await waitFor(() =>
expect(vi.mocked(listProviderModels)).toHaveBeenCalledWith('my-team-gateway')
);
});
// ─── local runtime: Ollama endpoint URL dialog ──────────────────────────────
@@ -707,6 +746,30 @@ describe('AIPanel', () => {
});
});
it('LM Studio save persists the local_ai provider and endpoint', async () => {
vi.mocked(loadAISettings).mockResolvedValue({ ...baseSettings, cloudProviders: [] });
renderWithProviders(<AIPanel />);
await waitFor(() =>
expect(screen.getByRole('switch', { name: /Connect LM Studio/i })).toBeInTheDocument()
);
fireEvent.click(screen.getByRole('switch', { name: /Connect LM Studio/i }));
const dialog = await screen.findByRole('dialog', { name: /Connect LM Studio/i });
fireEvent.change(within(dialog).getByLabelText(/Endpoint URL/i), {
target: { value: 'http://127.0.0.1:1234/v1' },
});
fireEvent.click(within(dialog).getByRole('button', { name: /^Save$/i }));
await waitFor(() => expect(openhumanUpdateLocalAiSettingsMock).toHaveBeenCalled());
const [arg] = vi.mocked(openhumanUpdateLocalAiSettingsMock).mock.calls[0];
expect(arg).toMatchObject({
base_url: 'http://127.0.0.1:1234/v1',
provider: 'lm_studio',
runtime_enabled: true,
opt_in_confirmed: true,
});
});
// ─── Custom routing dialog: per-workload temperature override ───────────────
it('Custom routing dialog saves the routing change immediately from the modal', async () => {
@@ -767,6 +830,125 @@ describe('AIPanel', () => {
});
});
it('Custom routing dialog can test the selected cloud model and show its reply', async () => {
const settingsWithOpenAI = {
cloudProviders: [
{
id: 'p_openai_1',
slug: 'openai',
label: 'OpenAI',
endpoint: 'https://api.openai.com/v1',
auth_style: 'bearer' as const,
has_api_key: true,
},
],
routing: {
...baseSettings.routing,
reasoning: { kind: 'cloud' as const, providerSlug: 'openai', model: 'gpt-4o' },
},
};
vi.mocked(loadAISettings).mockResolvedValue(settingsWithOpenAI);
vi.mocked(listProviderModels).mockResolvedValue([{ id: 'gpt-4o' }, { id: 'gpt-4o-mini' }]);
vi.mocked(testProviderModel).mockResolvedValue({ reply: 'Hello from gpt-4o.' });
renderWithProviders(<AIPanel />);
const reasoningRow = await screen.findByText(/Main chat agent/i);
const rowEl = reasoningRow.closest('div.flex.items-center.justify-between');
expect(rowEl).not.toBeNull();
fireEvent.click(within(rowEl as HTMLElement).getByRole('button', { name: /Custom/i }));
const dialog = await screen.findByRole('dialog', { name: /Custom routing/i });
fireEvent.click(within(dialog).getByRole('button', { name: /^Test$/i }));
await waitFor(() =>
expect(vi.mocked(testProviderModel)).toHaveBeenCalledWith(
'reasoning',
'openai:gpt-4o',
'Hello world'
)
);
expect(await within(dialog).findByText('Model response')).toBeInTheDocument();
expect(within(dialog).getByText('Hello from gpt-4o.')).toBeInTheDocument();
});
it('Custom routing dialog shows in-flight test status immediately', async () => {
const settingsWithOpenAI = {
cloudProviders: [
{
id: 'p_openai_1',
slug: 'openai',
label: 'OpenAI',
endpoint: 'https://api.openai.com/v1',
auth_style: 'bearer' as const,
has_api_key: true,
},
],
routing: {
...baseSettings.routing,
reasoning: { kind: 'cloud' as const, providerSlug: 'openai', model: 'gpt-4o' },
},
};
vi.mocked(loadAISettings).mockResolvedValue(settingsWithOpenAI);
vi.mocked(listProviderModels).mockResolvedValue([{ id: 'gpt-4o' }]);
let resolveTest: (value: { reply: string }) => void = () => {};
const pendingTest = new Promise<{ reply: string }>(resolve => {
resolveTest = resolve;
});
vi.mocked(testProviderModel).mockReturnValue(pendingTest);
renderWithProviders(<AIPanel />);
const reasoningRow = await screen.findByText(/Main chat agent/i);
const rowEl = reasoningRow.closest('div.flex.items-center.justify-between');
expect(rowEl).not.toBeNull();
fireEvent.click(within(rowEl as HTMLElement).getByRole('button', { name: /Custom/i }));
const dialog = await screen.findByRole('dialog', { name: /Custom routing/i });
fireEvent.click(within(dialog).getByRole('button', { name: /^Test$/i }));
expect(await within(dialog).findByText('Testing model…')).toBeInTheDocument();
expect(within(dialog).getByText(/Provider: openai:gpt-4o/i)).toBeInTheDocument();
expect(within(dialog).getByText(/Prompt: Hello world/i)).toBeInTheDocument();
resolveTest({ reply: 'Hello from gpt-4o.' });
expect(await within(dialog).findByText('Model response')).toBeInTheDocument();
});
it('Custom routing dialog shows test errors inline', async () => {
const settingsWithOpenAI = {
cloudProviders: [
{
id: 'p_openai_1',
slug: 'openai',
label: 'OpenAI',
endpoint: 'https://api.openai.com/v1',
auth_style: 'bearer' as const,
has_api_key: true,
},
],
routing: {
...baseSettings.routing,
reasoning: { kind: 'cloud' as const, providerSlug: 'openai', model: 'gpt-4o' },
},
};
vi.mocked(loadAISettings).mockResolvedValue(settingsWithOpenAI);
vi.mocked(listProviderModels).mockResolvedValue([{ id: 'gpt-4o' }]);
vi.mocked(testProviderModel).mockRejectedValue(new Error('401 invalid api key'));
renderWithProviders(<AIPanel />);
const reasoningRow = await screen.findByText(/Main chat agent/i);
const rowEl = reasoningRow.closest('div.flex.items-center.justify-between');
expect(rowEl).not.toBeNull();
fireEvent.click(within(rowEl as HTMLElement).getByRole('button', { name: /Custom/i }));
const dialog = await screen.findByRole('dialog', { name: /Custom routing/i });
fireEvent.click(within(dialog).getByRole('button', { name: /^Test$/i }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('401 invalid api key');
});
it('renders background loop diagnostics with newest spend row and budget math', async () => {
// BackgroundLoopControls was moved out of AIPanel into standalone panels.
renderWithProviders(
+4
View File
@@ -50,6 +50,7 @@ const ar1: TranslationMap = {
'common.showLess': 'عرض أقل',
'common.submit': 'إرسال',
'common.continue': 'متابعة',
'common.comingSoon': 'Coming Soon',
'settings.general': 'عام',
'settings.featuresAndAI': 'الميزات والذكاء الاصطناعي',
'settings.billingAndRewards': 'الفوترة والمكافآت',
@@ -428,6 +429,9 @@ const ar1: TranslationMap = {
'skills.tabs.composio': 'Composio',
'skills.tabs.channels': 'Channels',
'skills.tabs.mcp': 'MCP Servers',
'skills.mcpComingSoon.title': 'MCP Servers',
'skills.mcpComingSoon.description':
'MCP server management is coming soon. This tab will be the home for discovering, connecting, and monitoring your MCP server integrations.',
'settings.about.connection': 'Connection',
'settings.about.connectionMode': 'Mode',
'settings.about.connectionModeLocal': 'Local',
+4
View File
@@ -50,6 +50,7 @@ const bn1: TranslationMap = {
'common.showLess': 'কম দেখুন',
'common.submit': 'জমা দিন',
'common.continue': 'চালিয়ে যান',
'common.comingSoon': 'Coming Soon',
'settings.general': 'সাধারণ',
'settings.featuresAndAI': 'ফিচার ও AI',
'settings.billingAndRewards': 'বিলিং ও পুরস্কার',
@@ -437,6 +438,9 @@ const bn1: TranslationMap = {
'skills.tabs.composio': 'Composio',
'skills.tabs.channels': 'Channels',
'skills.tabs.mcp': 'MCP Servers',
'skills.mcpComingSoon.title': 'MCP Servers',
'skills.mcpComingSoon.description':
'MCP server management is coming soon. This tab will be the home for discovering, connecting, and monitoring your MCP server integrations.',
'settings.about.connection': 'Connection',
'settings.about.connectionMode': 'Mode',
'settings.about.connectionModeLocal': 'Local',
+4
View File
@@ -50,6 +50,7 @@ const de1: TranslationMap = {
'common.showLess': 'Weniger anzeigen',
'common.submit': 'Senden',
'common.continue': 'Weiter',
'common.comingSoon': 'Coming Soon',
'settings.general': 'Allgemein',
'settings.featuresAndAI': 'Funktionen und KI',
'settings.billingAndRewards': 'Abrechnung und Prämien',
@@ -449,6 +450,9 @@ const de1: TranslationMap = {
'skills.tabs.composio': 'Composio',
'skills.tabs.channels': 'Channels',
'skills.tabs.mcp': 'MCP Servers',
'skills.mcpComingSoon.title': 'MCP Servers',
'skills.mcpComingSoon.description':
'MCP server management is coming soon. This tab will be the home for discovering, connecting, and monitoring your MCP server integrations.',
'settings.about.connection': 'Connection',
'settings.about.connectionMode': 'Mode',
'settings.about.connectionModeLocal': 'Local',
+4
View File
@@ -50,6 +50,7 @@ const en1: TranslationMap = {
'common.showLess': 'Show less',
'common.submit': 'Submit',
'common.continue': 'Continue',
'common.comingSoon': 'Coming Soon',
'settings.general': 'General',
'settings.featuresAndAI': 'Features & AI',
'settings.billingAndRewards': 'Billing & Rewards',
@@ -210,6 +211,9 @@ const en1: TranslationMap = {
'skills.tabs.composio': 'Composio',
'skills.tabs.channels': 'Channels',
'skills.tabs.mcp': 'MCP Servers',
'skills.mcpComingSoon.title': 'MCP Servers',
'skills.mcpComingSoon.description':
'MCP server management is coming soon. This tab will be the home for discovering, connecting, and monitoring your MCP server integrations.',
'memory.title': 'Memory',
'memory.search': 'Search memories...',
'memory.noResults': 'No memories found',
+4
View File
@@ -50,6 +50,7 @@ const es1: TranslationMap = {
'common.showLess': 'Ver menos',
'common.submit': 'Enviar',
'common.continue': 'Continuar',
'common.comingSoon': 'Coming Soon',
'settings.general': 'General',
'settings.featuresAndAI': 'Funciones e IA',
'settings.billingAndRewards': 'Facturación y recompensas',
@@ -449,6 +450,9 @@ const es1: TranslationMap = {
'skills.tabs.composio': 'Composio',
'skills.tabs.channels': 'Channels',
'skills.tabs.mcp': 'MCP Servers',
'skills.mcpComingSoon.title': 'MCP Servers',
'skills.mcpComingSoon.description':
'MCP server management is coming soon. This tab will be the home for discovering, connecting, and monitoring your MCP server integrations.',
'settings.about.connection': 'Connection',
'settings.about.connectionMode': 'Mode',
'settings.about.connectionModeLocal': 'Local',
+4
View File
@@ -50,6 +50,7 @@ const fr1: TranslationMap = {
'common.showLess': 'Afficher moins',
'common.submit': 'Envoyer',
'common.continue': 'Continuer',
'common.comingSoon': 'Coming Soon',
'settings.general': 'Général',
'settings.featuresAndAI': 'Fonctionnalités & IA',
'settings.billingAndRewards': 'Facturation & Récompenses',
@@ -451,6 +452,9 @@ const fr1: TranslationMap = {
'skills.tabs.composio': 'Composio',
'skills.tabs.channels': 'Channels',
'skills.tabs.mcp': 'MCP Servers',
'skills.mcpComingSoon.title': 'MCP Servers',
'skills.mcpComingSoon.description':
'MCP server management is coming soon. This tab will be the home for discovering, connecting, and monitoring your MCP server integrations.',
'settings.about.connection': 'Connection',
'settings.about.connectionMode': 'Mode',
'settings.about.connectionModeLocal': 'Local',
+4
View File
@@ -50,6 +50,7 @@ const hi1: TranslationMap = {
'common.showLess': 'कम दिखाएं',
'common.submit': 'सबमिट करें',
'common.continue': 'जारी रखें',
'common.comingSoon': 'Coming Soon',
'settings.general': 'सामान्य',
'settings.featuresAndAI': 'फीचर्स और AI',
'settings.billingAndRewards': 'बिलिंग और रिवॉर्ड',
@@ -434,6 +435,9 @@ const hi1: TranslationMap = {
'skills.tabs.composio': 'Composio',
'skills.tabs.channels': 'Channels',
'skills.tabs.mcp': 'MCP Servers',
'skills.mcpComingSoon.title': 'MCP Servers',
'skills.mcpComingSoon.description':
'MCP server management is coming soon. This tab will be the home for discovering, connecting, and monitoring your MCP server integrations.',
'settings.about.connection': 'Connection',
'settings.about.connectionMode': 'Mode',
'settings.about.connectionModeLocal': 'Local',
+4
View File
@@ -50,6 +50,7 @@ const id1: TranslationMap = {
'common.showLess': 'Tampilkan sedikit',
'common.submit': 'Kirim',
'common.continue': 'Lanjutkan',
'common.comingSoon': 'Coming Soon',
'settings.general': 'Umum',
'settings.featuresAndAI': 'Fitur & AI',
'settings.billingAndRewards': 'Tagihan & Hadiah',
@@ -440,6 +441,9 @@ const id1: TranslationMap = {
'skills.tabs.composio': 'Composio',
'skills.tabs.channels': 'Channels',
'skills.tabs.mcp': 'MCP Servers',
'skills.mcpComingSoon.title': 'MCP Servers',
'skills.mcpComingSoon.description':
'MCP server management is coming soon. This tab will be the home for discovering, connecting, and monitoring your MCP server integrations.',
'settings.about.connection': 'Connection',
'settings.about.connectionMode': 'Mode',
'settings.about.connectionModeLocal': 'Local',
+4
View File
@@ -50,6 +50,7 @@ const it1: TranslationMap = {
'common.showLess': 'Mostra meno',
'common.submit': 'Invia',
'common.continue': 'Continua',
'common.comingSoon': 'Coming Soon',
'settings.general': 'Generale',
'settings.featuresAndAI': 'Funzionalità e AI',
'settings.billingAndRewards': 'Fatturazione e premi',
@@ -444,6 +445,9 @@ const it1: TranslationMap = {
'skills.tabs.composio': 'Composio',
'skills.tabs.channels': 'Channels',
'skills.tabs.mcp': 'MCP Servers',
'skills.mcpComingSoon.title': 'MCP Servers',
'skills.mcpComingSoon.description':
'MCP server management is coming soon. This tab will be the home for discovering, connecting, and monitoring your MCP server integrations.',
'settings.about.connection': 'Connection',
'settings.about.connectionMode': 'Mode',
'settings.about.connectionModeLocal': 'Local',
+4
View File
@@ -50,6 +50,7 @@ const ko1: TranslationMap = {
'common.showLess': '간단히 보기',
'common.submit': '제출',
'common.continue': '계속',
'common.comingSoon': 'Coming Soon',
'settings.general': '일반',
'settings.featuresAndAI': '기능 및 AI',
'settings.billingAndRewards': '결제 및 보상',
@@ -425,6 +426,9 @@ const ko1: TranslationMap = {
'skills.tabs.composio': 'Composio',
'skills.tabs.channels': 'Channels',
'skills.tabs.mcp': 'MCP Servers',
'skills.mcpComingSoon.title': 'MCP Servers',
'skills.mcpComingSoon.description':
'MCP server management is coming soon. This tab will be the home for discovering, connecting, and monitoring your MCP server integrations.',
'settings.about.connection': 'Connection',
'settings.about.connectionMode': 'Mode',
'settings.about.connectionModeLocal': 'Local',
+4
View File
@@ -50,6 +50,7 @@ const pt1: TranslationMap = {
'common.showLess': 'Mostrar menos',
'common.submit': 'Enviar',
'common.continue': 'Continuar',
'common.comingSoon': 'Coming Soon',
'settings.general': 'Geral',
'settings.featuresAndAI': 'Recursos e IA',
'settings.billingAndRewards': 'Cobrança e Recompensas',
@@ -449,6 +450,9 @@ const pt1: TranslationMap = {
'skills.tabs.composio': 'Composio',
'skills.tabs.channels': 'Channels',
'skills.tabs.mcp': 'MCP Servers',
'skills.mcpComingSoon.title': 'MCP Servers',
'skills.mcpComingSoon.description':
'MCP server management is coming soon. This tab will be the home for discovering, connecting, and monitoring your MCP server integrations.',
'settings.about.connection': 'Connection',
'settings.about.connectionMode': 'Mode',
'settings.about.connectionModeLocal': 'Local',
+4
View File
@@ -50,6 +50,7 @@ const ru1: TranslationMap = {
'common.showLess': 'Показать меньше',
'common.submit': 'Отправить',
'common.continue': 'Продолжить',
'common.comingSoon': 'Coming Soon',
'settings.general': 'Общие',
'settings.featuresAndAI': 'Функции и AI',
'settings.billingAndRewards': 'Оплата и награды',
@@ -439,6 +440,9 @@ const ru1: TranslationMap = {
'skills.tabs.composio': 'Composio',
'skills.tabs.channels': 'Channels',
'skills.tabs.mcp': 'MCP Servers',
'skills.mcpComingSoon.title': 'MCP Servers',
'skills.mcpComingSoon.description':
'MCP server management is coming soon. This tab will be the home for discovering, connecting, and monitoring your MCP server integrations.',
'settings.about.connection': 'Connection',
'settings.about.connectionMode': 'Mode',
'settings.about.connectionModeLocal': 'Local',
+4
View File
@@ -50,6 +50,7 @@ const zhCN1: TranslationMap = {
'common.showLess': '收起',
'common.submit': '提交',
'common.continue': '继续',
'common.comingSoon': 'Coming Soon',
'settings.general': '通用',
'settings.featuresAndAI': '功能与 AI',
'settings.billingAndRewards': '账单与奖励',
@@ -421,6 +422,9 @@ const zhCN1: TranslationMap = {
'skills.tabs.composio': 'Composio',
'skills.tabs.channels': 'Channels',
'skills.tabs.mcp': 'MCP Servers',
'skills.mcpComingSoon.title': 'MCP Servers',
'skills.mcpComingSoon.description':
'MCP server management is coming soon. This tab will be the home for discovering, connecting, and monitoring your MCP server integrations.',
'settings.about.connection': 'Connection',
'settings.about.connectionMode': 'Mode',
'settings.about.connectionModeLocal': 'Local',
+4
View File
@@ -52,6 +52,7 @@ const en: TranslationMap = {
'common.showLess': 'Show less',
'common.submit': 'Submit',
'common.continue': 'Continue',
'common.comingSoon': 'Coming Soon',
// Settings Home
'settings.general': 'General',
@@ -1817,6 +1818,9 @@ const en: TranslationMap = {
'settings.ai.openAiCompat.setKey': 'Set key',
'settings.ai.openAiCompat.title': 'OpenAI-compatible endpoint',
'settings.ai.providerLabel': 'Provider',
'skills.mcpComingSoon.title': 'MCP Servers',
'skills.mcpComingSoon.description':
'MCP server management is coming soon. This tab will be the home for discovering, connecting, and monitoring your MCP server integrations.',
'settings.ai.routing': 'Routing',
'settings.ai.routingCustom': 'Custom routing',
'settings.ai.routingDefault': 'Default',
+32 -2
View File
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import ChannelSetupModal from '../components/channels/ChannelSetupModal';
import McpServersTab from '../components/channels/mcp/McpServersTab';
import ComposioConnectModal from '../components/composio/ComposioConnectModal';
import {
composioToolkitMeta,
@@ -284,6 +283,37 @@ function ChannelTile({ def, status, icon, testId, onOpen }: ChannelTileProps) {
);
}
function McpComingSoonPanel() {
const { t } = useT();
return (
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-stone-300 dark:border-neutral-700 bg-stone-50/80 dark:bg-neutral-900/80 px-6 py-16 text-center">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-50 dark:bg-primary-500/10">
<svg
className="h-7 w-7 text-primary-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6.75 7.5h10.5m-10.5 4.5h10.5m-10.5 4.5h6m-9.75 3h13.5A2.25 2.25 0 0 0 19.5 17.25V6.75A2.25 2.25 0 0 0 17.25 4.5H6.75A2.25 2.25 0 0 0 4.5 6.75v10.5A2.25 2.25 0 0 0 6.75 19.5Z"
/>
</svg>
</div>
<h3 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
{t('skills.mcpComingSoon.title')}
</h3>
<p className="mt-2 max-w-md text-sm leading-relaxed text-stone-500 dark:text-neutral-400">
{t('skills.mcpComingSoon.description')}
</p>
<span className="mt-4 inline-flex items-center rounded-full bg-primary-50 dark:bg-primary-500/10 px-3 py-1 text-xs font-medium text-primary-600 dark:text-primary-400">
{t('common.comingSoon')}
</span>
</div>
);
}
// ─── Built-in skill definitions ────────────────────────────────────────────────
const BUILT_IN_SKILLS: Array<{
@@ -1035,7 +1065,7 @@ export default function Skills() {
{t('channels.mcp.description')}
</p>
</div>
<McpServersTab />
<McpComingSoonPanel />
</div>
)}
</>
@@ -0,0 +1,47 @@
import { fireEvent, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import '../../test/mockDefaultSkillStatusHooks';
import { renderWithProviders } from '../../test/test-utils';
import Skills from '../Skills';
vi.mock('../../hooks/useChannelDefinitions', () => ({
useChannelDefinitions: () => ({ definitions: [], loading: false, error: null }),
}));
vi.mock('../../services/api/skillsApi', async () => {
const actual = await vi.importActual<typeof import('../../services/api/skillsApi')>(
'../../services/api/skillsApi'
);
return {
...actual,
skillsApi: { ...actual.skillsApi, listSkills: vi.fn().mockResolvedValue([]) },
};
});
vi.mock('../../lib/composio/hooks', () => ({
useComposioIntegrations: () => ({
toolkits: [],
connectionByToolkit: new Map(),
refresh: vi.fn(),
loading: false,
error: null,
}),
useAgentReadyComposioToolkits: () => ({
agentReady: new Set<string>(),
loading: true,
error: null,
}),
}));
describe('Skills page — MCP tab', () => {
it('shows a coming soon placeholder for MCP server management', () => {
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
fireEvent.click(screen.getByRole('tab', { name: 'MCP Servers' }));
expect(screen.getAllByRole('heading', { name: 'MCP Servers' })).toHaveLength(2);
expect(screen.getByText(/MCP server management is coming soon/i)).toBeInTheDocument();
expect(screen.getByText('Coming Soon')).toBeInTheDocument();
});
});
@@ -25,6 +25,7 @@ import {
setCloudProviderKey,
setLocalRuntimeEnabled,
setOpenAICompatEndpointKey,
testProviderModel,
} from '../aiSettingsApi';
// ─── Mock declarations (must be hoisted before imports) ───────────────────────
@@ -809,6 +810,35 @@ describe('listProviderModels', () => {
});
});
describe('testProviderModel', () => {
beforeEach(() => {
mockCallCoreRpc.mockReset();
mockIsTauri.mockReturnValue(true);
});
it('dispatches openhuman.inference_test_provider_model and returns the reply', async () => {
mockCallCoreRpc.mockResolvedValue({ result: { reply: 'Hello from model' } });
const result = await testProviderModel('reasoning', 'openai:gpt-4o');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.inference_test_provider_model',
params: { workload: 'reasoning', provider: 'openai:gpt-4o', prompt: 'Hello world' },
timeoutMs: 120000,
});
expect(result).toEqual({ reply: 'Hello from model' });
});
it('throws when not running in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
await expect(testProviderModel('reasoning', 'openai:gpt-4o')).rejects.toThrow(
'Model testing is only available in the desktop app.'
);
expect(mockCallCoreRpc).not.toHaveBeenCalled();
});
});
// ─── flushCloudProviders ──────────────────────────────────────────────────────
describe('flushCloudProviders', () => {
+27
View File
@@ -113,6 +113,12 @@ export interface ModelInfo {
context_window?: number | null;
}
export interface ProviderModelTestResult {
reply: string;
}
const PROVIDER_MODEL_TEST_TIMEOUT_MS = 120_000;
/** Single in-memory snapshot the AI panel renders against. */
export interface AISettings {
cloudProviders: CloudProviderView[];
@@ -371,6 +377,27 @@ export async function listProviderModels(providerId: string): Promise<ModelInfo[
return res?.result?.models ?? [];
}
export async function testProviderModel(
workload: WorkloadId,
provider: string,
prompt = 'Hello world'
): Promise<ProviderModelTestResult> {
if (!isTauri()) {
throw new Error('Model testing is only available in the desktop app.');
}
const res = await callCoreRpc<{ result: ProviderModelTestResult }>({
method: 'openhuman.inference_test_provider_model',
params: { workload, provider, prompt },
timeoutMs: PROVIDER_MODEL_TEST_TIMEOUT_MS,
});
if (!res?.result) {
throw new Error(
`Model test RPC returned no result for ${workload} via ${provider} (openhuman.inference_test_provider_model).`
);
}
return res.result;
}
// ─── Local provider façade (Ollama install / detect / model manage) ───────
/** Snapshot of the Ollama daemon + installed-model state for the AI panel. */
@@ -187,6 +187,51 @@ pub(crate) struct LmStudioChatChoice {
pub(crate) struct LmStudioChatResponseMessage {
#[serde(default)]
pub content: Option<String>,
#[serde(default)]
pub reasoning_content: Option<String>,
}
impl LmStudioChatResponseMessage {
pub(crate) fn effective_content(&self) -> String {
let content = self
.content
.as_deref()
.map(crate::openhuman::inference::provider::compatible_parse::strip_think_tags)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_default();
if !content.is_empty() {
tracing::trace!(
source = "content",
output_chars = content.chars().count(),
"[lm-studio] effective content selected"
);
return content;
}
let reasoning = self
.reasoning_content
.as_deref()
.map(crate::openhuman::inference::provider::compatible_parse::strip_think_tags)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_default();
if !reasoning.is_empty() {
tracing::trace!(
source = "reasoning_content",
output_chars = reasoning.chars().count(),
"[lm-studio] effective content selected"
);
return reasoning;
}
tracing::trace!(
source = "none",
output_chars = 0,
"[lm-studio] effective content empty"
);
String::new()
}
}
#[derive(Debug, Deserialize)]
@@ -228,4 +273,22 @@ mod tests {
Some("http://127.0.0.1:1234/v1")
);
}
#[test]
fn effective_content_falls_back_to_reasoning_content() {
let msg = LmStudioChatResponseMessage {
content: Some("".into()),
reasoning_content: Some("thinking text".into()),
};
assert_eq!(msg.effective_content(), "thinking text");
}
#[test]
fn effective_content_strips_think_tags() {
let msg = LmStudioChatResponseMessage {
content: Some("<think>hidden</think>Visible reply".into()),
reasoning_content: None,
};
assert_eq!(msg.effective_content(), "Visible reply");
}
}
@@ -225,10 +225,8 @@ impl LocalAiService {
let reply = payload
.choices
.first()
.and_then(|choice| choice.message.content.as_deref())
.unwrap_or_default()
.trim()
.to_string();
.map(|choice| choice.message.effective_content())
.unwrap_or_default();
if reply.is_empty() && !allow_empty {
return Err("lm studio returned empty content".to_string());
@@ -236,6 +236,45 @@ async fn lm_studio_chat_with_history_returns_response() {
assert_eq!(reply, "history reply");
}
#[tokio::test]
async fn lm_studio_chat_with_history_falls_back_to_reasoning_content() {
let _guard = crate::openhuman::inference::inference_test_guard();
let app = Router::new().route(
"/v1/chat/completions",
post(|Json(_body): Json<serde_json::Value>| async move {
Json(json!({
"choices": [{
"message": {
"role": "assistant",
"content": "",
"reasoning_content": "reasoning-only reply"
}
}]
}))
}),
);
let base = spawn_mock(app).await;
let config = lm_studio_config(&base);
let service = ready_service(&config);
let reply = service
.chat_with_history(
&config,
vec![
crate::openhuman::inference::local::ollama::OllamaChatMessage {
role: "user".to_string(),
content: "hi".to_string(),
},
],
None,
)
.await
.expect("lm studio reasoning fallback");
assert_eq!(reply, "reasoning-only reply");
}
#[tokio::test]
async fn lm_studio_prompt_errors_on_non_success_status() {
let _guard = crate::openhuman::inference::inference_test_guard();
+95
View File
@@ -13,6 +13,11 @@ use tracing::{debug, error};
const LOG_PREFIX: &str = "[inference::ops]";
#[derive(Debug, Clone, serde::Serialize)]
pub struct InferenceTestProviderModelResult {
pub reply: String,
}
pub async fn inference_status(config: &Config) -> Result<RpcOutcome<LocalAiStatus>, String> {
debug!("{LOG_PREFIX} status:start");
let result = local_runtime::rpc::local_ai_status(config).await;
@@ -123,6 +128,96 @@ pub async fn inference_chat(
result
}
pub async fn inference_test_provider_model(
config: &Config,
workload: &str,
provider: &str,
prompt: &str,
) -> Result<RpcOutcome<InferenceTestProviderModelResult>, String> {
debug!(
workload,
provider,
prompt_len = prompt.len(),
"{LOG_PREFIX} test_provider_model:start"
);
let result =
if provider.trim().starts_with("lmstudio:") || provider.trim().starts_with("ollama:") {
let mut effective = config.clone();
let (local_kind, raw_model) = provider
.split_once(':')
.ok_or_else(|| "invalid local provider string".to_string())?;
let (model, temperature_override) = match raw_model.rsplit_once('@') {
Some((head, tail)) => match tail.trim().parse::<f64>() {
Ok(temp) if !head.trim().is_empty() => (head.trim().to_string(), Some(temp)),
_ => (raw_model.trim().to_string(), None),
},
None => (raw_model.trim().to_string(), None),
};
if model.is_empty() {
return Err("model must not be empty".to_string());
}
if let Some(temp) = temperature_override {
effective.default_temperature = temp;
}
if local_kind == "lmstudio" {
effective.local_ai.provider = "lm_studio".to_string();
if let Some(entry) = config.cloud_providers.iter().find(|e| e.slug == "lmstudio") {
effective.local_ai.base_url = Some(entry.endpoint.clone());
}
} else {
effective.local_ai.provider = "ollama".to_string();
if let Some(entry) = config.cloud_providers.iter().find(|e| e.slug == "ollama") {
effective.local_ai.base_url = Some(
entry
.endpoint
.trim_end_matches("/")
.trim_end_matches("/v1")
.to_string(),
);
}
}
effective.local_ai.chat_model_id = model;
let messages = vec![LocalAiChatMessage {
role: "user".to_string(),
content: prompt.to_string(),
}];
local_runtime::rpc::local_ai_chat(&effective, messages, None)
.await
.map(|outcome| {
RpcOutcome::single_log(
InferenceTestProviderModelResult {
reply: outcome.value,
},
"provider model test completed",
)
})
} else {
let (chat_provider, model) =
crate::openhuman::inference::provider::factory::create_chat_provider_from_string(
workload, provider, config,
)
.map_err(|e| e.to_string())?;
chat_provider
.simple_chat(prompt, &model, config.default_temperature)
.await
.map_err(|e| e.to_string())
.map(|reply| {
RpcOutcome::single_log(
InferenceTestProviderModelResult { reply },
"provider model test completed",
)
})
};
match &result {
Ok(outcome) => debug!(
output_len = outcome.value.reply.len(),
"{LOG_PREFIX} test_provider_model:ok"
),
Err(err) => error!(error = %err, "{LOG_PREFIX} test_provider_model:error"),
}
result
}
pub async fn inference_should_react(
config: &Config,
message: &str,
+9
View File
@@ -63,6 +63,15 @@ async fn inference_chat_rejects_empty_messages() {
assert!(err.contains("must not be empty"));
}
#[tokio::test]
async fn inference_test_provider_model_uses_local_runtime_branch_for_lmstudio_prefix() {
let (config, _tmp) = disabled_config();
let err = inference_test_provider_model(&config, "reasoning", "lmstudio:test-model", "Hello")
.await
.expect_err("lmstudio local test should fail when local ai is disabled");
assert!(err.contains("local ai is disabled"));
}
#[tokio::test]
async fn inference_should_react_short_circuits_for_empty_message() {
let (config, _tmp) = disabled_config();
+39
View File
@@ -44,6 +44,13 @@ struct InferenceChatParams {
max_tokens: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct InferenceTestProviderModelParams {
workload: String,
provider: String,
prompt: Option<String>,
}
#[derive(Debug, Deserialize)]
struct InferenceShouldReactParams {
message: String,
@@ -147,6 +154,7 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
schemas("vision_prompt"),
schemas("embed"),
schemas("chat"),
schemas("test_provider_model"),
schemas("should_react"),
schemas("analyze_sentiment"),
]
@@ -226,6 +234,10 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
schema: schemas("chat"),
handler: handle_inference_chat,
},
RegisteredController {
schema: schemas("test_provider_model"),
handler: handle_inference_test_provider_model,
},
RegisteredController {
schema: schemas("should_react"),
handler: handle_inference_should_react,
@@ -434,6 +446,17 @@ pub fn schemas(function: &str) -> ControllerSchema {
],
outputs: vec![json_output("reply", "Assistant reply text.")],
},
"test_provider_model" => ControllerSchema {
namespace: "inference",
function: "test_provider_model",
description: "Run a one-off Hello-world style test against an explicit provider:model binding without saving routing changes.",
inputs: vec![
required_string("workload", "Workload id context (chat, reasoning, coding, etc.)."),
required_string("provider", "Explicit provider string like 'openai:gpt-4o' or 'ollama:llama3.1:8b'."),
optional_string("prompt", "Optional prompt text to send; defaults to 'Hello world'."),
],
outputs: vec![json_output("reply", "Assistant reply text.")],
},
"should_react" => ControllerSchema {
namespace: "inference",
function: "should_react",
@@ -804,6 +827,22 @@ fn handle_inference_chat(params: Map<String, Value>) -> ControllerFuture {
})
}
fn handle_inference_test_provider_model(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let p = deserialize_params::<InferenceTestProviderModelParams>(params)?;
let config = config_rpc::load_config_with_timeout().await?;
to_json(
crate::openhuman::inference::rpc::inference_test_provider_model(
&config,
&p.workload,
&p.provider,
p.prompt.as_deref().unwrap_or("Hello world"),
)
.await?,
)
})
}
fn handle_inference_should_react(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let p = deserialize_params::<InferenceShouldReactParams>(params)?;