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:
Steven Enamakel's Droid
2026-06-01 20:36:29 -07:00
committed by GitHub
parent 37ac828258
commit e9f3e3a7da
35 changed files with 634 additions and 51 deletions
+25 -2
View File
@@ -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 () => {
+2
View File
@@ -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) لاستخدام نسخة مشتركة.',
+2
View File
@@ -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 ব্যবহার করা হচ্ছে)।',
+2
View File
@@ -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.',
+2
View File
@@ -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.',
+2
View File
@@ -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.',
+2
View File
@@ -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.",
+2
View File
@@ -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) एक साझा उदाहरण का उपयोग करने के लिए)।',
+2
View File
@@ -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.',
+2
View File
@@ -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.",
+2
View File
@@ -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)를 가리키세요.',
+2
View File
@@ -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.',
+2
View File
@@ -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.',
+2
View File
@@ -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), чтобы использовать общий экземпляр.',
+2
View File
@@ -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',
+2 -2
View File
@@ -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);
+9 -2
View File
@@ -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 };
}
+1 -1
View File
@@ -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,
},
+13 -5
View File
@@ -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 {
+37 -3
View File
@@ -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();
+28 -5
View File
@@ -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,
+37 -1
View File
@@ -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();
+3 -1
View File
@@ -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;
+158 -4
View File
@@ -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]
+14 -10
View File
@@ -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
+21 -4
View File
@@ -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()
}
+14
View File
@@ -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()
+60
View File
@@ -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)