From f519edf10d6f6f4887c56478252a44740a8f5768 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 16 Sep 2026 05:18:00 +0000 Subject: [PATCH] Add Models page local runtime start, one-click deps, and GGUF search. Start an already-installed Ollama daemon from known Windows/macOS paths, offer honest one-click install only when a package manager can do it, and scan for local GGUF/GGML weights in the background so they can be registered for chat. --- CHANGELOG.md | 1 + dashboard/src/api/modules/localModels.ts | 90 +++- dashboard/src/locales/en.json | 30 ++ dashboard/src/locales/zh.json | 30 ++ .../components/LocalHardwarePanel.test.tsx | 101 +++++ .../Models/components/LocalHardwarePanel.tsx | 420 +++++++++++++++++- docs/api.md | 17 + src/octop/api/routers/local_models.py | 158 ++++++- .../infra/agents/providers/local_probe.py | 77 ++-- .../infra/agents/providers/local_register.py | 138 ++++++ .../infra/agents/providers/local_scan.py | 156 +++++++ .../infra/agents/providers/local_weights.py | 315 +++++++++++++ .../infra/agents/providers/ollama_install.py | 214 +++++++++ src/octop/infra/utils/ollama_manager.py | 201 +++++++-- src/octop/infra/utils/ollama_paths.py | 113 +++++ tests/unit/api/test_acl_gate_coverage.py | 1 + tests/unit/test_local_probe.py | 3 + tests/unit/test_local_register.py | 87 ++++ tests/unit/test_local_scan.py | 36 ++ tests/unit/test_local_weights.py | 76 ++++ tests/unit/test_ollama_paths.py | 49 ++ tests/unit/test_ollama_start.py | 79 ++++ 22 files changed, 2305 insertions(+), 87 deletions(-) create mode 100644 dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.test.tsx create mode 100644 src/octop/infra/agents/providers/local_register.py create mode 100644 src/octop/infra/agents/providers/local_scan.py create mode 100644 src/octop/infra/agents/providers/local_weights.py create mode 100644 src/octop/infra/agents/providers/ollama_install.py create mode 100644 src/octop/infra/utils/ollama_paths.py create mode 100644 tests/unit/test_local_register.py create mode 100644 tests/unit/test_local_scan.py create mode 100644 tests/unit/test_local_weights.py create mode 100644 tests/unit/test_ollama_paths.py create mode 100644 tests/unit/test_ollama_start.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 54cd2301..984a0cc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### 新增 +- 模型页本机大模型:Ollama 已安装但未运行时显示「启动 Ollama」(Windows 走已知安装路径的 `Ollama.exe` / `ollama app` / `ollama serve`);缺少可自动处理的依赖时提供「一键下载安装」,否则说明需手动安装;「搜索本机模型」后台扫描 `.gguf` / `.ggml`(以及明确的本地 LLM safetensors 目录),结果进入本机列表并可注册到 Ollama 供对话切换。 - 组织页双向互生长:FreeOS 资产经 `/api/freeos/ingest`(共享写入令牌,无需边车登录墙)落到 openXYOS 员工/人才/插件/技能表;控制面导出再装配回 FreeOS。循环在边车健康时不再把「已推送到控制面」写成 false。 - 组织页可从本地语料或 ima / 云知识库指针一键生产同事;本地模型一键下载后会注册到 Ollama 供应商。 diff --git a/dashboard/src/api/modules/localModels.ts b/dashboard/src/api/modules/localModels.ts index 94e6c9a0..e24b12c2 100644 --- a/dashboard/src/api/modules/localModels.ts +++ b/dashboard/src/api/modules/localModels.ts @@ -7,7 +7,18 @@ export interface LocalHardware { ram_gb: number; gpu: string; ollama_binary: boolean; + ollama_installed?: boolean; ollama_reachable: boolean; + ollama_path?: string; +} + +export interface LocalDep { + id: string; + kind: string; + automatable: boolean; + method?: string | null; + docs_url?: string; + next_step?: string; } export interface LocalInstalledModel { @@ -15,6 +26,8 @@ export interface LocalInstalledModel { path: string; size: number; source: string; + registerable?: boolean; + registered?: boolean; } export interface LocalRecommendedModel { @@ -25,19 +38,80 @@ export interface LocalRecommendedModel { export interface LocalProbe { hardware: LocalHardware; + deps?: LocalDep[]; installed: LocalInstalledModel[]; recommended: LocalRecommendedModel[]; } +export interface LocalRuntimeResult { + ok: boolean; + installed: boolean; + running?: boolean; + action?: string; + automatable?: boolean; + method?: string | null; + command?: string; + docs_url?: string; + next_step?: string; + error?: string | null; + name?: string; + size?: number; + source?: string; + provider_name?: string; +} + +export interface LocalScanJob { + job_id: string | null; + status: string; + roots?: string[]; + full_disk?: boolean; + dirs_scanned?: number; + files_found?: number; + current?: string; + found?: LocalInstalledModel[]; + error?: string | null; +} + export const localModelsApi = { probe: () => request("/local-models/probe"), install: (name: string) => - request<{ ok: boolean; name: string; size: number; source: string }>( - "/local-models/install", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name }), - }, - ), + request("/local-models/install", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }), + startOllama: () => + request("/local-models/start-ollama", { + method: "POST", + }), + ensureDeps: (install: boolean) => + request("/local-models/ensure-deps", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ install }), + }), + startScan: (body: { root?: string; full_disk?: boolean }) => + request("/local-models/scan", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + getScan: (jobId: string) => + request(`/local-models/scan/${encodeURIComponent(jobId)}`), + getLatestScan: () => request("/local-models/scan"), + cancelScan: (jobId: string) => + request(`/local-models/scan/${encodeURIComponent(jobId)}`, { + method: "DELETE", + }), + register: (body: { + path: string; + name?: string; + source: string; + size?: number; + }) => + request("/local-models/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), }; diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 7660e674..64831e64 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -2988,6 +2988,36 @@ "localInstallDone": "Installed {{name}}", "localInstallFailed": "Install failed", "localProbeFailed": "Could not scan local models", + "localStartOllama": "Start Ollama", + "localOllamaStarted": "Ollama is running", + "localOllamaStartFailed": "Could not start Ollama", + "localOllamaInstalledStopped": "installed, not running", + "localOllamaInstalledNotRunning": "Ollama is installed on this PC, but the local API is not reachable.", + "localOllamaMissing": "Ollama is not installed", + "localOllamaMissingAuto": "FreeOS can try a one-click install with the package manager on this PC.", + "localOllamaMissingManual": "FreeOS cannot install Ollama automatically here. Download the official installer, then return to this page.", + "localOneClickInstall": "Download and install", + "localDepsNeededTitle": "Local runtime is missing", + "localDepsNeededBody": "This model needs Ollama. Start it if it is already installed, or run the one-click installer when FreeOS can automate that step.", + "localDepsInstalled": "Local runtime is ready", + "localDepsFailed": "Could not prepare the local runtime", + "localSearchModels": "Search this PC", + "localScanPickFolder": "Choose folder", + "localScanRootPlaceholder": "Optional folder (default: user profile + common model dirs)", + "localScanPickHint": "Type a folder path, or use the desktop app to pick one.", + "localScanFullDisk": "Entire disk", + "localScanFullDiskTitle": "Scan the entire disk?", + "localScanFullDiskWarn": "A full-disk scan can take a long time and will skip system folders. Prefer a folder if you know where the weights are.", + "localScanStart": "Start scan", + "localScanProgress": "Scanned {{dirs}} folders, found {{files}} · {{current}}", + "localScanDone": "Found {{count}} local model file(s)", + "localScanFailed": "Local model search failed", + "localScanCancelFailed": "Could not cancel the scan", + "localRegister": "Register", + "localRegisterDone": "Registered {{name}} for chat", + "localRegisterFailed": "Could not register this file", + "localRegisterManual": "Needs a manual GGUF conversion — FreeOS will not invent one", + "localOllamaManaged": "Managed by Ollama", "localDownloadConfirmTitle": "Confirm model download", "localDownloadConfirmOnnx": "Download {{name}} (about {{size}}). This may take a few minutes.", "localDownloadConfirmOllama": "Pull {{name}} via Ollama. Size depends on the model and may take a while.", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 48a45841..b9b442e3 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -2986,6 +2986,36 @@ "localInstallDone": "已安装 {{name}}", "localInstallFailed": "安装失败", "localProbeFailed": "无法扫描本地模型", + "localStartOllama": "启动 Ollama", + "localOllamaStarted": "Ollama 已启动", + "localOllamaStartFailed": "无法启动 Ollama", + "localOllamaInstalledStopped": "已安装未运行", + "localOllamaInstalledNotRunning": "已检测到本机安装了 Ollama,但本地接口尚未就绪。", + "localOllamaMissing": "未安装 Ollama", + "localOllamaMissingAuto": "这台电脑可以用包管理器由 FreeOS 尝试一键安装。", + "localOllamaMissingManual": "当前环境无法自动安装 Ollama。请从官网下载安装包,完成后再回到此页。", + "localOneClickInstall": "一键下载安装", + "localDepsNeededTitle": "缺少本地运行依赖", + "localDepsNeededBody": "该推荐模型需要 Ollama。若已安装请先启动;若尚未安装且 FreeOS 能自动处理,可一键下载安装。", + "localDepsInstalled": "本地运行环境已就绪", + "localDepsFailed": "无法准备本地运行环境", + "localSearchModels": "搜索本机模型", + "localScanPickFolder": "选择文件夹", + "localScanRootPlaceholder": "可选扫描目录(默认:用户目录 + 常见模型目录)", + "localScanPickHint": "可手动填写路径;桌面端可用系统文件夹选择器。", + "localScanFullDisk": "全盘", + "localScanFullDiskTitle": "要搜索全盘吗?", + "localScanFullDiskWarn": "全盘扫描可能很慢,并会跳过系统目录。如果知道模型所在位置,建议先选文件夹。", + "localScanStart": "开始搜索", + "localScanProgress": "已扫描 {{dirs}} 个目录,发现 {{files}} 个 · {{current}}", + "localScanDone": "找到 {{count}} 个本机模型文件", + "localScanFailed": "搜索本机模型失败", + "localScanCancelFailed": "无法取消搜索", + "localRegister": "注册", + "localRegisterDone": "已注册 {{name}},可在对话中切换", + "localRegisterFailed": "无法注册该文件", + "localRegisterManual": "需要手动转为 GGUF,FreeOS 不会代为转换", + "localOllamaManaged": "由 Ollama 管理", "localDownloadConfirmTitle": "确认下载模型", "localDownloadConfirmOnnx": "即将下载 {{name}}(约 {{size}})。下载可能需要几分钟,请保持网络畅通。", "localDownloadConfirmOllama": "即将通过 Ollama 拉取 {{name}}。下载体积取决于模型,可能需要较长时间。", diff --git a/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.test.tsx b/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.test.tsx new file mode 100644 index 00000000..18314076 --- /dev/null +++ b/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.test.tsx @@ -0,0 +1,101 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +const probe = vi.fn(); +const startOllama = vi.fn(); +const ensureDeps = vi.fn(); +const startScan = vi.fn(); +const register = vi.fn(); + +vi.mock("../../../../api/modules/localModels", () => ({ + localModelsApi: { + probe: (...args: unknown[]) => probe(...args), + startOllama: (...args: unknown[]) => startOllama(...args), + ensureDeps: (...args: unknown[]) => ensureDeps(...args), + install: vi.fn(), + startScan: (...args: unknown[]) => startScan(...args), + getScan: vi.fn(), + getLatestScan: vi.fn(), + cancelScan: vi.fn(), + register: (...args: unknown[]) => register(...args), + }, +})); + +vi.mock("../../../../utils/desktopFolder", () => ({ + canPickDesktopFolder: () => false, + pickDesktopFolder: async () => null, +})); + +vi.mock("../../../../utils/antdMessage", () => ({ + message: { success: vi.fn(), error: vi.fn(), info: vi.fn() }, +})); + +import { LocalHardwarePanel } from "./LocalHardwarePanel"; + +const installedStopped = { + hardware: { + os: "Windows", + arch: "AMD64", + cpu_count: 8, + ram_gb: 16, + gpu: "", + ollama_binary: true, + ollama_installed: true, + ollama_reachable: false, + ollama_path: "C:\\\\Ollama\\\\ollama.exe", + }, + deps: [ + { + id: "ollama_daemon", + kind: "runtime", + automatable: true, + method: "start", + }, + ], + installed: [ + { + name: "tiny", + path: "D:\\\\models\\\\tiny.gguf", + size: 2048, + source: "gguf", + registerable: true, + }, + ], + recommended: [{ id: "llama3.2:3b", reason: "3B", install: "ollama" }], +}; + +beforeEach(() => { + vi.clearAllMocks(); + probe.mockResolvedValue(installedStopped); + startOllama.mockResolvedValue({ ok: true, installed: true, running: true }); + startScan.mockResolvedValue({ + job_id: "job-1", + status: "completed", + found: installedStopped.installed, + }); +}); + +describe("", () => { + it("shows Start Ollama when the binary exists but the API is down", async () => { + render(); + await waitFor(() => expect(probe).toHaveBeenCalled()); + expect(screen.getByText("models.localStartOllama")).toBeInTheDocument(); + await userEvent.click(screen.getByText("models.localStartOllama")); + await waitFor(() => expect(startOllama).toHaveBeenCalled()); + }); + + it("lists discovered GGUF files with a register action", async () => { + render(); + await waitFor(() => expect(screen.getByText("tiny")).toBeInTheDocument()); + expect(screen.getByText("D:\\\\models\\\\tiny.gguf")).toBeInTheDocument(); + expect(screen.getByText("models.localRegister")).toBeInTheDocument(); + }); + + it("starts a local weight search", async () => { + render(); + await waitFor(() => expect(probe).toHaveBeenCalled()); + await userEvent.click(screen.getByText("models.localSearchModels")); + await waitFor(() => expect(startScan).toHaveBeenCalled()); + }); +}); diff --git a/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx b/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx index 373a962b..b6bfafed 100644 --- a/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx +++ b/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx @@ -1,17 +1,74 @@ -import { useEffect, useState } from "react"; -import { Button, Card, List, Space, Tag, Typography } from "antd"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + Alert, + Button, + Card, + Checkbox, + Input, + List, + Modal, + Progress, + Space, + Tag, + Typography, +} from "antd"; import { useTranslation } from "react-i18next"; import { localModelsApi, + type LocalInstalledModel, type LocalProbe, + type LocalRuntimeResult, + type LocalScanJob, } from "../../../../api/modules/localModels"; +import { formatBytes } from "../../../../utils/embeddingDownload"; +import { + canPickDesktopFolder, + pickDesktopFolder, +} from "../../../../utils/desktopFolder"; import { message } from "../../../../utils/antdMessage"; +function mergeModels( + ...groups: Array +): LocalInstalledModel[] { + const seen = new Set(); + const out: LocalInstalledModel[] = []; + for (const group of groups) { + for (const item of group ?? []) { + const key = `${item.source}:${item.path || item.name}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(item); + } + } + return out; +} + +function canRegister(item: LocalInstalledModel): boolean { + if (item.source === "ollama" || item.registered) return false; + if (item.registerable === false) return false; + return item.source === "gguf" || item.source === "ggml"; +} + export function LocalHardwarePanel() { const { t } = useTranslation(); const [probe, setProbe] = useState(null); const [loading, setLoading] = useState(false); const [installing, setInstalling] = useState(null); + const [starting, setStarting] = useState(false); + const [ensuring, setEnsuring] = useState(false); + const [registering, setRegistering] = useState(null); + const [scanRoot, setScanRoot] = useState(""); + const [fullDisk, setFullDisk] = useState(false); + const [scan, setScan] = useState(null); + const [scanning, setScanning] = useState(false); + const pollRef = useRef(null); + + const stopPoll = () => { + if (pollRef.current != null) { + window.clearInterval(pollRef.current); + pollRef.current = null; + } + }; const refresh = async () => { setLoading(true); @@ -28,12 +85,91 @@ export function LocalHardwarePanel() { useEffect(() => { void refresh(); + return () => stopPoll(); }, []); + const showRuntimeError = (result: LocalRuntimeResult, fallback: string) => { + message.error(result.next_step || result.error || fallback); + }; + + const startOllama = async () => { + setStarting(true); + try { + const result = await localModelsApi.startOllama(); + if (result.ok && result.running) { + message.success(t("models.localOllamaStarted")); + await refresh(); + return; + } + showRuntimeError(result, t("models.localOllamaStartFailed")); + await refresh(); + } catch (err) { + message.error( + err instanceof Error ? err.message : t("models.localOllamaStartFailed"), + ); + } finally { + setStarting(false); + } + }; + + const ensureDeps = async (install: boolean) => { + setEnsuring(true); + try { + const result = await localModelsApi.ensureDeps(install); + if (result.ok && (result.running || result.installed)) { + message.success( + result.running + ? t("models.localOllamaStarted") + : t("models.localDepsInstalled"), + ); + await refresh(); + return; + } + showRuntimeError(result, t("models.localDepsFailed")); + await refresh(); + } catch (err) { + message.error( + err instanceof Error ? err.message : t("models.localDepsFailed"), + ); + } finally { + setEnsuring(false); + } + }; + const install = async (name: string) => { + const hw = probe?.hardware; + const missing = (probe?.deps ?? []).length > 0 || !hw?.ollama_reachable; + if (missing) { + Modal.confirm({ + title: t("models.localDepsNeededTitle"), + content: t("models.localDepsNeededBody"), + okText: t("models.localOneClickInstall"), + cancelText: t("common.cancel"), + onOk: async () => { + const installed = hw?.ollama_installed || hw?.ollama_binary; + await ensureDeps(!installed); + const latest = await localModelsApi.probe(); + setProbe(latest); + if (!latest.hardware.ollama_reachable) { + return; + } + await pullModel(name); + }, + }); + return; + } + await pullModel(name); + }; + + const pullModel = async (name: string) => { setInstalling(name); try { - await localModelsApi.install(name); + const result = await localModelsApi.install(name); + if (!result.ok) { + showRuntimeError(result, t("models.localInstallFailed")); + await refresh(); + return; + } message.success(t("models.localInstallDone", { name })); await refresh(); } catch (err) { @@ -45,7 +181,142 @@ export function LocalHardwarePanel() { } }; + const pollScan = (jobId: string) => { + stopPoll(); + pollRef.current = window.setInterval(() => { + void (async () => { + try { + const next = await localModelsApi.getScan(jobId); + setScan(next); + if ( + next.status === "completed" || + next.status === "failed" || + next.status === "cancelled" + ) { + stopPoll(); + setScanning(false); + if (next.status === "completed") { + message.success( + t("models.localScanDone", { count: next.found?.length ?? 0 }), + ); + } else if (next.status === "failed") { + message.error(next.error || t("models.localScanFailed")); + } + await refresh(); + } + } catch (err) { + stopPoll(); + setScanning(false); + message.error( + err instanceof Error ? err.message : t("models.localScanFailed"), + ); + } + })(); + }, 800); + }; + + const beginScan = async () => { + const run = async () => { + setScanning(true); + try { + const job = await localModelsApi.startScan({ + root: scanRoot.trim() || undefined, + full_disk: fullDisk, + }); + setScan(job); + const terminal = + job.status === "completed" || + job.status === "failed" || + job.status === "cancelled"; + if (job.job_id && !terminal) { + pollScan(job.job_id); + } else { + setScanning(false); + } + } catch (err) { + setScanning(false); + message.error( + err instanceof Error ? err.message : t("models.localScanFailed"), + ); + } + }; + if (fullDisk) { + Modal.confirm({ + title: t("models.localScanFullDiskTitle"), + content: t("models.localScanFullDiskWarn"), + okText: t("models.localScanStart"), + cancelText: t("common.cancel"), + onOk: () => run(), + }); + return; + } + await run(); + }; + + const cancelScan = async () => { + if (!scan?.job_id) return; + try { + const next = await localModelsApi.cancelScan(scan.job_id); + setScan(next); + } catch (err) { + message.error( + err instanceof Error ? err.message : t("models.localScanCancelFailed"), + ); + } finally { + stopPoll(); + setScanning(false); + } + }; + + const pickFolder = async () => { + const path = await pickDesktopFolder(); + if (path) { + setScanRoot(path); + return; + } + if (!canPickDesktopFolder()) { + message.info(t("models.localScanPickHint")); + } + }; + + const register = async (item: LocalInstalledModel) => { + if (!canRegister(item)) { + message.info(t("models.localRegisterManual")); + return; + } + setRegistering(item.path || item.name); + try { + const result = await localModelsApi.register({ + path: item.path, + name: item.name, + source: item.source, + size: item.size, + }); + if (!result.ok) { + showRuntimeError(result, t("models.localRegisterFailed")); + return; + } + message.success(t("models.localRegisterDone", { name: result.name })); + await refresh(); + } catch (err) { + message.error( + err instanceof Error ? err.message : t("models.localRegisterFailed"), + ); + } finally { + setRegistering(null); + } + }; + const hw = probe?.hardware; + const ollamaInstalled = Boolean(hw?.ollama_installed || hw?.ollama_binary); + const ollamaUp = Boolean(hw?.ollama_reachable); + const deps = probe?.deps ?? []; + const models = useMemo( + () => mergeModels(probe?.installed, scan?.found), + [probe?.installed, scan?.found], + ); + const scanActive = scanning || scan?.status === "running"; + return ( {t("models.localGpu")}: {hw.gpu || t("models.localGpuNone")} - + Ollama{" "} - {hw.ollama_reachable ? t("organization.on") : t("organization.off")} + {ollamaUp + ? t("organization.on") + : ollamaInstalled + ? t("models.localOllamaInstalledStopped") + : t("organization.off")} )} {t("models.localHardwareHint")} + + {ollamaInstalled && !ollamaUp && ( + void startOllama()} + > + {t("models.localStartOllama")} + + } + /> + )} + + {deps.some((dep) => dep.id === "ollama") && ( + dep.id === "ollama")?.automatable + ? t("models.localOllamaMissingAuto") + : t("models.localOllamaMissingManual") + } + action={ + + } + /> + )} + {t("models.localInstalled")} + + setScanRoot(event.target.value)} + placeholder={t("models.localScanRootPlaceholder")} + /> + + setFullDisk(event.target.checked)} + > + {t("models.localScanFullDisk")} + + + {scanActive && ( + + )} + + {scanActive && ( +
+ + + {t("models.localScanProgress", { + dirs: scan?.dirs_scanned ?? 0, + files: scan?.files_found ?? 0, + current: scan?.current || "…", + })} + +
+ )} ( - - {item.name} {item.source} + void register(item)} + > + {t("models.localRegister")} + , + ] + : item.source === "safetensors" + ? [ + + {t("models.localRegisterManual")} + , + ] + : undefined + } + > + + {item.name} + {item.source} + {item.size > 0 && {formatBytes(item.size)}} + + } + description={item.path || t("models.localOllamaManaged")} + /> )} /> @@ -108,7 +511,6 @@ export function LocalHardwarePanel() { size="small" type="primary" loading={installing === item.id} - disabled={!hw?.ollama_binary} onClick={() => void install(item.id)} > {t("models.localInstall")} diff --git a/docs/api.md b/docs/api.md index aa56953c..f5fb3276 100644 --- a/docs/api.md +++ b/docs/api.md @@ -203,6 +203,23 @@ the server derives one from `prompt`. | `GET` | `/models/active` | user | `{provider_name, model}` | | `PUT` | `/models/active` | admin | body `{provider_name, model}` | +### Local desktop models + +These endpoints power the Models page local-runtime panel. Mutating routes +require the `ollama_models` permission. + +| Method | Path | Auth | Notes | +|--------|------|------|-------| +| `GET` | `/local-models/probe` | user | Hardware, Ollama install/reachability, known-dir GGUF/GGML hits, recommended pulls | +| `POST` | `/local-models/start-ollama` | ollama_models | Start an already-installed Ollama app/daemon. Does not download Ollama | +| `POST` | `/local-models/ensure-deps` | ollama_models | body `{install?}` — start Ollama, or one-click install only when winget/brew/official script can run | +| `POST` | `/local-models/install` | ollama_models | body `{name}` — pull a recommended Ollama tag after the daemon is up | +| `POST` | `/local-models/scan` | ollama_models | body `{root?, full_disk?}` — background weight search (progress + cancel) | +| `GET` | `/local-models/scan` | ollama_models | latest scan job | +| `GET` | `/local-models/scan/{job_id}` | ollama_models | poll scan progress | +| `DELETE` | `/local-models/scan/{job_id}` | ollama_models | cancel a running scan | +| `POST` | `/local-models/register` | ollama_models | body `{path, name?, source, size?}` — `ollama create` from GGUF/GGML and enable the local provider | + ### Media generation models These instance-wide endpoints require the `providers` permission (administrators bypass diff --git a/src/octop/api/routers/local_models.py b/src/octop/api/routers/local_models.py index f8f5863f..d37565f6 100644 --- a/src/octop/api/routers/local_models.py +++ b/src/octop/api/routers/local_models.py @@ -1,4 +1,4 @@ -"""Desktop local-model probe and one-click Ollama install.""" +"""Desktop local-model probe, Ollama start/install, and weight discovery.""" from __future__ import annotations @@ -11,8 +11,18 @@ from octop.api.deps import current_user, get_server, require_permission from octop.infra.agents.providers.local_probe import probe_local_models +from octop.infra.agents.providers.local_register import load_registered, register_local_weight +from octop.infra.agents.providers.local_scan import ( + cancel_scan_job, + get_scan_job, + latest_scan_job, + start_scan_job, +) +from octop.infra.agents.providers.model_flags import is_ollama_local_provider +from octop.infra.agents.providers.ollama_install import ensure_ollama_runtime +from octop.infra.errors import ErrorCode, OctopError from octop.infra.server import OctopServer -from octop.infra.utils.ollama_manager import OllamaModelManager +from octop.infra.utils.ollama_manager import OllamaModelManager, start_ollama_service_result router = APIRouter() @@ -21,11 +31,70 @@ class LocalInstallBody(BaseModel): name: str = Field(min_length=1, max_length=120, description="Ollama model tag to pull") +class LocalScanBody(BaseModel): + root: str | None = Field(default=None, max_length=1024, description="Optional folder to scan") + full_disk: bool = Field( + default=False, + description="Walk every drive / filesystem root. Slow; skip unless the user opts in.", + ) + + +class LocalRegisterBody(BaseModel): + path: str = Field( + min_length=1, max_length=1024, description="Absolute path to a GGUF/GGML file" + ) + name: str | None = Field(default=None, max_length=80, description="Ollama model name to create") + source: str = Field(default="gguf", max_length=32, description="gguf, ggml, or safetensors") + size: int = Field(default=0, ge=0, description="Size in bytes from the scan result") + + +class LocalEnsureBody(BaseModel): + install: bool = Field( + default=False, + description="When true, try one automated Ollama install if the binary is missing", + ) + + +def _merge_registered(probe: dict[str, Any], rows: list[dict[str, Any]]) -> dict[str, Any]: + installed = list(probe.get("installed") or []) + seen = {str(item.get("path") or item.get("name") or "") for item in installed} + for row in rows: + key = str(row.get("path") or row.get("name") or "") + if key and key in seen: + continue + installed.append(row) + if key: + seen.add(key) + probe["installed"] = installed + return probe + + @router.get("/probe", summary="Scan hardware and already-installed local models") async def local_models_probe( _: Any = Depends(current_user), + server: Any = Depends(get_server), +) -> dict[str, Any]: + payload = await asyncio.to_thread(probe_local_models) + registered = await asyncio.to_thread(load_registered, server.services.settings_repo) + return _merge_registered(payload, registered) + + +@router.post("/start-ollama", summary="Start the installed Ollama app or daemon") +async def local_models_start_ollama( + _: Any = Depends(require_permission("ollama_models")), +) -> dict[str, Any]: + """Launch Ollama only when it is already installed. Does not download it.""" + return await asyncio.to_thread(start_ollama_service_result) + + +@router.post( + "/ensure-deps", summary="Start Ollama, or one-click install when FreeOS can automate it" +) +async def local_models_ensure_deps( + body: LocalEnsureBody, + _: Any = Depends(require_permission("ollama_models")), ) -> dict[str, Any]: - return await asyncio.to_thread(probe_local_models) + return await asyncio.to_thread(ensure_ollama_runtime, install=body.install) def _register_ollama_model(server: OctopServer, name: str) -> bool: @@ -34,7 +103,15 @@ def _register_ollama_model(server: OctopServer, name: str) -> bool: if repo is None: return False model = {"id": name, "name": name, "enabled": True, "input": ["text"]} - row = repo.get_by_name("ollama") + row = None + for candidate in repo.list_all(): + if is_ollama_local_provider( + candidate.name, + provider_api_key=candidate.api_key, + provider_base_url=candidate.base_url, + ): + row = candidate + break if row is None: repo.create( name="ollama", @@ -59,6 +136,13 @@ async def local_models_install( server: OctopServer = Depends(get_server), _: Any = Depends(require_permission("ollama_models")), ) -> dict[str, Any]: + runtime = await asyncio.to_thread(ensure_ollama_runtime, install=False) + if not runtime.get("running"): + return { + "ok": False, + "name": body.name, + **runtime, + } try: info = await asyncio.to_thread(OllamaModelManager.pull_model, body.name) except Exception as exc: @@ -71,3 +155,69 @@ async def local_models_install( "source": "ollama", "registered": registered, } + + +@router.post("/scan", summary="Start a background scan for local GGUF / GGML weights") +async def local_models_scan( + body: LocalScanBody, + _: Any = Depends(require_permission("ollama_models")), +) -> dict[str, Any]: + job = start_scan_job(root=body.root, full_disk=body.full_disk) + return job.snapshot() + + +@router.get("/scan", summary="Latest local-weight scan job") +async def local_models_scan_latest( + _: Any = Depends(require_permission("ollama_models")), +) -> dict[str, Any]: + job = latest_scan_job() + if job is None: + return {"job_id": None, "status": "idle", "found": []} + return job.snapshot() + + +@router.get("/scan/{job_id}", summary="Poll a local-weight scan job") +async def local_models_scan_status( + job_id: str, + _: Any = Depends(require_permission("ollama_models")), +) -> dict[str, Any]: + job = get_scan_job(job_id) + if job is None: + raise OctopError(ErrorCode.NOT_FOUND, "scan job not found") + return job.snapshot() + + +@router.delete("/scan/{job_id}", summary="Cancel a running local-weight scan") +async def local_models_scan_cancel( + job_id: str, + _: Any = Depends(require_permission("ollama_models")), +) -> dict[str, Any]: + if not cancel_scan_job(job_id): + raise OctopError(ErrorCode.NOT_FOUND, "scan job not found") + job = get_scan_job(job_id) + return job.snapshot() if job is not None else {"job_id": job_id, "status": "cancelled"} + + +@router.post("/register", summary="Import a discovered GGUF/GGML file into Ollama") +async def local_models_register( + body: LocalRegisterBody, + server: Any = Depends(get_server), + _: Any = Depends(require_permission("ollama_models")), +) -> dict[str, Any]: + try: + result = await asyncio.to_thread( + register_local_weight, + path=body.path, + name=body.name, + source=body.source, + size=body.size, + provider_repo=server.services.provider_repo, + settings_repo=server.services.settings_repo, + ) + except OSError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if server.app_runtime is not None and result.get("ok"): + await server.app_runtime.agent_registry.on_provider_changed( + provider_name=str(result.get("provider_name") or "Ollama (Local)") + ) + return result diff --git a/src/octop/infra/agents/providers/local_probe.py b/src/octop/infra/agents/providers/local_probe.py index fff33281..6bcc512d 100644 --- a/src/octop/infra/agents/providers/local_probe.py +++ b/src/octop/infra/agents/providers/local_probe.py @@ -9,7 +9,10 @@ from pathlib import Path from typing import Any +from octop.infra.agents.providers.local_weights import common_model_roots, scan_weight_roots +from octop.infra.agents.providers.ollama_install import install_plan from octop.infra.utils.ollama_manager import OllamaModelManager, is_ollama_reachable +from octop.infra.utils.ollama_paths import find_ollama_binary, ollama_is_installed def _ram_gb() -> float: @@ -64,36 +67,6 @@ def _gpu_name() -> str: return "" -def _scan_gguf(roots: list[Path]) -> list[dict[str, Any]]: - found: list[dict[str, Any]] = [] - seen: set[str] = set() - for root in roots: - if not root.is_dir(): - continue - for path in root.rglob("*.gguf"): - key = str(path.resolve()) - if key in seen: - continue - seen.add(key) - try: - size = path.stat().st_size - except OSError: - size = 0 - found.append({"name": path.stem, "path": str(path), "size": size, "source": "gguf"}) - if len(found) >= 40: - return found - return found - - -def _lmstudio_roots() -> list[Path]: - home = Path.home() - return [ - home / ".lmstudio" / "models", - home / ".cache" / "lm-studio" / "models", - home / "Documents" / "LM Studio" / "models", - ] - - def _ollama_models() -> tuple[bool, list[dict[str, Any]]]: if not is_ollama_reachable(): return False, [] @@ -141,12 +114,49 @@ def recommend_models(ram_gb: float, has_gpu: bool) -> list[dict[str, str]]: return [{"id": mid, "reason": reason, "install": "ollama"} for mid, reason in picks] +def _deps(*, ollama_installed: bool, ollama_up: bool) -> list[dict[str, Any]]: + deps: list[dict[str, Any]] = [] + plan = install_plan() + if not ollama_installed: + deps.append( + { + "id": "ollama", + "kind": "runtime", + "automatable": bool(plan.get("automatable")), + "method": plan.get("method"), + "docs_url": plan.get("docs_url"), + "next_step": plan.get("next_step") + or "Install Ollama from https://ollama.com/download.", + } + ) + elif not ollama_up: + deps.append( + { + "id": "ollama_daemon", + "kind": "runtime", + "automatable": True, + "method": "start", + "docs_url": "https://ollama.com/download", + "next_step": "Ollama is installed. Start the local service to pull or chat with models.", + } + ) + return deps + + def probe_local_models() -> dict[str, Any]: ram = _ram_gb() gpu = _gpu_name() ollama_up, ollama_models = _ollama_models() + ollama_installed = ollama_is_installed() discovered = list(ollama_models) - discovered.extend(_scan_gguf(_lmstudio_roots())) + discovered.extend( + scan_weight_roots( + common_model_roots(), + full_disk=False, + user_picked=False, + max_files=40, + ) + ) return { "hardware": { "os": platform.system(), @@ -154,9 +164,12 @@ def probe_local_models() -> dict[str, Any]: "cpu_count": os.cpu_count() or 0, "ram_gb": ram, "gpu": gpu, - "ollama_binary": bool(shutil.which("ollama")), + "ollama_binary": bool(find_ollama_binary() or ollama_installed), + "ollama_installed": ollama_installed, "ollama_reachable": ollama_up, + "ollama_path": find_ollama_binary() or "", }, + "deps": _deps(ollama_installed=ollama_installed, ollama_up=ollama_up), "installed": discovered, "recommended": recommend_models(ram, bool(gpu)), } diff --git a/src/octop/infra/agents/providers/local_register.py b/src/octop/infra/agents/providers/local_register.py new file mode 100644 index 00000000..7f10d089 --- /dev/null +++ b/src/octop/infra/agents/providers/local_register.py @@ -0,0 +1,138 @@ +"""Register a discovered local weight so chat can switch to it.""" + +from __future__ import annotations + +import json +import re +from typing import Any + +from octop.infra.agents.providers.model_flags import is_ollama_local_provider +from octop.infra.utils.ollama_manager import OllamaModelManager, create_from_weight + +_SETTINGS_KEY = "local_registered_weights" +_NAME_SAFE = re.compile(r"[^a-z0-9._-]+") + + +def sanitize_model_name(raw: str) -> str: + name = _NAME_SAFE.sub("-", raw.strip().lower()).strip("-._") + if not name: + name = "local-model" + if len(name) > 80: + name = name[:80].rstrip("-._") + return name + + +def load_registered(settings_repo: Any) -> list[dict[str, Any]]: + raw = settings_repo.get(_SETTINGS_KEY) + if not raw: + return [] + try: + data = json.loads(raw) + except json.JSONDecodeError: + return [] + if not isinstance(data, list): + return [] + return [item for item in data if isinstance(item, dict)] + + +def save_registered(settings_repo: Any, rows: list[dict[str, Any]]) -> None: + settings_repo.set(_SETTINGS_KEY, json.dumps(rows, ensure_ascii=False)) + + +def remember_weight(settings_repo: Any, entry: dict[str, Any]) -> None: + rows = load_registered(settings_repo) + path = str(entry.get("path") or "") + kept = [item for item in rows if str(item.get("path") or "") != path] + kept.append(entry) + save_registered(settings_repo, kept) + + +def _find_ollama_row(provider_repo: Any) -> Any | None: + for row in provider_repo.list_all(): + if is_ollama_local_provider( + row.name, + provider_api_key=row.api_key, + provider_base_url=row.base_url, + ): + return row + return None + + +def _upsert_ollama_model(provider_repo: Any, model_id: str, display: str) -> str: + row = _find_ollama_row(provider_repo) + model = { + "id": model_id, + "name": display, + "enabled": True, + "input": ["text"], + } + if row is None: + provider_repo.create( + name="Ollama (Local)", + kind="ollama", + base_url="http://127.0.0.1:11434", + api_key="ollama", + models_json=json.dumps([model]), + ) + return "Ollama (Local)" + models = row.get_models() + existing = next((item for item in models if str(item.get("id") or "") == model_id), None) + if existing is None: + models.append(model) + else: + existing["enabled"] = True + existing["name"] = display + provider_repo.update( + row.id, + models_json=json.dumps(models), + enabled=True, + base_url=row.base_url or "http://127.0.0.1:11434", + ) + return str(row.name) + + +def register_local_weight( + *, + path: str, + name: str | None, + source: str, + size: int, + provider_repo: Any, + settings_repo: Any, +) -> dict[str, Any]: + """Import a GGUF/GGML file into Ollama and enable it on the local provider.""" + if source not in {"gguf", "ggml"}: + return { + "ok": False, + "action": "manual", + "next_step": ( + "This layout is not an Ollama GGUF/GGML file. Convert it to GGUF " + "or use the vendor tool that created it, then search again." + ), + } + model_id = sanitize_model_name(name or path) + create_from_weight(model_id, path) + info = None + for item in OllamaModelManager.list_models(): + if item.name == model_id or item.name.startswith(f"{model_id}:"): + info = item + break + provider_name = _upsert_ollama_model(provider_repo, model_id, model_id) + entry = { + "name": model_id, + "path": path, + "size": int(info.size) if info is not None else size, + "source": "ollama", + "registerable": False, + "registered": True, + "imported_from": source, + } + remember_weight(settings_repo, entry) + return { + "ok": True, + "action": "registered", + "name": model_id, + "path": path, + "provider_name": provider_name, + "source": "ollama", + } diff --git a/src/octop/infra/agents/providers/local_scan.py b/src/octop/infra/agents/providers/local_scan.py new file mode 100644 index 00000000..71e7cc2d --- /dev/null +++ b/src/octop/infra/agents/providers/local_scan.py @@ -0,0 +1,156 @@ +"""Background local-weight scan jobs (progress + cancel, never blocks the UI).""" + +from __future__ import annotations + +import threading +import time +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from octop.infra.agents.providers.local_weights import ( + default_scan_roots, + full_disk_roots, + scan_weight_roots, +) + +_MAX_JOBS = 8 + + +@dataclass +class ScanJob: + job_id: str + status: str = "pending" + roots: list[str] = field(default_factory=list) + full_disk: bool = False + dirs_scanned: int = 0 + files_found: int = 0 + current: str = "" + found: list[dict[str, Any]] = field(default_factory=list) + error: str | None = None + created_at: float = field(default_factory=time.time) + updated_at: float = field(default_factory=time.time) + cancel: threading.Event = field(default_factory=threading.Event) + + def snapshot(self) -> dict[str, Any]: + return { + "job_id": self.job_id, + "status": self.status, + "roots": list(self.roots), + "full_disk": self.full_disk, + "dirs_scanned": self.dirs_scanned, + "files_found": self.files_found, + "current": self.current, + "found": list(self.found), + "error": self.error, + "created_at": self.created_at, + "updated_at": self.updated_at, + } + + +_lock = threading.Lock() +_jobs: dict[str, ScanJob] = {} + + +def _remember(job: ScanJob) -> None: + with _lock: + _jobs[job.job_id] = job + if len(_jobs) <= _MAX_JOBS: + return + oldest = sorted(_jobs.values(), key=lambda item: item.created_at) + for stale in oldest: + if stale.status in {"running", "pending"}: + continue + _jobs.pop(stale.job_id, None) + if len(_jobs) <= _MAX_JOBS: + break + + +def get_scan_job(job_id: str) -> ScanJob | None: + with _lock: + return _jobs.get(job_id) + + +def latest_scan_job() -> ScanJob | None: + with _lock: + if not _jobs: + return None + return max(_jobs.values(), key=lambda item: item.created_at) + + +def cancel_scan_job(job_id: str) -> bool: + job = get_scan_job(job_id) + if job is None: + return False + job.cancel.set() + if job.status in {"pending", "running"}: + job.status = "cancelled" + job.updated_at = time.time() + return True + + +def _resolve_roots(root: str | None, *, full_disk: bool) -> tuple[list[Path], bool]: + if full_disk: + return full_disk_roots(), False + if root and root.strip(): + return [Path(root.strip()).expanduser()], True + return default_scan_roots(), False + + +def _run_job(job: ScanJob) -> None: + job.status = "running" + job.updated_at = time.time() + try: + roots, user_picked = _resolve_roots( + job.roots[0] if job.roots else None, + full_disk=job.full_disk, + ) + if job.full_disk or not job.roots: + job.roots = [str(item) for item in roots] + + def on_progress(current: str, dirs_scanned: int, files_found: int) -> None: + job.current = current + job.dirs_scanned = dirs_scanned + job.files_found = files_found + job.updated_at = time.time() + + found = scan_weight_roots( + roots, + full_disk=job.full_disk, + user_picked=user_picked, + should_cancel=job.cancel.is_set, + on_progress=on_progress, + ) + job.found = found + job.files_found = len(found) + if job.cancel.is_set(): + job.status = "cancelled" + else: + job.status = "completed" + except Exception as exc: # noqa: BLE001 — surface scan failure to the UI + job.status = "failed" + job.error = str(exc) + finally: + job.updated_at = time.time() + + +def start_scan_job(*, root: str | None = None, full_disk: bool = False) -> ScanJob: + """Start a background walk. Existing running jobs are cancelled first.""" + with _lock: + for existing in _jobs.values(): + if existing.status in {"pending", "running"}: + existing.cancel.set() + existing.status = "cancelled" + existing.updated_at = time.time() + job = ScanJob( + job_id=str(uuid.uuid4()), + roots=[root] if root and root.strip() else [], + full_disk=full_disk, + ) + _remember(job) + thread = threading.Thread( + target=_run_job, args=(job,), name=f"local-weight-scan-{job.job_id}", daemon=True + ) + thread.start() + return job diff --git a/src/octop/infra/agents/providers/local_weights.py b/src/octop/infra/agents/providers/local_weights.py new file mode 100644 index 00000000..933673ca --- /dev/null +++ b/src/octop/infra/agents/providers/local_weights.py @@ -0,0 +1,315 @@ +"""Discover local LLM weight files with precision over noisy false positives.""" + +from __future__ import annotations + +import json +import os +import string +from collections.abc import Callable, Iterable +from pathlib import Path +from typing import Any + +WEIGHT_FILES = {".gguf", ".ggml"} +_SKIP_DIR_NAMES = { + "$recycle.bin", + "system volume information", + "recovery", + "windows", + "program files", + "program files (x86)", + "programdata", + "node_modules", + ".git", + ".hg", + ".svn", + "__pycache__", + ".venv", + "venv", + "temp", + "tmp", + "cache", +} +_SKIP_DIR_NAMES_ALWAYS = { + "$recycle.bin", + "system volume information", + "recovery", + "node_modules", + ".git", + ".hg", + ".svn", + "__pycache__", + ".venv", + "venv", + "appdata", + "library", +} +_SKIP_FULL_DISK_WINDOWS = { + "windows", + "program files", + "program files (x86)", + "programdata", + "temp", +} +_CAUSAL_HINTS = ( + "llama", + "mistral", + "qwen", + "gemma", + "phi", + "gpt", + "mpt", + "falcon", + "bloom", + "yi", + "deepseek", + "internlm", + "chatglm", + "baichuan", + "glm", + "stablelm", + "olmo", +) +_POSIX_DENIED = ("/proc", "/sys", "/dev", "/etc", "/root", "/boot", "/run") + + +def common_model_roots(*, home: Path | None = None) -> list[Path]: + """Well-known desktop model folders only (fast automatic probe).""" + root = home if home is not None else Path.home() + extras = [ + root / "models", + root / ".models", + root / ".cache" / "huggingface", + root / ".cache" / "lm-studio", + root / ".lmstudio" / "models", + root / ".cache" / "lm-studio" / "models", + root / "Documents" / "LM Studio" / "models", + root / "jan" / "models", + ] + local = os.environ.get("LOCALAPPDATA", "").strip() + if local: + extras.append(Path(local) / "nomic.ai" / "GPT4All") + extras.append(Path(local) / "Programs" / "Ollama") + roaming = os.environ.get("APPDATA", "").strip() + if roaming: + extras.append(Path(roaming) / "nomic.ai" / "GPT4All") + seen: set[str] = set() + out: list[Path] = [] + for path in extras: + key = os.path.normcase(str(path)) + if key in seen: + continue + seen.add(key) + try: + if path.is_dir(): + out.append(path) + except OSError: + continue + return out + + +def default_scan_roots(*, home: Path | None = None) -> list[Path]: + """User profile plus well-known desktop model folders (manual search).""" + root = home if home is not None else Path.home() + extras = [ + root / "Downloads", + root / "Documents", + *common_model_roots(home=root), + ] + seen: set[str] = set() + out: list[Path] = [] + for path in [root, *extras]: + key = os.path.normcase(str(path)) + if key in seen: + continue + seen.add(key) + try: + if path.is_dir(): + out.append(path) + except OSError: + continue + return out + + +def windows_drives() -> list[Path]: + drives: list[Path] = [] + for letter in string.ascii_uppercase: + candidate = Path(f"{letter}:\\") + try: + if candidate.exists(): + drives.append(candidate) + except OSError: + continue + return drives + + +def full_disk_roots() -> list[Path]: + if os.name == "nt": + return windows_drives() + return [Path("/")] + + +def _skip_dir(name: str, *, full_disk: bool, user_root: bool) -> bool: + lowered = name.lower() + if lowered in _SKIP_DIR_NAMES_ALWAYS: + return True + if user_root: + return False + if full_disk and lowered in _SKIP_FULL_DISK_WINDOWS: + return True + return lowered in _SKIP_DIR_NAMES + + +def _posix_denied(path: Path) -> bool: + if os.name != "posix": + return False + text = path.as_posix() + return any(text == denied or text.startswith(f"{denied}/") for denied in _POSIX_DENIED) + + +def is_weight_file(path: Path) -> bool: + return path.suffix.lower() in WEIGHT_FILES + + +def _read_json(path: Path) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + return None + + +def is_local_llm_safetensors_dir(path: Path) -> bool: + """True only for a Hugging Face-style causal-LM folder.""" + try: + if not path.is_dir(): + return False + except OSError: + return False + config_path = path / "config.json" + if not config_path.is_file(): + return False + has_weight = False + has_tokenizer = False + try: + for child in path.iterdir(): + name = child.name.lower() + if name.endswith(".safetensors"): + has_weight = True + if name in {"tokenizer.json", "tokenizer.model", "tokenizer_config.json"}: + has_tokenizer = True + except OSError: + return False + if not (has_weight and has_tokenizer): + return False + data = _read_json(config_path) + if not isinstance(data, dict): + return False + model_type = str(data.get("model_type") or "").lower() + architectures = data.get("architectures") + arch_text = ( + " ".join(str(item) for item in architectures) if isinstance(architectures, list) else "" + ) + blob = f"{model_type} {arch_text.lower()}" + return any(hint in blob for hint in _CAUSAL_HINTS) + + +def weight_entry(path: Path, *, source: str) -> dict[str, Any]: + try: + size = path.stat().st_size if path.is_file() else _dir_size(path) + except OSError: + size = 0 + return { + "name": path.stem if path.is_file() else path.name, + "path": str(path), + "size": size, + "source": source, + "registerable": source in {"gguf", "ggml"}, + } + + +def _dir_size(path: Path, *, limit: int = 12) -> int: + total = 0 + try: + children = list(path.iterdir()) + except OSError: + return 0 + counted = 0 + for child in children: + if counted >= limit: + break + try: + if child.is_file() and child.suffix.lower() == ".safetensors": + total += child.stat().st_size + counted += 1 + except OSError: + continue + return total + + +ProgressFn = Callable[[str, int, int], None] + + +def scan_weight_roots( + roots: Iterable[Path], + *, + full_disk: bool = False, + user_picked: bool = False, + max_files: int = 400, + should_cancel: Callable[[], bool] | None = None, + on_progress: ProgressFn | None = None, +) -> list[dict[str, Any]]: + """Walk *roots* looking for GGUF/GGML files and clear local-LLM folders.""" + found: list[dict[str, Any]] = [] + seen: set[str] = set() + dirs_scanned = 0 + + def add(entry: dict[str, Any]) -> None: + key = os.path.normcase(str(entry["path"])) + if key in seen: + return + seen.add(key) + found.append(entry) + + for root in roots: + if should_cancel and should_cancel(): + break + try: + if not root.exists(): + continue + except OSError: + continue + if _posix_denied(root): + continue + if root.is_file() and is_weight_file(root): + add(weight_entry(root, source=root.suffix.lower().lstrip("."))) + continue + for dirpath, dirnames, filenames in os.walk(root, topdown=True, followlinks=False): + if should_cancel and should_cancel(): + return found + current = Path(dirpath) + if _posix_denied(current): + dirnames[:] = [] + continue + dirs_scanned += 1 + if on_progress is not None: + on_progress(str(current), dirs_scanned, len(found)) + keep: list[str] = [] + for name in dirnames: + if _skip_dir(name, full_disk=full_disk, user_root=user_picked): + continue + keep.append(name) + dirnames[:] = keep + if is_local_llm_safetensors_dir(current): + add(weight_entry(current, source="safetensors")) + dirnames[:] = [] + if len(found) >= max_files: + return found + continue + for name in filenames: + suffix = Path(name).suffix.lower() + if suffix not in WEIGHT_FILES: + continue + path = current / name + add(weight_entry(path, source=suffix.lstrip("."))) + if len(found) >= max_files: + return found + return found diff --git a/src/octop/infra/agents/providers/ollama_install.py b/src/octop/infra/agents/providers/ollama_install.py new file mode 100644 index 00000000..e6a36912 --- /dev/null +++ b/src/octop/infra/agents/providers/ollama_install.py @@ -0,0 +1,214 @@ +"""Best-effort Ollama install. Honest when the host cannot automate it.""" + +from __future__ import annotations + +import logging +import os +import platform +import shutil +import subprocess +from typing import Any + +from octop.infra.utils.ollama_manager import ( + is_ollama_reachable, + start_ollama_service_result, +) +from octop.infra.utils.ollama_paths import find_ollama_binary, ollama_is_installed + +logger = logging.getLogger(__name__) + +_DOCS_URL = "https://ollama.com/download" +_INSTALL_TIMEOUT_SEC = 300 + + +def _winget() -> str | None: + return shutil.which("winget") + + +def _brew() -> str | None: + return shutil.which("brew") + + +def _can_sudo_n() -> bool: + if hasattr(os, "geteuid") and os.geteuid() == 0: + return True + sudo = shutil.which("sudo") + if sudo is None: + return False + try: + result = subprocess.run( + [sudo, "-n", "true"], + check=False, + capture_output=True, + timeout=5, + ) + except (OSError, subprocess.TimeoutExpired): + return False + return result.returncode == 0 + + +def install_plan() -> dict[str, Any]: + """What this host can actually automate for Ollama.""" + system = platform.system() + if ollama_is_installed(): + return { + "needed": False, + "automatable": False, + "method": None, + "docs_url": _DOCS_URL, + } + if system == "Windows" and _winget(): + return { + "needed": True, + "automatable": True, + "method": "winget", + "command": "winget install --id Ollama.Ollama -e --accept-package-agreements --accept-source-agreements", + "docs_url": _DOCS_URL, + } + if system == "Darwin" and _brew(): + return { + "needed": True, + "automatable": True, + "method": "brew", + "command": "brew install --cask ollama", + "docs_url": _DOCS_URL, + } + if system == "Linux" and _can_sudo_n() and shutil.which("curl"): + return { + "needed": True, + "automatable": True, + "method": "install_sh", + "command": "curl -fsSL https://ollama.com/install.sh | sh", + "docs_url": _DOCS_URL, + } + return { + "needed": True, + "automatable": False, + "method": "manual", + "docs_url": _DOCS_URL, + "next_step": ( + "FreeOS cannot install Ollama automatically on this machine. " + "Download the official installer from https://ollama.com/download " + "and run it, then return here." + ), + } + + +def _run(argv: list[str], *, shell: bool = False) -> tuple[bool, str]: + try: + result = subprocess.run( + argv if not shell else argv[0], + check=False, + capture_output=True, + text=True, + timeout=_INSTALL_TIMEOUT_SEC, + shell=shell, + ) + except subprocess.TimeoutExpired: + return False, "Install timed out" + except OSError as exc: + return False, str(exc) + if result.returncode != 0: + err = (result.stderr or result.stdout or "").strip() + return False, err[:500] or f"exit {result.returncode}" + return True, "" + + +def try_install_ollama() -> dict[str, Any]: + """Run the one automated installer this host supports, or explain why not.""" + plan = install_plan() + if not plan["needed"]: + return {"ok": True, "installed": True, "action": "already_installed", **plan} + if not plan["automatable"]: + return { + "ok": False, + "installed": False, + "action": "manual", + **plan, + } + method = str(plan.get("method") or "") + ok = False + error = "" + if method == "winget": + winget = _winget() + assert winget is not None + ok, error = _run( + [ + winget, + "install", + "--id", + "Ollama.Ollama", + "-e", + "--accept-package-agreements", + "--accept-source-agreements", + ] + ) + elif method == "brew": + brew = _brew() + assert brew is not None + ok, error = _run([brew, "install", "--cask", "ollama"]) + elif method == "install_sh": + ok, error = _run(["curl -fsSL https://ollama.com/install.sh | sh"], shell=True) + else: + return {"ok": False, "installed": False, "action": "manual", **plan} + + installed = ollama_is_installed() + if not ok and not installed: + return { + "ok": False, + "installed": False, + "action": "install_failed", + "error": error, + "docs_url": _DOCS_URL, + "next_step": ( + "Automatic install failed. Download Ollama from " + "https://ollama.com/download and install it yourself, then retry." + ), + **{k: v for k, v in plan.items() if k not in {"needed"}}, + } + return { + "ok": True, + "installed": installed or ok, + "action": "installed", + "docs_url": _DOCS_URL, + "method": method, + } + + +def ensure_ollama_runtime(*, install: bool = False) -> dict[str, Any]: + """Start Ollama if present; optionally try one-click install when missing.""" + if is_ollama_reachable(): + return { + "ok": True, + "installed": True, + "running": True, + "action": "already_running", + "binary": find_ollama_binary(), + "docs_url": _DOCS_URL, + } + if ollama_is_installed(): + started = start_ollama_service_result() + return started + if not install: + plan = install_plan() + return { + "ok": False, + "installed": False, + "running": False, + "action": "not_installed", + "automatable": bool(plan.get("automatable")), + "method": plan.get("method"), + "command": plan.get("command"), + "docs_url": _DOCS_URL, + "next_step": plan.get("next_step") + or "Install Ollama from https://ollama.com/download, then click Start Ollama.", + } + installed = try_install_ollama() + if not installed.get("ok"): + return {**installed, "running": False} + started = start_ollama_service_result() + return { + **started, + "install_action": installed.get("action"), + "method": installed.get("method"), + } diff --git a/src/octop/infra/utils/ollama_manager.py b/src/octop/infra/utils/ollama_manager.py index d7b0e1dc..9678aa59 100644 --- a/src/octop/infra/utils/ollama_manager.py +++ b/src/octop/infra/utils/ollama_manager.py @@ -1,18 +1,69 @@ from __future__ import annotations +import contextlib import logging +import os import platform -import shutil import subprocess import time from datetime import datetime +from pathlib import Path from typing import Any from pydantic import BaseModel, Field, field_validator +from octop.infra.utils.ollama_paths import ( + find_ollama_app, + find_ollama_binary, + ollama_is_installed, +) + logger = logging.getLogger(__name__) _OLLAMA_SERVER_STARTED = False +_OLLAMA_DOCS_URL = "https://ollama.com/download" +_START_WAIT_SEC = 30 if os.name == "nt" else 15 + + +def _popen_kwargs() -> dict[str, Any]: + kwargs: dict[str, Any] = { + "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL, + } + if os.name == "nt": + flags = 0 + flags |= int(getattr(subprocess, "CREATE_NO_WINDOW", 0)) + flags |= int(getattr(subprocess, "DETACHED_PROCESS", 0)) + flags |= int(getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)) + kwargs["creationflags"] = flags + kwargs["close_fds"] = True + else: + kwargs["start_new_session"] = True + return kwargs + + +def _launch_argv_candidates() -> list[list[str]]: + """Vendor-supported ways to bring the local daemon up. + + Windows installs are typically started via the tray app (``Ollama.exe`` or + ``ollama app``). ``ollama serve`` remains the fallback on every OS. + """ + argv: list[list[str]] = [] + app = find_ollama_app() + binary = find_ollama_binary() + if platform.system() == "Windows": + if app: + argv.append([app]) + if binary: + argv.append([binary, "app"]) + argv.append([binary, "serve"]) + return argv + if app and os.path.isdir(app): + opener = "open" + argv.append([opener, "-a", app]) + if binary: + argv.append([binary, "serve"]) + return argv class OllamaModelInfo(BaseModel): @@ -84,42 +135,81 @@ def _is_ollama_reachable() -> bool: def _start_ollama_server() -> None: - """Try to start ``ollama serve`` in the background.""" - global _OLLAMA_SERVER_STARTED + """Try to start the vendor Ollama daemon in the background.""" + result = start_ollama_service_result() + if not result.get("running"): + raise OSError(str(result.get("next_step") or result.get("error") or "Ollama did not start")) - ollama_bin = shutil.which("ollama") - if ollama_bin is None and platform.system() == "Darwin": - candidates = [ - "/usr/local/bin/ollama", - "/opt/homebrew/bin/ollama", - ] - for c in candidates: - if shutil.which(c): - ollama_bin = c - break - if ollama_bin is None: - raise OSError( - "Ollama binary not found on this system. Please install Ollama from https://ollama.com/download" - ) - logger.info("Ollama daemon not reachable, starting ollama serve …") - try: - subprocess.Popen( - [ollama_bin, "serve"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True, - ) - except Exception as exc: - raise OSError(f"Failed to start ollama serve: {exc}") from exc +def start_ollama_service_result() -> dict[str, Any]: + """Start Ollama when it is already installed. Never invents an install. - for _ in range(10): - time.sleep(1) - if _is_ollama_reachable(): - logger.info("Ollama daemon is now running.") - _OLLAMA_SERVER_STARTED = True - return - raise OSError("Started ollama serve but it did not become reachable within 10 seconds.") + Returns a structured payload the dashboard can render without guessing. + """ + global _OLLAMA_SERVER_STARTED + if _is_ollama_reachable(): + _OLLAMA_SERVER_STARTED = True + return { + "ok": True, + "installed": True, + "running": True, + "action": "already_running", + "binary": find_ollama_binary(), + } + if not ollama_is_installed(): + return { + "ok": False, + "installed": False, + "running": False, + "action": "not_installed", + "docs_url": _OLLAMA_DOCS_URL, + "next_step": ( + "Ollama is not installed. Install it from https://ollama.com/download " + "then click Start Ollama." + ), + } + + launched = False + last_error = "" + for argv in _launch_argv_candidates(): + logger.info("Starting Ollama via %s", argv) + try: + subprocess.Popen(argv, **_popen_kwargs()) + launched = True + except OSError as exc: + last_error = str(exc) + logger.warning("Ollama launch failed (%s): %s", argv, exc) + continue + for _ in range(_START_WAIT_SEC): + time.sleep(1) + if _is_ollama_reachable(): + logger.info("Ollama daemon is now running.") + _OLLAMA_SERVER_STARTED = True + return { + "ok": True, + "installed": True, + "running": True, + "action": "started", + "binary": find_ollama_binary(), + } + + next_step = ( + "Ollama is installed but the API at http://127.0.0.1:11434 did not come up. " + "Open the Ollama app from the Start menu, wait until the tray icon appears, " + "then click Start Ollama again." + ) + if last_error: + next_step = f"{next_step} ({last_error})" + return { + "ok": False, + "installed": True, + "running": False, + "action": "start_failed" if launched else "launch_failed", + "docs_url": _OLLAMA_DOCS_URL, + "error": last_error or None, + "next_step": next_step, + "binary": find_ollama_binary(), + } def _ensure_ollama_server() -> None: @@ -146,6 +236,49 @@ def start_ollama_service() -> None: _ensure_ollama_server() +def create_from_weight(name: str, weight_path: str) -> None: + """Import a local GGUF/GGML file into Ollama via ``ollama create``.""" + binary = find_ollama_binary() + if binary is None: + raise OSError( + "Ollama is not installed. Install it from https://ollama.com/download " + "before registering a local weight file." + ) + _ensure_ollama_server() + source = Path(weight_path) + if not source.is_file(): + raise OSError(f"Weight file not found: {weight_path}") + import tempfile + + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + suffix=".Modelfile", + delete=False, + ) as handle: + handle.write(f"FROM {source}\n") + modelfile = handle.name + run_kwargs: dict[str, Any] = { + "check": False, + "capture_output": True, + "text": True, + "timeout": 600, + } + if os.name == "nt": + run_kwargs["creationflags"] = int(getattr(subprocess, "CREATE_NO_WINDOW", 0)) + try: + completed = subprocess.run( + [binary, "create", name, "-f", modelfile], + **run_kwargs, + ) + finally: + with contextlib.suppress(OSError): + os.unlink(modelfile) + if completed.returncode != 0: + err = (completed.stderr or completed.stdout or "").strip() or "ollama create failed" + raise OSError(err) + + def stop_ollama_service() -> bool: """Best-effort stop of an Ollama daemon we started; returns True if stopped.""" global _OLLAMA_SERVER_STARTED diff --git a/src/octop/infra/utils/ollama_paths.py b/src/octop/infra/utils/ollama_paths.py new file mode 100644 index 00000000..243380c8 --- /dev/null +++ b/src/octop/infra/utils/ollama_paths.py @@ -0,0 +1,113 @@ +"""Locate an existing Ollama install without inventing one. + +PATH is checked first. Windows/macOS known vendor paths are probed only +when the binary is already on disk. Missing software yields ``None``. +""" + +from __future__ import annotations + +import os +import platform +import shutil +from pathlib import Path + + +def _windows_candidate_dirs() -> list[Path]: + dirs: list[Path] = [] + for env_name in ("LOCALAPPDATA", "PROGRAMFILES", "PROGRAMFILES(X86)"): + raw = os.environ.get(env_name, "").strip() + if raw: + dirs.append(Path(raw) / "Programs" / "Ollama") + dirs.append(Path(raw) / "Ollama") + home = Path.home() + dirs.extend( + [ + home / "AppData" / "Local" / "Programs" / "Ollama", + home / "AppData" / "Local" / "Ollama", + ] + ) + seen: set[str] = set() + unique: list[Path] = [] + for item in dirs: + key = os.path.normcase(str(item)) + if key in seen: + continue + seen.add(key) + unique.append(item) + return unique + + +def _darwin_candidates() -> list[Path]: + return [ + Path("/usr/local/bin/ollama"), + Path("/opt/homebrew/bin/ollama"), + Path("/Applications/Ollama.app/Contents/Resources/ollama"), + Path("/Applications/Ollama.app/Contents/MacOS/ollama"), + ] + + +def _linux_candidates() -> list[Path]: + home = Path.home() + return [ + Path("/usr/local/bin/ollama"), + Path("/usr/bin/ollama"), + home / ".local" / "bin" / "ollama", + ] + + +def _existing_file(path: Path) -> Path | None: + try: + if path.is_file(): + return path + except OSError: + return None + return None + + +def find_ollama_binary() -> str | None: + """Return an executable ``ollama`` path, or ``None`` if it is not installed.""" + which = shutil.which("ollama") + if which: + return which + system = platform.system() + candidates: list[Path] = [] + if system == "Windows": + for folder in _windows_candidate_dirs(): + candidates.append(folder / "ollama.exe") + candidates.append(folder / "ollama") + elif system == "Darwin": # pragma: no cover - Darwin CI rare + candidates.extend(_darwin_candidates()) + else: + candidates.extend(_linux_candidates()) + for path in candidates: + found = _existing_file(path) + if found is not None: + return str(found) + return None + + +def find_ollama_app() -> str | None: + """Return the Windows/macOS GUI app path when present. + + Used to launch the tray app on Windows when ``ollama serve`` alone is + not how the vendor install is meant to start. + """ + if platform.system() == "Windows": + for folder in _windows_candidate_dirs(): + for name in ("Ollama.exe", "ollama app.exe"): + found = _existing_file(folder / name) + if found is not None: + return str(found) + return None + app = Path("/Applications/Ollama.app") + try: + if app.is_dir(): + return str(app) + except OSError: + return None + return None + + +def ollama_is_installed() -> bool: + """True when a vendor binary or GUI app is already on disk.""" + return find_ollama_binary() is not None or find_ollama_app() is not None diff --git a/tests/unit/api/test_acl_gate_coverage.py b/tests/unit/api/test_acl_gate_coverage.py index d9f9a60a..04039348 100644 --- a/tests/unit/api/test_acl_gate_coverage.py +++ b/tests/unit/api/test_acl_gate_coverage.py @@ -25,6 +25,7 @@ "routers/search.py", "routers/ollama_models.py", "routers/onnx_models.py", + "routers/local_models.py", "routers/connectors.py", "routers/knowledge_bases.py", "routers/browser/uninstall.py", diff --git a/tests/unit/test_local_probe.py b/tests/unit/test_local_probe.py index 61c9997e..c7bf4d10 100644 --- a/tests/unit/test_local_probe.py +++ b/tests/unit/test_local_probe.py @@ -15,7 +15,10 @@ def test_probe_local_models_shape() -> None: assert "hardware" in payload assert "installed" in payload assert "recommended" in payload + assert "deps" in payload hw = payload["hardware"] assert "os" in hw assert "cpu_count" in hw assert "ram_gb" in hw + assert "ollama_installed" in hw + assert "ollama_reachable" in hw diff --git a/tests/unit/test_local_register.py b/tests/unit/test_local_register.py new file mode 100644 index 00000000..583e3ad7 --- /dev/null +++ b/tests/unit/test_local_register.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from octop.infra.agents.providers.local_register import ( + register_local_weight, + sanitize_model_name, +) + + +class _Settings: + def __init__(self) -> None: + self.data: dict[str, str] = {} + + def get(self, key: str) -> str | None: + return self.data.get(key) + + def set(self, key: str, value: str) -> None: + self.data[key] = value + + +class _Providers: + def __init__(self) -> None: + self.rows: list[SimpleNamespace] = [] + + def list_all(self) -> list[SimpleNamespace]: + return self.rows + + def create(self, **kwargs: object) -> int: + row = SimpleNamespace( + id=1, + name=kwargs["name"], + kind=kwargs["kind"], + base_url=kwargs.get("base_url"), + api_key=kwargs.get("api_key"), + models_json=kwargs.get("models_json"), + get_models=lambda: [], + ) + self.rows.append(row) + return 1 + + def update(self, provider_id: int, **kwargs: object) -> None: + del provider_id, kwargs + + +def test_sanitize_model_name() -> None: + assert sanitize_model_name("Qwen 2.5 / Chat.gguf") == "qwen-2.5-chat.gguf" + assert sanitize_model_name("@@@") == "local-model" + + +def test_register_refuses_safetensors() -> None: + result = register_local_weight( + path="/tmp/model", + name="model", + source="safetensors", + size=1, + provider_repo=_Providers(), + settings_repo=_Settings(), + ) + assert result["ok"] is False + assert result["action"] == "manual" + + +def test_register_imports_gguf(monkeypatch) -> None: + created: list[tuple[str, str]] = [] + monkeypatch.setattr( + "octop.infra.agents.providers.local_register.create_from_weight", + lambda name, path: created.append((name, path)), + ) + monkeypatch.setattr( + "octop.infra.agents.providers.local_register.OllamaModelManager.list_models", + lambda: [SimpleNamespace(name="tiny", size=12)], + ) + providers = _Providers() + settings = _Settings() + result = register_local_weight( + path="/tmp/tiny.gguf", + name="tiny", + source="gguf", + size=12, + provider_repo=providers, + settings_repo=settings, + ) + assert result["ok"] is True + assert created == [("tiny", "/tmp/tiny.gguf")] + assert providers.rows[0].name == "Ollama (Local)" + assert "tiny.gguf" in settings.data.get("local_registered_weights", "") diff --git a/tests/unit/test_local_scan.py b/tests/unit/test_local_scan.py new file mode 100644 index 00000000..5d5d1064 --- /dev/null +++ b/tests/unit/test_local_scan.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import time +from pathlib import Path + +from octop.infra.agents.providers import local_scan + + +def test_start_scan_finds_file_and_can_cancel(tmp_path: Path, monkeypatch) -> None: + (tmp_path / "chat.gguf").write_bytes(b"gguf") + monkeypatch.setattr(local_scan, "default_scan_roots", lambda: [tmp_path]) + job = local_scan.start_scan_job() + deadline = time.time() + 5 + while job.status in {"pending", "running"} and time.time() < deadline: + time.sleep(0.05) + assert job.status == "completed" + assert any(item["name"] == "chat" for item in job.found) + snapshot = local_scan.get_scan_job(job.job_id) + assert snapshot is not None + assert snapshot.files_found >= 1 + + +def test_full_disk_false_uses_optional_root(tmp_path: Path) -> None: + nested = tmp_path / "only-here" + nested.mkdir() + (nested / "solo.gguf").write_bytes(b"x") + job = local_scan.start_scan_job(root=str(nested), full_disk=False) + deadline = time.time() + 5 + while job.status in {"pending", "running"} and time.time() < deadline: + time.sleep(0.05) + assert job.status == "completed" + assert [item["name"] for item in job.found] == ["solo"] + + +def test_cancel_unknown_job() -> None: + assert local_scan.cancel_scan_job("missing") is False diff --git a/tests/unit/test_local_weights.py b/tests/unit/test_local_weights.py new file mode 100644 index 00000000..2d85d1d6 --- /dev/null +++ b/tests/unit/test_local_weights.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from octop.infra.agents.providers.local_weights import ( + common_model_roots, + default_scan_roots, + is_local_llm_safetensors_dir, + scan_weight_roots, +) + + +def test_scan_finds_gguf_and_ggml(tmp_path: Path) -> None: + models = tmp_path / "models" + models.mkdir() + (models / "tiny.gguf").write_bytes(b"gguf") + (models / "old.ggml").write_bytes(b"ggml") + (models / "notes.txt").write_text("nope", encoding="utf-8") + found = scan_weight_roots([models]) + sources = {item["source"] for item in found} + names = {item["name"] for item in found} + assert sources == {"gguf", "ggml"} + assert names == {"tiny", "old"} + assert all(item["registerable"] for item in found) + + +def test_scan_skips_random_safetensors(tmp_path: Path) -> None: + folder = tmp_path / "weights" + folder.mkdir() + (folder / "model.safetensors").write_bytes(b"x") + assert scan_weight_roots([tmp_path]) == [] + + +def test_scan_accepts_clear_local_llm_folder(tmp_path: Path) -> None: + folder = tmp_path / "qwen-local" + folder.mkdir() + (folder / "config.json").write_text( + json.dumps({"model_type": "qwen2", "architectures": ["Qwen2ForCausalLM"]}), + encoding="utf-8", + ) + (folder / "tokenizer.json").write_text("{}", encoding="utf-8") + (folder / "model.safetensors").write_bytes(b"weight") + assert is_local_llm_safetensors_dir(folder) is True + found = scan_weight_roots([tmp_path]) + assert len(found) == 1 + assert found[0]["source"] == "safetensors" + assert found[0]["registerable"] is False + + +def test_scan_skips_inaccessible_and_node_modules(tmp_path: Path) -> None: + hidden = tmp_path / "node_modules" / "pkg" + hidden.mkdir(parents=True) + (hidden / "vendor.gguf").write_bytes(b"no") + visible = tmp_path / "ok.gguf" + visible.write_bytes(b"yes") + found = scan_weight_roots([tmp_path]) + assert [item["name"] for item in found] == ["ok"] + + +def test_scan_cancel_stops(tmp_path: Path) -> None: + (tmp_path / "a.gguf").write_bytes(b"a") + found = scan_weight_roots([tmp_path], should_cancel=lambda: True) + assert found == [] + + +def test_default_roots_include_home_and_known_dirs(tmp_path: Path) -> None: + (tmp_path / "models").mkdir() + (tmp_path / "Downloads").mkdir() + roots = default_scan_roots(home=tmp_path) + assert tmp_path in roots + assert tmp_path / "models" in roots + assert tmp_path / "Downloads" in roots + common = common_model_roots(home=tmp_path) + assert tmp_path not in common + assert tmp_path / "models" in common diff --git a/tests/unit/test_ollama_paths.py b/tests/unit/test_ollama_paths.py new file mode 100644 index 00000000..cb616351 --- /dev/null +++ b/tests/unit/test_ollama_paths.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from pathlib import Path + +from octop.infra.utils import ollama_paths + + +def test_find_ollama_binary_uses_path(monkeypatch, tmp_path: Path) -> None: + binary = tmp_path / "ollama" + binary.write_text("", encoding="utf-8") + monkeypatch.setattr( + ollama_paths.shutil, "which", lambda name: str(binary) if name == "ollama" else None + ) + assert ollama_paths.find_ollama_binary() == str(binary) + + +def test_find_ollama_binary_windows_known_path(monkeypatch, tmp_path: Path) -> None: + local = tmp_path / "Local" + exe = local / "Programs" / "Ollama" / "ollama.exe" + exe.parent.mkdir(parents=True) + exe.write_text("", encoding="utf-8") + monkeypatch.setattr(ollama_paths.shutil, "which", lambda name: None) + monkeypatch.setattr(ollama_paths.platform, "system", lambda: "Windows") + monkeypatch.setenv("LOCALAPPDATA", str(local)) + monkeypatch.delenv("PROGRAMFILES", raising=False) + monkeypatch.delenv("PROGRAMFILES(X86)", raising=False) + assert ollama_paths.find_ollama_binary() == str(exe) + + +def test_ollama_not_installed_when_missing(monkeypatch) -> None: + monkeypatch.setattr(ollama_paths.shutil, "which", lambda name: None) + monkeypatch.setattr(ollama_paths.platform, "system", lambda: "Linux") + monkeypatch.setattr(ollama_paths, "_linux_candidates", lambda: []) + monkeypatch.setattr(ollama_paths, "_darwin_candidates", lambda: []) + monkeypatch.setattr(ollama_paths, "_windows_candidate_dirs", lambda: []) + assert ollama_paths.find_ollama_binary() is None + assert ollama_paths.ollama_is_installed() is False + + +def test_find_ollama_app_windows(monkeypatch, tmp_path: Path) -> None: + local = tmp_path / "Local" + app = local / "Programs" / "Ollama" / "Ollama.exe" + app.parent.mkdir(parents=True) + app.write_text("", encoding="utf-8") + monkeypatch.setattr(ollama_paths.platform, "system", lambda: "Windows") + monkeypatch.setenv("LOCALAPPDATA", str(local)) + monkeypatch.delenv("PROGRAMFILES", raising=False) + monkeypatch.delenv("PROGRAMFILES(X86)", raising=False) + assert ollama_paths.find_ollama_app() == str(app) diff --git a/tests/unit/test_ollama_start.py b/tests/unit/test_ollama_start.py new file mode 100644 index 00000000..0a174860 --- /dev/null +++ b/tests/unit/test_ollama_start.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from octop.infra.agents.providers import ollama_install +from octop.infra.utils import ollama_manager + + +def test_start_result_when_not_installed(monkeypatch) -> None: + monkeypatch.setattr(ollama_manager, "_is_ollama_reachable", lambda: False) + monkeypatch.setattr(ollama_manager, "ollama_is_installed", lambda: False) + result = ollama_manager.start_ollama_service_result() + assert result["ok"] is False + assert result["action"] == "not_installed" + assert "ollama.com/download" in str(result["docs_url"]) + + +def test_start_result_when_already_running(monkeypatch) -> None: + monkeypatch.setattr(ollama_manager, "_is_ollama_reachable", lambda: True) + monkeypatch.setattr(ollama_manager, "find_ollama_binary", lambda: "/usr/bin/ollama") + result = ollama_manager.start_ollama_service_result() + assert result["ok"] is True + assert result["action"] == "already_running" + + +def test_start_result_launches_windows_app(monkeypatch) -> None: + monkeypatch.setattr(ollama_manager, "_is_ollama_reachable", lambda: False) + monkeypatch.setattr(ollama_manager, "ollama_is_installed", lambda: True) + monkeypatch.setattr(ollama_manager.platform, "system", lambda: "Windows") + monkeypatch.setattr(ollama_manager, "find_ollama_app", lambda: r"C:\Ollama\Ollama.exe") + monkeypatch.setattr(ollama_manager, "find_ollama_binary", lambda: r"C:\Ollama\ollama.exe") + launched: list[list[str]] = [] + + def _popen(argv: list[str], **kwargs: object) -> None: + del kwargs + launched.append(argv) + + monkeypatch.setattr(ollama_manager.subprocess, "Popen", _popen) + monkeypatch.setattr(ollama_manager, "_START_WAIT_SEC", 1) + sleeps = {"n": 0} + + def _sleep(_sec: float) -> None: + sleeps["n"] += 1 + if sleeps["n"] >= 1: + monkeypatch.setattr(ollama_manager, "_is_ollama_reachable", lambda: True) + + monkeypatch.setattr(ollama_manager.time, "sleep", _sleep) + result = ollama_manager.start_ollama_service_result() + assert launched + assert launched[0][0].endswith("Ollama.exe") or launched[0][-1] in {"app", "serve"} + assert result["ok"] is True + assert result["action"] == "started" + + +def test_ensure_runtime_without_install_is_honest(monkeypatch) -> None: + monkeypatch.setattr(ollama_install, "is_ollama_reachable", lambda: False) + monkeypatch.setattr(ollama_install, "ollama_is_installed", lambda: False) + monkeypatch.setattr( + ollama_install, + "install_plan", + lambda: { + "needed": True, + "automatable": False, + "method": "manual", + "docs_url": "https://ollama.com/download", + "next_step": "Download the official installer", + }, + ) + result = ollama_install.ensure_ollama_runtime(install=False) + assert result["ok"] is False + assert result["action"] == "not_installed" + assert result["automatable"] is False + + +def test_install_plan_windows_winget(monkeypatch) -> None: + monkeypatch.setattr(ollama_install, "ollama_is_installed", lambda: False) + monkeypatch.setattr(ollama_install.platform, "system", lambda: "Windows") + monkeypatch.setattr(ollama_install, "_winget", lambda: "winget") + plan = ollama_install.install_plan() + assert plan["automatable"] is True + assert plan["method"] == "winget"