mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(inference): normalize 0.0.0.0 to 127.0.0.1 in Ollama URL + add endpoint edit button (#3161)
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
* per row, so the resolved provider+model is always rendered inline.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { LuCheck, LuCircleAlert, LuKeyRound } from 'react-icons/lu';
|
||||
import { LuCheck, LuCircleAlert, LuKeyRound, LuPencil } from 'react-icons/lu';
|
||||
|
||||
import { listConnections as listComposioConnections } from '../../../lib/composio/composioApi';
|
||||
import type { ComposioConnection } from '../../../lib/composio/types';
|
||||
@@ -582,6 +582,7 @@ const ProviderKeyDialog = ({
|
||||
slug,
|
||||
label,
|
||||
isLocalRuntime,
|
||||
initialValue,
|
||||
oauthAction,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
@@ -590,6 +591,8 @@ const ProviderKeyDialog = ({
|
||||
label: string;
|
||||
/** When true, render an "Endpoint URL" field instead of API key. */
|
||||
isLocalRuntime: boolean;
|
||||
/** Pre-populate the field when editing an existing provider's endpoint. */
|
||||
initialValue?: string;
|
||||
oauthAction?: { label: string; description?: string; onClick: () => Promise<void> | void } | null;
|
||||
onCancel: () => void;
|
||||
/** Returns the entered value. For local runtimes this is the endpoint URL;
|
||||
@@ -597,7 +600,9 @@ const ProviderKeyDialog = ({
|
||||
onSubmit: (value: string) => Promise<void> | void;
|
||||
}) => {
|
||||
const { t } = useT();
|
||||
const [value, setValue] = useState<string>(isLocalRuntime ? defaultEndpointFor(slug) : '');
|
||||
const [value, setValue] = useState<string>(
|
||||
initialValue ?? (isLocalRuntime ? defaultEndpointFor(slug) : '')
|
||||
);
|
||||
const [phase, setPhase] = useState<'idle' | 'saving' | 'oauth'>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const busy = phase !== 'idle';
|
||||
@@ -2884,6 +2889,19 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
|
||||
key={localKind}
|
||||
className={`inline-flex items-center gap-2 rounded-full px-2.5 py-1 text-xs font-medium ring-1 transition-colors ${tone}`}>
|
||||
<span>{label}</span>
|
||||
{enabled && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('settings.ai.editEndpoint')}
|
||||
title={t('settings.ai.editEndpoint')}
|
||||
onClick={() => {
|
||||
setKeyDialogFor(localKind);
|
||||
setPendingLocalLabel(label);
|
||||
}}
|
||||
className="rounded p-0.5 hover:bg-black/10 dark:hover:bg-white/10 transition-colors">
|
||||
<LuPencil className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
@@ -3245,6 +3263,11 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
|
||||
slug={keyDialogFor}
|
||||
label={pendingLocalLabel ?? BUILTIN_PROVIDER_META[keyDialogFor]?.label ?? keyDialogFor}
|
||||
isLocalRuntime={Boolean(pendingLocalLabel)}
|
||||
initialValue={
|
||||
pendingLocalLabel
|
||||
? (draft.cloudProviders.find(cp => cp.slug === keyDialogFor)?.endpoint ?? undefined)
|
||||
: undefined
|
||||
}
|
||||
oauthAction={
|
||||
keyDialogFor === 'openrouter' && !pendingLocalLabel
|
||||
? {
|
||||
|
||||
@@ -900,6 +900,48 @@ describe('AIPanel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('passes Ollama 0.0.0.0 endpoint through to the Rust normalizer', async () => {
|
||||
vi.mocked(loadAISettings).mockResolvedValue({ ...baseSettings, cloudProviders: [] });
|
||||
renderWithProviders(<AIPanel />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('switch', { name: /Connect Ollama/i })).toBeInTheDocument()
|
||||
);
|
||||
fireEvent.click(screen.getByRole('switch', { name: /Connect Ollama/i }));
|
||||
const dialog = await screen.findByRole('dialog', { name: /Connect Ollama/i });
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText(/Endpoint URL/i), {
|
||||
target: { value: 'http://0.0.0.0:11434' },
|
||||
});
|
||||
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://0.0.0.0:11434' });
|
||||
});
|
||||
|
||||
it('lets users edit an existing Ollama endpoint from the provider chip', async () => {
|
||||
vi.mocked(loadAISettings).mockResolvedValue({
|
||||
...baseSettings,
|
||||
cloudProviders: [
|
||||
{
|
||||
id: 'p_ollama_1',
|
||||
slug: 'ollama',
|
||||
label: 'Ollama',
|
||||
endpoint: 'http://127.0.0.1:11434/v1',
|
||||
auth_style: 'none' as const,
|
||||
has_api_key: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
renderWithProviders(<AIPanel />);
|
||||
const editButton = await screen.findByRole('button', { name: /Edit endpoint/i });
|
||||
fireEvent.click(editButton);
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: /Connect Ollama/i });
|
||||
const urlInput = within(dialog).getByLabelText(/Endpoint URL/i) as HTMLInputElement;
|
||||
expect(urlInput.value).toBe('http://127.0.0.1:11434/v1');
|
||||
});
|
||||
|
||||
it('LM Studio save persists the local_ai provider and endpoint', async () => {
|
||||
vi.mocked(loadAISettings).mockResolvedValue({ ...baseSettings, cloudProviders: [] });
|
||||
renderWithProviders(<AIPanel />);
|
||||
@@ -924,6 +966,65 @@ describe('AIPanel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── local runtime: edit endpoint button on enabled chip ────────────────────
|
||||
|
||||
it('shows an edit-endpoint button on enabled Ollama chip', async () => {
|
||||
const settingsWithOllama = {
|
||||
...baseSettings,
|
||||
cloudProviders: [
|
||||
...baseSettings.cloudProviders,
|
||||
{
|
||||
id: 'p_ollama_1',
|
||||
slug: 'ollama',
|
||||
label: 'Ollama',
|
||||
endpoint: 'http://192.168.1.5:11434/v1',
|
||||
auth_style: 'bearer' as const,
|
||||
has_api_key: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.mocked(loadAISettings).mockResolvedValue(settingsWithOllama);
|
||||
renderWithProviders(<AIPanel />);
|
||||
|
||||
const editBtn = await screen.findByRole('button', { name: /Edit endpoint/i });
|
||||
expect(editBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('edit-endpoint button opens the dialog pre-populated with the saved URL', async () => {
|
||||
const settingsWithOllama = {
|
||||
...baseSettings,
|
||||
cloudProviders: [
|
||||
...baseSettings.cloudProviders,
|
||||
{
|
||||
id: 'p_ollama_1',
|
||||
slug: 'ollama',
|
||||
label: 'Ollama',
|
||||
endpoint: 'http://192.168.1.5:11434/v1',
|
||||
auth_style: 'bearer' as const,
|
||||
has_api_key: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.mocked(loadAISettings).mockResolvedValue(settingsWithOllama);
|
||||
renderWithProviders(<AIPanel />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Edit endpoint/i }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: /Connect Ollama/i });
|
||||
const urlInput = within(dialog).getByLabelText(/Endpoint URL/i) as HTMLInputElement;
|
||||
expect(urlInput.value).toBe('http://192.168.1.5:11434/v1');
|
||||
});
|
||||
|
||||
it('does not show an edit-endpoint button when Ollama is disabled', async () => {
|
||||
vi.mocked(loadAISettings).mockResolvedValue({ ...baseSettings, cloudProviders: [] });
|
||||
renderWithProviders(<AIPanel />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('switch', { name: /Connect Ollama/i })).toBeInTheDocument()
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: /Edit endpoint/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ─── Custom routing dialog: per-workload temperature override ───────────────
|
||||
|
||||
it('Custom routing dialog saves the routing change immediately from the modal', async () => {
|
||||
|
||||
@@ -2752,7 +2752,9 @@ const messages: TranslationMap = {
|
||||
'settings.ai.workloadGroupChat': 'مجموعة عبء عمل المحادثة',
|
||||
'settings.ai.disconnectProvider': 'قطع الاتصال {label}',
|
||||
'settings.ai.connectProviderLabel': 'الاتصال {label}',
|
||||
'settings.ai.editProviderEndpoint': 'تعديل نقطة النهاية {label}',
|
||||
'settings.ai.defaultLocalEndpoint': 'http://localhost:11434/v1',
|
||||
'settings.ai.editEndpoint': 'تعديل نقطة الاتصال',
|
||||
'settings.ai.endpointUrlLabel': 'نقطة النهاية URL',
|
||||
'settings.ai.localRuntimeHelper':
|
||||
'حيث يمكن الوصول إلى {label}. الإعداد الافتراضي هو المضيف المحلي؛ وجّه هذا إلى مضيف بعيد (مثلًا http://10.0.0.4:11434/v1) لاستخدام نسخة مشتركة.',
|
||||
|
||||
@@ -2803,7 +2803,9 @@ const messages: TranslationMap = {
|
||||
'settings.ai.workloadGroupChat': 'ওয়ার্কলোড গ্রুপ চ্যাট',
|
||||
'settings.ai.disconnectProvider': 'সংযোগ বিচ্ছিন্ন করুন {label}',
|
||||
'settings.ai.connectProviderLabel': 'সংযোগ {label}',
|
||||
'settings.ai.editProviderEndpoint': '{label} শেষবিন্দু সম্পাদনা করুন',
|
||||
'settings.ai.defaultLocalEndpoint': 'http://localhost:11434/v1',
|
||||
'settings.ai.editEndpoint': 'শেষবিন্দু সম্পাদনা করুন',
|
||||
'settings.ai.endpointUrlLabel': 'শেষবিন্দু URL',
|
||||
'settings.ai.localRuntimeHelper':
|
||||
'xqxqx সহযোগে সংযোগ ব্যবস্থা করা হবে। ডিফল্ট স্থানীয় হোস্ট; দূরবর্তী হোস্টটিকে এই অবস্থায় দেখা যাবে (যেমন, xq1xqxqx ব্যবহার করা হচ্ছে)।',
|
||||
|
||||
@@ -2873,7 +2873,9 @@ const messages: TranslationMap = {
|
||||
'settings.ai.workloadGroupChat': 'Workload-Gruppenchat',
|
||||
'settings.ai.disconnectProvider': 'Trennen {label}',
|
||||
'settings.ai.connectProviderLabel': 'Verbinden {label}',
|
||||
'settings.ai.editProviderEndpoint': 'Endpunkt für {label} bearbeiten',
|
||||
'settings.ai.defaultLocalEndpoint': 'http://localhost:11434/v1',
|
||||
'settings.ai.editEndpoint': 'Endpunkt bearbeiten',
|
||||
'settings.ai.endpointUrlLabel': 'Endpunkt URL',
|
||||
'settings.ai.localRuntimeHelper':
|
||||
'Wo {label} erreichbar ist. Die Standardeinstellung ist localhost. Richten Sie dies auf einen Remote-Host (z. B. http://10.0.0.4:11434/v1), um eine freigegebene Instanz zu verwenden.',
|
||||
|
||||
@@ -3067,7 +3067,9 @@ const en: TranslationMap = {
|
||||
'settings.ai.workloadGroupChat': 'Chat and conversations',
|
||||
'settings.ai.disconnectProvider': 'Disconnect {label}',
|
||||
'settings.ai.connectProviderLabel': 'Connect {label}',
|
||||
'settings.ai.editProviderEndpoint': 'Edit {label} endpoint',
|
||||
'settings.ai.defaultLocalEndpoint': 'http://localhost:11434/v1',
|
||||
'settings.ai.editEndpoint': 'Edit endpoint',
|
||||
'settings.ai.endpointUrlLabel': 'Endpoint URL',
|
||||
'settings.ai.localRuntimeHelper':
|
||||
'Where {label} is reachable. Default is localhost; point this at a remote host (for example, http://10.0.0.4:11434/v1) to use a shared instance.',
|
||||
|
||||
@@ -2855,7 +2855,9 @@ const messages: TranslationMap = {
|
||||
'settings.ai.workloadGroupChat': 'Grupo de carga de trabajo de chat',
|
||||
'settings.ai.disconnectProvider': 'Desconectar {label}',
|
||||
'settings.ai.connectProviderLabel': 'Conectar {label}',
|
||||
'settings.ai.editProviderEndpoint': 'Editar punto de conexión de {label}',
|
||||
'settings.ai.defaultLocalEndpoint': 'http://localhost:11434/v1',
|
||||
'settings.ai.editEndpoint': 'Editar punto de conexión',
|
||||
'settings.ai.endpointUrlLabel': 'Punto final URL',
|
||||
'settings.ai.localRuntimeHelper':
|
||||
'Donde {label} es accesible. Por defecto es localhost; apunte esto a un host remoto (por ejemplo, http://10.0.0.4:11434/v1) para usar una instancia compartida.',
|
||||
|
||||
@@ -2863,7 +2863,9 @@ const messages: TranslationMap = {
|
||||
'settings.ai.workloadGroupChat': 'Groupe de charge chat',
|
||||
'settings.ai.disconnectProvider': 'Déconnecter {label}',
|
||||
'settings.ai.connectProviderLabel': 'Connecter {label}',
|
||||
'settings.ai.editProviderEndpoint': 'Modifier le point de terminaison {label}',
|
||||
'settings.ai.defaultLocalEndpoint': 'http://localhost:11434/v1',
|
||||
'settings.ai.editEndpoint': 'Modifier le point de terminaison',
|
||||
'settings.ai.endpointUrlLabel': 'Point de terminaison URL',
|
||||
'settings.ai.localRuntimeHelper':
|
||||
"Là où {label} est accessible. Par défaut, c'est localhost; pointez ceci vers un hôte distant (par exemple, http://10.0.0.4:11434/v1) pour utiliser une instance partagée.",
|
||||
|
||||
@@ -2808,7 +2808,9 @@ const messages: TranslationMap = {
|
||||
'settings.ai.workloadGroupChat': 'चैट वर्कलोड ग्रुप',
|
||||
'settings.ai.disconnectProvider': 'डिस्कनेक्ट करें {label}',
|
||||
'settings.ai.connectProviderLabel': 'कनेक्ट करें {label}',
|
||||
'settings.ai.editProviderEndpoint': '{label} समापन बिंदु संपादित करें',
|
||||
'settings.ai.defaultLocalEndpoint': 'http://localhost:11434/v1',
|
||||
'settings.ai.editEndpoint': 'समापन बिंदु संपादित करें',
|
||||
'settings.ai.endpointUrlLabel': 'समापन बिंदु URL',
|
||||
'settings.ai.localRuntimeHelper':
|
||||
'जहां {label} पहुंच योग्य है। डिफ़ॉल्ट स्थानीयहोस्ट है; इसे रिमोट होस्ट पर इंगित करें (उदाहरण के लिए, http://10.0.0.4:11434/v1) एक साझा उदाहरण का उपयोग करने के लिए)।',
|
||||
|
||||
@@ -2812,7 +2812,9 @@ const messages: TranslationMap = {
|
||||
'settings.ai.workloadGroupChat': 'Grup beban kerja chat',
|
||||
'settings.ai.disconnectProvider': 'Putuskan sambungan {label}',
|
||||
'settings.ai.connectProviderLabel': 'Sambungkan {label}',
|
||||
'settings.ai.editProviderEndpoint': 'Ubah titik akhir {label}',
|
||||
'settings.ai.defaultLocalEndpoint': 'http://localhost:11434/v1',
|
||||
'settings.ai.editEndpoint': 'Edit titik akhir',
|
||||
'settings.ai.endpointUrlLabel': 'Titik Akhir URL',
|
||||
'settings.ai.localRuntimeHelper':
|
||||
'Dimana {label} bisa dihubungi. Baku adalah localhost; arahkan ini ke host jarak jauh (misalnya, http://10.0.0.4:11434/v1) untuk memakai suatu contoh bersama.',
|
||||
|
||||
@@ -2846,7 +2846,9 @@ const messages: TranslationMap = {
|
||||
'settings.ai.workloadGroupChat': 'Gruppo carico di lavoro chat',
|
||||
'settings.ai.disconnectProvider': 'Disconnetti {label}',
|
||||
'settings.ai.connectProviderLabel': 'Connetti {label}',
|
||||
'settings.ai.editProviderEndpoint': 'Modifica punto finale di {label}',
|
||||
'settings.ai.defaultLocalEndpoint': 'http://localhost:11434/v1',
|
||||
'settings.ai.editEndpoint': "Modifica l'endpoint",
|
||||
'settings.ai.endpointUrlLabel': "URL dell'endpoint",
|
||||
'settings.ai.localRuntimeHelper':
|
||||
"Dove {label} è raggiungibile. Il valore predefinito è localhost; punta questo a un host remoto (per esempio, http://10.0.0.4:11434/v1) per utilizzare un'istanza condivisa.",
|
||||
|
||||
@@ -2786,7 +2786,9 @@ const messages: TranslationMap = {
|
||||
'settings.ai.workloadGroupChat': '채팅 작업 그룹',
|
||||
'settings.ai.disconnectProvider': '{label} 연결 끊기',
|
||||
'settings.ai.connectProviderLabel': '{label} 연결',
|
||||
'settings.ai.editProviderEndpoint': '{label} 엔드포인트 편집',
|
||||
'settings.ai.defaultLocalEndpoint': 'http://localhost:11434/v1',
|
||||
'settings.ai.editEndpoint': '엔드포인트 편집',
|
||||
'settings.ai.endpointUrlLabel': '끝점 URL',
|
||||
'settings.ai.localRuntimeHelper':
|
||||
'{label}에 연결할 수 있는 위치입니다. 기본값은 localhost입니다. 공유 인스턴스를 사용하려면 원격 호스트(예: http://10.0.0.4:11434/v1)를 가리키세요.',
|
||||
|
||||
@@ -2852,7 +2852,9 @@ const messages: TranslationMap = {
|
||||
'settings.ai.workloadGroupChat': 'Grupa workloadów: czat',
|
||||
'settings.ai.disconnectProvider': 'Rozłącz {label}',
|
||||
'settings.ai.connectProviderLabel': 'Połącz {label}',
|
||||
'settings.ai.editProviderEndpoint': 'Edytuj punkt końcowy {label}',
|
||||
'settings.ai.defaultLocalEndpoint': 'http://localhost:11434/v1',
|
||||
'settings.ai.editEndpoint': 'Edytuj endpoint',
|
||||
'settings.ai.endpointUrlLabel': 'URL endpointu',
|
||||
'settings.ai.localRuntimeHelper':
|
||||
'Miejsce, w którym dostępny jest {label}. Domyślnie jest to localhost; wskaż zdalny host (na przykład http://10.0.0.4:11434/v1), aby użyć współdzielonej instancji.',
|
||||
|
||||
@@ -2851,7 +2851,9 @@ const messages: TranslationMap = {
|
||||
'settings.ai.workloadGroupChat': 'Grupo de carga de trabalho de chat',
|
||||
'settings.ai.disconnectProvider': 'Desconecte {label}',
|
||||
'settings.ai.connectProviderLabel': 'Conecte {label}',
|
||||
'settings.ai.editProviderEndpoint': 'Editar ponto de extremidade de {label}',
|
||||
'settings.ai.defaultLocalEndpoint': 'http://localhost:11434/v1',
|
||||
'settings.ai.editEndpoint': 'Editar ponto de extremidade',
|
||||
'settings.ai.endpointUrlLabel': 'Ponto de extremidade URL',
|
||||
'settings.ai.localRuntimeHelper':
|
||||
'Onde {label} é acessível. O padrão é localhost; aponte isso para um host remoto (por exemplo, http://10.0.0.4:11434/v1) para usar uma instância compartilhada.',
|
||||
|
||||
@@ -2822,7 +2822,9 @@ const messages: TranslationMap = {
|
||||
'settings.ai.workloadGroupChat': 'Чат',
|
||||
'settings.ai.disconnectProvider': 'Отключить {label}',
|
||||
'settings.ai.connectProviderLabel': 'Подключить {label}',
|
||||
'settings.ai.editProviderEndpoint': 'Изменить конечную точку {label}',
|
||||
'settings.ai.defaultLocalEndpoint': 'http://localhost:11434/v1',
|
||||
'settings.ai.editEndpoint': 'Изменить конечную точку',
|
||||
'settings.ai.endpointUrlLabel': 'Конечная точка URL',
|
||||
'settings.ai.localRuntimeHelper':
|
||||
'Где доступен {label}. По умолчанию используется локальный хост; укажите это на удаленном хосте (например, http://10.0.0.4:11434/v1), чтобы использовать общий экземпляр.',
|
||||
|
||||
@@ -2674,7 +2674,9 @@ const messages: TranslationMap = {
|
||||
'settings.ai.workloadGroupChat': '对话工作负载',
|
||||
'settings.ai.disconnectProvider': '断开 {label}',
|
||||
'settings.ai.connectProviderLabel': '连接 {label}',
|
||||
'settings.ai.editProviderEndpoint': '编辑 {label} 端点',
|
||||
'settings.ai.defaultLocalEndpoint': 'http://localhost:11434/v1',
|
||||
'settings.ai.editEndpoint': '编辑端点',
|
||||
'settings.ai.endpointUrlLabel': '端点 URL',
|
||||
'settings.ai.localRuntimeHelper':
|
||||
'{label} 可访问的位置。默认是 localhost;如需使用共享实例,可指向远程主机(例如 http://10.0.0.4:11434/v1)。',
|
||||
|
||||
@@ -411,6 +411,29 @@ describe('loadAISettings', () => {
|
||||
expect(settings.cloudProviders[0].has_api_key).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps local runtime endpoint providers so the AI panel can edit them', async () => {
|
||||
mockOpenhumanGetClientConfig.mockResolvedValue(
|
||||
makeClientConfigResult({
|
||||
cloud_providers: [
|
||||
{
|
||||
id: 'p_ollama_1',
|
||||
slug: 'ollama',
|
||||
label: 'Ollama',
|
||||
endpoint: 'http://127.0.0.1:11434/v1',
|
||||
auth_style: 'none',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
mockAuthListProviderCredentials.mockResolvedValue(makeAuthProfileResult([]));
|
||||
|
||||
const settings = await loadAISettings();
|
||||
|
||||
expect(settings.cloudProviders).toHaveLength(1);
|
||||
expect(settings.cloudProviders[0].slug).toBe('ollama');
|
||||
expect(settings.cloudProviders[0].endpoint).toBe('http://127.0.0.1:11434/v1');
|
||||
});
|
||||
|
||||
it('includes two cloud providers with correct labels and endpoints', async () => {
|
||||
mockOpenhumanGetClientConfig.mockResolvedValue(
|
||||
makeClientConfigResult({
|
||||
@@ -602,6 +625,32 @@ describe('saveAISettings', () => {
|
||||
expect(patch.cloud_providers![0]).not.toHaveProperty('has_api_key');
|
||||
});
|
||||
|
||||
it('preserves local runtime providers in the cloud_providers payload', async () => {
|
||||
const prev = makeSettings({ cloudProviders: [] });
|
||||
const next = makeSettings({
|
||||
cloudProviders: [
|
||||
{
|
||||
id: 'p_ollama_1',
|
||||
slug: 'ollama',
|
||||
label: 'Ollama',
|
||||
endpoint: 'http://127.0.0.1:11434/v1',
|
||||
auth_style: 'none',
|
||||
has_api_key: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await saveAISettings(prev, next);
|
||||
|
||||
const patch = mockOpenhumanUpdateModelSettings.mock.calls[0][0];
|
||||
expect(patch.cloud_providers).toHaveLength(1);
|
||||
expect(patch.cloud_providers![0]).toMatchObject({
|
||||
slug: 'ollama',
|
||||
endpoint: 'http://127.0.0.1:11434/v1',
|
||||
auth_style: 'none',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves auth_style through save round-trip for anthropic', async () => {
|
||||
const anthropicProvider = {
|
||||
id: 'p_anthropic_1',
|
||||
|
||||
@@ -215,7 +215,7 @@ export async function loadAISettings(): Promise<AISettings> {
|
||||
);
|
||||
|
||||
const cloudProviders: CloudProviderView[] = config.cloud_providers
|
||||
.filter(p => !['', 'cloud', 'openhuman', 'ollama', 'pid'].includes(p.slug.trim()))
|
||||
.filter(p => !['', 'cloud', 'openhuman', 'pid'].includes(p.slug.trim()))
|
||||
.map(p => {
|
||||
const newKey = authKeyForSlug(p.slug).toLowerCase();
|
||||
const legacyKey = p.slug.toLowerCase();
|
||||
@@ -282,7 +282,7 @@ export async function saveAISettings(prev: AISettings, next: AISettings): Promis
|
||||
})
|
||||
) {
|
||||
patch.cloud_providers = next.cloudProviders
|
||||
.filter(p => !['', 'cloud', 'openhuman', 'ollama', 'pid'].includes(p.slug.trim()))
|
||||
.filter(p => !['', 'cloud', 'openhuman', 'pid'].includes(p.slug.trim()))
|
||||
.map(({ id, slug, label, endpoint, auth_style }) => ({
|
||||
id,
|
||||
slug,
|
||||
|
||||
@@ -71,6 +71,12 @@ describe('validateOllamaUrl', () => {
|
||||
expect(result.normalized).toBe('https://example.com');
|
||||
});
|
||||
|
||||
it('normalizes server bind addresses to client loopback addresses', () => {
|
||||
const result = validateOllamaUrl('http://0.0.0.0:11434');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.normalized).toBe('http://localhost:11434');
|
||||
});
|
||||
|
||||
it('rejects a URL that starts with http:// but is not parseable', () => {
|
||||
const result = validateOllamaUrl('http:// has spaces');
|
||||
expect(result.valid).toBe(false);
|
||||
|
||||
@@ -41,8 +41,15 @@ export function validateOllamaUrl(raw: string): OllamaUrlValidationResult {
|
||||
if (parsed.hash) {
|
||||
return { valid: false, error: 'URL must not contain a fragment' };
|
||||
}
|
||||
// Normalize to scheme://host[:port]
|
||||
// Normalize to scheme://host[:port]; rewrite bind-all addresses to loopback.
|
||||
// JS URL API strips brackets from IPv6 hostnames: new URL('http://[::]:11434').hostname === '::'
|
||||
const hostname =
|
||||
parsed.hostname === '0.0.0.0'
|
||||
? 'localhost'
|
||||
: parsed.hostname === '::'
|
||||
? '[::1]'
|
||||
: parsed.hostname;
|
||||
const port = parsed.port ? `:${parsed.port}` : '';
|
||||
const normalized = `${parsed.protocol}//${parsed.hostname}${port}`;
|
||||
const normalized = `${parsed.protocol}//${hostname}${port}`;
|
||||
return { valid: true, normalized };
|
||||
}
|
||||
|
||||
@@ -668,7 +668,7 @@ pub(super) const CAPABILITIES: &[Capability] = &[
|
||||
domain: "local_ai",
|
||||
category: CapabilityCategory::LocalAI,
|
||||
description: "Select Ollama or LM Studio as the local model provider and configure the local server endpoint.",
|
||||
how_to: "Settings > Local AI Model",
|
||||
how_to: "Settings > AI > providers, or Settings > Local AI Model > Ollama server URL",
|
||||
status: CapabilityStatus::Beta,
|
||||
privacy: None,
|
||||
},
|
||||
|
||||
@@ -458,7 +458,7 @@ pub struct LocalAiSettingsPatch {
|
||||
/// behaviour without needing to apply a preset first.
|
||||
pub opt_in_confirmed: Option<bool>,
|
||||
pub provider: Option<String>,
|
||||
pub base_url: Option<String>,
|
||||
pub base_url: Option<Option<String>>,
|
||||
pub model_id: Option<String>,
|
||||
pub chat_model_id: Option<String>,
|
||||
pub usage_embeddings: Option<bool>,
|
||||
@@ -1316,10 +1316,18 @@ pub async fn apply_local_ai_settings(
|
||||
crate::openhuman::inference::local::provider::normalize_provider(&provider);
|
||||
}
|
||||
if let Some(base_url) = update.base_url {
|
||||
config.local_ai.base_url = if base_url.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(base_url.trim().to_string())
|
||||
config.local_ai.base_url = match base_url {
|
||||
None => None,
|
||||
Some(base_url) if base_url.trim().is_empty() => None,
|
||||
Some(base_url)
|
||||
if crate::openhuman::inference::local::provider::provider_from_config(config)
|
||||
== crate::openhuman::inference::local::provider::LocalAiProvider::Ollama =>
|
||||
{
|
||||
Some(crate::openhuman::inference::local::validate_ollama_url(
|
||||
&base_url,
|
||||
)?)
|
||||
}
|
||||
Some(base_url) => Some(base_url.trim().trim_end_matches('/').to_string()),
|
||||
};
|
||||
}
|
||||
if let Some(model_id) = update.model_id {
|
||||
|
||||
@@ -802,7 +802,7 @@ async fn apply_local_ai_settings_updates_lm_studio_provider_fields() {
|
||||
runtime_enabled: Some(true),
|
||||
opt_in_confirmed: Some(true),
|
||||
provider: Some("lm-studio".into()),
|
||||
base_url: Some(" http://localhost:1234/v1/ ".into()),
|
||||
base_url: Some(Some(" http://localhost:1234/v1/ ".into())),
|
||||
model_id: Some(" local-default ".into()),
|
||||
chat_model_id: Some(" local-chat ".into()),
|
||||
usage_embeddings: Some(true),
|
||||
@@ -820,7 +820,7 @@ async fn apply_local_ai_settings_updates_lm_studio_provider_fields() {
|
||||
assert_eq!(cfg.local_ai.provider, "lm_studio");
|
||||
assert_eq!(
|
||||
cfg.local_ai.base_url.as_deref(),
|
||||
Some("http://localhost:1234/v1/")
|
||||
Some("http://localhost:1234/v1")
|
||||
);
|
||||
assert_eq!(cfg.local_ai.model_id, "local-default");
|
||||
assert_eq!(cfg.local_ai.chat_model_id, "local-chat");
|
||||
@@ -832,7 +832,7 @@ async fn apply_local_ai_settings_updates_lm_studio_provider_fields() {
|
||||
|
||||
let clear_and_fallback = LocalAiSettingsPatch {
|
||||
provider: Some("unknown-provider".into()),
|
||||
base_url: Some(" ".into()),
|
||||
base_url: Some(Some(" ".into())),
|
||||
model_id: Some(" ".into()),
|
||||
chat_model_id: Some("".into()),
|
||||
..LocalAiSettingsPatch::default()
|
||||
@@ -847,6 +847,40 @@ async fn apply_local_ai_settings_updates_lm_studio_provider_fields() {
|
||||
assert_eq!(cfg.local_ai.chat_model_id, "");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_local_ai_settings_normalizes_ollama_unspecified_host_and_allows_null_clear() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let mut cfg = tmp_config(&tmp);
|
||||
|
||||
apply_local_ai_settings(
|
||||
&mut cfg,
|
||||
LocalAiSettingsPatch {
|
||||
provider: Some("ollama".into()),
|
||||
base_url: Some(Some("http://0.0.0.0:11434/api/tags".into())),
|
||||
..LocalAiSettingsPatch::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("apply ollama base url");
|
||||
|
||||
assert_eq!(
|
||||
cfg.local_ai.base_url.as_deref(),
|
||||
Some("http://localhost:11434")
|
||||
);
|
||||
|
||||
apply_local_ai_settings(
|
||||
&mut cfg,
|
||||
LocalAiSettingsPatch {
|
||||
base_url: Some(None),
|
||||
..LocalAiSettingsPatch::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("clear ollama base url");
|
||||
|
||||
assert!(cfg.local_ai.base_url.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_analytics_settings_updates_enabled() {
|
||||
let tmp = tempdir().unwrap();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::de::{DeserializeOwned, Deserializer};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
@@ -142,7 +142,8 @@ struct LocalAiSettingsUpdate {
|
||||
/// having to also apply a tier preset.
|
||||
opt_in_confirmed: Option<bool>,
|
||||
provider: Option<String>,
|
||||
base_url: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_present_json")]
|
||||
base_url: Option<Value>,
|
||||
model_id: Option<String>,
|
||||
chat_model_id: Option<String>,
|
||||
usage_embeddings: Option<bool>,
|
||||
@@ -685,9 +686,9 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
"provider",
|
||||
"Local provider identifier. Supported values: ollama, lm_studio.",
|
||||
),
|
||||
optional_string(
|
||||
optional_json(
|
||||
"base_url",
|
||||
"Provider base URL. For LM Studio this defaults to http://localhost:1234/v1.",
|
||||
"Provider base URL string, or null to clear. For LM Studio this defaults to http://localhost:1234/v1.",
|
||||
),
|
||||
optional_string("model_id", "Default local chat model identifier."),
|
||||
optional_string("chat_model_id", "Local chat model identifier."),
|
||||
@@ -1345,11 +1346,17 @@ fn handle_update_browser_settings(params: Map<String, Value>) -> ControllerFutur
|
||||
fn handle_update_local_ai_settings(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let update = deserialize_params::<LocalAiSettingsUpdate>(params)?;
|
||||
let base_url = match update.base_url {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(Value::String(value)) => Some(Some(value)),
|
||||
Some(_) => return Err("invalid params: base_url must be a string or null".to_string()),
|
||||
};
|
||||
let patch = config_rpc::LocalAiSettingsPatch {
|
||||
runtime_enabled: update.runtime_enabled,
|
||||
opt_in_confirmed: update.opt_in_confirmed,
|
||||
provider: update.provider,
|
||||
base_url: update.base_url,
|
||||
base_url,
|
||||
model_id: update.model_id,
|
||||
chat_model_id: update.chat_model_id,
|
||||
usage_embeddings: update.usage_embeddings,
|
||||
@@ -1654,6 +1661,13 @@ fn deserialize_params<T: DeserializeOwned>(params: Map<String, Value>) -> Result
|
||||
serde_json::from_value(Value::Object(params)).map_err(|e| format!("invalid params: {e}"))
|
||||
}
|
||||
|
||||
fn deserialize_present_json<'de, D>(deserializer: D) -> Result<Option<Value>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
Value::deserialize(deserializer).map(Some)
|
||||
}
|
||||
|
||||
fn optional_string(name: &'static str, comment: &'static str) -> FieldSchema {
|
||||
FieldSchema {
|
||||
name,
|
||||
@@ -1663,6 +1677,15 @@ fn optional_string(name: &'static str, comment: &'static str) -> FieldSchema {
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_json(name: &'static str, comment: &'static str) -> FieldSchema {
|
||||
FieldSchema {
|
||||
name,
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::Json)),
|
||||
comment,
|
||||
required: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn required_string(name: &'static str, comment: &'static str) -> FieldSchema {
|
||||
FieldSchema {
|
||||
name,
|
||||
|
||||
@@ -106,6 +106,16 @@ fn optional_string_builds_option_string_field() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_json_builds_option_json_field() {
|
||||
let f = optional_json("payload", "json payload");
|
||||
assert!(!f.required);
|
||||
match &f.ty {
|
||||
TypeSchema::Option(inner) => assert!(matches!(**inner, TypeSchema::Json)),
|
||||
other => panic!("expected Option<Json>, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_bool_builds_option_bool_field() {
|
||||
let f = optional_bool("enabled", "Whether enabled");
|
||||
@@ -206,13 +216,39 @@ fn deserialize_params_parses_local_ai_settings_update() {
|
||||
assert_eq!(out.runtime_enabled, Some(true));
|
||||
assert_eq!(out.opt_in_confirmed, Some(true));
|
||||
assert_eq!(out.provider.as_deref(), Some("lm_studio"));
|
||||
assert_eq!(out.base_url.as_deref(), Some("http://localhost:1234/v1"));
|
||||
assert_eq!(
|
||||
out.base_url.as_ref().and_then(Value::as_str),
|
||||
Some("http://localhost:1234/v1")
|
||||
);
|
||||
assert_eq!(out.model_id.as_deref(), Some("local-default"));
|
||||
assert_eq!(out.chat_model_id.as_deref(), Some("local-chat"));
|
||||
assert_eq!(out.usage_embeddings, Some(true));
|
||||
assert_eq!(out.usage_subconscious, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_params_preserves_local_ai_base_url_null() {
|
||||
let mut m = Map::new();
|
||||
m.insert("base_url".into(), Value::Null);
|
||||
|
||||
let out: LocalAiSettingsUpdate = deserialize_params(m).unwrap();
|
||||
assert!(out.base_url.as_ref().is_some_and(Value::is_null));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_local_ai_settings_schema_allows_json_base_url() {
|
||||
let schema = schemas("update_local_ai_settings");
|
||||
let field = schema
|
||||
.inputs
|
||||
.iter()
|
||||
.find(|field| field.name == "base_url")
|
||||
.expect("base_url field");
|
||||
match &field.ty {
|
||||
TypeSchema::Option(inner) => assert!(matches!(**inner, TypeSchema::Json)),
|
||||
other => panic!("expected Option<Json>, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_params_parses_workspace_onboarding_flag_params() {
|
||||
let out: WorkspaceOnboardingFlagParams = deserialize_params(Map::new()).unwrap();
|
||||
|
||||
@@ -38,7 +38,9 @@ mod ollama;
|
||||
mod process_util;
|
||||
pub(crate) mod provider;
|
||||
pub(crate) use model_requirements::{evaluate_context, ContextEligibility, MIN_CONTEXT_TOKENS};
|
||||
pub(crate) use ollama::{ollama_base_url, ollama_base_url_from_config, OLLAMA_BASE_URL};
|
||||
pub(crate) use ollama::{
|
||||
ollama_base_url, ollama_base_url_from_config, validate_ollama_url, OLLAMA_BASE_URL,
|
||||
};
|
||||
pub mod service;
|
||||
pub(crate) mod voice_install_common;
|
||||
|
||||
|
||||
@@ -4,6 +4,34 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
pub(crate) const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434";
|
||||
|
||||
/// Rewrite unspecified bind addresses (`0.0.0.0`, `[::]`) to their loopback
|
||||
/// equivalents (`127.0.0.1`, `[::1]`). Ollama's default `OLLAMA_HOST` is
|
||||
/// `0.0.0.0:11434` — a valid *server-side* bind address but an invalid
|
||||
/// *client-side* connect target on Windows (and misleading on other OSes).
|
||||
fn normalize_unspecified_host(url: &str) -> String {
|
||||
if let Ok(parsed) = reqwest::Url::parse(url) {
|
||||
let replacement = match parsed.host() {
|
||||
Some(url::Host::Ipv4(addr)) if addr.is_unspecified() => Some("localhost"),
|
||||
Some(url::Host::Ipv6(addr)) if addr.is_unspecified() => Some("[::1]"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(new_host) = replacement {
|
||||
let scheme = parsed.scheme();
|
||||
let port_suffix = parsed.port().map(|p| format!(":{p}")).unwrap_or_default();
|
||||
let path = parsed.path().trim_end_matches('/');
|
||||
let result = format!("{scheme}://{new_host}{port_suffix}{path}");
|
||||
let result = result.trim_end_matches('/').to_string();
|
||||
log::debug!(
|
||||
"[local_ai] normalize_unspecified_host: rewrote {} -> {}",
|
||||
redact_ollama_base_url(url),
|
||||
redact_ollama_base_url(&result)
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
url.to_string()
|
||||
}
|
||||
|
||||
/// Returns the effective Ollama base URL.
|
||||
///
|
||||
/// Priority (highest to lowest):
|
||||
@@ -11,11 +39,14 @@ pub(crate) const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434";
|
||||
/// 2. `OLLAMA_HOST` — Ollama's own env var; normalized to a full URL by
|
||||
/// prepending `http://` when no scheme is present.
|
||||
/// 3. [`DEFAULT_OLLAMA_BASE_URL`] — `http://localhost:11434`.
|
||||
///
|
||||
/// Unspecified bind addresses (`0.0.0.0`, `[::]`) are rewritten to their
|
||||
/// loopback equivalents so the URL is valid as a client connect target.
|
||||
pub(crate) fn ollama_base_url() -> String {
|
||||
if let Ok(url) = std::env::var("OPENHUMAN_OLLAMA_BASE_URL") {
|
||||
let trimmed = url.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return trimmed.trim_end_matches('/').to_string();
|
||||
return normalize_unspecified_host(trimmed.trim_end_matches('/'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +59,7 @@ pub(crate) fn ollama_base_url() -> String {
|
||||
format!("http://{trimmed}")
|
||||
};
|
||||
log::debug!("[local_ai] ollama_base_url: using OLLAMA_HOST -> {url}");
|
||||
return url;
|
||||
return normalize_unspecified_host(&url);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,15 +74,19 @@ pub(crate) fn ollama_base_url() -> String {
|
||||
/// 2. `OPENHUMAN_OLLAMA_BASE_URL` env var
|
||||
/// 3. `OLLAMA_HOST` env var
|
||||
/// 4. [`DEFAULT_OLLAMA_BASE_URL`]
|
||||
///
|
||||
/// Unspecified bind addresses (`0.0.0.0`, `[::]`) are rewritten to their
|
||||
/// loopback equivalents so the URL is valid as a client connect target.
|
||||
pub(crate) fn ollama_base_url_from_config(config: &crate::openhuman::config::Config) -> String {
|
||||
if let Some(ref url) = config.local_ai.base_url {
|
||||
let trimmed = url.trim().trim_end_matches('/');
|
||||
if !trimmed.is_empty() {
|
||||
let normalized = normalize_unspecified_host(trimmed);
|
||||
log::debug!(
|
||||
"[local_ai] ollama_base_url_from_config: using config base_url -> {}",
|
||||
redact_ollama_base_url(trimmed)
|
||||
redact_ollama_base_url(&normalized)
|
||||
);
|
||||
return trimmed.to_string();
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
let resolved = ollama_base_url();
|
||||
@@ -101,6 +136,8 @@ pub(crate) fn validate_ollama_url(raw: &str) -> Result<String, String> {
|
||||
// Use the Host enum so IPv6 addresses are always re-bracketed correctly,
|
||||
// regardless of whether host_str() includes brackets in a given url-crate version.
|
||||
let host_formatted = match parsed.host() {
|
||||
Some(url::Host::Ipv4(addr)) if addr.is_unspecified() => "localhost".to_string(),
|
||||
Some(url::Host::Ipv6(addr)) if addr.is_unspecified() => "[::1]".to_string(),
|
||||
Some(url::Host::Ipv6(addr)) => format!("[{addr}]"),
|
||||
Some(h) => h.to_string(),
|
||||
None => String::new(),
|
||||
@@ -683,6 +720,107 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── normalize_unspecified_host ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn normalize_rewrites_ipv4_unspecified() {
|
||||
assert_eq!(
|
||||
normalize_unspecified_host("http://0.0.0.0:11434"),
|
||||
"http://localhost:11434"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_rewrites_ipv6_unspecified() {
|
||||
assert_eq!(
|
||||
normalize_unspecified_host("http://[::]:11434"),
|
||||
"http://[::1]:11434"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_preserves_loopback() {
|
||||
assert_eq!(
|
||||
normalize_unspecified_host("http://127.0.0.1:11434"),
|
||||
"http://127.0.0.1:11434"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_unspecified_host("http://[::1]:11434"),
|
||||
"http://[::1]:11434"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_preserves_named_host() {
|
||||
assert_eq!(
|
||||
normalize_unspecified_host("http://localhost:11434"),
|
||||
"http://localhost:11434"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_unspecified_host("http://my-ollama.lan:11434"),
|
||||
"http://my-ollama.lan:11434"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_preserves_private_ip() {
|
||||
assert_eq!(
|
||||
normalize_unspecified_host("http://192.168.1.5:11434"),
|
||||
"http://192.168.1.5:11434"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_handles_invalid_url() {
|
||||
assert_eq!(normalize_unspecified_host("not a url"), "not a url");
|
||||
}
|
||||
|
||||
// ── ollama_base_url: 0.0.0.0 normalization ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn ollama_base_url_normalizes_unspecified_in_env_override() {
|
||||
let _lock = test_lock();
|
||||
let _g = OllamaEnvGuard::set("http://0.0.0.0:11434");
|
||||
assert_eq!(ollama_base_url(), "http://localhost:11434");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ollama_base_url_normalizes_unspecified_in_ollama_host() {
|
||||
let _lock = test_lock();
|
||||
let _g1 = OllamaEnvGuard::clear();
|
||||
let _g2 = OllamaEnvGuard::set_var(OLLAMA_HOST_VAR, "0.0.0.0:11434");
|
||||
assert_eq!(ollama_base_url(), "http://localhost:11434");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ollama_base_url_normalizes_ipv6_unspecified_in_ollama_host() {
|
||||
let _lock = test_lock();
|
||||
let _g1 = OllamaEnvGuard::clear();
|
||||
let _g2 = OllamaEnvGuard::set_var(OLLAMA_HOST_VAR, "http://[::]:11434");
|
||||
assert_eq!(ollama_base_url(), "http://[::1]:11434");
|
||||
}
|
||||
|
||||
// ── ollama_base_url_from_config: 0.0.0.0 normalization ──────────────
|
||||
|
||||
#[test]
|
||||
fn ollama_base_url_from_config_normalizes_unspecified() {
|
||||
let _lock = test_lock();
|
||||
let _g = OllamaEnvGuard::clear();
|
||||
let config = make_config_with_base_url(Some("http://0.0.0.0:11434"));
|
||||
assert_eq!(
|
||||
ollama_base_url_from_config(&config),
|
||||
"http://localhost:11434"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ollama_base_url_from_config_normalizes_ipv6_unspecified() {
|
||||
let _lock = test_lock();
|
||||
let _g = OllamaEnvGuard::clear();
|
||||
let config = make_config_with_base_url(Some("http://[::]:11434"));
|
||||
assert_eq!(ollama_base_url_from_config(&config), "http://[::1]:11434");
|
||||
}
|
||||
|
||||
// ── validate_ollama_url ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
@@ -734,6 +872,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_ollama_url_rewrites_ipv4_unspecified_to_localhost() {
|
||||
assert_eq!(
|
||||
validate_ollama_url("http://0.0.0.0:11434"),
|
||||
Ok("http://localhost:11434".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_ollama_url_rewrites_ipv6_unspecified_to_loopback() {
|
||||
assert_eq!(
|
||||
validate_ollama_url("http://[::]:11434"),
|
||||
Ok("http://[::1]:11434".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
// ── redact_ollama_base_url ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -45,7 +45,8 @@ impl LocalAiService {
|
||||
LocalAiProvider::Ollama | LocalAiProvider::LmStudio
|
||||
);
|
||||
let ollama_available = if uses_ollama_assets {
|
||||
let present = self.ollama_healthy().await;
|
||||
let base_url = crate::openhuman::inference::local::ollama_base_url_from_config(config);
|
||||
let present = self.ollama_healthy_at(&base_url).await;
|
||||
debug!(
|
||||
target: "local_ai::assets",
|
||||
%correlation_id,
|
||||
@@ -89,7 +90,7 @@ impl LocalAiService {
|
||||
}
|
||||
};
|
||||
let embedding_ready = if ollama_available {
|
||||
match self.has_model(&embedding_model).await {
|
||||
match self.has_model_for_config(config, &embedding_model).await {
|
||||
Ok(ready) => {
|
||||
debug!(
|
||||
target: "local_ai::assets",
|
||||
@@ -131,7 +132,7 @@ impl LocalAiService {
|
||||
branch = "ollama",
|
||||
"[local_ai:assets:provider_routing] selected provider branch"
|
||||
);
|
||||
let chat_ready = match self.has_model(&chat_model).await {
|
||||
let chat_ready = match self.has_model_for_config(config, &chat_model).await {
|
||||
Ok(ready) => {
|
||||
debug!(
|
||||
target: "local_ai::assets",
|
||||
@@ -157,7 +158,7 @@ impl LocalAiService {
|
||||
false
|
||||
}
|
||||
};
|
||||
let vision_ready = match self.has_model(&vision_model).await {
|
||||
let vision_ready = match self.has_model_for_config(config, &vision_model).await {
|
||||
Ok(ready) => {
|
||||
debug!(
|
||||
target: "local_ai::assets",
|
||||
@@ -183,7 +184,7 @@ impl LocalAiService {
|
||||
false
|
||||
}
|
||||
};
|
||||
let embedding_ready = match self.has_model(&embedding_model).await {
|
||||
let embedding_ready = match self.has_model_for_config(config, &embedding_model).await {
|
||||
Ok(ready) => {
|
||||
debug!(
|
||||
target: "local_ai::assets",
|
||||
@@ -533,7 +534,7 @@ impl LocalAiService {
|
||||
}
|
||||
if let Err(err) = async {
|
||||
self.ensure_ollama_server(config).await?;
|
||||
self.ensure_ollama_model_available(&model_id, "embedding")
|
||||
self.ensure_ollama_model_available(config, &model_id, "embedding")
|
||||
.await
|
||||
}
|
||||
.await
|
||||
@@ -625,7 +626,8 @@ impl LocalAiService {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.ensure_ollama_model_available(&model_id, label).await?;
|
||||
self.ensure_ollama_model_available(config, &model_id, label)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let mut stt_warning = None;
|
||||
@@ -687,7 +689,8 @@ impl LocalAiService {
|
||||
"chat" => {
|
||||
self.ensure_ollama_server(config).await?;
|
||||
let model = model_ids::effective_chat_model_id(config);
|
||||
self.ensure_ollama_model_available(&model, "chat").await?;
|
||||
self.ensure_ollama_model_available(config, &model, "chat")
|
||||
.await?;
|
||||
}
|
||||
"vision" => {
|
||||
if matches!(
|
||||
@@ -701,12 +704,13 @@ impl LocalAiService {
|
||||
}
|
||||
self.ensure_ollama_server(config).await?;
|
||||
let model = model_ids::effective_vision_model_id(config);
|
||||
self.ensure_ollama_model_available(&model, "vision").await?;
|
||||
self.ensure_ollama_model_available(config, &model, "vision")
|
||||
.await?;
|
||||
}
|
||||
"embedding" | "embeddings" => {
|
||||
self.ensure_ollama_server(config).await?;
|
||||
let model = model_ids::effective_embedding_model_id(config);
|
||||
self.ensure_ollama_model_available(&model, "embedding")
|
||||
self.ensure_ollama_model_available(config, &model, "embedding")
|
||||
.await?;
|
||||
}
|
||||
"stt" => {
|
||||
|
||||
@@ -223,7 +223,7 @@ impl LocalAiService {
|
||||
log::trace!(
|
||||
"[local_ai] LM Studio bootstrap embedding ensure_ollama_model_available start model={embedding_model}"
|
||||
);
|
||||
self.ensure_ollama_model_available(&embedding_model, "embedding")
|
||||
self.ensure_ollama_model_available(&effective_config, &embedding_model, "embedding")
|
||||
.await?;
|
||||
log::trace!(
|
||||
"[local_ai] LM Studio bootstrap embedding ensure_ollama_model_available succeeded model={embedding_model}"
|
||||
|
||||
@@ -541,7 +541,7 @@ impl LocalAiService {
|
||||
config: &Config,
|
||||
) -> Result<(), String> {
|
||||
let chat_model = model_ids::effective_chat_model_id(config);
|
||||
self.ensure_ollama_model_available(&chat_model, "chat")
|
||||
self.ensure_ollama_model_available(config, &chat_model, "chat")
|
||||
.await?;
|
||||
|
||||
match presets::vision_mode_for_config(&config.local_ai) {
|
||||
@@ -553,7 +553,7 @@ impl LocalAiService {
|
||||
}
|
||||
VisionMode::Bundled => {
|
||||
let vision_model = model_ids::effective_vision_model_id(config);
|
||||
self.ensure_ollama_model_available(&vision_model, "vision")
|
||||
self.ensure_ollama_model_available(config, &vision_model, "vision")
|
||||
.await?;
|
||||
self.status.lock().vision_state = "ready".to_string();
|
||||
}
|
||||
@@ -561,7 +561,7 @@ impl LocalAiService {
|
||||
|
||||
let embedding_model = model_ids::effective_embedding_model_id(config);
|
||||
if config.local_ai.preload_embedding_model {
|
||||
self.ensure_ollama_model_available(&embedding_model, "embedding")
|
||||
self.ensure_ollama_model_available(config, &embedding_model, "embedding")
|
||||
.await?;
|
||||
self.status.lock().embedding_state = "ready".to_string();
|
||||
}
|
||||
@@ -579,10 +579,12 @@ impl LocalAiService {
|
||||
|
||||
pub(in crate::openhuman::inference::local::service) async fn ensure_ollama_model_available(
|
||||
&self,
|
||||
config: &Config,
|
||||
model_id: &str,
|
||||
label: &str,
|
||||
) -> Result<(), String> {
|
||||
if self.has_model(model_id).await? {
|
||||
let base_url = ollama_base_url_from_config(config);
|
||||
if self.has_model_at(&base_url, model_id).await? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -635,7 +637,7 @@ impl LocalAiService {
|
||||
|
||||
let response = match self
|
||||
.http
|
||||
.post(format!("{}/api/pull", ollama_base_url()))
|
||||
.post(format!("{base_url}/api/pull"))
|
||||
.json(&OllamaPullRequest {
|
||||
name: model_id.to_string(),
|
||||
stream: true,
|
||||
@@ -735,6 +737,7 @@ impl LocalAiService {
|
||||
last_error = Some(err.clone());
|
||||
let resumed = self
|
||||
.wait_for_model_after_pull_interruption(
|
||||
&base_url,
|
||||
model_id,
|
||||
attempt,
|
||||
MAX_PULL_RETRIES,
|
||||
@@ -751,7 +754,7 @@ impl LocalAiService {
|
||||
return Err(format!("{err} after {MAX_PULL_RETRIES} attempts"));
|
||||
}
|
||||
|
||||
if self.has_model(model_id).await? {
|
||||
if self.has_model_at(&base_url, model_id).await? {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -761,6 +764,7 @@ impl LocalAiService {
|
||||
));
|
||||
let resumed = self
|
||||
.wait_for_model_after_pull_interruption(
|
||||
&base_url,
|
||||
model_id,
|
||||
attempt,
|
||||
MAX_PULL_RETRIES,
|
||||
@@ -776,7 +780,7 @@ impl LocalAiService {
|
||||
}
|
||||
}
|
||||
|
||||
if !self.has_model(model_id).await? {
|
||||
if !self.has_model_at(&base_url, model_id).await? {
|
||||
return Err(last_error.unwrap_or_else(|| {
|
||||
format!(
|
||||
"ollama pull finished but model `{}` was not found",
|
||||
@@ -796,6 +800,7 @@ impl LocalAiService {
|
||||
|
||||
async fn wait_for_model_after_pull_interruption(
|
||||
&self,
|
||||
base_url: &str,
|
||||
model_id: &str,
|
||||
attempt: usize,
|
||||
max_attempts: usize,
|
||||
@@ -826,7 +831,7 @@ impl LocalAiService {
|
||||
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(wait_secs);
|
||||
while std::time::Instant::now() < deadline {
|
||||
if self.has_model(model_id).await? {
|
||||
if self.has_model_at(base_url, model_id).await? {
|
||||
log::info!(
|
||||
"[local_ai] model `{}` became available after interrupted pull stream",
|
||||
model_id
|
||||
@@ -1407,6 +1412,15 @@ impl LocalAiService {
|
||||
self.has_model_at(&ollama_base_url(), model).await
|
||||
}
|
||||
|
||||
pub(in crate::openhuman::inference::local::service) async fn has_model_for_config(
|
||||
&self,
|
||||
config: &Config,
|
||||
model: &str,
|
||||
) -> Result<bool, String> {
|
||||
self.has_model_at(&ollama_base_url_from_config(config), model)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn has_model_at(&self, base_url: &str, model: &str) -> Result<bool, String> {
|
||||
// Issue the /api/tags GET directly. We previously short-circuited via
|
||||
// ollama_healthy(), but that doubled the number of /api/tags round-trips
|
||||
|
||||
@@ -36,7 +36,7 @@ impl LocalAiService {
|
||||
}
|
||||
self.bootstrap(config).await;
|
||||
let vision_model = model_ids::effective_vision_model_id(config);
|
||||
self.ensure_ollama_model_available(&vision_model, "vision")
|
||||
self.ensure_ollama_model_available(config, &vision_model, "vision")
|
||||
.await?;
|
||||
|
||||
let images: Vec<String> = image_refs
|
||||
@@ -148,7 +148,7 @@ impl LocalAiService {
|
||||
}
|
||||
self.bootstrap(config).await;
|
||||
let embedding_model = model_ids::effective_embedding_model_id(config);
|
||||
self.ensure_ollama_model_available(&embedding_model, "embedding")
|
||||
self.ensure_ollama_model_available(config, &embedding_model, "embedding")
|
||||
.await?;
|
||||
|
||||
// Embeds are bge-m3 calls (8K context, ~1.3 GB resident) — the
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::de::{DeserializeOwned, Deserializer};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
@@ -92,7 +92,8 @@ struct InferenceUpdateLocalSettingsParams {
|
||||
runtime_enabled: Option<bool>,
|
||||
opt_in_confirmed: Option<bool>,
|
||||
provider: Option<String>,
|
||||
base_url: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_present_json")]
|
||||
base_url: Option<Value>,
|
||||
model_id: Option<String>,
|
||||
chat_model_id: Option<String>,
|
||||
usage_embeddings: Option<bool>,
|
||||
@@ -276,7 +277,10 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
optional_bool("runtime_enabled", "Enable or disable local inference runtime routing."),
|
||||
optional_bool("opt_in_confirmed", "Persist the local inference opt-in flag."),
|
||||
optional_string("provider", "Optional local provider slug, e.g. ollama or lm_studio."),
|
||||
optional_string("base_url", "Optional local provider base URL."),
|
||||
optional_json(
|
||||
"base_url",
|
||||
"Optional local provider base URL string, or null to clear.",
|
||||
),
|
||||
optional_string("model_id", "Optional generic model id override."),
|
||||
optional_string("chat_model_id", "Optional chat model id override."),
|
||||
optional_bool("usage_embeddings", "Whether embeddings workload may use the local provider."),
|
||||
@@ -629,11 +633,17 @@ fn handle_inference_update_model_settings(params: Map<String, Value>) -> Control
|
||||
fn handle_inference_update_local_settings(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let update = deserialize_params::<InferenceUpdateLocalSettingsParams>(params)?;
|
||||
let base_url = match update.base_url {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(Value::String(value)) => Some(Some(value)),
|
||||
Some(_) => return Err("invalid params: base_url must be a string or null".to_string()),
|
||||
};
|
||||
let patch = config_rpc::LocalAiSettingsPatch {
|
||||
runtime_enabled: update.runtime_enabled,
|
||||
opt_in_confirmed: update.opt_in_confirmed,
|
||||
provider: update.provider,
|
||||
base_url: update.base_url,
|
||||
base_url,
|
||||
model_id: update.model_id,
|
||||
chat_model_id: update.chat_model_id,
|
||||
usage_embeddings: update.usage_embeddings,
|
||||
@@ -812,6 +822,13 @@ fn deserialize_params<T: DeserializeOwned>(params: Map<String, Value>) -> Result
|
||||
serde_json::from_value(Value::Object(params)).map_err(|e| format!("invalid params: {e}"))
|
||||
}
|
||||
|
||||
fn deserialize_present_json<'de, D>(deserializer: D) -> Result<Option<Value>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
Value::deserialize(deserializer).map(Some)
|
||||
}
|
||||
|
||||
fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
|
||||
outcome.into_cli_compatible_json()
|
||||
}
|
||||
|
||||
@@ -58,6 +58,20 @@ fn inference_prompt_schema_reuses_local_ai_shape_with_new_namespace() {
|
||||
assert!(schema.inputs.iter().any(|field| field.name == "max_tokens"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inference_update_local_settings_schema_allows_json_base_url() {
|
||||
let schema = schemas("update_local_settings");
|
||||
let field = schema
|
||||
.inputs
|
||||
.iter()
|
||||
.find(|field| field.name == "base_url")
|
||||
.expect("base_url field");
|
||||
match &field.ty {
|
||||
TypeSchema::Option(inner) => assert!(matches!(**inner, TypeSchema::Json)),
|
||||
other => panic!("expected Option<Json>, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inference_openai_oauth_schemas_are_registered_with_expected_shapes() {
|
||||
let registered: Vec<&str> = all_registered_controllers()
|
||||
|
||||
@@ -5582,6 +5582,66 @@ async fn json_rpc_local_ai_lm_studio_config_diagnostics_and_prompt() {
|
||||
rpc_join.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn json_rpc_local_ai_ollama_endpoint_normalizes_bind_address_and_clears() {
|
||||
let _env_lock = json_rpc_e2e_env_lock();
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let home = tmp.path();
|
||||
let openhuman_home = home.join(".openhuman");
|
||||
|
||||
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
|
||||
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
|
||||
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
|
||||
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
|
||||
|
||||
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
|
||||
let mock_origin = format!("http://{}", mock_addr);
|
||||
write_min_config(&openhuman_home, &mock_origin);
|
||||
|
||||
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
|
||||
let rpc_base = format!("http://{}", rpc_addr);
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
let update = post_json_rpc(
|
||||
&rpc_base,
|
||||
390,
|
||||
"openhuman.inference_update_local_settings",
|
||||
json!({
|
||||
"provider": "ollama",
|
||||
"base_url": "http://0.0.0.0:11434/api/tags"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let update_result = assert_no_jsonrpc_error(&update, "normalize ollama base_url");
|
||||
assert_eq!(
|
||||
update_result
|
||||
.get("result")
|
||||
.and_then(|value| value.get("config"))
|
||||
.and_then(|config| config.get("local_ai"))
|
||||
.and_then(|local_ai| local_ai.get("base_url"))
|
||||
.and_then(Value::as_str),
|
||||
Some("http://localhost:11434")
|
||||
);
|
||||
|
||||
let clear = post_json_rpc(
|
||||
&rpc_base,
|
||||
391,
|
||||
"openhuman.inference_update_local_settings",
|
||||
json!({ "base_url": null }),
|
||||
)
|
||||
.await;
|
||||
let clear_result = assert_no_jsonrpc_error(&clear, "clear ollama base_url");
|
||||
assert!(clear_result
|
||||
.get("result")
|
||||
.and_then(|value| value.get("config"))
|
||||
.and_then(|config| config.get("local_ai"))
|
||||
.and_then(|local_ai| local_ai.get("base_url"))
|
||||
.is_none_or(Value::is_null));
|
||||
|
||||
mock_join.abort();
|
||||
rpc_join.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn json_rpc_inference_namespace_lm_studio_prompt_and_status() {
|
||||
let _env_lock = json_rpc_e2e_env_lock();
|
||||
|
||||
@@ -404,6 +404,9 @@ async fn github_reader_uses_fake_gh_for_list_and_read_paths() {
|
||||
let reader = openhuman_core::openhuman::memory_sources::readers::github::GithubReader;
|
||||
let mut entry = source(SourceKind::GithubRepo, "github-round15");
|
||||
entry.url = Some("https://github.com/tinyhumansai/openhuman.git".to_string());
|
||||
entry.max_commits = Some(30);
|
||||
entry.max_issues = Some(30);
|
||||
entry.max_prs = Some(30);
|
||||
|
||||
let items = reader
|
||||
.list_items(&entry, &config)
|
||||
|
||||
Reference in New Issue
Block a user