diff --git a/.claude/memory.md b/.claude/memory.md index f6a0f56720..a56cf9aa42 100644 --- a/.claude/memory.md +++ b/.claude/memory.md @@ -33,6 +33,8 @@ Quick reference for anyone starting with Claude on this project. Updated by the - **`OPENHUMAN_LOCAL_AI_TIER` env var** overrides the selected tier at config load time (in `load.rs`). - **Frontend tier selector** is in `LocalModelPanel.tsx` under Settings > Local AI Model. Uses `coreRpcClient` to call 3 RPC methods: `local_ai_device_profile`, `local_ai_presets`, `local_ai_apply_preset`. - **Default config maps to Medium tier** (`gemma3:4b-it-qat`). If someone changes `model_ids.rs` defaults, they should keep `presets.rs` in sync. +- **`ollama_base_url()` previously ignored `config.local_ai.base_url`** — It only read env vars. Fixed in feat/ollama-external-server-url by adding `ollama_base_url_from_config(config)`. Any new Ollama URL resolution must go through the config-aware helper, not the env-only one. +- **`LocalModelDebugPanel.tsx` must seed URL from config on mount** — Previously initialized `ollamaBaseUrlInput` to the hardcoded default and only loaded the persisted URL when diagnostics ran. Fix: `useEffect` on mount calls `openhumanGetConfig()` and sets state from `config.local_ai.base_url`. Pattern to follow for any settings field backed by Rust config. ## Core process (in-process, no sidecar) @@ -149,6 +151,7 @@ Quick reference for anyone starting with Claude on this project. Updated by the - **`pnpm typecheck` script was renamed** — Check `app/package.json` for the current name; as of issue #830 work, use `pnpm workspace openhuman-app compile` for tsc checks. - **PR #745 (command palette) merged without its deps** — `@radix-ui/react-dialog`, `cmdk`, and `@testing-library/user-event` are missing from `package.json`. Install them if tsc fails after syncing main. - **Pre-push hooks fail on upstream lint warnings** — ESLint warns on `setState` in effects and unused `eslint-disable` directives inherited from upstream. Use `--no-verify` only when the lint errors are pre-existing upstream issues, not new code. +- **`pnpm test:coverage` ENOENT on `coverage/.tmp/coverage-0.json`** — Race condition in coverage file collection; flaky, not reproducible every run. Use `pnpm debug unit` instead — runs Vitest without coverage, faster and reliable for iteration. ## Mascot Native Window (macOS) @@ -188,3 +191,35 @@ Quick reference for anyone starting with Claude on this project. Updated by the - **`pnpm core:stage`** — no-op (sidecar removed in PR #1061). Use `pnpm dev:app` for full Tauri+core dev. - **Kill stuck processes** — `lsof -i :7788` then `kill `. Useful when `dev:app` reports a stale listener and you want to force a fresh boot rather than relying on the handle's auto-recovery. - **Skills runtime removed** — the QuickJS / `rquickjs` runtime is gone; `src/openhuman/skills/` is metadata-only ("Legacy skill metadata helpers retained after QuickJS runtime removal"). Skill execution surfaces are being rebuilt; don't assume a `.skill` can run end-to-end without checking the current code. + +## Project Board & Issue Queries + +- **Project #2 paginates at 100 items** — Board has 627+ items. Use GraphQL cursor pagination to find all open P0 issues; a single query only returns the first 100. +- **jq regex `\s+` causes parse errors** — Use plain `test("#NNNN")` to check if a PR/issue body references an issue number. `\s+` in jq regex triggers parse errors. +- **Most open P0s are security or Linux AppImage GLIBC issues** — When triaging P0s, filter for those categories first. +- **Project #2 shows only closed items on the board view** — Use `gh issue list --repo tinyhumansai/openhuman --state open --assignee ""` to find unassigned open issues instead of querying the project board. +- **Check linked PRs via timeline API, not body regex** — `gh api repos/tinyhumansai/openhuman/issues/$N/timeline --paginate | jq '[.[] | select(.event == "cross-referenced" and .source.issue.state == "open")] | length'` is more reliable than searching issue body text for PR references. + +## Git Submodules + +- **`tauri-cef` and `tauri-plugin-notification` are git submodules** — When upstream/main updates them, fix with `git submodule update --remote --checkout`, not by manually patching the vendored crate. + +## Pre-existing Test Failures + +- **`composio::action_tool::tests::factory_routes_through_direct_when_mode_is_direct` fails in `cargo test -p openhuman`** — Pre-existing failure unrelated to WhatsApp or any recent branch work. Do not attempt to fix unless explicitly tasked. Also intermittently flaky when run as part of the full suite — see "Pre-existing Flaky Tests" section. + +## Workflow Gate (must not skip) + +- **Steps 4–6 of `workflow/00-full-workflow.md` are mandatory before committing** — Step 4: architectobot verify. Step 5: full checks (`pnpm test:coverage`, `pnpm build`, `bash scripts/install.sh --dry-run`, PR quality scripts). Step 6: memory-keeper. Skipping any of these violates the workflow contract. +- **Encode architectobot answers in the codecrusher prompt** — When the architectobot plan includes clarifying questions and the user approves specific answers, embed those decisions as explicit constraints in the codecrusher prompt so the agent doesn't re-ask. + +## Security Policy + +- **Path validation entry point** — `src/openhuman/security/policy.rs` exposes `validate_path` / `validate_parent_path`. All file I/O path validation must go through this API. `is_path_string_allowed()` is a string-only first pass, not sufficient on its own. +- **validate_parent_path before create_dir_all** — For write operations, `validate_parent_path` MUST be called before any `create_dir_all` call. Calling it after allows symlink attacks to create directories outside the workspace before the security check fires (Issue #1927). +- **Tool callers must use `validate_path` / `validate_parent_path`** — All tool implementations under `src/openhuman/tools/impl/filesystem/` must use these functions, not the legacy `is_path_allowed` / `is_resolved_path_allowed`. +- **Security policy test filter** — Run only security policy tests with: `cargo test -p openhuman -- "security::policy"`. Runs the 100 tests in `src/openhuman/security/policy_tests.rs` cleanly. + +## Pre-existing Flaky Tests + +- **`composio::action_tool` and `agent::harness::session::turn` intermittent failures** — These tests fail randomly when run as part of the full suite (likely shared state or timing), but pass individually. Not related to security/policy changes. Do not treat as blockers for security-module PRs. diff --git a/app/src/components/settings/panels/LocalModelDebugPanel.tsx b/app/src/components/settings/panels/LocalModelDebugPanel.tsx index ec721da885..68743bc64e 100644 --- a/app/src/components/settings/panels/LocalModelDebugPanel.tsx +++ b/app/src/components/settings/panels/LocalModelDebugPanel.tsx @@ -1,3 +1,4 @@ +import debug from 'debug'; import { useEffect, useMemo, useState } from 'react'; import { useT } from '../../../lib/i18n/I18nContext'; @@ -15,6 +16,7 @@ import { type LocalAiSpeechResult, type LocalAiStatus, type LocalAiTtsResult, + type OllamaConnectionTestResult, openhumanLocalAiAssetsStatus, openhumanLocalAiDiagnostics, openhumanLocalAiDownloadAsset, @@ -23,15 +25,20 @@ import { openhumanLocalAiPrompt, openhumanLocalAiStatus, openhumanLocalAiSummarize, + openhumanLocalAiTestConnection, openhumanLocalAiTranscribe, openhumanLocalAiTts, openhumanLocalAiVisionPrompt, + openhumanUpdateLocalAiSettings, } from '../../../utils/tauriCommands'; +import { openhumanGetConfig } from '../../../utils/tauriCommands/config'; import SettingsHeader from '../components/SettingsHeader'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; import ModelDownloadSection from './local-model/ModelDownloadSection'; import ModelStatusSection from './local-model/ModelStatusSection'; +const log = debug('openhuman:local-model-debug'); + const statusTone = (state: string): string => { switch (state) { case 'ready': @@ -93,6 +100,14 @@ const LocalModelDebugPanel = () => { const [showErrorDetail, setShowErrorDetail] = useState(false); + const DEFAULT_OLLAMA_URL = 'http://localhost:11434'; + const [ollamaBaseUrlInput, setOllamaBaseUrlInput] = useState(DEFAULT_OLLAMA_URL); + const [savedOllamaBaseUrl, setSavedOllamaBaseUrl] = useState(DEFAULT_OLLAMA_URL); + const [isTestingConnection, setIsTestingConnection] = useState(false); + const [connectionTestResult, setConnectionTestResult] = + useState(null); + const [isSavingUrl, setIsSavingUrl] = useState(false); + const progress = useMemo(() => { const downloadProgress = progressFromDownloads(downloads); if (downloadProgress != null) return downloadProgress; @@ -151,6 +166,25 @@ const LocalModelDebugPanel = () => { }; }, []); + useEffect(() => { + const seedSavedUrl = async () => { + try { + const configResponse = await openhumanGetConfig(); + const localAi = configResponse.result?.config?.local_ai as + | Record + | undefined; + const saved = localAi?.base_url as string | undefined | null; + if (saved && saved.trim()) { + setOllamaBaseUrlInput(saved.trim()); + setSavedOllamaBaseUrl(saved.trim()); + } + } catch { + // Non-critical — stay on default. + } + }; + void seedSavedUrl(); + }, []); + const runSummaryTest = async () => { if (!runtimeEnabled || !summaryInput.trim()) return; setIsSummaryLoading(true); @@ -281,6 +315,12 @@ const LocalModelDebugPanel = () => { try { const result = await openhumanLocalAiDiagnostics(); setDiagnostics(result); + if (result.ollama_base_url) { + const reported = result.ollama_base_url; + // Only overwrite the input if the user hasn't made unsaved edits. + setOllamaBaseUrlInput(prev => (prev === savedOllamaBaseUrl ? reported : prev)); + setSavedOllamaBaseUrl(reported); + } } catch (err) { setDiagnosticsError(err instanceof Error ? err.message : 'Diagnostics failed'); } finally { @@ -288,6 +328,55 @@ const LocalModelDebugPanel = () => { } }; + const handleTestConnection = async () => { + setIsTestingConnection(true); + setConnectionTestResult(null); + try { + const result = await openhumanLocalAiTestConnection(ollamaBaseUrlInput); + log('[local_ai:ui] test_connection result: reachable=%o', result.reachable); + setConnectionTestResult(result); + } catch (err) { + setConnectionTestResult({ + reachable: false, + error: err instanceof Error ? err.message : 'Connection test failed', + models_count: null, + }); + } finally { + setIsTestingConnection(false); + } + }; + + const handleSaveOllamaBaseUrl = async () => { + setIsSavingUrl(true); + try { + await openhumanUpdateLocalAiSettings({ base_url: ollamaBaseUrlInput }); + log( + '[local_ai:ui] saved ollama base_url=%s', + ollamaBaseUrlInput.replace(/\/\/[^@]*@/, '//***@') + ); + setSavedOllamaBaseUrl(ollamaBaseUrlInput); + } catch (err) { + setStatusError(err instanceof Error ? err.message : 'Failed to save URL'); + } finally { + setIsSavingUrl(false); + } + }; + + const handleResetOllamaBaseUrl = async () => { + setOllamaBaseUrlInput(DEFAULT_OLLAMA_URL); + setConnectionTestResult(null); + setIsSavingUrl(true); + try { + await openhumanUpdateLocalAiSettings({ base_url: null }); + log('[local_ai:ui] reset ollama base_url to default'); + setSavedOllamaBaseUrl(DEFAULT_OLLAMA_URL); + } catch (err) { + setStatusError(err instanceof Error ? err.message : 'Failed to reset URL'); + } finally { + setIsSavingUrl(false); + } + }; + return (
{ etaText={etaText} statusTone={statusTone} runtimeEnabled={runtimeEnabled} + ollamaBaseUrlInput={ollamaBaseUrlInput} + isTestingConnection={isTestingConnection} + connectionTestResult={connectionTestResult} + isSavingUrl={isSavingUrl} + savedOllamaBaseUrl={savedOllamaBaseUrl} onRefreshStatus={() => void loadStatus()} onTriggerDownload={() => {}} onSetOllamaPath={() => {}} @@ -326,6 +420,10 @@ const LocalModelDebugPanel = () => { onSetOllamaPathInput={() => {}} onToggleErrorDetail={() => setShowErrorDetail(v => !v)} onRunDiagnostics={() => void handleRunDiagnostics()} + onSetOllamaBaseUrlInput={setOllamaBaseUrlInput} + onTestConnection={() => void handleTestConnection()} + onSaveOllamaBaseUrl={() => void handleSaveOllamaBaseUrl()} + onResetOllamaBaseUrl={() => void handleResetOllamaBaseUrl()} /> ({ mockNavigateBack: vi.fn() })); + +vi.mock('../../hooks/useSettingsNavigation', () => ({ + useSettingsNavigation: () => ({ navigateBack: mockNavigateBack, breadcrumbs: [] }), +})); + +const mockGetConfig = vi.fn(); +vi.mock('../../../../utils/tauriCommands/config', () => ({ + openhumanGetConfig: (...args: unknown[]) => mockGetConfig(...args), +})); + +const mockLocalAiStatus = vi.fn(); +const mockLocalAiAssetsStatus = vi.fn(); +const mockLocalAiDownloadsProgress = vi.fn(); +const mockLocalAiTestConnection = vi.fn(); +const mockUpdateLocalAiSettings = vi.fn(); +const mockLocalAiDiagnostics = vi.fn(); + +vi.mock('../../../../utils/tauriCommands', () => ({ + openhumanLocalAiStatus: (...args: unknown[]) => mockLocalAiStatus(...args), + openhumanLocalAiAssetsStatus: (...args: unknown[]) => mockLocalAiAssetsStatus(...args), + openhumanLocalAiDownloadsProgress: (...args: unknown[]) => mockLocalAiDownloadsProgress(...args), + openhumanLocalAiTestConnection: (...args: unknown[]) => mockLocalAiTestConnection(...args), + openhumanUpdateLocalAiSettings: (...args: unknown[]) => mockUpdateLocalAiSettings(...args), + openhumanLocalAiDiagnostics: (...args: unknown[]) => mockLocalAiDiagnostics(...args), + openhumanLocalAiSummarize: vi.fn().mockResolvedValue({ result: '' }), + openhumanLocalAiPrompt: vi.fn().mockResolvedValue({ result: '' }), + openhumanLocalAiEmbed: vi.fn().mockResolvedValue({ result: [] }), + openhumanLocalAiVisionPrompt: vi.fn().mockResolvedValue({ result: '' }), + openhumanLocalAiTranscribe: vi.fn().mockResolvedValue({ result: '' }), + openhumanLocalAiTts: vi.fn().mockResolvedValue({ result: '' }), + openhumanLocalAiDownloadAsset: vi.fn().mockResolvedValue({ result: null }), +})); + +function renderPanel() { + return render( + + + + ); +} + +describe('LocalModelDebugPanel', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockLocalAiStatus.mockResolvedValue({ result: null }); + mockLocalAiAssetsStatus.mockResolvedValue({ result: null }); + mockLocalAiDownloadsProgress.mockResolvedValue({ result: null }); + mockGetConfig.mockResolvedValue({ result: { config: {} } }); + mockLocalAiDiagnostics.mockResolvedValue({ + ok: true, + ollama_running: false, + ollama_base_url: null, + ollama_binary_path: null, + installed_models: [], + expected: { + chat_model: '', + chat_found: false, + embedding_model: '', + embedding_found: false, + vision_model: '', + vision_found: false, + }, + issues: [], + repair_actions: [], + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders the Ollama Server URL section with default URL', () => { + renderPanel(); + const input = screen.getByPlaceholderText('http://localhost:11434') as HTMLInputElement; + expect(input.value).toBe('http://localhost:11434'); + }); + + it('seeds the URL input from config on mount', async () => { + mockGetConfig.mockResolvedValue({ + result: { config: { local_ai: { base_url: 'http://192.168.1.5:11434' } } }, + }); + renderPanel(); + await waitFor(() => { + const input = screen.getByPlaceholderText('http://localhost:11434') as HTMLInputElement; + expect(input.value).toBe('http://192.168.1.5:11434'); + }); + }); + + it('keeps the default URL when config returns no base_url', async () => { + mockGetConfig.mockResolvedValue({ result: { config: { local_ai: {} } } }); + renderPanel(); + await waitFor(() => { + const input = screen.getByPlaceholderText('http://localhost:11434') as HTMLInputElement; + expect(input.value).toBe('http://localhost:11434'); + }); + expect(mockGetConfig).toHaveBeenCalledTimes(1); + }); + + it('calls openhumanLocalAiTestConnection when Test Connection is clicked', async () => { + mockLocalAiTestConnection.mockResolvedValue({ reachable: true, models_count: 2 }); + renderPanel(); + const testBtn = screen.getByRole('button', { name: /Test Connection/i }); + fireEvent.click(testBtn); + await waitFor(() => { + expect(mockLocalAiTestConnection).toHaveBeenCalledWith('http://localhost:11434'); + }); + }); + + it('shows reachable result after a successful connection test', async () => { + mockLocalAiTestConnection.mockResolvedValue({ reachable: true, models_count: 5 }); + renderPanel(); + fireEvent.click(screen.getByRole('button', { name: /Test Connection/i })); + await waitFor(() => expect(screen.getByText(/Reachable/)).toBeTruthy()); + expect(screen.getByText(/5 models/)).toBeTruthy(); + }); + + it('shows unreachable result when connection test throws', async () => { + mockLocalAiTestConnection.mockRejectedValue(new Error('connect ECONNREFUSED')); + renderPanel(); + fireEvent.click(screen.getByRole('button', { name: /Test Connection/i })); + await waitFor(() => expect(screen.getByText(/connect ECONNREFUSED/)).toBeTruthy()); + }); + + it('saves the URL when Save is clicked after changing the input', async () => { + mockUpdateLocalAiSettings.mockResolvedValue({ result: true }); + renderPanel(); + const urlInput = screen.getByPlaceholderText('http://localhost:11434'); + fireEvent.change(urlInput, { target: { value: 'http://192.168.1.5:11434' } }); + const saveBtn = await screen.findByRole('button', { name: 'Save' }); + expect((saveBtn as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(saveBtn); + await waitFor(() => { + expect(mockUpdateLocalAiSettings).toHaveBeenCalledWith({ + base_url: 'http://192.168.1.5:11434', + }); + }); + }); + + it('resets the URL to default when Reset to default is clicked', async () => { + mockUpdateLocalAiSettings.mockResolvedValue({ result: true }); + renderPanel(); + const resetBtn = screen.getByRole('button', { name: /Reset to default/i }); + fireEvent.click(resetBtn); + await waitFor(() => { + expect(mockUpdateLocalAiSettings).toHaveBeenCalledWith({ base_url: null }); + }); + const urlInput = screen.getByPlaceholderText('http://localhost:11434') as HTMLInputElement; + expect(urlInput.value).toBe('http://localhost:11434'); + }); +}); diff --git a/app/src/components/settings/panels/local-model/ModelStatusSection.test.tsx b/app/src/components/settings/panels/local-model/ModelStatusSection.test.tsx index f9ba1039b9..94907ea501 100644 --- a/app/src/components/settings/panels/local-model/ModelStatusSection.test.tsx +++ b/app/src/components/settings/panels/local-model/ModelStatusSection.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import type { LocalAiDiagnostics } from '../../../../utils/tauriCommands'; @@ -25,6 +25,11 @@ const defaultProps = { etaText: '', statusTone: (_state: string) => '', runtimeEnabled: true, + ollamaBaseUrlInput: 'http://localhost:11434', + isTestingConnection: false, + connectionTestResult: null, + isSavingUrl: false, + savedOllamaBaseUrl: 'http://localhost:11434', onRefreshStatus: vi.fn(), onTriggerDownload: vi.fn(), onSetOllamaPath: vi.fn(), @@ -33,6 +38,10 @@ const defaultProps = { onToggleErrorDetail: vi.fn(), onRunDiagnostics: vi.fn(), onRepairAction: vi.fn(), + onSetOllamaBaseUrlInput: vi.fn(), + onTestConnection: vi.fn(), + onSaveOllamaBaseUrl: vi.fn(), + onResetOllamaBaseUrl: vi.fn(), }; const makeDiagnostics = (overrides: Partial = {}): LocalAiDiagnostics => ({ @@ -397,3 +406,140 @@ describe('ModelStatusSection diagnostics', () => { expect(screen.queryByText(/ctx/)).toBeNull(); }); }); + +describe('ModelStatusSection — Ollama server URL', () => { + it('renders the URL input with the default value', () => { + render(); + const input = screen.getByPlaceholderText('http://localhost:11434') as HTMLInputElement; + expect(input).toBeTruthy(); + expect(input.value).toBe('http://localhost:11434'); + }); + + it('shows a validation error for a bad URL', () => { + render( + + ); + expect(screen.getByText(/http:\/\/ or https:\/\//i)).toBeTruthy(); + }); + + it('disables Save when URL is unchanged', () => { + render( + + ); + const saveBtn = screen.getByRole('button', { name: 'Save' }); + expect((saveBtn as HTMLButtonElement).disabled).toBe(true); + }); + + it('enables Save when URL has changed and is valid', () => { + render( + + ); + const saveBtn = screen.getByRole('button', { name: 'Save' }); + expect((saveBtn as HTMLButtonElement).disabled).toBe(false); + }); + + it('shows reachable status after a successful test', () => { + render( + + ); + expect(screen.getByText(/Reachable/)).toBeTruthy(); + expect(screen.getByText(/3 models/)).toBeTruthy(); + }); + + it('shows unreachable status after a failed test', () => { + render( + + ); + expect(screen.getByText(/Unreachable/)).toBeTruthy(); + expect(screen.getByText(/connection refused/)).toBeTruthy(); + }); + + it('calls onTestConnection when Test Connection is clicked', async () => { + const onTestConnection = vi.fn(); + render( + + ); + const testBtn = screen.getByRole('button', { name: /Test Connection/ }); + testBtn.click(); + expect(onTestConnection).toHaveBeenCalledTimes(1); + }); + + it('calls onResetOllamaBaseUrl when Reset to default is clicked', () => { + const onResetOllamaBaseUrl = vi.fn(); + render(); + const resetBtn = screen.getByRole('button', { name: /Reset to default/ }); + resetBtn.click(); + expect(onResetOllamaBaseUrl).toHaveBeenCalledTimes(1); + }); + + it('calls onSetOllamaBaseUrlInput when the URL input changes', () => { + const onSetOllamaBaseUrlInput = vi.fn(); + render( + + ); + const input = screen.getByPlaceholderText('http://localhost:11434'); + fireEvent.change(input, { target: { value: 'http://192.168.1.5:11434' } }); + expect(onSetOllamaBaseUrlInput).toHaveBeenCalledWith('http://192.168.1.5:11434'); + }); + + it('shows spinner when isTestingConnection is true', () => { + render(); + const testBtn = screen.getByRole('button', { name: /Test Connection/i }); + expect(testBtn.querySelector('.animate-spin')).toBeTruthy(); + }); + + it('shows reachable result with model count when models_count is a number', () => { + render( + + ); + expect(screen.getByText(/Reachable/)).toBeTruthy(); + expect(screen.getByText(/7 models/)).toBeTruthy(); + }); + + it('shows unreachable result with error text when reachable is false', () => { + render( + + ); + expect(screen.getByText(/Unreachable/)).toBeTruthy(); + expect(screen.getByText(/dial tcp refused/)).toBeTruthy(); + }); + + it('shows validation error message for an invalid URL', () => { + render( + + ); + expect(screen.getByText(/http:\/\/ or https:\/\//i)).toBeTruthy(); + }); +}); diff --git a/app/src/components/settings/panels/local-model/ModelStatusSection.tsx b/app/src/components/settings/panels/local-model/ModelStatusSection.tsx index 05edcaca29..ea80945997 100644 --- a/app/src/components/settings/panels/local-model/ModelStatusSection.tsx +++ b/app/src/components/settings/panels/local-model/ModelStatusSection.tsx @@ -1,10 +1,12 @@ import { useT } from '../../../../lib/i18n/I18nContext'; import { formatBytes, statusLabel } from '../../../../utils/localAiHelpers'; +import { validateOllamaUrl } from '../../../../utils/ollamaUrlValidation'; import type { LocalAiDiagnostics, LocalAiDownloadsProgress, LocalAiStatus, ModelContextEligibility, + OllamaConnectionTestResult, RepairAction, } from '../../../../utils/tauriCommands'; @@ -68,6 +70,10 @@ interface ModelStatusSectionProps { etaText: string; statusTone: (state: string) => string; runtimeEnabled: boolean; + ollamaBaseUrlInput: string; + isTestingConnection: boolean; + connectionTestResult: OllamaConnectionTestResult | null; + isSavingUrl: boolean; onRefreshStatus: () => void; onTriggerDownload: (force: boolean) => void; onSetOllamaPath: () => void; @@ -76,6 +82,11 @@ interface ModelStatusSectionProps { onToggleErrorDetail: () => void; onRunDiagnostics: () => void; onRepairAction?: (action: RepairAction) => void; + onSetOllamaBaseUrlInput: (value: string) => void; + onTestConnection: () => void; + onSaveOllamaBaseUrl: () => void; + onResetOllamaBaseUrl: () => void; + savedOllamaBaseUrl: string; } const ModelStatusSection = ({ @@ -99,6 +110,10 @@ const ModelStatusSection = ({ etaText, statusTone, runtimeEnabled, + ollamaBaseUrlInput, + isTestingConnection, + connectionTestResult, + isSavingUrl, onRefreshStatus, onTriggerDownload, onSetOllamaPath, @@ -107,6 +122,11 @@ const ModelStatusSection = ({ onToggleErrorDetail, onRunDiagnostics, onRepairAction, + onSetOllamaBaseUrlInput, + onTestConnection, + onSaveOllamaBaseUrl, + onResetOllamaBaseUrl, + savedOllamaBaseUrl, }: ModelStatusSectionProps) => { const { t } = useT(); // OpenHuman no longer installs or launches Ollama itself. When the runtime @@ -128,6 +148,11 @@ const ModelStatusSection = ({ void onToggleErrorDetail; void onRepairAction; + const urlValidation = validateOllamaUrl(ollamaBaseUrlInput); + const urlChanged = ollamaBaseUrlInput !== savedOllamaBaseUrl; + const canSave = urlValidation.valid && urlChanged && !isSavingUrl; + const canTest = urlValidation.valid && !isTestingConnection; + if (showInstallOllamaCta) { return (
@@ -186,6 +211,69 @@ const ModelStatusSection = ({ return ( <> +
+

+ {t('localModel.ollamaServer.label')} +

+
+
+ onSetOllamaBaseUrlInput(e.target.value)} + placeholder={t('localModel.ollamaServer.placeholder')} + className="w-full rounded-md border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-1.5 text-sm text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-primary-500" + /> + {ollamaBaseUrlInput && !urlValidation.valid && ( +

+ {urlValidation.error ?? t('localModel.ollamaServer.validationError')} +

+ )} +

+ {t('localModel.ollamaServer.helperText')} +

+
+ + {connectionTestResult !== null && ( +
+ {connectionTestResult.reachable ? '✓' : '✗'} + + {connectionTestResult.reachable + ? `${t('localModel.ollamaServer.reachable')}${typeof connectionTestResult.models_count === 'number' ? ` (${connectionTestResult.models_count} ${t('localModel.ollamaServer.modelCount')})` : ''}` + : `${t('localModel.ollamaServer.unreachable')}${connectionTestResult.error ? `: ${connectionTestResult.error}` : ''}`} + +
+ )} + +
+ + + +
+
+
+

diff --git a/app/src/lib/i18n/chunks/ar-2.ts b/app/src/lib/i18n/chunks/ar-2.ts index 27a26a3ac4..5ec17cc700 100644 --- a/app/src/lib/i18n/chunks/ar-2.ts +++ b/app/src/lib/i18n/chunks/ar-2.ts @@ -239,6 +239,17 @@ const ar2: TranslationMap = { 'مفتاح رئيسي. معطّل افتراضيًا — Ollama يبقى خاملاً. عند التشغيل، يستخدم ملخّص الشجرة وذكاء الشاشة والإكمال التلقائي النموذجَ المحلي دائمًا.', 'localModel.advancedSettings': 'إعدادات متقدمة', 'localModel.debugTitle': 'تصحيح النموذج المحلي', + 'localModel.ollamaServer.helperText': 'مثال: http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'عنوان URL لخادم Ollama', + 'localModel.ollamaServer.modelCount': 'نماذج', + 'localModel.ollamaServer.placeholder': 'http://localhost:11434', + 'localModel.ollamaServer.reachable': 'قابل للوصول', + 'localModel.ollamaServer.resetButton': 'إعادة التعيين إلى الافتراضي', + 'localModel.ollamaServer.saveButton': 'حفظ', + 'localModel.ollamaServer.testButton': 'اختبار الاتصال', + 'localModel.ollamaServer.unreachable': 'غير قابل للوصول', + 'localModel.ollamaServer.validationError': + 'يجب أن يكون عنوان URL صالحاً يبدأ بـ http:// أو https://', 'screenAwareness.debugTitle': 'تصحيح وعي الشاشة', 'memory.debugTitle': 'تصحيح الذاكرة', 'webhooks.debugTitle': 'تصحيح الـ Webhooks', diff --git a/app/src/lib/i18n/chunks/bn-2.ts b/app/src/lib/i18n/chunks/bn-2.ts index 59b3057d81..b98eb17074 100644 --- a/app/src/lib/i18n/chunks/bn-2.ts +++ b/app/src/lib/i18n/chunks/bn-2.ts @@ -248,6 +248,16 @@ const bn2: TranslationMap = { 'মাস্টার সুইচ। ডিফল্টে বন্ধ — Ollama নিষ্ক্রিয় থাকে। চালু হলে, ট্রি সামারাইজার, স্ক্রিন ইন্টেলিজেন্স এবং অটোকমপ্লিট সর্বদা লোকাল মডেল ব্যবহার করে।', 'localModel.advancedSettings': 'অ্যাডভান্সড সেটিংস', 'localModel.debugTitle': 'লোকাল মডেল ডিবাগ', + 'localModel.ollamaServer.helperText': 'উদাহরণ: http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'Ollama সার্ভার URL', + 'localModel.ollamaServer.modelCount': 'মডেল', + 'localModel.ollamaServer.placeholder': 'http://localhost:11434', + 'localModel.ollamaServer.reachable': 'পৌঁছানো যাচ্ছে', + 'localModel.ollamaServer.resetButton': 'ডিফল্টে পুনরায় সেট করুন', + 'localModel.ollamaServer.saveButton': 'সংরক্ষণ করুন', + 'localModel.ollamaServer.testButton': 'সংযোগ পরীক্ষা করুন', + 'localModel.ollamaServer.unreachable': 'পৌঁছানো যাচ্ছে না', + 'localModel.ollamaServer.validationError': 'একটি বৈধ http:// বা https:// URL হতে হবে', 'screenAwareness.debugTitle': 'স্ক্রিন সচেতনতা ডিবাগ', 'memory.debugTitle': 'মেমোরি ডিবাগ', 'webhooks.debugTitle': 'Webhooks ডিবাগ', diff --git a/app/src/lib/i18n/chunks/en-2.ts b/app/src/lib/i18n/chunks/en-2.ts index fa893311ca..fce03c2d45 100644 --- a/app/src/lib/i18n/chunks/en-2.ts +++ b/app/src/lib/i18n/chunks/en-2.ts @@ -245,6 +245,16 @@ const en2: TranslationMap = { 'Master switch. Off by default — Ollama stays idle. When on, the tree summarizer, screen intelligence, and autocomplete always use the local model.', 'localModel.advancedSettings': 'Advanced settings', 'localModel.debugTitle': 'Local Model Debug', + 'localModel.ollamaServer.helperText': 'Example: http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'Ollama Server URL', + 'localModel.ollamaServer.modelCount': 'models', + 'localModel.ollamaServer.placeholder': 'http://localhost:11434', + 'localModel.ollamaServer.reachable': 'Reachable', + 'localModel.ollamaServer.resetButton': 'Reset to default', + 'localModel.ollamaServer.saveButton': 'Save', + 'localModel.ollamaServer.testButton': 'Test Connection', + 'localModel.ollamaServer.unreachable': 'Unreachable', + 'localModel.ollamaServer.validationError': 'Must be a valid http:// or https:// URL', 'screenAwareness.debugTitle': 'Screen Awareness Debug', 'memory.debugTitle': 'Memory Debug', 'webhooks.debugTitle': 'Webhooks Debug', diff --git a/app/src/lib/i18n/chunks/es-2.ts b/app/src/lib/i18n/chunks/es-2.ts index 98aca48fdf..f2eae56e04 100644 --- a/app/src/lib/i18n/chunks/es-2.ts +++ b/app/src/lib/i18n/chunks/es-2.ts @@ -251,6 +251,16 @@ const es2: TranslationMap = { 'Interruptor principal. Desactivado por defecto — Ollama permanece inactivo. Cuando está activado, el resumidor de árbol, la inteligencia de pantalla y el autocompletado siempre usan el modelo local.', 'localModel.advancedSettings': 'Configuración avanzada', 'localModel.debugTitle': 'Depuración de modelo local', + 'localModel.ollamaServer.helperText': 'Ejemplo: http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'URL del servidor Ollama', + 'localModel.ollamaServer.modelCount': 'modelos', + 'localModel.ollamaServer.placeholder': 'http://localhost:11434', + 'localModel.ollamaServer.reachable': 'Accesible', + 'localModel.ollamaServer.resetButton': 'Restablecer al valor predeterminado', + 'localModel.ollamaServer.saveButton': 'Guardar', + 'localModel.ollamaServer.testButton': 'Probar conexión', + 'localModel.ollamaServer.unreachable': 'No accesible', + 'localModel.ollamaServer.validationError': 'Debe ser una URL http:// o https:// válida', 'screenAwareness.debugTitle': 'Depuración de Conciencia de pantalla', 'memory.debugTitle': 'Depuración de memoria', 'webhooks.debugTitle': 'Depuración de webhooks', diff --git a/app/src/lib/i18n/chunks/fr-2.ts b/app/src/lib/i18n/chunks/fr-2.ts index 8e0525370b..2537a1d088 100644 --- a/app/src/lib/i18n/chunks/fr-2.ts +++ b/app/src/lib/i18n/chunks/fr-2.ts @@ -253,6 +253,16 @@ const fr2: TranslationMap = { "Interrupteur principal. Désactivé par défaut — Ollama reste en veille. Quand activé, le résumeur d'arbre, l'intelligence d'écran et l'autocomplétion utilisent toujours le modèle local.", 'localModel.advancedSettings': 'Paramètres avancés', 'localModel.debugTitle': 'Débogage du modèle local', + 'localModel.ollamaServer.helperText': 'Exemple : http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'URL du serveur Ollama', + 'localModel.ollamaServer.modelCount': 'modèles', + 'localModel.ollamaServer.placeholder': 'http://localhost:11434', + 'localModel.ollamaServer.reachable': 'Accessible', + 'localModel.ollamaServer.resetButton': 'Réinitialiser par défaut', + 'localModel.ollamaServer.saveButton': 'Enregistrer', + 'localModel.ollamaServer.testButton': 'Tester la connexion', + 'localModel.ollamaServer.unreachable': 'Inaccessible', + 'localModel.ollamaServer.validationError': 'Doit être une URL http:// ou https:// valide', 'screenAwareness.debugTitle': "Débogage de la surveillance de l'écran", 'memory.debugTitle': 'Débogage de la mémoire', 'webhooks.debugTitle': 'Débogage des webhooks', diff --git a/app/src/lib/i18n/chunks/hi-2.ts b/app/src/lib/i18n/chunks/hi-2.ts index 2eb3cf6b69..c16df59ff3 100644 --- a/app/src/lib/i18n/chunks/hi-2.ts +++ b/app/src/lib/i18n/chunks/hi-2.ts @@ -246,6 +246,16 @@ const hi2: TranslationMap = { 'मास्टर स्विच। डिफ़ॉल्ट रूप से बंद — Ollama आइडल रहता है। चालू होने पर tree summarizer, screen intelligence और autocomplete हमेशा लोकल मॉडल इस्तेमाल करते हैं।', 'localModel.advancedSettings': 'एडवांस्ड सेटिंग्स', 'localModel.debugTitle': 'लोकल मॉडल डिबग', + 'localModel.ollamaServer.helperText': 'उदाहरण: http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'Ollama सर्वर URL', + 'localModel.ollamaServer.modelCount': 'मॉडल', + 'localModel.ollamaServer.placeholder': 'http://localhost:11434', + 'localModel.ollamaServer.reachable': 'पहुंचने योग्य', + 'localModel.ollamaServer.resetButton': 'डिफ़ॉल्ट पर रीसेट करें', + 'localModel.ollamaServer.saveButton': 'सहेजें', + 'localModel.ollamaServer.testButton': 'कनेक्शन जांचें', + 'localModel.ollamaServer.unreachable': 'पहुंचने योग्य नहीं', + 'localModel.ollamaServer.validationError': 'एक मान्य http:// या https:// URL होना चाहिए', 'screenAwareness.debugTitle': 'स्क्रीन अवेयरनेस डिबग', 'memory.debugTitle': 'मेमोरी डिबग', 'webhooks.debugTitle': 'Webhooks डिबग', diff --git a/app/src/lib/i18n/chunks/id-2.ts b/app/src/lib/i18n/chunks/id-2.ts index 2297ad061c..636baaea50 100644 --- a/app/src/lib/i18n/chunks/id-2.ts +++ b/app/src/lib/i18n/chunks/id-2.ts @@ -247,6 +247,16 @@ const id2: TranslationMap = { 'Sakelar utama. Nonaktif secara default; Ollama tetap idle. Saat aktif, peringkas tree, kecerdasan layar, dan autocomplete selalu memakai model lokal.', 'localModel.advancedSettings': 'Pengaturan lanjutan', 'localModel.debugTitle': 'Debug Model Lokal', + 'localModel.ollamaServer.helperText': 'Contoh: http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'URL Server Ollama', + 'localModel.ollamaServer.modelCount': 'model', + 'localModel.ollamaServer.placeholder': 'http://localhost:11434', + 'localModel.ollamaServer.reachable': 'Dapat Dijangkau', + 'localModel.ollamaServer.resetButton': 'Setel Ulang ke Default', + 'localModel.ollamaServer.saveButton': 'Simpan', + 'localModel.ollamaServer.testButton': 'Uji Koneksi', + 'localModel.ollamaServer.unreachable': 'Tidak Dapat Dijangkau', + 'localModel.ollamaServer.validationError': 'Harus berupa URL http:// atau https:// yang valid', 'screenAwareness.debugTitle': 'Debug Kesadaran Layar', 'memory.debugTitle': 'Debug Memori', 'webhooks.debugTitle': 'Debug Webhook', diff --git a/app/src/lib/i18n/chunks/it-2.ts b/app/src/lib/i18n/chunks/it-2.ts index c9438295c1..e46ad19ebb 100644 --- a/app/src/lib/i18n/chunks/it-2.ts +++ b/app/src/lib/i18n/chunks/it-2.ts @@ -248,6 +248,16 @@ const it2: TranslationMap = { "Interruttore principale. Disattivato di default — Ollama resta inattivo. Quando attivo, il tree summarizer, lo screen intelligence e l'autocompletamento usano sempre il modello locale.", 'localModel.advancedSettings': 'Impostazioni avanzate', 'localModel.debugTitle': 'Debug modello locale', + 'localModel.ollamaServer.helperText': 'Esempio: http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'URL del server Ollama', + 'localModel.ollamaServer.modelCount': 'modelli', + 'localModel.ollamaServer.placeholder': 'http://localhost:11434', + 'localModel.ollamaServer.reachable': 'Raggiungibile', + 'localModel.ollamaServer.resetButton': 'Ripristina predefinito', + 'localModel.ollamaServer.saveButton': 'Salva', + 'localModel.ollamaServer.testButton': 'Testa connessione', + 'localModel.ollamaServer.unreachable': 'Non raggiungibile', + 'localModel.ollamaServer.validationError': 'Deve essere un URL http:// o https:// valido', 'screenAwareness.debugTitle': 'Debug consapevolezza schermo', 'memory.debugTitle': 'Debug memoria', 'webhooks.debugTitle': 'Debug webhook', diff --git a/app/src/lib/i18n/chunks/pt-2.ts b/app/src/lib/i18n/chunks/pt-2.ts index 0094a52b9c..536edb6a55 100644 --- a/app/src/lib/i18n/chunks/pt-2.ts +++ b/app/src/lib/i18n/chunks/pt-2.ts @@ -251,6 +251,16 @@ const pt2: TranslationMap = { 'Chave mestre. Desligado por padrão — Ollama fica inativo. Quando ligado, o sumarizador de árvore, inteligência de tela e autocompletar sempre usam o modelo local.', 'localModel.advancedSettings': 'Configurações avançadas', 'localModel.debugTitle': 'Depuração de Modelo Local', + 'localModel.ollamaServer.helperText': 'Exemplo: http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'URL do servidor Ollama', + 'localModel.ollamaServer.modelCount': 'modelos', + 'localModel.ollamaServer.placeholder': 'http://localhost:11434', + 'localModel.ollamaServer.reachable': 'Acessível', + 'localModel.ollamaServer.resetButton': 'Redefinir para padrão', + 'localModel.ollamaServer.saveButton': 'Salvar', + 'localModel.ollamaServer.testButton': 'Testar conexão', + 'localModel.ollamaServer.unreachable': 'Inacessível', + 'localModel.ollamaServer.validationError': 'Deve ser uma URL http:// ou https:// válida', 'screenAwareness.debugTitle': 'Depuração de Reconhecimento de Tela', 'memory.debugTitle': 'Depuração de Memória', 'webhooks.debugTitle': 'Depuração de Webhooks', diff --git a/app/src/lib/i18n/chunks/ru-2.ts b/app/src/lib/i18n/chunks/ru-2.ts index 721b047b07..3d1199bb6e 100644 --- a/app/src/lib/i18n/chunks/ru-2.ts +++ b/app/src/lib/i18n/chunks/ru-2.ts @@ -246,6 +246,17 @@ const ru2: TranslationMap = { 'Главный переключатель. По умолчанию выключен — Ollama простаивает. При включении суммаризатор деревьев, интеллект экрана и автодополнение всегда используют локальную модель.', 'localModel.advancedSettings': 'Дополнительные настройки', 'localModel.debugTitle': 'Отладка локальной модели', + 'localModel.ollamaServer.helperText': 'Пример: http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'URL сервера Ollama', + 'localModel.ollamaServer.modelCount': 'моделей', + 'localModel.ollamaServer.placeholder': 'http://localhost:11434', + 'localModel.ollamaServer.reachable': 'Доступен', + 'localModel.ollamaServer.resetButton': 'Сбросить до значения по умолчанию', + 'localModel.ollamaServer.saveButton': 'Сохранить', + 'localModel.ollamaServer.testButton': 'Проверить соединение', + 'localModel.ollamaServer.unreachable': 'Недоступен', + 'localModel.ollamaServer.validationError': + 'Должен быть допустимым URL с протоколом http:// или https://', 'screenAwareness.debugTitle': 'Отладка слежения за экраном', 'memory.debugTitle': 'Отладка памяти', 'webhooks.debugTitle': 'Отладка вебхуков', diff --git a/app/src/lib/i18n/chunks/zh-CN-2.ts b/app/src/lib/i18n/chunks/zh-CN-2.ts index e11ba7ebbb..3ccf40ab42 100644 --- a/app/src/lib/i18n/chunks/zh-CN-2.ts +++ b/app/src/lib/i18n/chunks/zh-CN-2.ts @@ -230,6 +230,16 @@ const zhCN2: TranslationMap = { '总开关。默认关闭——Ollama 保持空闲。启用后,树摘要器、屏幕智能和自动补全始终使用本地模型。', 'localModel.advancedSettings': '高级设置', 'localModel.debugTitle': '本地模型调试', + 'localModel.ollamaServer.helperText': '示例:http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'Ollama 服务器 URL', + 'localModel.ollamaServer.modelCount': '个模型', + 'localModel.ollamaServer.placeholder': 'http://localhost:11434', + 'localModel.ollamaServer.reachable': '可访问', + 'localModel.ollamaServer.resetButton': '重置为默认值', + 'localModel.ollamaServer.saveButton': '保存', + 'localModel.ollamaServer.testButton': '测试连接', + 'localModel.ollamaServer.unreachable': '无法访问', + 'localModel.ollamaServer.validationError': '必须是有效的 http:// 或 https:// URL', 'screenAwareness.debugTitle': '屏幕感知调试', 'memory.debugTitle': '记忆调试', 'webhooks.debugTitle': 'Webhook 调试', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index abf4d88ee9..12a6b08a62 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -1878,6 +1878,16 @@ const en: TranslationMap = { 'settings.localModel.status.notFound': 'Not found', 'settings.localModel.status.notRunning': 'Not running', 'settings.localModel.status.ollamaBinaryPath': 'Ollama binary path', + 'localModel.ollamaServer.helperText': 'Example: http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'Ollama Server URL', + 'localModel.ollamaServer.modelCount': 'models', + 'localModel.ollamaServer.placeholder': 'http://localhost:11434', + 'localModel.ollamaServer.reachable': 'Reachable', + 'localModel.ollamaServer.resetButton': 'Reset to default', + 'localModel.ollamaServer.saveButton': 'Save', + 'localModel.ollamaServer.testButton': 'Test Connection', + 'localModel.ollamaServer.unreachable': 'Unreachable', + 'localModel.ollamaServer.validationError': 'Must be a valid http:// or https:// URL', 'settings.localModel.status.ollamaDiagnostics': 'Ollama Diagnostics', 'settings.localModel.status.ollamaNotInstalled': 'Ollama runtime unavailable', 'settings.localModel.status.ollamaNotInstalledDesc': diff --git a/app/src/utils/ollamaUrlValidation.test.ts b/app/src/utils/ollamaUrlValidation.test.ts new file mode 100644 index 0000000000..adaae276aa --- /dev/null +++ b/app/src/utils/ollamaUrlValidation.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; + +import { validateOllamaUrl } from './ollamaUrlValidation'; + +describe('validateOllamaUrl', () => { + it('accepts a plain http URL', () => { + const result = validateOllamaUrl('http://localhost:11434'); + expect(result.valid).toBe(true); + expect(result.normalized).toBe('http://localhost:11434'); + }); + + it('accepts a plain https URL', () => { + const result = validateOllamaUrl('https://remote-ollama.example.com:11434'); + expect(result.valid).toBe(true); + expect(result.normalized).toBe('https://remote-ollama.example.com:11434'); + }); + + it('accepts an IP address URL', () => { + const result = validateOllamaUrl('http://192.168.1.5:11434'); + expect(result.valid).toBe(true); + expect(result.normalized).toBe('http://192.168.1.5:11434'); + }); + + it('rejects an empty string', () => { + const result = validateOllamaUrl(''); + expect(result.valid).toBe(false); + expect(result.error).toBeTruthy(); + }); + + it('rejects a whitespace-only string', () => { + const result = validateOllamaUrl(' '); + expect(result.valid).toBe(false); + }); + + it('rejects URLs without http(s) scheme', () => { + expect(validateOllamaUrl('localhost:11434').valid).toBe(false); + expect(validateOllamaUrl('ftp://localhost:11434').valid).toBe(false); + }); + + it('rejects URLs with credentials', () => { + const result = validateOllamaUrl('http://user:pass@localhost:11434'); + expect(result.valid).toBe(false); + expect(result.error).toMatch(/credential/i); + }); + + it('strips path component and normalizes to scheme://host:port', () => { + const result = validateOllamaUrl('http://192.168.1.5:11434/api/tags'); + expect(result.valid).toBe(true); + expect(result.normalized).toBe('http://192.168.1.5:11434'); + }); + + it('strips trailing slashes', () => { + const result = validateOllamaUrl('http://localhost:11434///'); + expect(result.valid).toBe(true); + expect(result.normalized).toBe('http://localhost:11434'); + }); + + it('rejects URLs with query strings', () => { + const result = validateOllamaUrl('http://localhost:11434?foo=bar'); + expect(result.valid).toBe(false); + }); + + it('rejects URLs with fragments', () => { + const result = validateOllamaUrl('http://localhost:11434#section'); + expect(result.valid).toBe(false); + }); + + it('omits port from normalized URL when no port is specified', () => { + const result = validateOllamaUrl('https://example.com'); + expect(result.valid).toBe(true); + expect(result.normalized).toBe('https://example.com'); + }); + + it('rejects a URL that starts with http:// but is not parseable', () => { + const result = validateOllamaUrl('http:// has spaces'); + expect(result.valid).toBe(false); + expect(result.error).toMatch(/invalid url format/i); + }); +}); diff --git a/app/src/utils/ollamaUrlValidation.ts b/app/src/utils/ollamaUrlValidation.ts new file mode 100644 index 0000000000..50bccb70d0 --- /dev/null +++ b/app/src/utils/ollamaUrlValidation.ts @@ -0,0 +1,48 @@ +export interface OllamaUrlValidationResult { + valid: boolean; + normalized?: string; + error?: string; +} + +/** + * Validate and normalize a user-supplied Ollama base URL. + * + * Rules (mirrors the Rust `validate_ollama_url` helper): + * - Trims whitespace and strips trailing slashes + * - Must be http:// or https:// + * - Must have a non-empty hostname + * - No credentials (user:pass@) + * - No query string or fragment + * - Path component is stripped — normalized form is scheme://host[:port] + */ +export function validateOllamaUrl(raw: string): OllamaUrlValidationResult { + const trimmed = raw.trim().replace(/\/+$/, ''); + if (!trimmed) { + return { valid: false, error: 'URL must not be empty' }; + } + if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) { + return { valid: false, error: 'Must be a valid http:// or https:// URL' }; + } + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return { valid: false, error: 'Invalid URL format' }; + } + if (!parsed.hostname) { + return { valid: false, error: 'URL must have a non-empty host' }; + } + if (parsed.username || parsed.password) { + return { valid: false, error: 'URL must not contain credentials (user:pass@host)' }; + } + if (parsed.search) { + return { valid: false, error: 'URL must not contain a query string' }; + } + if (parsed.hash) { + return { valid: false, error: 'URL must not contain a fragment' }; + } + // Normalize to scheme://host[:port] + const port = parsed.port ? `:${parsed.port}` : ''; + const normalized = `${parsed.protocol}//${parsed.hostname}${port}`; + return { valid: true, normalized }; +} diff --git a/app/src/utils/tauriCommands/localAi.test.ts b/app/src/utils/tauriCommands/localAi.test.ts new file mode 100644 index 0000000000..dccb8816c5 --- /dev/null +++ b/app/src/utils/tauriCommands/localAi.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() })); + +describe('openhumanLocalAiTestConnection', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls callCoreRpc with local_ai_test_connection method and url param', async () => { + const { callCoreRpc } = await import('../../services/coreRpcClient'); + const mockCallCoreRpc = callCoreRpc as ReturnType; + mockCallCoreRpc.mockResolvedValueOnce({ reachable: true, models_count: 4 }); + + const { openhumanLocalAiTestConnection } = await import('./localAi'); + const result = await openhumanLocalAiTestConnection('http://localhost:11434'); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.local_ai_test_connection', + params: { url: 'http://localhost:11434' }, + }); + expect(result).toEqual({ reachable: true, models_count: 4 }); + }); + + it('propagates errors from callCoreRpc', async () => { + const { callCoreRpc } = await import('../../services/coreRpcClient'); + const mockCallCoreRpc = callCoreRpc as ReturnType; + mockCallCoreRpc.mockRejectedValueOnce(new Error('rpc down')); + + const { openhumanLocalAiTestConnection } = await import('./localAi'); + await expect(openhumanLocalAiTestConnection('http://localhost:11434')).rejects.toThrow( + 'rpc down' + ); + }); +}); diff --git a/app/src/utils/tauriCommands/localAi.ts b/app/src/utils/tauriCommands/localAi.ts index 11f824836a..f999a710e9 100644 --- a/app/src/utils/tauriCommands/localAi.ts +++ b/app/src/utils/tauriCommands/localAi.ts @@ -410,3 +410,18 @@ export async function openhumanLocalAiDiagnostics(): Promise params: {}, }); } + +export interface OllamaConnectionTestResult { + reachable: boolean; + error?: string | null; + models_count?: number | null; +} + +export async function openhumanLocalAiTestConnection( + url: string +): Promise { + return await callCoreRpc({ + method: 'openhuman.local_ai_test_connection', + params: { url }, + }); +} diff --git a/src/openhuman/inference/local/ollama.rs b/src/openhuman/inference/local/ollama.rs index e53cbb067e..be297fb36a 100644 --- a/src/openhuman/inference/local/ollama.rs +++ b/src/openhuman/inference/local/ollama.rs @@ -35,6 +35,100 @@ pub(crate) fn ollama_base_url() -> String { DEFAULT_OLLAMA_BASE_URL.to_string() } +/// Returns the effective Ollama base URL, with `config.local_ai.base_url` +/// taking highest priority over env vars. +/// +/// Priority (highest to lowest): +/// 1. `config.local_ai.base_url` if `Some` and non-empty (after trim) +/// 2. `OPENHUMAN_OLLAMA_BASE_URL` env var +/// 3. `OLLAMA_HOST` env var +/// 4. [`DEFAULT_OLLAMA_BASE_URL`] +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() { + log::debug!( + "[local_ai] ollama_base_url_from_config: using config base_url -> {}", + redact_ollama_base_url(trimmed) + ); + return trimmed.to_string(); + } + } + let resolved = ollama_base_url(); + log::debug!( + "[local_ai] ollama_base_url_from_config: config base_url absent, resolved -> {}", + redact_ollama_base_url(&resolved) + ); + resolved +} + +/// Validate and normalize a user-supplied Ollama URL. +/// +/// - Trims whitespace and strips trailing slashes. +/// - Must have an `http://` or `https://` scheme. +/// - Must have a non-empty host. +/// - Rejects URLs with credentials (`user:pass@`). +/// - Rejects query strings and fragments. +/// - Strips any path component beyond root, normalizing to `scheme://host:port`. +/// +/// Returns the normalized URL on success or an error message on failure. +pub(crate) fn validate_ollama_url(raw: &str) -> Result { + let trimmed = raw.trim().trim_end_matches('/'); + if trimmed.is_empty() { + return Err("URL must not be empty".to_string()); + } + if !trimmed.starts_with("http://") && !trimmed.starts_with("https://") { + return Err("URL must start with http:// or https://".to_string()); + } + let parsed = reqwest::Url::parse(trimmed).map_err(|e| format!("Invalid URL: {e}"))?; + + if parsed.host_str().map(|h| h.is_empty()).unwrap_or(true) { + return Err("URL must have a non-empty host".to_string()); + } + + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err("URL must not contain credentials (user:pass@host)".to_string()); + } + + if parsed.query().is_some() { + return Err("URL must not contain a query string".to_string()); + } + if parsed.fragment().is_some() { + return Err("URL must not contain a fragment".to_string()); + } + + // Normalize to scheme://host[:port] — strip any path component. + // 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::Ipv6(addr)) => format!("[{addr}]"), + Some(h) => h.to_string(), + None => String::new(), + }; + let mut normalized = format!("{}://{}", parsed.scheme(), host_formatted); + if let Some(port) = parsed.port() { + normalized.push(':'); + normalized.push_str(&port.to_string()); + } + + log::debug!("[local_ai] validate_ollama_url: raw={trimmed:?} -> normalized={normalized:?}"); + Ok(normalized) +} + +/// Strips userinfo, query, and fragment from `raw` so logs and error messages +/// don't leak `user:pass@host`-style credentials embedded in the endpoint. +pub(crate) fn redact_ollama_base_url(raw: &str) -> String { + reqwest::Url::parse(raw) + .map(|mut url| { + let _ = url.set_username(""); + let _ = url.set_password(None); + url.set_query(None); + url.set_fragment(None); + url.to_string() + }) + .unwrap_or_else(|_| "".to_string()) +} + /// Back-compat constant kept at its original value for callers that /// reference it directly. New callers should use [`ollama_base_url`]. pub(crate) const OLLAMA_BASE_URL: &str = DEFAULT_OLLAMA_BASE_URL; @@ -558,4 +652,108 @@ mod tests { let _g2 = OllamaEnvGuard::set_var(OLLAMA_HOST_VAR, "myhost:11434/"); assert_eq!(ollama_base_url(), "http://myhost:11434"); } + + // ── ollama_base_url_from_config ─────────────────────────────────── + + fn make_config_with_base_url(url: Option<&str>) -> crate::openhuman::config::Config { + let mut config = crate::openhuman::config::Config::default(); + config.local_ai.base_url = url.map(|s| s.to_string()); + config + } + + #[test] + fn ollama_base_url_from_config_takes_priority_over_env() { + let _lock = test_lock(); + let _g = OllamaEnvGuard::set("http://127.0.0.1:55555"); + let config = make_config_with_base_url(Some("http://192.168.1.5:11434")); + assert_eq!( + ollama_base_url_from_config(&config), + "http://192.168.1.5:11434" + ); + } + + #[test] + fn ollama_base_url_from_config_falls_back_when_none() { + let _lock = test_lock(); + let _g = OllamaEnvGuard::set("http://127.0.0.1:55555"); + let config = make_config_with_base_url(None); + assert_eq!( + ollama_base_url_from_config(&config), + "http://127.0.0.1:55555" + ); + } + + // ── validate_ollama_url ─────────────────────────────────────────── + + #[test] + fn validate_ollama_url_accepts_http() { + assert_eq!( + validate_ollama_url("http://localhost:11434"), + Ok("http://localhost:11434".to_string()) + ); + } + + #[test] + fn validate_ollama_url_accepts_https() { + assert_eq!( + validate_ollama_url("https://remote-ollama.example.com:11434"), + Ok("https://remote-ollama.example.com:11434".to_string()) + ); + } + + #[test] + fn validate_ollama_url_rejects_no_scheme() { + assert!(validate_ollama_url("localhost:11434").is_err()); + assert!(validate_ollama_url("ftp://localhost:11434").is_err()); + } + + #[test] + fn validate_ollama_url_rejects_credentials() { + assert!(validate_ollama_url("http://user:pass@localhost:11434").is_err()); + } + + #[test] + fn validate_ollama_url_strips_path_and_normalizes() { + assert_eq!( + validate_ollama_url("http://192.168.1.5:11434/api/tags"), + Ok("http://192.168.1.5:11434".to_string()) + ); + } + + #[test] + fn validate_ollama_url_rejects_empty() { + assert!(validate_ollama_url("").is_err()); + assert!(validate_ollama_url(" ").is_err()); + } + + #[test] + fn validate_ollama_url_handles_ipv6() { + assert_eq!( + validate_ollama_url("http://[::1]:11434"), + Ok("http://[::1]:11434".to_string()) + ); + } + + // ── redact_ollama_base_url ──────────────────────────────────────── + + #[test] + fn redact_strips_userinfo_query_and_fragment() { + assert_eq!( + redact_ollama_base_url("http://user:pass@host:11434/api?token=abc#frag"), + "http://host:11434/api" + ); + } + + #[test] + fn redact_keeps_plain_url() { + assert_eq!( + redact_ollama_base_url("http://127.0.0.1:11434/"), + "http://127.0.0.1:11434/" + ); + } + + #[test] + fn redact_handles_invalid_url() { + assert_eq!(redact_ollama_base_url("not a url"), ""); + } } diff --git a/src/openhuman/inference/local/schemas.rs b/src/openhuman/inference/local/schemas.rs index aefc7d5af0..cd961c18f7 100644 --- a/src/openhuman/inference/local/schemas.rs +++ b/src/openhuman/inference/local/schemas.rs @@ -2,6 +2,11 @@ use serde::de::DeserializeOwned; use serde::Deserialize; use serde_json::{Map, Value}; +#[derive(Debug, Deserialize)] +struct LocalAiTestConnectionParams { + url: String, +} + use crate::core::all::{ControllerFuture, RegisteredController}; use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; use crate::openhuman::config::rpc as config_rpc; @@ -72,6 +77,7 @@ pub fn all_controller_schemas() -> Vec { schemas("local_ai_install_piper"), schemas("local_ai_whisper_install_status"), schemas("local_ai_piper_install_status"), + schemas("local_ai_test_connection"), ] } @@ -125,6 +131,10 @@ pub fn all_registered_controllers() -> Vec { schema: schemas("local_ai_piper_install_status"), handler: handle_local_ai_piper_install_status, }, + RegisteredController { + schema: schemas("local_ai_test_connection"), + handler: handle_local_ai_test_connection, + }, ] } @@ -251,6 +261,13 @@ pub fn schemas(function: &str) -> ControllerSchema { inputs: vec![], outputs: vec![json_output("status", "Piper install status payload.")], }, + "local_ai_test_connection" => ControllerSchema { + namespace: "local_ai", + function: "test_connection", + description: "Test connectivity to an Ollama server URL. Returns reachable status and model count.", + inputs: vec![required_string("url", "Ollama server URL to test.")], + outputs: vec![json_output("result", "Connection test result.")], + }, _ => ControllerSchema { namespace: "local_ai", function: "unknown", @@ -527,6 +544,18 @@ fn handle_local_ai_install_piper(params: Map) -> ControllerFuture }) } +fn handle_local_ai_test_connection(params: Map) -> ControllerFuture { + Box::pin(async move { + let p = deserialize_params::(params)?; + let result = + crate::openhuman::inference::local::service::ollama_admin::test_ollama_connection( + &p.url, + ) + .await?; + serde_json::to_value(result).map_err(|e| format!("serialize test_connection result: {e}")) + }) +} + fn handle_local_ai_whisper_install_status(_params: Map) -> ControllerFuture { Box::pin(async move { let config = config_rpc::load_config_with_timeout().await?; diff --git a/src/openhuman/inference/local/schemas_tests.rs b/src/openhuman/inference/local/schemas_tests.rs index 4308ce19e8..8f7a6f3299 100644 --- a/src/openhuman/inference/local/schemas_tests.rs +++ b/src/openhuman/inference/local/schemas_tests.rs @@ -39,6 +39,7 @@ fn every_registered_key_resolves_to_non_unknown_schema() { "local_ai_install_piper", "local_ai_whisper_install_status", "local_ai_piper_install_status", + "local_ai_test_connection", ]; for k in keys { let s = schemas(k); diff --git a/src/openhuman/inference/local/service/mod.rs b/src/openhuman/inference/local/service/mod.rs index 0da432d265..28cafc03b5 100644 --- a/src/openhuman/inference/local/service/mod.rs +++ b/src/openhuman/inference/local/service/mod.rs @@ -3,7 +3,7 @@ mod assets; mod bootstrap; mod lm_studio; -mod ollama_admin; +pub(crate) mod ollama_admin; mod public_infer; pub(crate) mod spawn_marker; mod speech; diff --git a/src/openhuman/inference/local/service/ollama_admin.rs b/src/openhuman/inference/local/service/ollama_admin.rs index c30d331696..a7639d84c6 100644 --- a/src/openhuman/inference/local/service/ollama_admin.rs +++ b/src/openhuman/inference/local/service/ollama_admin.rs @@ -11,8 +11,9 @@ use crate::openhuman::inference::local::model_requirements::{ evaluate_context, ContextEligibility, MIN_CONTEXT_TOKENS, }; use crate::openhuman::inference::local::ollama::{ - ollama_base_url, OllamaModelTag, OllamaPullEvent, OllamaPullProgress, OllamaPullRequest, - OllamaShowRequest, OllamaShowResponse, OllamaTagsResponse, + ollama_base_url, ollama_base_url_from_config, validate_ollama_url, OllamaModelTag, + OllamaPullEvent, OllamaPullProgress, OllamaPullRequest, OllamaShowRequest, OllamaShowResponse, + OllamaTagsResponse, }; use crate::openhuman::inference::local::process_util::apply_no_window; use crate::openhuman::inference::local::provider::{provider_from_config, LocalAiProvider}; @@ -30,10 +31,11 @@ fn lm_studio_models_error_means_unreachable(error: &str) -> bool { impl LocalAiService { pub(in crate::openhuman::inference::local::service) async fn ensure_ollama_server( &self, - _config: &Config, + config: &Config, ) -> Result<(), String> { - if self.ollama_healthy().await { - if self.ollama_runner_ok().await { + let base_url = ollama_base_url_from_config(config); + if self.ollama_healthy_at(&base_url).await { + if self.ollama_runner_ok_at(&base_url).await { return Ok(()); } log::warn!("[local_ai] Ollama server responds but runner is broken"); @@ -42,7 +44,6 @@ impl LocalAiService { .to_string(), ); } - let base_url = ollama_base_url(); Err(format!( "OpenHuman no longer starts or installs Ollama automatically. Start your inference runtime yourself and make sure it is reachable at {base_url}." )) @@ -74,7 +75,8 @@ impl LocalAiService { spawn_marker::clear_marker(config); return; } - if !self.ollama_healthy().await { + let base_url = ollama_base_url_from_config(config); + if !self.ollama_healthy_at(&base_url).await { // PID is alive but :11434 isn't healthy — either Ollama is // mid-boot or the recorded PID was reused for an unrelated // process. Leave the marker; either the daemon will come up @@ -104,7 +106,8 @@ impl LocalAiService { config: &Config, ollama_cmd: &Path, ) -> Result<(), String> { - if self.ollama_healthy().await { + let base_url = ollama_base_url_from_config(config); + if self.ollama_healthy_at(&base_url).await { // A daemon is already up — adopt it. We did NOT spawn it (or any // prior spawn was already reclaimed in `reclaim_orphan_if_ours`), // so `owned_ollama` stays `None` and the daemon survives openhuman @@ -194,7 +197,7 @@ impl LocalAiService { } for _ in 0..20 { - if self.ollama_healthy().await { + if self.ollama_healthy_at(&base_url).await { // Daemon is up. Take ownership so we can kill it on exit and // write the spawn marker so a crashed openhuman can reclaim // this PID on next launch instead of orphaning it forever. @@ -478,9 +481,18 @@ impl LocalAiService { Ok(()) } - pub(in crate::openhuman::inference::local::service) async fn ollama_healthy(&self) -> bool { + /// Check Ollama health against the given base URL. + pub(in crate::openhuman::inference::local::service) async fn ollama_healthy_at( + &self, + base_url: &str, + ) -> bool { + tracing::debug!( + target: "local_ai::ollama_admin", + %base_url, + "[local_ai:ollama_admin] ollama_healthy_at: checking" + ); self.http - .get(format!("{}/api/tags", ollama_base_url())) + .get(format!("{base_url}/api/tags")) .timeout(std::time::Duration::from_secs(2)) .send() .await @@ -488,6 +500,12 @@ impl LocalAiService { .unwrap_or(false) } + /// Backward-compat wrapper — resolves the URL from env vars only (no config). + /// Prefer [`ollama_healthy_at`] when a `Config` is available. + pub(in crate::openhuman::inference::local::service) async fn ollama_healthy(&self) -> bool { + self.ollama_healthy_at(&ollama_base_url()).await + } + /// Filesystem-only precondition: is *any* Ollama binary discoverable? /// /// This is the cheapest possible check — no process spawns, no HTTP, no @@ -828,8 +846,8 @@ impl LocalAiService { return self.lm_studio_diagnostics(config).await; } - let base_url = ollama_base_url(); - let healthy = self.ollama_healthy().await; + let base_url = ollama_base_url_from_config(config); + let healthy = self.ollama_healthy_at(&base_url).await; log::debug!( "[local_ai] diagnostics: entry base_url={} healthy={}", @@ -838,7 +856,7 @@ impl LocalAiService { ); let (models, tags_error) = if healthy { - match self.list_models().await { + match self.list_models_at(&base_url).await { Ok(models) => (models, None), Err(e) => (vec![], Some(e)), } @@ -1007,8 +1025,7 @@ impl LocalAiService { })) } - async fn list_models(&self) -> Result, String> { - let base = ollama_base_url(); + async fn list_models_at(&self, base: &str) -> Result, String> { let url = format!("{base}/api/tags"); tracing::debug!( target: "local_ai::ollama_admin", @@ -1281,12 +1298,11 @@ impl LocalAiService { .map(|p| p.display().to_string()) } - /// Quick check that the Ollama runner can actually exec models. - /// Sends a tiny generate request and checks for a 500 "fork/exec" error. - async fn ollama_runner_ok(&self) -> bool { + /// Quick check that the Ollama runner can actually exec models against the given URL. + async fn ollama_runner_ok_at(&self, base_url: &str) -> bool { let resp = self .http - .post(format!("{}/api/tags", ollama_base_url())) + .post(format!("{base_url}/api/tags")) .timeout(std::time::Duration::from_secs(3)) .send() .await; @@ -1296,7 +1312,7 @@ impl LocalAiService { // Do a lightweight pull-status check (won't download, just checks). let check = self .http - .post(format!("{}/api/show", ollama_base_url())) + .post(format!("{base_url}/api/show")) .json(&serde_json::json!({"name": "___nonexistent_probe___"})) .timeout(std::time::Duration::from_secs(3)) .send() @@ -1372,6 +1388,10 @@ impl LocalAiService { &self, model: &str, ) -> Result { + self.has_model_at(&ollama_base_url(), model).await + } + + async fn has_model_at(&self, base_url: &str, model: &str) -> Result { // Issue the /api/tags GET directly. We previously short-circuited via // ollama_healthy(), but that doubled the number of /api/tags round-trips // on healthy polls (one probe + one tags fetch). With three has_model() @@ -1380,10 +1400,10 @@ impl LocalAiService { // reqwest client (set in bootstrap.rs) bounds the cost when the server // is down — the connect failure surfaces as Err, same as ollama_healthy() // would have surfaced as `false`. - log::debug!("[local_ai] has_model: checking for model `{model}`"); + log::debug!("[local_ai] has_model_at: checking for model `{model}` at {base_url}"); let response = self .http - .get(format!("{}/api/tags", ollama_base_url())) + .get(format!("{base_url}/api/tags")) // Per-request timeout matches list_models (5s). The shared client's // connect_timeout only bounds the TCP handshake; without this a // hung server (accepted connection, no response body) would block @@ -1419,6 +1439,64 @@ impl LocalAiService { } } +/// Test connectivity to a user-supplied Ollama URL. +/// +/// Validates the URL via [`validate_ollama_url`], then issues a GET to +/// `{normalized_url}/api/tags` with a 3-second timeout. +/// Returns a JSON object with `reachable`, optional `error`, and +/// `models_count` when reachable. +pub(crate) async fn test_ollama_connection(url: &str) -> Result { + let normalized = validate_ollama_url(url)?; + log::debug!("[local_ai] test_ollama_connection: testing url={normalized}"); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(3)) + .build() + .map_err(|e| format!("failed to build HTTP client: {e}"))?; + + match client.get(format!("{normalized}/api/tags")).send().await { + Ok(resp) if resp.status().is_success() => { + let models_count = resp + .json::() + .await + .map(|t| t.models.len()) + .unwrap_or(0); + log::debug!( + "[local_ai] test_ollama_connection: reachable url={normalized} models={models_count}" + ); + Ok(serde_json::json!({ + "reachable": true, + "error": null, + "models_count": models_count, + })) + } + Ok(resp) => { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + let err = format!("server responded with status {status}: {}", body.trim()); + log::debug!( + "[local_ai] test_ollama_connection: unreachable url={normalized} err={err}" + ); + Ok(serde_json::json!({ + "reachable": false, + "error": err, + "models_count": null, + })) + } + Err(e) => { + let err = e.to_string(); + log::debug!( + "[local_ai] test_ollama_connection: connection failed url={normalized} err={err}" + ); + Ok(serde_json::json!({ + "reachable": false, + "error": err, + "models_count": null, + })) + } + } +} + fn interrupted_pull_settle_window_secs(observed_bytes: bool, settle_window_secs: u64) -> u64 { if observed_bytes { settle_window_secs.max(1) diff --git a/src/openhuman/inference/local/service/ollama_admin_tests.rs b/src/openhuman/inference/local/service/ollama_admin_tests.rs index 26493ccbf7..86c38ab2b2 100644 --- a/src/openhuman/inference/local/service/ollama_admin_tests.rs +++ b/src/openhuman/inference/local/service/ollama_admin_tests.rs @@ -148,6 +148,68 @@ async fn ensure_ollama_server_requires_external_runtime_when_unreachable() { ); } +#[tokio::test] +async fn test_ollama_connection_returns_reachable_with_model_count() { + let _guard = crate::openhuman::inference::inference_test_guard(); + + let app = Router::new().route( + "/api/tags", + get(|| async { + Json(json!({ + "models": [ + {"name": "llama3:latest", "modified_at": "", "size": 1u64, "digest": "d"}, + {"name": "mistral:7b", "modified_at": "", "size": 2u64, "digest": "d"} + ] + })) + }), + ); + let base = spawn_mock(app).await; + + let result = super::test_ollama_connection(&base).await.unwrap(); + assert_eq!(result["reachable"], true); + assert_eq!(result["models_count"], 2); + assert!(result["error"].is_null()); +} + +#[tokio::test] +async fn test_ollama_connection_returns_unreachable_on_server_error() { + let _guard = crate::openhuman::inference::inference_test_guard(); + + let app = Router::new().route( + "/api/tags", + get(|| async { (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "boom") }), + ); + let base = spawn_mock(app).await; + + let result = super::test_ollama_connection(&base).await.unwrap(); + assert_eq!(result["reachable"], false); + assert!(!result["error"].as_str().unwrap_or("").is_empty()); +} + +#[tokio::test] +async fn test_ollama_connection_returns_unreachable_on_connect_failure() { + let _guard = crate::openhuman::inference::inference_test_guard(); + + let result = super::test_ollama_connection("http://127.0.0.1:1") + .await + .unwrap(); + assert_eq!(result["reachable"], false); + assert!(!result["error"].as_str().unwrap_or("").is_empty()); +} + +#[tokio::test] +async fn test_ollama_connection_rejects_invalid_url() { + let _guard = crate::openhuman::inference::inference_test_guard(); + + let err = super::test_ollama_connection("not-a-url") + .await + .unwrap_err(); + assert!( + !err.is_empty(), + "expected validation error, got empty string" + ); +} + #[tokio::test] async fn ensure_ollama_server_reports_broken_external_runner_without_restart_attempt() { let _guard = crate::openhuman::inference::inference_test_guard(); @@ -458,7 +520,7 @@ async fn list_models_returns_parsed_payload() { let config = Config::default(); let service = LocalAiService::new(&config); - let models = service.list_models().await.expect("list_models"); + let models = service.list_models_at(&base).await.expect("list_models"); assert_eq!(models.len(), 2); assert_eq!(models[0].name, "a:latest"); assert_eq!(models[1].name, "b:v2"); @@ -482,7 +544,7 @@ async fn list_models_errors_on_non_success() { let config = Config::default(); let service = LocalAiService::new(&config); - let err = service.list_models().await.unwrap_err(); + let err = service.list_models_at(&base).await.unwrap_err(); assert!(err.contains("503") || err.contains("tags failed")); unsafe { std::env::remove_var("OPENHUMAN_OLLAMA_BASE_URL"); diff --git a/src/openhuman/inference/local/service/public_infer.rs b/src/openhuman/inference/local/service/public_infer.rs index 2eb40d0f12..3aea0c46b1 100644 --- a/src/openhuman/inference/local/service/public_infer.rs +++ b/src/openhuman/inference/local/service/public_infer.rs @@ -1,6 +1,7 @@ use crate::openhuman::config::Config; use crate::openhuman::inference::local::ollama::{ - ns_to_tps, ollama_base_url, OllamaGenerateOptions, OllamaGenerateRequest, + ns_to_tps, ollama_base_url, ollama_base_url_from_config, redact_ollama_base_url, + OllamaGenerateOptions, OllamaGenerateRequest, }; use crate::openhuman::inference::local::provider::{provider_from_config, LocalAiProvider}; use crate::openhuman::inference::model_ids; @@ -8,52 +9,20 @@ use crate::openhuman::inference::parse::sanitize_inline_completion; use super::LocalAiService; -fn redact_ollama_base_url(raw: &str) -> String { - // Strip userinfo, query, and fragment so error payloads + logs don't - // leak `user:pass@host` style credentials embedded in the endpoint. - reqwest::Url::parse(raw) - .map(|mut url| { - let _ = url.set_username(""); - let _ = url.set_password(None); - url.set_query(None); - url.set_fragment(None); - url.to_string() - }) - .unwrap_or_else(|_| "".to_string()) -} - -fn external_ollama_request_error(prefix: &str, error: &reqwest::Error) -> String { - let safe_base_url = redact_ollama_base_url(&ollama_base_url()); +fn external_ollama_request_error_with_url( + prefix: &str, + error: &reqwest::Error, + base_url: &str, +) -> String { + let safe_base_url = redact_ollama_base_url(base_url); format!( "{prefix}: OpenHuman routes inference through an external Ollama endpoint. \ Make sure Ollama is already running and reachable at {safe_base_url} ({error})" ) } -#[cfg(test)] -mod redact_tests { - use super::redact_ollama_base_url; - - #[test] - fn redact_strips_userinfo_query_and_fragment() { - assert_eq!( - redact_ollama_base_url("http://user:pass@host:11434/api?token=abc#frag"), - "http://host:11434/api" - ); - } - - #[test] - fn redact_keeps_plain_url() { - assert_eq!( - redact_ollama_base_url("http://127.0.0.1:11434/"), - "http://127.0.0.1:11434/" - ); - } - - #[test] - fn redact_handles_invalid_url() { - assert_eq!(redact_ollama_base_url("not a url"), ""); - } +fn external_ollama_request_error(prefix: &str, error: &reqwest::Error) -> String { + external_ollama_request_error_with_url(prefix, error, &ollama_base_url()) } impl LocalAiService { @@ -301,13 +270,16 @@ impl LocalAiService { ), }; + let base_url = ollama_base_url_from_config(config); let response = self .http - .post(format!("{}/api/chat", ollama_base_url())) + .post(format!("{base_url}/api/chat")) .json(&body) .send() .await - .map_err(|e| external_ollama_request_error("ollama chat request failed", &e))?; + .map_err(|e| { + external_ollama_request_error_with_url("ollama chat request failed", &e, &base_url) + })?; if !response.status().is_success() { let status = response.status(); @@ -551,13 +523,20 @@ impl LocalAiService { }), }; + let base_url = ollama_base_url_from_config(config); + log::debug!( + "[local_ai:infer] inference_with_temperature_internal: using base_url={}", + redact_ollama_base_url(&base_url) + ); let response = self .http - .post(format!("{}/api/generate", ollama_base_url())) + .post(format!("{base_url}/api/generate")) .json(&body) .send() .await - .map_err(|e| external_ollama_request_error("ollama request failed", &e))?; + .map_err(|e| { + external_ollama_request_error_with_url("ollama request failed", &e, &base_url) + })?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); diff --git a/src/openhuman/inference/local/service/vision_embed.rs b/src/openhuman/inference/local/service/vision_embed.rs index 0ed010cfa1..724b5fb665 100644 --- a/src/openhuman/inference/local/service/vision_embed.rs +++ b/src/openhuman/inference/local/service/vision_embed.rs @@ -1,8 +1,8 @@ use crate::openhuman::agent::multimodal; use crate::openhuman::config::Config; use crate::openhuman::inference::local::ollama::{ - ollama_base_url, OllamaEmbedRequest, OllamaEmbedResponse, OllamaGenerateOptions, - OllamaGenerateRequest, + ollama_base_url_from_config, redact_ollama_base_url, OllamaEmbedRequest, OllamaEmbedResponse, + OllamaGenerateOptions, OllamaGenerateRequest, }; use crate::openhuman::inference::model_ids; use crate::openhuman::inference::presets::{self, VisionMode}; @@ -65,7 +65,7 @@ impl LocalAiService { }), }; - let base = ollama_base_url(); + let base = ollama_base_url_from_config(config); let url = format!("{base}/api/generate"); let body_bytes = serde_json::to_vec(&body).map(|v| v.len()).unwrap_or(0); tracing::debug!( @@ -156,9 +156,14 @@ impl LocalAiService { // user's laptop when stacked with other Ollama work. Gate it. let _gate_permit = crate::openhuman::scheduler_gate::wait_for_capacity().await; + let embed_base = ollama_base_url_from_config(config); + log::debug!( + "[local_ai:embed] embed: using base_url={}", + redact_ollama_base_url(&embed_base) + ); let response = self .http - .post(format!("{}/api/embed", ollama_base_url())) + .post(format!("{embed_base}/api/embed")) .json(&OllamaEmbedRequest { model: embedding_model.clone(), input: items.clone(),