diff --git a/app/src/components/settings/panels/AIPanel.tsx b/app/src/components/settings/panels/AIPanel.tsx index 5695b7ebc..104a82704 100644 --- a/app/src/components/settings/panels/AIPanel.tsx +++ b/app/src/components/settings/panels/AIPanel.tsx @@ -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(null); const [modelsKey, setModelsKey] = useState(0); + const [testBusy, setTestBusy] = useState(false); + const [testReply, setTestReply] = useState(null); + const [testError, setTestError] = useState(null); + const [testStartedAt, setTestStartedAt] = useState(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( @@ -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' ? ( 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 ? ( 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 = ({ 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" /> { 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.

+ + {(testBusy || testReply || testError || testStartedAt) && ( +
+
+ {testError ? 'Test failed' : testBusy ? 'Testing model…' : 'Model response'} +
+
+
+ Provider: {currentProviderString ?? '—'} +
+
Prompt: Hello world
+ {testStartedAt && ( +
+ Started: {testStartedAt} +
+ )} +
+ {testBusy ? ( +
+ Waiting for response from the selected model… +
+ ) : testError ? ( +
+ {testError} +
+ ) : ( +
+
+ Response +
+
+ {testReply} +
+
+ )} +
+ )} )} @@ -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')} + + )} + + 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-...'} /> - {!isOpenHuman && ( -
- - 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-...'} - /> -
- )} {submitError ? : null}
@@ -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') diff --git a/app/src/components/settings/panels/__tests__/AIPanel.test.tsx b/app/src/components/settings/panels/__tests__/AIPanel.test.tsx index c0122e961..7ab2a3376 100644 --- a/app/src/components/settings/panels/__tests__/AIPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/AIPanel.test.tsx @@ -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(); + 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(); + 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(); + + 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(); + + 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(); + + 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( diff --git a/app/src/lib/i18n/chunks/ar-1.ts b/app/src/lib/i18n/chunks/ar-1.ts index 2025fa576..f61578cfd 100644 --- a/app/src/lib/i18n/chunks/ar-1.ts +++ b/app/src/lib/i18n/chunks/ar-1.ts @@ -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', diff --git a/app/src/lib/i18n/chunks/bn-1.ts b/app/src/lib/i18n/chunks/bn-1.ts index 3a4821df3..6f0515733 100644 --- a/app/src/lib/i18n/chunks/bn-1.ts +++ b/app/src/lib/i18n/chunks/bn-1.ts @@ -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', diff --git a/app/src/lib/i18n/chunks/de-1.ts b/app/src/lib/i18n/chunks/de-1.ts index a1e24124c..45244024a 100644 --- a/app/src/lib/i18n/chunks/de-1.ts +++ b/app/src/lib/i18n/chunks/de-1.ts @@ -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', diff --git a/app/src/lib/i18n/chunks/en-1.ts b/app/src/lib/i18n/chunks/en-1.ts index 440f6f08e..98914d37b 100644 --- a/app/src/lib/i18n/chunks/en-1.ts +++ b/app/src/lib/i18n/chunks/en-1.ts @@ -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', diff --git a/app/src/lib/i18n/chunks/es-1.ts b/app/src/lib/i18n/chunks/es-1.ts index 7e0301ff1..b842d2cd5 100644 --- a/app/src/lib/i18n/chunks/es-1.ts +++ b/app/src/lib/i18n/chunks/es-1.ts @@ -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', diff --git a/app/src/lib/i18n/chunks/fr-1.ts b/app/src/lib/i18n/chunks/fr-1.ts index 4647b030d..e9dc9c659 100644 --- a/app/src/lib/i18n/chunks/fr-1.ts +++ b/app/src/lib/i18n/chunks/fr-1.ts @@ -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', diff --git a/app/src/lib/i18n/chunks/hi-1.ts b/app/src/lib/i18n/chunks/hi-1.ts index 70d7a5143..6b3d3bab1 100644 --- a/app/src/lib/i18n/chunks/hi-1.ts +++ b/app/src/lib/i18n/chunks/hi-1.ts @@ -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', diff --git a/app/src/lib/i18n/chunks/id-1.ts b/app/src/lib/i18n/chunks/id-1.ts index b093a49d5..9f4d59919 100644 --- a/app/src/lib/i18n/chunks/id-1.ts +++ b/app/src/lib/i18n/chunks/id-1.ts @@ -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', diff --git a/app/src/lib/i18n/chunks/it-1.ts b/app/src/lib/i18n/chunks/it-1.ts index 48b0071da..8c8543719 100644 --- a/app/src/lib/i18n/chunks/it-1.ts +++ b/app/src/lib/i18n/chunks/it-1.ts @@ -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', diff --git a/app/src/lib/i18n/chunks/ko-1.ts b/app/src/lib/i18n/chunks/ko-1.ts index cd35fde5e..f4c4ebf39 100644 --- a/app/src/lib/i18n/chunks/ko-1.ts +++ b/app/src/lib/i18n/chunks/ko-1.ts @@ -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', diff --git a/app/src/lib/i18n/chunks/pt-1.ts b/app/src/lib/i18n/chunks/pt-1.ts index 9de3752fa..ecb65af2a 100644 --- a/app/src/lib/i18n/chunks/pt-1.ts +++ b/app/src/lib/i18n/chunks/pt-1.ts @@ -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', diff --git a/app/src/lib/i18n/chunks/ru-1.ts b/app/src/lib/i18n/chunks/ru-1.ts index a3e3b1acc..f1da90d9b 100644 --- a/app/src/lib/i18n/chunks/ru-1.ts +++ b/app/src/lib/i18n/chunks/ru-1.ts @@ -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', diff --git a/app/src/lib/i18n/chunks/zh-CN-1.ts b/app/src/lib/i18n/chunks/zh-CN-1.ts index 03469f69f..1de1c3a20 100644 --- a/app/src/lib/i18n/chunks/zh-CN-1.ts +++ b/app/src/lib/i18n/chunks/zh-CN-1.ts @@ -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', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index e759f262e..5171606cc 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -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', diff --git a/app/src/pages/Skills.tsx b/app/src/pages/Skills.tsx index 921fd9147..dfd8aa1c1 100644 --- a/app/src/pages/Skills.tsx +++ b/app/src/pages/Skills.tsx @@ -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 ( +
+
+ + + +
+

+ {t('skills.mcpComingSoon.title')} +

+

+ {t('skills.mcpComingSoon.description')} +

+ + {t('common.comingSoon')} + +
+ ); +} + // ─── Built-in skill definitions ──────────────────────────────────────────────── const BUILT_IN_SKILLS: Array<{ @@ -1035,7 +1065,7 @@ export default function Skills() { {t('channels.mcp.description')}

- + )} diff --git a/app/src/pages/__tests__/Skills.mcp-coming-soon.test.tsx b/app/src/pages/__tests__/Skills.mcp-coming-soon.test.tsx new file mode 100644 index 000000000..95becce43 --- /dev/null +++ b/app/src/pages/__tests__/Skills.mcp-coming-soon.test.tsx @@ -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( + '../../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(), + loading: true, + error: null, + }), +})); + +describe('Skills page — MCP tab', () => { + it('shows a coming soon placeholder for MCP server management', () => { + renderWithProviders(, { 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(); + }); +}); diff --git a/app/src/services/api/__tests__/aiSettingsApi.test.ts b/app/src/services/api/__tests__/aiSettingsApi.test.ts index 71736b400..148758bdc 100644 --- a/app/src/services/api/__tests__/aiSettingsApi.test.ts +++ b/app/src/services/api/__tests__/aiSettingsApi.test.ts @@ -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', () => { diff --git a/app/src/services/api/aiSettingsApi.ts b/app/src/services/api/aiSettingsApi.ts index 0f6c5a5f0..963e0155b 100644 --- a/app/src/services/api/aiSettingsApi.ts +++ b/app/src/services/api/aiSettingsApi.ts @@ -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 { + 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. */ diff --git a/src/openhuman/inference/local/lm_studio.rs b/src/openhuman/inference/local/lm_studio.rs index 03344c7e4..854fed187 100644 --- a/src/openhuman/inference/local/lm_studio.rs +++ b/src/openhuman/inference/local/lm_studio.rs @@ -187,6 +187,51 @@ pub(crate) struct LmStudioChatChoice { pub(crate) struct LmStudioChatResponseMessage { #[serde(default)] pub content: Option, + #[serde(default)] + pub reasoning_content: Option, +} + +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("hiddenVisible reply".into()), + reasoning_content: None, + }; + assert_eq!(msg.effective_content(), "Visible reply"); + } } diff --git a/src/openhuman/inference/local/service/lm_studio.rs b/src/openhuman/inference/local/service/lm_studio.rs index 742d7c46b..38163e344 100644 --- a/src/openhuman/inference/local/service/lm_studio.rs +++ b/src/openhuman/inference/local/service/lm_studio.rs @@ -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()); diff --git a/src/openhuman/inference/local/service/public_infer_tests.rs b/src/openhuman/inference/local/service/public_infer_tests.rs index 8931438e2..a384087b8 100644 --- a/src/openhuman/inference/local/service/public_infer_tests.rs +++ b/src/openhuman/inference/local/service/public_infer_tests.rs @@ -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| 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(); diff --git a/src/openhuman/inference/ops.rs b/src/openhuman/inference/ops.rs index 17aed2696..5573cb223 100644 --- a/src/openhuman/inference/ops.rs +++ b/src/openhuman/inference/ops.rs @@ -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, 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, 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::() { + 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, diff --git a/src/openhuman/inference/ops_tests.rs b/src/openhuman/inference/ops_tests.rs index 771ac9d95..66100bd44 100644 --- a/src/openhuman/inference/ops_tests.rs +++ b/src/openhuman/inference/ops_tests.rs @@ -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(); diff --git a/src/openhuman/inference/schemas.rs b/src/openhuman/inference/schemas.rs index c4f792c5a..59fa99d8d 100644 --- a/src/openhuman/inference/schemas.rs +++ b/src/openhuman/inference/schemas.rs @@ -44,6 +44,13 @@ struct InferenceChatParams { max_tokens: Option, } +#[derive(Debug, Deserialize)] +struct InferenceTestProviderModelParams { + workload: String, + provider: String, + prompt: Option, +} + #[derive(Debug, Deserialize)] struct InferenceShouldReactParams { message: String, @@ -147,6 +154,7 @@ pub fn all_controller_schemas() -> Vec { 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 { 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) -> ControllerFuture { }) } +fn handle_inference_test_provider_model(params: Map) -> ControllerFuture { + Box::pin(async move { + let p = deserialize_params::(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) -> ControllerFuture { Box::pin(async move { let p = deserialize_params::(params)?;