diff --git a/CHANGELOG.md b/CHANGELOG.md index c88b2b72..900f4bdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ ## [Unreleased] +## [0.0.7] - 2026-09-29 + +### 新增 + +- 面向未安装 Ollama、也没有 GGUF 模型的新用户,模型页提供经过固定版本与 SHA-256 校验的 GGUF 模型目录、一键下载、实时进度、自动启动内置 llama.cpp、首次测速并设为默认模型。 +- Windows amd64 桌面包内置固定版本的 llama.cpp sidecar;已安装 Ollama 但服务未启动时,首次配置页允许用户自行选择启动 Ollama。 + +### 变更 + +- 本机模型下载任务和测速结果跨 FreeOS 重启保留;下载中断、取消或临时失败后可从已有部分文件继续,开始前会检查剩余磁盘空间。 +- 用户明确启动过的 llama.cpp 模型会在下次启动 FreeOS 时自动恢复;用户主动停止后不会再次自动拉起。 + +### 修复 + +- 同一目录模型下载自动去重,服务器不支持 HTTP Range 时安全回退为完整重下,完整 `.part` 文件会先校验再直接完成,避免重复流量与并发覆盖。 + ## [0.0.6] - 2026-09-22 ### 安全 diff --git a/dashboard/src/api/modules/localModels.ts b/dashboard/src/api/modules/localModels.ts index e23db5f5..edc99ef2 100644 --- a/dashboard/src/api/modules/localModels.ts +++ b/dashboard/src/api/modules/localModels.ts @@ -10,6 +10,9 @@ export interface LocalHardware { ollama_installed?: boolean; ollama_reachable: boolean; ollama_path?: string; + llamacpp_binary?: boolean; + llamacpp_reachable?: boolean; + llamacpp_path?: string; } export interface LocalDep { @@ -28,16 +31,37 @@ export interface LocalInstalledModel { source: string; registerable?: boolean; registered?: boolean; + runtime?: string; + managed_by_freeos?: boolean; + base_url?: string; + model_path?: string; + alias?: string; provider_name?: string; is_default?: boolean; } export interface LocalRecommendedModel { id: string; + name?: string; + display_name?: string; + size?: number; reason: string; install: string; } +export interface LocalDownloadJob { + job_id: string; + catalog_id: string; + name?: string; + status: string; + downloaded_bytes: number; + total_bytes: number; + percent: number; + path: string; + error?: string | null; + resumable?: boolean; +} + export interface LocalProbe { hardware: LocalHardware; deps?: LocalDep[]; @@ -110,10 +134,43 @@ export const localModelsApi = { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }), }), + startDownload: (catalogId: string) => + request("/local-models/downloads", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ catalog_id: catalogId }), + }), + listDownloads: () => request("/local-models/downloads"), + getDownload: (jobId: string) => + request( + `/local-models/downloads/${encodeURIComponent(jobId)}`, + ), + cancelDownload: (jobId: string) => + request( + `/local-models/downloads/${encodeURIComponent(jobId)}`, + { method: "DELETE" }, + ), startOllama: () => request("/local-models/start-ollama", { method: "POST", }), + llamaCppStatus: () => + request("/local-models/llamacpp/status"), + startLlamaCpp: (body: { + model_path: string; + alias?: string; + context_size?: number; + gpu_layers?: number; + }) => + request("/local-models/llamacpp/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + stopLlamaCpp: () => + request("/local-models/llamacpp", { + method: "DELETE", + }), ensureDeps: (install: boolean) => request("/local-models/ensure-deps", { method: "POST", @@ -144,18 +201,18 @@ export const localModelsApi = { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }), - speedTest: (name: string, init?: RequestInit) => + speedTest: (name: string, providerName?: string, init?: RequestInit) => request("/local-models/speed-test", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name }), + body: JSON.stringify({ name, provider_name: providerName }), ...init, }), - setDefault: (name: string) => + setDefault: (name: string, providerName?: string) => request("/local-models/default", { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name }), + body: JSON.stringify({ name, provider_name: providerName }), }), clearDefault: (name?: string) => request( diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 17a0907d..d361d312 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -3205,7 +3205,7 @@ "localNoModels": "No downloaded models yet", "ollamaUnavailable": "Ollama is not installed or not running. Please install Ollama and start the daemon.", "localDownloadSuccess": "Model downloaded successfully", - "localDownloadFailed": "Failed to download model", + "localDownloadFailed": "Model download failed", "localDeleteModel": "Delete Model", "localDeleteConfirm": "Delete model \"{{name}}\"? The model will be removed.", "localModelDeleted": "Model \"{{name}}\" deleted", @@ -3483,7 +3483,23 @@ "onnxQuickDownload": "Quick download from catalog…", "useInChat": "Switch in chat", "localInstallRegistered": "Installed {{name}} and registered it. Open Conversations to switch.", - "localInstallNeedProvider": "Downloaded. Enable the Ollama provider on this page, then switch in chat." + "localInstallNeedProvider": "Downloaded. Enable the Ollama provider on this page, then switch in chat.", + "localBuiltinRuntime": "FreeOS built-in runtime", + "localBuiltinMissing": "The built-in local-model runtime is missing", + "localBuiltinMissingHelp": "Repair or update FreeOS to restore the bundled llama.cpp runtime.", + "localRunBuiltin": "Run with FreeOS", + "localBuiltinStarted": "Started {{name}} with the FreeOS local runtime", + "localBuiltinStartFailed": "Failed to start the FreeOS local runtime", + "localRecommendedHint": "No Ollama required. FreeOS downloads the model, starts its bundled runtime, benchmarks it, and selects it for local chat after a successful test.", + "localInstallAndRecommend": "Download and recommend", + "localDownloadProgress": "Downloaded {{current}} / {{total}}", + "localDownloadInterrupted": "The previous model download did not finish. You can continue from the saved progress.", + "localDownloadResume": "Resume download", + "localAutoSetupDone": "{{name}} was downloaded, benchmarked, and selected for local chat", + "localCatalogReason": { + "recommended_for_hardware": "Recommended for this computer", + "lighter_or_stronger_alternative": "Alternative model" + } }, "advancedSettings": { "description": "Manage runtime configuration and environment variables.", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 55b824b1..3cc0ad3e 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -3480,7 +3480,23 @@ "onnxQuickDownload": "从目录快速下载…", "useInChat": "去对话切换", "localInstallRegistered": "已安装 {{name}} 并登记到模型列表。打开「对话」即可切换。", - "localInstallNeedProvider": "模型已下载。请先在本页启用 Ollama 提供商,再到对话里切换。" + "localInstallNeedProvider": "模型已下载。请先在本页启用 Ollama 提供商,再到对话里切换。", + "localBuiltinRuntime": "FreeOS 内置运行时", + "localBuiltinMissing": "缺少内置本地模型运行时", + "localBuiltinMissingHelp": "请修复或更新 FreeOS,以恢复随软件提供的 llama.cpp 运行时。", + "localRunBuiltin": "使用 FreeOS 运行", + "localBuiltinStarted": "已使用 FreeOS 本地运行时启动 {{name}}", + "localBuiltinStartFailed": "FreeOS 本地运行时启动失败", + "localRecommendedHint": "无需安装 Ollama。下载完成后,FreeOS 会自动启动内置运行时、测速,并将通过测试的模型设为本地对话推荐。", + "localInstallAndRecommend": "一键下载并推荐", + "localDownloadProgress": "已下载 {{current}} / {{total}}", + "localDownloadInterrupted": "上次模型下载尚未完成,可以从已有进度继续。", + "localDownloadResume": "继续下载", + "localAutoSetupDone": "{{name}} 已下载、测速并设为本地推荐模型", + "localCatalogReason": { + "recommended_for_hardware": "根据本机内存推荐", + "lighter_or_stronger_alternative": "备选模型" + } }, "advancedSettings": { "description": "管理运行配置和环境变量等高级选项。", diff --git a/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.test.tsx b/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.test.tsx index 6d93b45a..18d19996 100644 --- a/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.test.tsx +++ b/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.test.tsx @@ -5,19 +5,27 @@ import { MemoryRouter } from "react-router-dom"; const probe = vi.fn(); const startOllama = vi.fn(); +const startLlamaCpp = vi.fn(); const ensureDeps = vi.fn(); const startScan = vi.fn(); const register = vi.fn(); const speedTest = vi.fn(); const setDefault = vi.fn(); const clearDefault = vi.fn(); +const startDownload = vi.fn(); +const listDownloads = vi.fn(); vi.mock("../../../../api/modules/localModels", () => ({ localModelsApi: { probe: (...args: unknown[]) => probe(...args), startOllama: (...args: unknown[]) => startOllama(...args), + startLlamaCpp: (...args: unknown[]) => startLlamaCpp(...args), ensureDeps: (...args: unknown[]) => ensureDeps(...args), install: vi.fn(), + startDownload: (...args: unknown[]) => startDownload(...args), + listDownloads: (...args: unknown[]) => listDownloads(...args), + getDownload: vi.fn(), + cancelDownload: vi.fn(), startScan: (...args: unknown[]) => startScan(...args), getScan: vi.fn(), getLatestScan: vi.fn(), @@ -51,6 +59,9 @@ const installedStopped = { ollama_installed: true, ollama_reachable: false, ollama_path: "C:\\\\Ollama\\\\ollama.exe", + llamacpp_binary: true, + llamacpp_reachable: false, + llamacpp_path: "C:\\\\FreeOS\\\\llama.cpp\\\\llama-server.exe", }, deps: [ { @@ -76,11 +87,18 @@ beforeEach(() => { vi.clearAllMocks(); probe.mockResolvedValue(installedStopped); startOllama.mockResolvedValue({ ok: true, installed: true, running: true }); + startLlamaCpp.mockResolvedValue({ + ok: true, + installed: true, + running: true, + registered: true, + }); startScan.mockResolvedValue({ job_id: "job-1", status: "completed", found: installedStopped.installed, }); + listDownloads.mockResolvedValue([]); }); function renderPanel(props: { onSaved?: () => void | Promise } = {}) { @@ -105,6 +123,20 @@ describe("", () => { await waitFor(() => expect(screen.getByText("tiny")).toBeInTheDocument()); expect(screen.getByText("D:\\\\models\\\\tiny.gguf")).toBeInTheDocument(); expect(screen.getByText("models.localRegister")).toBeInTheDocument(); + expect(screen.getByText("models.localRunBuiltin")).toBeInTheDocument(); + }); + + it("starts a discovered GGUF with the built-in runtime", async () => { + renderPanel(); + await waitFor(() => expect(screen.getByText("tiny")).toBeInTheDocument()); + await userEvent.click(screen.getByText("models.localRunBuiltin")); + await waitFor(() => + expect(startLlamaCpp).toHaveBeenCalledWith({ + model_path: "D:\\\\models\\\\tiny.gguf", + alias: "tiny", + gpu_layers: -1, + }), + ); }); it("starts a local weight search", async () => { @@ -142,10 +174,16 @@ describe("", () => { expect(screen.getByText("models.localSetDefault")).toBeInTheDocument(); await userEvent.click(screen.getByText("models.localSpeedTest")); await waitFor(() => - expect(speedTest).toHaveBeenCalledWith("tiny", expect.anything()), + expect(speedTest).toHaveBeenCalledWith( + "tiny", + undefined, + expect.anything(), + ), ); await userEvent.click(screen.getByText("models.localSetDefault")); - await waitFor(() => expect(setDefault).toHaveBeenCalledWith("tiny")); + await waitFor(() => + expect(setDefault).toHaveBeenCalledWith("tiny", undefined), + ); }); it("shows a default badge and can clear it", async () => { @@ -184,4 +222,88 @@ describe("", () => { expect(onSaved).toHaveBeenCalled(); window.removeEventListener("octop:models-changed", heard); }); + + it("offers to resume an interrupted catalog download", async () => { + listDownloads.mockResolvedValue([ + { + job_id: "download-old", + catalog_id: "starter", + name: "starter-model", + status: "interrupted", + downloaded_bytes: 512, + total_bytes: 1024, + percent: 50, + path: "D:\\models\\starter.gguf", + resumable: true, + }, + ]); + startDownload.mockResolvedValue({ + job_id: "download-new", + catalog_id: "starter", + name: "starter-model", + status: "running", + downloaded_bytes: 512, + total_bytes: 1024, + percent: 50, + path: "D:\\models\\starter.gguf", + resumable: true, + }); + + renderPanel(); + await screen.findByText("models.localDownloadResume"); + await userEvent.click(screen.getByText("models.localDownloadResume")); + + await waitFor(() => expect(startDownload).toHaveBeenCalledWith("starter")); + }); + + it("downloads, benchmarks, and selects a catalog model", async () => { + probe.mockResolvedValue({ + ...installedStopped, + recommended: [ + { + id: "starter", + name: "starter-model", + display_name: "Starter model", + reason: "recommended_for_hardware", + install: "freeos", + size: 1024, + }, + ], + }); + startDownload.mockResolvedValue({ + job_id: "download-1", + catalog_id: "starter", + name: "starter-model", + status: "completed", + downloaded_bytes: 1024, + total_bytes: 1024, + percent: 100, + path: "D:\\models\\starter.gguf", + }); + startLlamaCpp.mockResolvedValue({ + ok: true, + installed: true, + running: true, + name: "starter-model", + provider_name: "FreeOS llama.cpp", + }); + speedTest.mockResolvedValue({ ok: true, latency_ms: 50 }); + setDefault.mockResolvedValue({ ok: true }); + + renderPanel(); + await screen.findByText("Starter model"); + await userEvent.click(screen.getByText("models.localInstallAndRecommend")); + + await waitFor(() => expect(startDownload).toHaveBeenCalledWith("starter")); + expect(startLlamaCpp).toHaveBeenCalledWith({ + model_path: "D:\\models\\starter.gguf", + alias: "starter-model", + gpu_layers: -1, + }); + expect(speedTest).toHaveBeenCalledWith("starter-model", "FreeOS llama.cpp"); + expect(setDefault).toHaveBeenCalledWith( + "starter-model", + "FreeOS llama.cpp", + ); + }); }); diff --git a/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx b/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx index 9c377cbd..80d0f4cd 100644 --- a/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx +++ b/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx @@ -16,6 +16,7 @@ import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { localModelsApi, + type LocalDownloadJob, type LocalInstalledModel, type LocalProbe, type LocalRuntimeResult, @@ -81,9 +82,11 @@ export function LocalHardwarePanel({ const [probe, setProbe] = useState(null); const [loading, setLoading] = useState(false); const [installing, setInstalling] = useState(null); + const [download, setDownload] = useState(null); const [starting, setStarting] = useState(false); const [ensuring, setEnsuring] = useState(false); const [registering, setRegistering] = useState(null); + const [startingLocal, setStartingLocal] = useState(null); const [testingKey, setTestingKey] = useState(null); const [settingDefault, setSettingDefault] = useState(null); const [speedResults, setSpeedResults] = useState< @@ -94,6 +97,7 @@ export function LocalHardwarePanel({ const [scan, setScan] = useState(null); const [scanning, setScanning] = useState(false); const pollRef = useRef(null); + const downloadPollRef = useRef(null); const speedAbortRef = useRef(null); const speedAbortReasonRef = useRef<"user" | "timeout" | null>(null); @@ -104,6 +108,13 @@ export function LocalHardwarePanel({ } }; + const stopDownloadPoll = () => { + if (downloadPollRef.current != null) { + window.clearInterval(downloadPollRef.current); + downloadPollRef.current = null; + } + }; + const refresh = async () => { setLoading(true); try { @@ -119,10 +130,31 @@ export function LocalHardwarePanel({ useEffect(() => { void refresh(); + void localModelsApi + .listDownloads() + .then((jobs) => { + const recent = jobs.find((job) => + ["pending", "running", "interrupted", "cancelled", "failed"].includes( + job.status, + ), + ); + if (!recent) return; + setDownload(recent); + if (["pending", "running"].includes(recent.status)) { + setInstalling(recent.catalog_id); + pollDownload(recent.job_id); + } + }) + .catch(() => { + /* download history is best-effort; hardware discovery still works */ + }); return () => { stopPoll(); + stopDownloadPoll(); speedAbortRef.current?.abort(); }; + // The initial probe owns these timers for the panel lifetime. + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const showRuntimeError = (result: LocalRuntimeResult, fallback: string) => { @@ -175,7 +207,9 @@ export function LocalHardwarePanel({ const install = async (name: string) => { const hw = probe?.hardware; - const missing = (probe?.deps ?? []).length > 0 || !hw?.ollama_reachable; + const missing = + (probe?.deps ?? []).some((dep) => dep.id === "ollama") || + !hw?.ollama_reachable; if (missing) { Modal.confirm({ title: t("models.localDepsNeededTitle"), @@ -227,6 +261,100 @@ export function LocalHardwarePanel({ } }; + const finishCatalogSetup = async (job: LocalDownloadJob) => { + const runtime = await localModelsApi.startLlamaCpp({ + model_path: job.path, + alias: job.name || job.catalog_id, + gpu_layers: -1, + }); + if (!runtime.ok || !runtime.provider_name) { + showRuntimeError(runtime, t("models.localBuiltinStartFailed")); + return; + } + const name = runtime.name || job.name || job.catalog_id; + const speed = await localModelsApi.speedTest(name, runtime.provider_name); + saveSpeedResult("gguf", name, speed); + if (!speed.ok) { + message.warning( + speed.next_step || speed.error || t("models.localSpeedFailed"), + ); + await refresh(); + return; + } + const preferred = await localModelsApi.setDefault( + name, + runtime.provider_name, + ); + if (!preferred.ok) { + message.warning( + preferred.next_step || + preferred.error || + t("models.localDefaultFailed"), + ); + await refresh(); + return; + } + notifyModelsChanged(); + await onSaved?.(); + message.success(t("models.localAutoSetupDone", { name })); + await refresh(); + }; + + const pollDownload = (jobId: string) => { + stopDownloadPoll(); + downloadPollRef.current = window.setInterval(() => { + void (async () => { + try { + const next = await localModelsApi.getDownload(jobId); + setDownload(next); + if (["completed", "failed", "cancelled"].includes(next.status)) { + stopDownloadPoll(); + setInstalling(null); + if (next.status === "completed") { + await finishCatalogSetup(next); + } else if (next.status === "failed") { + message.error(next.error || t("models.localDownloadFailed")); + } + } + } catch (err) { + stopDownloadPoll(); + setInstalling(null); + message.error( + err instanceof Error + ? err.message + : t("models.localDownloadFailed"), + ); + } + })(); + }, 800); + }; + + const installCatalogModel = async (catalogId: string) => { + setInstalling(catalogId); + try { + const job = await localModelsApi.startDownload(catalogId); + setDownload(job); + if (job.status === "completed") { + await finishCatalogSetup(job); + setInstalling(null); + } else { + pollDownload(job.job_id); + } + } catch (err) { + setInstalling(null); + message.error( + err instanceof Error ? err.message : t("models.localDownloadFailed"), + ); + } + }; + + const cancelDownload = async () => { + if (!download?.job_id) return; + setDownload(await localModelsApi.cancelDownload(download.job_id)); + stopDownloadPoll(); + setInstalling(null); + }; + const pollScan = (jobId: string) => { stopPoll(); pollRef.current = window.setInterval(() => { @@ -353,6 +481,31 @@ export function LocalHardwarePanel({ } }; + const startWithFreeOS = async (item: LocalInstalledModel) => { + setStartingLocal(item.path || item.name); + try { + const result = await localModelsApi.startLlamaCpp({ + model_path: item.path, + alias: item.name, + gpu_layers: -1, + }); + if (!result.ok) { + showRuntimeError(result, t("models.localBuiltinStartFailed")); + return; + } + notifyModelsChanged(); + await onSaved?.(); + message.success(t("models.localBuiltinStarted", { name: item.name })); + await refresh(); + } catch (err) { + message.error( + apiErrorMessage(err, t("models.localBuiltinStartFailed"), t), + ); + } finally { + setStartingLocal(null); + } + }; + const itemKey = (item: LocalInstalledModel) => speedResultKey(item.source, item.name); @@ -368,9 +521,11 @@ export function LocalHardwarePanel({ }, SPEED_TEST_TIMEOUT_MS); setTestingKey(key); try { - const result = await localModelsApi.speedTest(item.name, { - signal: controller.signal, - }); + const result = await localModelsApi.speedTest( + item.name, + item.provider_name, + { signal: controller.signal }, + ); const stored = saveSpeedResult(item.source, item.name, result); setSpeedResults((prev) => ({ ...prev, [key]: stored })); if (result.ok) { @@ -424,7 +579,10 @@ export function LocalHardwarePanel({ const setAsDefault = async (item: LocalInstalledModel) => { setSettingDefault(item.name); try { - const result = await localModelsApi.setDefault(item.name); + const result = await localModelsApi.setDefault( + item.name, + item.provider_name, + ); if (!result.ok) { message.error( result.action === "not_registered" @@ -495,6 +653,8 @@ export function LocalHardwarePanel({ const hw = probe?.hardware; const ollamaInstalled = Boolean(hw?.ollama_installed || hw?.ollama_binary); const ollamaUp = Boolean(hw?.ollama_reachable); + const llamaCppInstalled = Boolean(hw?.llamacpp_binary); + const llamaCppUp = Boolean(hw?.llamacpp_reachable); const deps = probe?.deps ?? []; const models = useMemo( () => mergeModels(probe?.installed, scan?.found), @@ -538,6 +698,12 @@ export function LocalHardwarePanel({ ? t("models.localOllamaInstalledStopped") : t("organization.off")} + + {t("models.localBuiltinRuntime")}{" "} + {llamaCppUp ? t("organization.on") : t("organization.off")} + )} @@ -585,6 +751,15 @@ export function LocalHardwarePanel({ } /> )} + {deps.some((dep) => dep.id === "llamacpp") && ( + + )} , + ); + } if (canSpeedTest(item)) { + const runtimeUp = item.provider_name?.includes("llama.cpp") + ? llamaCppUp + : ollamaUp; actions.push( testing ? ( + + + )} + {download?.resumable && + ["interrupted", "cancelled", "failed"].includes(download.status) && ( + + + {t("models.localDownloadProgress", { + current: formatBytes(download.downloaded_bytes), + total: formatBytes(download.total_bytes), + })} + + + + } + /> + )} void install(item.id)} + disabled={installing != null && installing !== item.id} + onClick={() => + void (item.install === "freeos" + ? installCatalogModel(item.id) + : install(item.id)) + } > - {t("models.localInstall")} + {t( + item.install === "freeos" + ? "models.localInstallAndRecommend" + : "models.localInstall", + )} , ]} > - + + {t(`models.localCatalogReason.${item.reason}`)} + {item.size ? {formatBytes(item.size)} : null} + + } + /> )} /> diff --git a/dashboard/src/pages/Setup/steps/ModelStep.ollama.test.tsx b/dashboard/src/pages/Setup/steps/ModelStep.ollama.test.tsx new file mode 100644 index 00000000..96acb267 --- /dev/null +++ b/dashboard/src/pages/Setup/steps/ModelStep.ollama.test.tsx @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } 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(); + +vi.mock("../../../api/request", () => ({ + request: vi.fn().mockResolvedValue([ + { + id: "ollama", + name: "Ollama (Local)", + base_url: "http://localhost:11434/v1", + protocol: "openai", + api_key_prefix: "", + models: [{ id: "qwen3:8b", name: "qwen3:8b" }], + }, + ]), +})); + +vi.mock("../../../api/modules/localModels", () => ({ + localModelsApi: { + probe: (...args: unknown[]) => probe(...args), + startOllama: (...args: unknown[]) => startOllama(...args), + }, +})); + +vi.mock("../wizardClient", () => ({ + wizardApi: { testProvider: vi.fn() }, + wizardSession: { saveDraft: vi.fn() }, + resolveSetupProbeToken: vi.fn(), +})); + +import ModelStep from "./ModelStep"; + +describe("ModelStep Ollama startup", () => { + beforeEach(() => { + vi.clearAllMocks(); + probe + .mockResolvedValueOnce({ + hardware: { + ollama_installed: true, + ollama_binary: true, + ollama_reachable: false, + }, + installed: [], + recommended: [], + }) + .mockResolvedValue({ + hardware: { + ollama_installed: true, + ollama_binary: true, + ollama_reachable: true, + }, + installed: [], + recommended: [], + }); + startOllama.mockResolvedValue({ ok: true, installed: true, running: true }); + }); + + it("offers to start an installed Ollama and refreshes its status", async () => { + render( + , + ); + + const button = await screen.findByText("models.localStartOllama"); + await userEvent.click(button); + + await waitFor(() => expect(startOllama).toHaveBeenCalledOnce()); + await waitFor(() => expect(probe).toHaveBeenCalledTimes(2)); + expect(screen.queryByText("models.localStartOllama")).toBeNull(); + }); +}); diff --git a/dashboard/src/pages/Setup/steps/ModelStep.tsx b/dashboard/src/pages/Setup/steps/ModelStep.tsx index e40633d1..262a2841 100644 --- a/dashboard/src/pages/Setup/steps/ModelStep.tsx +++ b/dashboard/src/pages/Setup/steps/ModelStep.tsx @@ -157,6 +157,7 @@ export default function ModelStep({ >([]); const [selectedModelIds, setSelectedModelIds] = useState([]); const [testing, setTesting] = useState(false); + const [startingOllama, setStartingOllama] = useState(false); const [testPassed, setTestPassed] = useState(false); const [variantGroup, setVariantGroup] = useState(null); const apiKeySectionRef = useRef(null); @@ -274,6 +275,36 @@ export default function ModelStep({ return t("wizard.model.ollamaMissing"); })(); + const canStartDetectedOllama = Boolean( + detectLocal && + localProbe && + !localProbe.hardware.ollama_reachable && + (localProbe.hardware.ollama_installed || + localProbe.hardware.ollama_binary), + ); + + const handleStartOllama = async () => { + setStartingOllama(true); + try { + const result = await localModelsApi.startOllama(); + const nextProbe = await localModelsApi.probe(); + setLocalProbe(nextProbe); + if (result.ok && nextProbe.hardware.ollama_reachable) { + message.success(t("models.localOllamaStarted")); + return; + } + message.error( + result.next_step || result.error || t("models.localOllamaStartFailed"), + ); + } catch (err) { + message.error( + err instanceof Error ? err.message : t("models.localOllamaStartFailed"), + ); + } finally { + setStartingOllama(false); + } + }; + const renderStepIntro = (fallback: string) => ( <> @@ -284,6 +315,18 @@ export default function ModelStep({ {localDetectHint} ) : null} + {canStartDetectedOllama ? ( +
+ +
+ ) : null} {detectLocal ? ( {t("wizard.model.nextHint")} diff --git a/dashboard/src/utils/localSpeedResults.test.ts b/dashboard/src/utils/localSpeedResults.test.ts index 956cfe25..0989ac4e 100644 --- a/dashboard/src/utils/localSpeedResults.test.ts +++ b/dashboard/src/utils/localSpeedResults.test.ts @@ -7,10 +7,11 @@ import { afterEach(() => { sessionStorage.clear(); + if (typeof localStorage.clear === "function") localStorage.clear(); }); describe("localSpeedResults", () => { - it("round-trips the last speed result in sessionStorage", () => { + it("round-trips the last speed result in persistent localStorage", () => { const stored = saveSpeedResult("ollama", "tiny", { ok: true, latency_ms: 120, diff --git a/dashboard/src/utils/localSpeedResults.ts b/dashboard/src/utils/localSpeedResults.ts index 5cdd016e..a0231597 100644 --- a/dashboard/src/utils/localSpeedResults.ts +++ b/dashboard/src/utils/localSpeedResults.ts @@ -16,7 +16,10 @@ export interface StoredLocalSpeedResult { function storage(): Storage | null { if (typeof window === "undefined") return null; try { - return window.sessionStorage; + const persistent = window.localStorage; + if (typeof persistent?.getItem === "function") return persistent; + const session = window.sessionStorage; + return typeof session?.getItem === "function" ? session : null; } catch { return null; } diff --git a/desktop/portable/LLAMA_CPP_LICENSE b/desktop/portable/LLAMA_CPP_LICENSE new file mode 100644 index 00000000..e7dca554 --- /dev/null +++ b/desktop/portable/LLAMA_CPP_LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023-2026 The ggml authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/desktop/portable/llamacpp-runtime.json b/desktop/portable/llamacpp-runtime.json new file mode 100644 index 00000000..dff1f998 --- /dev/null +++ b/desktop/portable/llamacpp-runtime.json @@ -0,0 +1,12 @@ +{ + "version": "b11225", + "license": "MIT", + "source": "https://github.com/ggml-org/llama.cpp", + "platforms": { + "windows-amd64": { + "archive": "llama-b11225-bin-win-vulkan-x64.zip", + "url": "https://github.com/ggml-org/llama.cpp/releases/download/b11225/llama-b11225-bin-win-vulkan-x64.zip", + "sha256": "2f59fd248b4d2756b36f664c693b7706b336323598df90d4e8b724eb1171c505" + } + } +} diff --git a/desktop/portable/package.sh b/desktop/portable/package.sh index 0a74e52f..e8eeb411 100755 --- a/desktop/portable/package.sh +++ b/desktop/portable/package.sh @@ -241,6 +241,16 @@ assemble_one() { cp "${TEMPLATES}/README.txt" "${staging}/README.txt" chmod +x "${staging}/start.sh" + # FreeOS owns this lightweight local inference fallback. The archive URL and + # digest are pinned in llamacpp-runtime.json; unsupported platforms skip. + if [[ "${SHIP_LLAMA_CPP_RUNTIME:-1}" == "1" ]]; then + if command -v python3 >/dev/null 2>&1; then + python3 "${REPO_ROOT}/desktop/portable/stage-llamacpp-runtime.py" "$plat" "$staging" + else + python "${REPO_ROOT}/desktop/portable/stage-llamacpp-runtime.py" "$plat" "$staging" + fi + fi + # Default is **zero-Node** (uv run / Docker / source / Phase-5 installer). # Desktop CI sets SHIP_OPENXYOS_RUNTIME=1 for the transitional iframe bridge. # Explicit SKIP_ORG_SIDECAR=0 still ships the sidecar even if the ship flag diff --git a/desktop/portable/stage-llamacpp-runtime.py b/desktop/portable/stage-llamacpp-runtime.py new file mode 100644 index 00000000..7cef5d82 --- /dev/null +++ b/desktop/portable/stage-llamacpp-runtime.py @@ -0,0 +1,87 @@ +"""Download and verify the pinned llama.cpp runtime for portable packages.""" + +from __future__ import annotations + +import hashlib +import json +import shutil +import sys +import tempfile +import urllib.request +import zipfile +from pathlib import Path, PurePosixPath + + +def _safe_extract(archive: Path, destination: Path) -> None: + destination.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(archive) as bundle: + for info in bundle.infolist(): + relative = PurePosixPath(info.filename) + if relative.is_absolute() or ".." in relative.parts: + raise ValueError(f"unsafe archive member: {info.filename}") + target = destination.joinpath(*relative.parts) + if info.is_dir(): + target.mkdir(parents=True, exist_ok=True) + continue + target.parent.mkdir(parents=True, exist_ok=True) + with bundle.open(info) as source, target.open("wb") as output: + shutil.copyfileobj(source, output) + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def stage(platform: str, staging: Path, *, manifest_path: Path) -> Path | None: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + entry = manifest.get("platforms", {}).get(platform) + if not isinstance(entry, dict): + return None + expected = str(entry["sha256"]).lower() + destination = staging / "llama.cpp" + with tempfile.TemporaryDirectory(prefix="freeos-llamacpp-") as temp: + archive = Path(temp) / str(entry["archive"]) + urllib.request.urlretrieve(str(entry["url"]), archive) # noqa: S310 + actual = _sha256(archive) + if actual != expected: + raise ValueError(f"llama.cpp archive checksum mismatch: {actual}") + if destination.exists(): + shutil.rmtree(destination) + _safe_extract(archive, destination) + server = destination / ("llama-server.exe" if platform.startswith("windows-") else "llama-server") + if not server.is_file(): + raise FileNotFoundError(f"llama-server missing after extraction: {server}") + license_path = Path(__file__).with_name("LLAMA_CPP_LICENSE") + shutil.copy2(license_path, destination / "LICENSE-llama.cpp") + metadata = { + "version": manifest["version"], + "license": manifest["license"], + "source": manifest["source"], + "archive": entry["archive"], + "sha256": expected, + } + (destination / "FREEOS_RUNTIME.json").write_text( + json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + return destination + + +def main(argv: list[str]) -> int: + if len(argv) != 3: + print("usage: stage-llamacpp-runtime.py ", file=sys.stderr) + return 2 + manifest = Path(__file__).with_name("llamacpp-runtime.json") + result = stage(argv[1], Path(argv[2]), manifest_path=manifest) + if result is None: + print(f"[llama.cpp] no pinned runtime for {argv[1]}; skipping") + else: + print(f"[llama.cpp] staged {result}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/docs/api.md b/docs/api.md index 15ffc841..cd46c2d9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -217,8 +217,16 @@ require the `ollama_models` permission. |--------|------|------|-------| | `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 | +| `GET` | `/local-models/llamacpp/status` | ollama_models | Inspect the bundled llama.cpp runtime and its loopback service | +| `POST` | `/local-models/llamacpp/start` | ollama_models | body `{model_path, alias?, context_size?, gpu_layers?}` — start a GGUF model and remember it for restart recovery | +| `DELETE` | `/local-models/llamacpp` | ollama_models | Stop the managed llama.cpp process and disable automatic restart recovery | | `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 | +| `GET` | `/local-models/catalog` | ollama_models | List pinned, checksum-verified GGUF models recommended for this device | +| `POST` | `/local-models/downloads` | ollama_models | body `{catalog_id}` — create or resume a trusted GGUF download | +| `GET` | `/local-models/downloads` | ollama_models | List recent downloads, including interrupted resumable jobs | +| `GET` | `/local-models/downloads/{job_id}` | ollama_models | Poll durable download state and progress | +| `DELETE` | `/local-models/downloads/{job_id}` | ollama_models | Cancel a download while retaining its partial file for later resume | | `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 | diff --git a/docs/install-0.0.7.zh-CN.md b/docs/install-0.0.7.zh-CN.md new file mode 100644 index 00000000..7f1e4dee --- /dev/null +++ b/docs/install-0.0.7.zh-CN.md @@ -0,0 +1,33 @@ +# FreeOS 0.0.7 安装与本机模型指南 + +0.0.7 应只从 FreeOS 的 [GitHub Release v0.0.7](https://github.com/XYAIStudio/FreeOS/releases/tag/v0.0.7) 下载,不要把开发分支构建当作正式安装包。 + +## Windows + +- 常见 Intel/AMD 电脑:下载 `FreeOS-desktop-windows-amd64-0.0.7.exe`。 +- Windows ARM 电脑:下载 `FreeOS-desktop-windows-arm64-0.0.7.exe`。 +- 免安装版:下载对应架构的 `FreeOS-portable-windows-*.zip`,完整解压后运行。 + +升级前先从系统托盘退出旧版 FreeOS,再运行安装程序。安装程序保留 `%USERPROFILE%\.freeos` 中的账号、模型设置、知识库和组织数据。 + +## 首次选择本机模型 + +1. 如果电脑已安装 Ollama,FreeOS 会显示当前状态;服务未启动时,用户可点击“启动 Ollama”。 +2. 如果没有 Ollama,可在“设置 → 模型 → 本地”选择 FreeOS 推荐的 GGUF 模型并点击一键安装。Windows amd64 包已带 llama.cpp 运行时,不需要另装推理程序。 +3. FreeOS 会显示下载进度;关闭程序、网络中断或取消后,再次进入模型页可继续下载。 +4. 下载完成后,FreeOS 会校验文件、启动模型、执行轻量测速并将成功的模型设为默认对话模型。 +5. 测速结果会保留在本机。用户仍可手动测速、切换默认模型或停止 llama.cpp。 + +模型文件通常较大。FreeOS 会在下载前检查目标磁盘的剩余空间,并额外预留 256 MiB。模型默认保存在 FreeOS 数据目录的 `models` 文件夹。 + +## macOS 与 Linux + +下载与设备架构一致的桌面包或 portable 包。当前内置 llama.cpp 运行时首先覆盖 Windows amd64;其他平台可以使用已安装的 Ollama,或接入 LM Studio、llama.cpp server、vLLM 等 OpenAI 兼容本机服务。 + +## 常见问题 + +- **下载中断**:重新进入本地模型页,点击“继续下载”。不要手工删除同名 `.part` 文件。 +- **模型启动失败**:查看 `%USERPROFILE%\.freeos\logs\llama-sidecar.log`,确认显存或内存足够。 +- **重启后模型不可用**:先确认模型文件仍在原路径;用户曾主动点击“停止”时,FreeOS 不会自动恢复。 +- **Ollama 已安装但未运行**:在首次配置或本地模型页点击“启动 Ollama”,FreeOS 不会在未征得用户选择时强制启动它。 +- **诊断资料**:反馈时附上发生时间、FreeOS 版本和相关日志,并移除 API Key、令牌等敏感信息。 diff --git a/docs/local-model-auto-benchmark-routing-plan.md b/docs/local-model-auto-benchmark-routing-plan.md new file mode 100644 index 00000000..f981b95a --- /dev/null +++ b/docs/local-model-auto-benchmark-routing-plan.md @@ -0,0 +1,496 @@ +# FreeOS 本地模型自动评测与场景路由规划 + +状态:0.0.7 核心功能候选 + +范围:本地模型运行环境安装、模型下载、Ollama 模型评测与路由;兼容用户已有 Ollama 和其他 OpenAI 兼容本地运行时 + +原则:本地优先、可解释、用户可覆盖、低扰动、不上传提示或结果 + +## 1. 要解决的问题 + +普通用户通常只知道已经下载了哪些模型,不知道模型在自己的 CPU、GPU、内存和运行参数下是否真正可用。模型参数量、厂商宣传和社区榜单不能代替本机实测。FreeOS 需要把“选择模型”从一次人工猜测改成可重复、可解释的本机评测。 + +目标不是选出一个全局最快模型,而是为不同场景提供合适候选: + +1. **实时聊天**:首字快、连续输出稳定,适合长时间陪伴和高频问答。 +2. **长时任务**:长上下文稳定,失败率低,持续生成速度不会明显衰减。 +3. **工具智能体**:能正确调用工具并产出合法参数。 +4. **视觉理解**:能接收图像并完成基础识别任务。 +5. **低资源模式**:在内存或显存紧张时仍可稳定工作。 + +明确不做: + +- 不根据模型名称或参数量直接宣称“最好”。 +- 不在用户未确认模型大小、磁盘位置和预计下载量时下载模型,也不删除用户模型。 +- 不在每次启动时跑完整基准。 +- 不在一轮对话中频繁切换模型,破坏上下文一致性。 +- 不把本机硬件、测试提示、模型输出或评分上传到 FreeOS 服务。 + +## 2. 现有基础与改造边界 + +现有能力可以直接复用: + +- `infra/agents/providers/local_speed.py` 已能测量总延迟、首字时间和近似 tokens/s。 +- `/api/local-models/probe` 已能发现硬件、Ollama 和已安装模型。 +- `/api/local-models/speed-test` 已提供单模型手动测速。 +- `local_default.py`、用户偏好和 provider store 已负责默认模型解析。 +- gateway processor 已支持线程模型覆盖、智能体默认模型和多模态升级。 +- 0.0.6 的 Ollama tool fallback 已能识别“不支持 tools”,但降级结果目前不是持久化能力画像。 + +本功能新增一个领域服务,并保持 API 路由为薄适配层: + +```text +dashboard/模型页 + │ + ▼ +api/routers/local_runtime.py + │ + ▼ +infra/agents/local_runtime/ + detector.py + installer.py + service.py + model_catalog.py + downloads.py + │ + ▼ +api/routers/local_model_benchmarks.py + │ + ▼ +infra/agents/model_selection/ + benchmark_service.py + capability_probe.py + scoring.py + router.py + fingerprints.py + │ + ├── provider store / Ollama HTTP + └── db repos / settings +``` + +运行环境检测、安装状态机、下载任务、模型选择和评分属于 `infra/` 领域逻辑;HTTP 状态码、请求校验和 SSE 进度属于 `api/`;结果展示和人工覆盖属于 `dashboard/`。桌面壳只负责调用经过校验的系统安装能力和启动本机服务,不在 dashboard 中直接执行命令。 + +## 3. 用户流程 + +### 3.1 本地运行环境引导 + +FreeOS 首次进入模型设置时先检测本地模型运行环境,并区分以下状态: + +| 状态 | 判断依据 | 用户操作 | +|---|---|---| +| `not_installed` | 找不到 Ollama 可执行文件,loopback API 也不可达 | 显示“一键安装本地模型环境” | +| `installed_stopped` | 找到可信可执行文件,但 `/api/tags` 不可达 | 显示“启动服务”并给出诊断 | +| `running_empty` | `/api/tags` 返回合法空模型列表 | 显示推荐模型和下载按钮 | +| `running_ready` | `/api/tags` 返回至少一个有效模型 | 进入模型发现和评测 | +| `external_runtime` | 用户配置了可达的 OpenAI 兼容本地地址 | 验证连接后注册,不要求安装 Ollama | +| `broken` | 安装残缺、版本不兼容或服务启动失败 | 显示具体原因、日志位置和修复入口 | + +“一键安装”采用用户可见的任务状态机: + +```text +检测系统与架构 + → 展示来源、版本、许可、下载体积和安装位置 + → 用户确认 + → 下载到临时目录 + → 校验 HTTPS 来源、文件摘要和数字签名(平台支持时) + → 调用官方静默安装器或受控包管理命令 + → 启动本机服务 + → 轮询 loopback 健康状态 + → 显示安装成功或可操作的失败原因 +``` + +首版支持 Windows;macOS 和 Linux 只有在安装方式、权限提示、卸载路径及 CI/实机验收完整后才开放按钮。安装需要管理员权限时,由操作系统权限窗口向用户确认,FreeOS 不保存管理员密码。安装包不内嵌未知版本的 Ollama,也不从第三方镜像执行脚本;版本清单由 FreeOS 发布时固定,并允许后续受签名清单更新。 + +若检测到用户已有 Ollama,FreeOS 只连接并验证,不覆盖安装、不修改服务启动方式。若端口被其他程序占用,则停止流程并显示占用信息,不能通过杀死未知进程来“修复”。 + +### 3.2 模型选择与下载 + +运行环境就绪但没有模型时,FreeOS 根据硬件和剩余磁盘空间显示一个小型推荐目录。目录元数据包括模型标识、量化、下载体积、预计内存/显存需求、上下文和已知能力;这些是筛选信息,不等同于本机实测成绩。 + +默认只推荐少量经过 FreeOS 验证的模型档位,例如低资源聊天、通用聊天、工具模型和视觉模型。用户确认后通过 Ollama API 拉取模型,并获得可取消、可重试的进度。下载使用 Ollama 自己的模型存储和断点机制;FreeOS 不复制模型文件,也不自行解析 Ollama blob。下载前必须检查磁盘余量并保留安全空间,完成后重新读取 `/api/tags`,只有返回对应模型 digest 才记为成功。 + +用户还可以: + +- 跳过推荐,在完整目录中输入合法模型标识并确认下载风险。 +- 使用已经安装的模型,不重复下载。 +- 注册 `127.0.0.1` / `localhost` 上的 OpenAI 兼容服务并测试连接。 +- 暂时跳过本地模型,继续使用已配置的云模型。 + +### 3.3 首次发现与评测 + +1. FreeOS 扫描已安装并已注册的本地模型。 +2. 如果存在两个或更多未评测模型,显示“为本机推荐模型”卡片。 +3. 用户点击开始后先展示预计耗时、资源占用和将要测试的模型。 +4. 默认执行 2–5 分钟轻量评测;用户可以取消。 +5. 完成后分别给出“实时聊天”“长时任务”“工具智能体”“视觉理解”推荐。 +6. 用户可以接受某一推荐、维持当前模型或锁定指定模型。 + +首次版本不在无明确提示时自动触发高负载测试。后续可增加“设备空闲时自动复测”,默认关闭。 + +### 3.4 日常使用 + +- 对话创建时根据任务要求选择模型,并在该线程内保持稳定。 +- 若用户手动选定模型或智能体设置了 `default_model`,始终优先使用用户设置。 +- 当请求含图片、工具或长上下文时,只在满足能力硬约束的候选中评分。 +- 当前模型连续失败时可以切换至同场景备选,但必须在界面显示切换原因。 +- 已开始生成有效内容后不自动更换模型;失败重试发生在新一次模型调用上。 + +### 3.5 复测条件 + +以下指纹变化时将结果标记为“需要复测”,而不是立即启动测试: + +- 模型 digest、量化版本或模型参数变化。 +- CPU、GPU、显存、内存或 Ollama 版本变化。 +- FreeOS 基准套件版本变化。 +- 用户改变上下文长度、GPU 层数或运行参数。 +- 结果超过 30 天,或近期真实调用稳定性明显恶化。 + +## 4. 基准套件 + +### 4.1 两级评测 + +**快速评测**用于首次推荐:每个模型约 20–45 秒。 + +- 冷启动一次:记录模型加载时间。 +- 热启动两次:记录首字时间和 tokens/s,取中位数。 +- 256–512 token 上下文测试一次。 +- 能力声明与最小能力探测。 +- 进程峰值内存、显存信息在运行时可获得时记录;不可获得时明确标为未知。 + +**完整评测**由用户主动选择:每个模型约 2–5 分钟。 + +- 冷、热启动分开统计。 +- 2K、8K 上下文档位;更大档位仅在模型声明和设备资源允许时执行。 +- 固定长度持续生成,观察 tokens/s 衰减和超时。 +- 工具调用、结构化输出和视觉探测。 +- 重复三次,保存中位数、P90 和成功率。 + +### 4.2 指标定义 + +| 指标 | 定义 | 用途 | +|---|---|---| +| `load_ms` | 冷启动到请求可处理的时间 | 首次体验、切换成本 | +| `ttft_ms` | 请求发送到第一个非空输出片段 | 实时聊天核心指标 | +| `decode_tokens_per_sec` | 模型报告的 eval_count / eval_duration,缺失时才使用近似值 | 持续生成速度 | +| `total_latency_ms` | 完整请求总耗时 | 综合延迟 | +| `success_rate` | 有效完成次数 / 总次数 | 稳定性硬指标 | +| `timeout_rate` | 超时次数 / 总次数 | 排除不可用候选 | +| `long_context_pass` | 指定上下文档位是否成功并回答校验点 | 长时任务约束 | +| `tool_call_pass` | 是否返回合法工具名及符合 schema 的参数 | 工具智能体约束 | +| `vision_pass` | 是否接受图片并返回可验证内容 | 视觉任务约束 | +| `json_schema_pass` | 是否能稳定生成符合 schema 的输出 | 工作流约束 | +| `peak_ram_mb` / `peak_vram_mb` | 测试期间可观测峰值 | 资源安全与低资源推荐 | +| `sustained_speed_ratio` | 长生成后半段速度 / 前半段速度 | 热降频和长时稳定性 | + +tokens/s 必须优先使用运行时给出的真实 token 计数。只有片段数量时,字段应标为 `estimated_tokens_per_sec`,不能与真实 tokens/s 混用。 + +### 4.3 能力探测 + +- **工具调用**:提供一个无副作用的本地 `echo` schema,要求返回固定参数;禁止执行文件、网络或系统工具。 +- **视觉**:使用随安装包提供的小型测试图,不读取用户照片。 +- **长上下文**:在合成文本中放置随机校验码,要求模型准确找回;不测试主观回答质量。 +- **结构化输出**:校验 JSON schema,不用模糊文本匹配代替。 +- **中文基础能力**:只作为最低可用门槛,首版不把小型题库分数包装成通用智力评分。 + +## 5. 评分与推荐算法 + +### 5.1 先约束,再评分 + +路由先应用硬约束: + +```text +视觉请求 → vision_pass = true +工具请求 → tool_call_pass = true +目标上下文 N → verified_context_tokens >= N +内存安全 → 未触发 OOM,且资源余量高于安全阈值 +用户锁定 → 直接使用锁定模型;能力不满足时明确提示 +``` + +不满足硬约束的模型不会因为速度快而进入候选。 + +### 5.2 归一化 + +评分只在本机、同一基准版本和同一场景的候选模型之间归一化。延迟类指标使用对数缩放,避免极端慢模型把其他模型挤在很小区间;缺失指标不按零分处理,而是降低置信度。 + +```text +higher_is_better(x) = percentile_rank(log1p(x)) +lower_is_better(x) = 1 - percentile_rank(log1p(x)) +confidence = 已完成必测项权重 / 场景必测项总权重 +``` + +### 5.3 首版权重 + +| 场景 | TTFT | tokens/s | 稳定性 | 长上下文 | 资源余量 | 能力校验 | +|---|---:|---:|---:|---:|---:|---:| +| 实时聊天 | 35% | 25% | 25% | 5% | 10% | 硬约束 | +| 长时任务 | 10% | 20% | 30% | 25% | 15% | 硬约束 | +| 工具智能体 | 15% | 15% | 30% | 15% | 10% | 15% + 硬约束 | +| 视觉理解 | 15% | 15% | 25% | 10% | 10% | 25% + 硬约束 | +| 低资源 | 20% | 15% | 25% | 5% | 35% | 硬约束 | + +总分用于排序,推荐理由必须引用原始事实,例如:“热启动首字中位数 620ms;3/3 成功;工具探测通过;峰值显存 4.1GB”。不展示无法从测试推导的笼统结论。 + +### 5.4 抖动控制 + +- 新模型需比当前模型高至少 8 分,或当前模型不满足硬约束,才建议切换。 +- 同一线程不因微小分数变化切换。 +- 自动故障转移后设置冷却时间,避免两个模型来回切换。 +- 连续 3 次同类运行时故障才降低健康评分;用户取消、网络断开和应用退出不计入模型失败。 + +## 6. 数据模型 + +新增迁移需要同时提供 SQLite 和 PostgreSQL 版本,并遵循当前未发布 schema 的折叠规则。 + +### `local_runtime_state` + +运行环境本身只需要一份当前状态,可优先放入现有 settings;若安装任务需要跨重启恢复,再建立任务表: + +- `runtime_kind`、`detected_version`、`executable_path_hash`、`api_base_url`。 +- `status`、`last_health_check_at`、`last_error_code`。 +- `managed_by_freeos`:标记本次安装是否由 FreeOS 发起,不能据此接管用户原有安装。 +- `install_manifest_version`、`installer_sha256`;不保存提权凭据。 +- 模型下载任务保存 `model_id`、阶段、已下载/总字节、错误分类和时间;不保存 Ollama blob 路径。 + +### `local_model_profiles` + +每个模型指纹一行,保存最近聚合结果: + +- `model_profile_id`:公开 ULID。 +- `provider_name`、`model_id`、`model_digest`。 +- `runtime_kind`、`runtime_version`。 +- `hardware_fingerprint`:仅保存本机不可逆摘要。 +- `benchmark_suite_version`、`status`、`started_at`、`completed_at`。 +- `metrics_json`、`capabilities_json`、`scenario_scores_json`。 +- `confidence`、`stale_reason`、`last_error_code`。 + +唯一键:`provider_name + model_id + model_digest + hardware_fingerprint + benchmark_suite_version`。 + +### `local_model_benchmark_runs` + +保存有限期运行记录,用于审计和重新聚合: + +- `benchmark_run_id`、`model_profile_id`、`mode`、`status`。 +- 每个测试项的耗时、指标、错误分类和开始结束时间。 +- 不保存自由文本模型输出,只保存校验是否通过和必要摘要。 +- 默认保留最近 10 次或 30 天,由用户清除。 + +### `local_model_routing_prefs` + +建议优先放入现有用户偏好 JSON,避免过早增加表: + +```json +{ + "mode": "recommend", + "locked_model": null, + "scenario_overrides": { + "realtime_chat": null, + "long_task": null, + "tool_agent": null, + "vision": null + }, + "allow_background_benchmark": false, + "allow_runtime_failover": true +} +``` + +`mode` 首版提供 `manual` 和 `recommend`。等推荐逻辑经过真实用户验证后,再增加默认自动路由模式。 + +## 7. API 设计 + +建议新增: + +| 方法 | 路径 | 作用 | +|---|---|---| +| `GET` | `/api/local-runtime/status` | 返回运行环境检测、服务健康和可用安装动作 | +| `POST` | `/api/local-runtime/install` | 用户确认后创建受控安装任务 | +| `GET` | `/api/local-runtime/install/{job_id}` | 查询下载、校验、安装、启动和健康检查进度 | +| `DELETE` | `/api/local-runtime/install/{job_id}` | 在可取消阶段停止任务并清理临时文件 | +| `POST` | `/api/local-runtime/start` | 启动已安装但停止的本地运行时 | +| `GET` | `/api/local-models/catalog` | 返回按硬件过滤的推荐模型及资源要求 | +| `POST` | `/api/local-models/pulls` | 用户确认后创建 Ollama 模型下载任务 | +| `GET` | `/api/local-models/pulls/{job_id}` | 查询模型下载和校验进度 | +| `DELETE` | `/api/local-models/pulls/{job_id}` | 取消尚未完成的模型下载 | +| `GET` | `/api/local-models/benchmarks` | 列出模型画像、评分、陈旧原因和当前任务 | +| `POST` | `/api/local-models/benchmarks` | 创建批量评测任务 | +| `GET` | `/api/local-models/benchmarks/{job_id}` | 查询任务与逐模型进度 | +| `DELETE` | `/api/local-models/benchmarks/{job_id}` | 协作取消,保留已完成结果 | +| `POST` | `/api/local-models/benchmarks/{profile_id}/retry` | 重测单一模型 | +| `GET` | `/api/local-models/recommendations` | 返回各场景推荐、备选、理由与置信度 | +| `PUT` | `/api/local-models/routing-preferences` | 保存人工锁定和自动策略 | + +批量任务使用轮询或 SSE 返回阶段:`queued → loading → warmup → latency → sustained → capability → scoring → completed`。任务必须限制为单机一次只运行一个重型模型,避免多个模型同时占满显存。 + +请求示例: + +```json +{ + "model_refs": ["Ollama (Local)/qwen2.5:7b", "Ollama (Local)/llama3.2:3b"], + "mode": "quick", + "scenarios": ["realtime_chat", "long_task", "tool_agent"] +} +``` + +推荐响应必须同时返回 `eligible`、`disqualifiers`、`score`、`confidence` 和原始关键指标,供 UI 解释。 + +## 8. 运行时路由 + +在 gateway processor 现有 `_resolve_harness_model()` 前增加一个领域选择步骤,保持优先级明确: + +```text +线程手动覆盖 + > 智能体显式 default_model + > 用户场景锁定 + > 满足硬约束且置信度足够的本机推荐 + > 现有全局 active model / fallback +``` + +场景推断首版只使用确定性信号: + +- 附件含图片 → `vision`。 +- 当前智能体拥有工具且本轮允许工具 → `tool_agent`。 +- 估算上下文超过已验证阈值的 60% → `long_task`。 +- 其他 → `realtime_chat`。 + +不使用另一个 LLM 来判断路由,以免增加延迟、成本和循环依赖。 + +### 故障转移 + +允许故障转移的错误:模型明确不支持所需能力、OOM、服务端 5xx、模型进程退出、在限定时间内无首字。认证失败、用户取消、输入不合法和工具自身错误不触发换模型。 + +故障转移顺序:同场景第二名 → 当前全局默认 → 返回明确错误。每次切换写入结构化审计事件,并在聊天界面显示“已从 A 切换到 B:A 工具调用不受支持”。 + +## 9. 前端设计 + +模型设置页新增“本机模型推荐”区域: + +- 未安装运行环境时先显示三步引导:“安装运行环境 → 下载模型 → 本机评测”,当前步骤以外的按钮禁用并说明原因。 +- 安装确认页显示官方来源、固定版本、下载体积、安装位置、管理员权限需求和隐私说明。 +- 模型目录根据硬件分档,显示下载大小、预计内存/显存、能力与磁盘余量;下载进度可取消、失败可重试。 +- 对已有 Ollama、外部 loopback 服务和云模型提供等权入口,不强迫用户重复安装或下载。 +- 顶部显示硬件摘要、上次评测时间、基准版本和“开始快速评测”。 +- 模型表显示状态、TTFT、tokens/s、稳定性、上下文、视觉、工具、资源占用。 +- 推荐卡按场景排列,展示首选、备选、分数、置信度和三条主要理由。 +- 支持“设为此场景默认”“锁定当前模型”“取消评测”“完整评测”。 +- 结果陈旧时显示具体原因,不能继续以绿色“推荐”展示。 + +聊天消息的模型徽标应保留实际执行模型;发生自动切换时记录两个模型和原因,避免用户误以为一直由原模型回答。 + +## 10. 调度、资源与安全 + +- 评测默认串行;GPU/统一内存低于安全余量时暂停并提示。 +- 笔记本使用电池、系统高负载、正在语音通话或有活跃生成时不启动后台评测。 +- 每项测试设独立超时,总任务有预算;取消必须能终止后续请求并释放模型。 +- 所有提示均来自版本化内置基准,不读取用户聊天、知识库或组织数据。 +- 日志不得记录模型完整输出、API Key、用户目录或硬件序列号。 +- 安装器只能来自 HTTPS 官方发布地址或操作系统可信包管理器;下载后必须核对发布清单中的 SHA-256,并在平台支持时验证发布者签名。 +- 安装、启动、下载模型分别使用允许列表参数调用,禁止将模型名、路径或服务输出拼接成 shell 命令。 +- 安装前检查系统架构、磁盘余量和当前任务;失败时清理 FreeOS 临时下载,但不删除用户现有 Ollama、模型或配置。 +- FreeOS 不静默卸载运行时;卸载属于独立的用户确认操作,首版可只提供官方卸载指南。 +- 远程 URL 即使配置为“本地 provider”,也不能被当成本机基准;首版仅允许 loopback Ollama。 +- 基准接口沿用 `ollama_models` 权限;普通用户只能查看和修改自己的推荐偏好。 + +## 11. 可观测性 + +新增结构化指标: + +- `local_runtime_install_started/completed/cancelled/failed` +- `local_model_pull_started/completed/cancelled/failed` +- `local_model_benchmark_started/completed/cancelled/failed` +- `local_model_benchmark_duration_seconds` +- `local_model_route_selected{scenario,reason}` +- `local_model_failover{error_class}` +- `local_model_recommendation_accepted/rejected` + +指标只记录类别和计数,不包含模型输出和硬件原始标识。诊断页提供本机可导出的 JSON 报告,默认脱敏。 + +## 12. 分阶段交付 + +### M0:运行环境与模型就绪 + +- Windows Ollama 检测、一键安装、启动和 loopback 健康检查。 +- 固定版本与摘要清单、官方来源校验、管理员权限提示和失败诊断。 +- 硬件分档模型目录、磁盘预检、下载/取消/重试及完成后 digest 验证。 +- 已有 Ollama、OpenAI 兼容 loopback 服务和云模型的跳过路径。 + +验收:在一台未安装 Ollama 的干净 Windows 虚拟机中,普通用户可从 FreeOS 完成确认、安装、启动、小模型下载并进行一次真实对话;取消安装和取消模型下载不会留下 FreeOS 临时文件;已有 Ollama 和模型不会被覆盖。 + +### M1:可信批量评测 + +- 后端 profile/run 存储、模型指纹、任务取消与串行执行。 +- 快速评测:冷/热 TTFT、真实 tokens/s、成功率、资源可用性。 +- 模型页批量进度和结果表。 +- 不改变聊天实际选模。 + +验收:两种以上 Ollama 模型可在 Windows/macOS/Linux 完成、取消并复用结果;重启后结果仍在;模型 digest 变化后结果变陈旧。 + +### M2:能力探测与场景推荐 + +- 工具、视觉、长上下文、JSON schema 探测。 +- 场景硬约束、评分、置信度和解释。 +- 用户接受推荐、场景锁定和手动覆盖。 + +验收:不支持 tools 的 qwen2.5vl:3b 不进入工具智能体候选,但可进入通过验证的视觉候选;缺失数据不会被伪装成零分或已通过。 + +### M3:受控运行时路由 + +- 接入 `_resolve_harness_model()` 优先级链。 +- 线程粘性、明确切换提示、同场景故障转移和冷却。 +- 实际调用健康反馈只影响稳定性,不覆盖离线基准原始数据。 + +验收:手动锁定始终优先;视觉/工具/长上下文硬约束生效;失败不会产生模型切换循环;历史消息显示实际执行模型。 + +### M4:空闲复测与长期优化 + +- 可选的空闲时复测、速度衰减检测和推荐变化提示。 +- 根据匿名关闭的本机接受/拒绝行为调整用户自己的权重,不进行云端训练。 +- 扩展 LM Studio、vLLM 等 loopback OpenAI 兼容运行时。 + +## 13. 测试与发布门禁 + +单元测试: + +- 系统/架构检测、安装状态机、版本清单和摘要校验、允许列表参数及磁盘安全阈值。 +- 模型目录硬件过滤、下载进度解析、取消、重试和 digest 完成校验。 +- 指纹稳定性、指标聚合、缺失值、硬约束、归一化和抖动控制。 +- 工具/视觉/上下文探测的成功、能力不支持、超时、OOM 和取消。 +- 路由优先级、线程粘性、故障分类与冷却。 +- SQLite/PostgreSQL repo 和迁移等价性。 + +集成测试: + +- 伪安装器与伪 Ollama 服务覆盖未安装、已停止、空模型、已有模型、版本不兼容和端口占用。 +- 安装或下载中断后重启 FreeOS,状态可恢复或明确失败,不会误报成功。 +- 伪 Ollama SSE 流覆盖冷/热指标及错误分类。 +- API 权限、任务并发限制、取消和重启恢复。 +- 用户接受推荐后默认模型更新;手动锁定不被覆盖。 + +真实验收矩阵: + +- 干净 Windows 虚拟机一键安装;已有 Ollama;已有模型;无管理员权限;磁盘不足;下载中断;端口占用。 +- Windows NVIDIA、Windows 纯 CPU、Apple Silicon、Linux NVIDIA。 +- 至少一个文本模型、一个视觉但不支持 tools 的模型、一个工具模型。 +- 快速评测、完整评测、应用重启、模型更新、低内存、Ollama 中途退出。 +- 连续长对话至少 60 分钟,确认无频繁切换、无显存持续增长、失败可恢复。 + +发布门禁仍为 `make all`,并增加 dashboard 类型检查、真实 Ollama 可选测试和桌面安装包隔离验收。模拟测试通过不等于真实模型和安装包验收。 + +## 14. 成功指标 + +- 首次有两个以上本地模型的用户,80% 能在 5 分钟内得到至少一个高置信度推荐。 +- 接受推荐后的实时聊天 TTFT 中位数相对原默认模型改善至少 20%,或稳定性显著提高。 +- 因模型能力不匹配导致的 `stream_error` 降低 80%。 +- 自动故障转移循环为零;错误切换率低于 1%。 +- 用户手动锁定被覆盖的事件为零。 +- 基准期间用户数据外发事件为零。 + +## 15. 开发顺序与估算 + +建议五个迭代完成: + +1. **1 周**:Windows 运行环境检测、一键安装、健康检查、模型目录与下载状态机。 +2. **1 周**:数据结构、指纹、批量任务、现有测速重构和 API。 +3. **1 周**:能力探测、评分解释、模型页 UI。 +4. **1 周**:受控路由、线程粘性、故障转移和审计事件。 +5. **1 周**:干净机器及多硬件真实验收、性能调优、文档与灰度开关。 + +M0、M1 和 M2 可以在 0.0.7 默认提供;M3 建议先以“推荐模式”灰度,收集本机验收证据后再把自动路由设为可选。macOS/Linux 一键安装和 M4 不应阻塞 0.0.7,但必须保留手动连接现有运行时的路径。 diff --git a/pyproject.toml b/pyproject.toml index 2b226f42..36a20594 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "octop" -version = "0.0.6" +version = "0.0.7" description = "FreeOS — self-hosted multi-user multi-agent assistant with an optional organization OS module (Octop-compatible package name)" readme = "README.md" license = { text = "MIT" } diff --git a/src/octop/__init__.py b/src/octop/__init__.py index 51428754..ac8e926d 100644 --- a/src/octop/__init__.py +++ b/src/octop/__init__.py @@ -2,4 +2,4 @@ from __future__ import annotations -__version__ = "0.0.6" +__version__ = "0.0.7" diff --git a/src/octop/api/routers/local_models.py b/src/octop/api/routers/local_models.py index 424703af..342f29d7 100644 --- a/src/octop/api/routers/local_models.py +++ b/src/octop/api/routers/local_models.py @@ -11,18 +11,41 @@ from pydantic import BaseModel, Field from octop.api.deps import current_user, get_server, require_permission +from octop.infra.agents.providers.llamacpp_runtime import ( + LLAMACPP_PROVIDER_NAME, +) +from octop.infra.agents.providers.llamacpp_runtime import ( + start as start_llamacpp, +) +from octop.infra.agents.providers.llamacpp_runtime import ( + status as llamacpp_status, +) +from octop.infra.agents.providers.llamacpp_runtime import ( + stop as stop_llamacpp, +) +from octop.infra.agents.providers.llamacpp_runtime import ( + upsert_provider as upsert_llamacpp_provider, +) +from octop.infra.agents.providers.local_catalog import catalog from octop.infra.agents.providers.local_default import ( annotate_local_models, provider_base_url, resolve_local_model_ref, resolve_registered_or_usable, ) +from octop.infra.agents.providers.local_download import ( + cancel_download_job, + get_download_job, + list_download_jobs, + start_download_job, +) from octop.infra.agents.providers.local_probe import probe_local_models from octop.infra.agents.providers.local_register import ( ensure_ollama_service_flag, find_ollama_row, load_registered, register_local_weight, + remember_weight, upsert_ollama_model, ) from octop.infra.agents.providers.local_scan import ( @@ -58,6 +81,10 @@ class LocalInstallBody(BaseModel): name: str = Field(min_length=1, max_length=120, description="Ollama model tag to pull") +class LocalCatalogDownloadBody(BaseModel): + catalog_id: str = Field(min_length=1, max_length=120, description="Trusted catalog model id") + + class LocalScanBody(BaseModel): root: str | None = Field(default=None, max_length=1024, description="Optional folder to scan") full_disk: bool = Field( @@ -90,6 +117,23 @@ class LocalEnsureBody(BaseModel): class LocalSpeedTestBody(BaseModel): name: str = Field(min_length=1, max_length=120, description="Local model tag to ping") + provider_name: str | None = Field(default=None, max_length=160) + + +class LlamaCppStartBody(BaseModel): + model_path: str = Field( + min_length=1, + max_length=1024, + description="Absolute path to an existing GGUF model", + ) + alias: str = Field(default="", max_length=80, description="Display/model id") + context_size: int = Field(default=8192, ge=512, le=262_144) + gpu_layers: int = Field( + default=-1, + ge=-1, + le=999, + description="-1 lets llama.cpp choose the maximum supported GPU offload", + ) class LocalDefaultBody(BaseModel): @@ -98,6 +142,7 @@ class LocalDefaultBody(BaseModel): max_length=120, description="Registered local model to use as the chat default", ) + provider_name: str | None = Field(default=None, max_length=160) def register_failure(exc: BaseException) -> OctopError: @@ -157,6 +202,80 @@ async def local_models_start_ollama( return await asyncio.to_thread(start_ollama_service_result) +@router.get("/llamacpp/status", summary="Inspect the bundled llama.cpp sidecar") +async def local_models_llamacpp_status( + _: Any = Depends(require_permission("ollama_models")), +) -> dict[str, Any]: + return await asyncio.to_thread(llamacpp_status) + + +@router.post("/llamacpp/start", summary="Start the bundled llama.cpp sidecar with a GGUF") +async def local_models_llamacpp_start( + body: LlamaCppStartBody, + server: OctopServer = Depends(get_server), + _: Any = Depends(require_permission("ollama_models")), +) -> dict[str, Any]: + services = server.services + if services is None: + raise register_failure(OSError("provider store is not available")) + try: + result = await asyncio.to_thread( + start_llamacpp, + model_path=body.model_path, + alias=body.alias, + context_size=body.context_size, + gpu_layers=body.gpu_layers, + ) + except (OSError, ValueError, subprocess.SubprocessError) as exc: + raise register_failure(exc) from exc + if not result.get("ok"): + return result + alias = str(result.get("alias") or body.alias).strip() + try: + provider_name = await asyncio.to_thread( + upsert_llamacpp_provider, + services.provider_repo, + alias=alias, + model_path=body.model_path, + ) + except (OSError, ValueError, TypeError, AttributeError) as exc: + await asyncio.to_thread(stop_llamacpp) + raise register_failure(exc) from exc + await asyncio.to_thread( + remember_weight, + services.settings_repo, + { + "name": alias, + "path": body.model_path, + "source": "gguf", + "registered": True, + "registerable": False, + "provider_name": provider_name, + }, + ) + await _reload_after_register(server, provider_name, alias) + return {**result, "provider_name": provider_name, "name": alias, "registered": True} + + +@router.delete("/llamacpp", summary="Stop the FreeOS-managed llama.cpp sidecar") +async def local_models_llamacpp_stop( + server: OctopServer = Depends(get_server), + _: Any = Depends(require_permission("ollama_models")), +) -> dict[str, Any]: + result = await asyncio.to_thread(stop_llamacpp) + services = server.services + if services is None: + return result + row = services.provider_repo.get_by_name(LLAMACPP_PROVIDER_NAME) + if row is not None: + services.provider_repo.update(row.id, enabled=False) + if server.app_runtime is not None: + await server.app_runtime.agent_registry.on_provider_changed( + provider_name=LLAMACPP_PROVIDER_NAME + ) + return result + + @router.post( "/ensure-deps", summary="Start Ollama, or one-click install when FreeOS can automate it" ) @@ -222,6 +341,53 @@ async def local_models_install( } +@router.get("/catalog", summary="List pinned GGUF models available for one-click setup") +async def local_models_catalog( + _: Any = Depends(require_permission("ollama_models")), +) -> list[dict[str, Any]]: + return catalog() + + +@router.post("/downloads", summary="Download a GGUF from the trusted model catalog") +async def local_models_download_start( + body: LocalCatalogDownloadBody, + _: Any = Depends(require_permission("ollama_models")), +) -> dict[str, Any]: + try: + return start_download_job(body.catalog_id).snapshot() + except ValueError as exc: + raise OctopError(ErrorCode.NOT_FOUND, str(exc)) from exc + + +@router.get("/downloads", summary="List recent GGUF download tasks") +async def local_models_download_list( + _: Any = Depends(require_permission("ollama_models")), +) -> list[dict[str, Any]]: + return [job.snapshot() for job in list_download_jobs()] + + +@router.get("/downloads/{job_id}", summary="Poll a catalog model download") +async def local_models_download_status( + job_id: str, + _: Any = Depends(require_permission("ollama_models")), +) -> dict[str, Any]: + job = get_download_job(job_id) + if job is None: + raise OctopError(ErrorCode.NOT_FOUND, "download job not found") + return job.snapshot() + + +@router.delete("/downloads/{job_id}", summary="Cancel a catalog model download") +async def local_models_download_cancel( + job_id: str, + _: Any = Depends(require_permission("ollama_models")), +) -> dict[str, Any]: + if not cancel_download_job(job_id): + raise OctopError(ErrorCode.NOT_FOUND, "download job not found") + job = get_download_job(job_id) + return job.snapshot() if job is not None else {"job_id": job_id, "status": "cancelled"} + + @router.post("/scan", summary="Start a background scan for local GGUF / GGML weights") async def local_models_scan( body: LocalScanBody, @@ -290,8 +456,10 @@ async def local_models_register( return result -def _local_runtime_url(server: Any) -> str | None: - return provider_base_url(find_ollama_row(server.services.provider_repo)) +def _local_runtime_url(server: Any, provider_name: str | None = None) -> str | None: + repo = server.services.provider_repo + row = repo.get_by_name(provider_name) if provider_name else find_ollama_row(repo) + return provider_base_url(row) async def _apply_local_default( @@ -315,9 +483,12 @@ async def local_models_speed_test( _: Any = Depends(require_permission("ollama_models")), ) -> dict[str, Any]: """Ping Ollama ``/api/generate`` (or chat completions) with a tiny fixed prompt.""" - resolved = resolve_local_model_ref(server.services.provider_repo, body.name) + resolved = resolve_local_model_ref(server.services.provider_repo, body.name, body.provider_name) model_id = resolved[1] if resolved is not None else body.name.strip() - result = await speed_test_local_model(name=model_id, base_url=_local_runtime_url(server)) + resolved_provider = resolved[0] if resolved is not None else body.provider_name + result = await speed_test_local_model( + name=model_id, base_url=_local_runtime_url(server, resolved_provider) + ) result["name"] = model_id if resolved is not None: result["provider_name"] = resolved[0] @@ -339,6 +510,7 @@ async def local_models_set_default( provider_repo=server.services.provider_repo, settings_repo=server.services.settings_repo, name=body.name, + provider_name=body.provider_name, ) if resolved is None: return { diff --git a/src/octop/infra/agents/providers/llamacpp_runtime.py b/src/octop/infra/agents/providers/llamacpp_runtime.py new file mode 100644 index 00000000..55de5d1e --- /dev/null +++ b/src/octop/infra/agents/providers/llamacpp_runtime.py @@ -0,0 +1,330 @@ +"""Lifecycle and provider registration for the bundled llama.cpp server.""" + +from __future__ import annotations + +import atexit +import json +import os +import re +import subprocess +import sys +import threading +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + +from octop.infra.utils.paths import PathLayout + +LLAMACPP_PROVIDER_NAME = "FreeOS Local (llama.cpp)" +LLAMACPP_HOST = "127.0.0.1" +LLAMACPP_PORT = 11435 +LLAMACPP_BASE_URL = f"http://{LLAMACPP_HOST}:{LLAMACPP_PORT}/v1" +_ALIAS_SAFE = re.compile(r"[^A-Za-z0-9._:-]+") +_PROCESS: subprocess.Popen[bytes] | None = None +_PROCESS_MODEL: str | None = None +_LOCK = threading.RLock() + + +def _binary_name() -> str: + return "llama-server.exe" if os.name == "nt" else "llama-server" + + +def binary_candidates() -> list[Path]: + """Return ordered, explicit locations; never search writable model folders.""" + name = _binary_name() + configured = os.environ.get("FREEOS_LLAMA_SERVER", "").strip() + roots = [Path(sys.executable).resolve().parent, Path(__file__).resolve().parents[5]] + candidates: list[Path] = [] + if configured: + candidates.append(Path(configured).expanduser()) + for root in roots: + candidates.extend( + [ + root / "llama.cpp" / name, + root / "runtime" / "llama.cpp" / name, + root / "tools" / "llama.cpp" / name, + root / name, + ] + ) + seen: set[str] = set() + out: list[Path] = [] + for candidate in candidates: + key = str(candidate) + if key not in seen: + seen.add(key) + out.append(candidate) + return out + + +def find_llama_server() -> Path | None: + for candidate in binary_candidates(): + try: + if candidate.is_file(): + return candidate.resolve() + except OSError: + continue + return None + + +def _request_json(path: str, *, timeout: float = 1.0) -> dict[str, Any] | None: + try: + with urllib.request.urlopen( # noqa: S310 - fixed loopback endpoint + f"http://{LLAMACPP_HOST}:{LLAMACPP_PORT}{path}", timeout=timeout + ) as response: + if response.status >= 500: + return None + raw = response.read(1024 * 1024) + except (OSError, TimeoutError, urllib.error.URLError): + return None + try: + payload = json.loads(raw.decode("utf-8")) + except (UnicodeError, json.JSONDecodeError): + return {} + return payload if isinstance(payload, dict) else {} + + +def is_llamacpp_reachable() -> bool: + return _request_json("/health") is not None or _request_json("/v1/models") is not None + + +def _safe_alias(raw: str, model_path: Path) -> str: + alias = _ALIAS_SAFE.sub("-", raw.strip()).strip("-._") + if not alias: + alias = _ALIAS_SAFE.sub("-", model_path.stem).strip("-._") + if not alias: + alias = "freeos-local" + return alias[:80] + + +def _validated_model_path(raw: str) -> Path: + model = Path(raw).expanduser() + if not model.is_absolute(): + raise ValueError("GGUF model path must be absolute") + resolved = model.resolve(strict=True) + if not resolved.is_file() or resolved.suffix.lower() != ".gguf": + raise ValueError("llama.cpp sidecar requires an existing .gguf file") + return resolved + + +def _log_path() -> Path: + configured = os.environ.get("OCTOP_HOME", "").strip() + root = Path(configured).expanduser() if configured else Path.home() / ".octop" + path = root / "logs" / "llama-sidecar.log" + path.parent.mkdir(parents=True, exist_ok=True) + return path + + +def _restore_state_path() -> Path: + return PathLayout.from_env().root / "models" / "llamacpp-state.json" + + +def _save_restore_state( + *, model_path: Path, alias: str, context_size: int, gpu_layers: int +) -> None: + target = _restore_state_path() + target.parent.mkdir(parents=True, exist_ok=True) + temporary = target.with_suffix(".json.tmp") + temporary.write_text( + json.dumps( + { + "model_path": str(model_path), + "alias": alias, + "context_size": context_size, + "gpu_layers": gpu_layers, + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + temporary.replace(target) + + +def status() -> dict[str, Any]: + binary = find_llama_server() + with _LOCK: + proc = _PROCESS + model = _PROCESS_MODEL + owned_running = proc is not None and proc.poll() is None + reachable = is_llamacpp_reachable() + return { + "ok": bool(binary and reachable), + "runtime": "llamacpp", + "installed": binary is not None, + "running": reachable, + "managed_by_freeos": owned_running, + "binary": str(binary) if binary else "", + "base_url": LLAMACPP_BASE_URL, + "model_path": model or "", + "action": "ready" if reachable else "start" if binary else "install_runtime", + } + + +def start( + *, + model_path: str, + alias: str = "", + context_size: int = 8192, + gpu_layers: int = -1, +) -> dict[str, Any]: + binary = find_llama_server() + if binary is None: + return { + **status(), + "error": "Bundled llama.cpp runtime is not installed.", + "next_step": "Repair or update FreeOS to install the llama.cpp runtime.", + } + if not 512 <= context_size <= 262_144: + raise ValueError("context_size must be between 512 and 262144") + if not -1 <= gpu_layers <= 999: + raise ValueError("gpu_layers must be between -1 and 999") + model = _validated_model_path(model_path) + model_alias = _safe_alias(alias, model) + + global _PROCESS, _PROCESS_MODEL + with _LOCK: + if _PROCESS is not None and _PROCESS.poll() is None: + if str(model) == _PROCESS_MODEL and is_llamacpp_reachable(): + return {**status(), "alias": model_alias} + stop(clear_restore=False) + if is_llamacpp_reachable(): + return { + **status(), + "error": "Port 11435 is already served by another process.", + "next_step": "Stop the other local runtime or configure a different port.", + } + args = [ + str(binary), + "--model", + str(model), + "--alias", + model_alias, + "--host", + LLAMACPP_HOST, + "--port", + str(LLAMACPP_PORT), + "--ctx-size", + str(context_size), + "--n-gpu-layers", + str(gpu_layers), + ] + flags = int(getattr(subprocess, "CREATE_NO_WINDOW", 0)) if os.name == "nt" else 0 + log_handle = _log_path().open("ab") + try: + _PROCESS = subprocess.Popen( # noqa: S603 - fixed binary and argument vector + args, + stdin=subprocess.DEVNULL, + stdout=log_handle, + stderr=subprocess.STDOUT, + creationflags=flags, + ) + finally: + log_handle.close() + _PROCESS_MODEL = str(model) + + for _ in range(40): + if is_llamacpp_reachable(): + _save_restore_state( + model_path=model, + alias=model_alias, + context_size=context_size, + gpu_layers=gpu_layers, + ) + return {**status(), "alias": model_alias} + with _LOCK: + if _PROCESS is None or _PROCESS.poll() is not None: + break + threading.Event().wait(0.25) + failed = status() + stop(clear_restore=False) + return { + **failed, + "ok": False, + "running": False, + "error": "llama.cpp did not become ready within 10 seconds.", + "next_step": f"Review {_log_path()} for the startup error.", + } + + +def stop(*, clear_restore: bool = True) -> dict[str, Any]: + global _PROCESS, _PROCESS_MODEL + with _LOCK: + proc = _PROCESS + _PROCESS = None + _PROCESS_MODEL = None + if proc is not None and proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=3) + if clear_restore: + _restore_state_path().unlink(missing_ok=True) + return status() + + +def restore() -> dict[str, Any] | None: + """Restart the last explicitly started sidecar after a FreeOS restart.""" + path = _restore_state_path() + if not path.is_file() or is_llamacpp_reachable(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + return start( + model_path=str(data["model_path"]), + alias=str(data.get("alias") or ""), + context_size=int(data.get("context_size") or 8192), + gpu_layers=int(data.get("gpu_layers", -1)), + ) + except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError): + return None + + +def _shutdown() -> None: + """Terminate only the process owned by this interpreter during shutdown.""" + global _PROCESS, _PROCESS_MODEL + with _LOCK: + proc = _PROCESS + _PROCESS = None + _PROCESS_MODEL = None + if proc is not None and proc.poll() is None: + proc.terminate() + + +def upsert_provider(provider_repo: Any, *, alias: str, model_path: str) -> str: + """Expose the currently served GGUF through the existing provider abstraction.""" + model = _validated_model_path(model_path) + model_id = _safe_alias(alias, model) + row = provider_repo.get_by_name(LLAMACPP_PROVIDER_NAME) + model_data = { + "id": model_id, + "name": model_id, + "enabled": True, + "input": ["text"], + } + extra = json.dumps({"runtime": "llamacpp", "model_path": str(model)}) + if row is None: + provider_repo.create( + name=LLAMACPP_PROVIDER_NAME, + kind="openai", + base_url=LLAMACPP_BASE_URL, + api_key="local", + extra_json=extra, + models_json=json.dumps([model_data]), + note="Managed by the bundled FreeOS llama.cpp sidecar", + ) + else: + provider_repo.update( + row.id, + kind="openai", + base_url=LLAMACPP_BASE_URL, + api_key=row.api_key or "local", + extra_json=extra, + models_json=json.dumps([model_data]), + enabled=True, + ) + return LLAMACPP_PROVIDER_NAME + + +atexit.register(_shutdown) diff --git a/src/octop/infra/agents/providers/local_catalog.py b/src/octop/infra/agents/providers/local_catalog.py new file mode 100644 index 00000000..97546caf --- /dev/null +++ b/src/octop/infra/agents/providers/local_catalog.py @@ -0,0 +1,49 @@ +"""Pinned, redistributable-friendly GGUF starter catalog for first-run setup.""" + +from __future__ import annotations + +from typing import Any + +_CATALOG: tuple[dict[str, Any], ...] = ( + { + "id": "qwen2.5-1.5b-instruct-q4-k-m", + "name": "qwen2.5-1.5b-instruct", + "display_name": "Qwen2.5 1.5B Instruct (Q4_K_M)", + "filename": "qwen2.5-1.5b-instruct-q4_k_m.gguf", + "url": "https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/91cad51170dc346986eccefdc2dd33a9da36ead9/qwen2.5-1.5b-instruct-q4_k_m.gguf", + "sha256": "6a1a2eb6d15622bf3c96857206351ba97e1af16c30d7a74ee38970e434e9407e", + "size": 1_117_320_736, + "min_ram_gb": 6, + "license": "Apache-2.0", + "source_url": "https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF", + }, + { + "id": "qwen2.5-3b-instruct-q4-k-m", + "name": "qwen2.5-3b-instruct", + "display_name": "Qwen2.5 3B Instruct (Q4_K_M)", + "filename": "qwen2.5-3b-instruct-q4_k_m.gguf", + "url": "https://huggingface.co/Qwen/Qwen2.5-3B-Instruct-GGUF/resolve/7dabda4d13d513e3e842b20f0d435c732f172cbe/qwen2.5-3b-instruct-q4_k_m.gguf", + "sha256": "626b4a6678b86442240e33df819e00132d3ba7dddfe1cdc4fbb18e0a9615c62d", + "size": 2_104_932_768, + "min_ram_gb": 10, + "license": "Apache-2.0", + "source_url": "https://huggingface.co/Qwen/Qwen2.5-3B-Instruct-GGUF", + }, +) + + +def catalog() -> list[dict[str, Any]]: + return [dict(item) for item in _CATALOG] + + +def catalog_entry(catalog_id: str) -> dict[str, Any] | None: + return next((dict(item) for item in _CATALOG if item["id"] == catalog_id), None) + + +def recommended_catalog(ram_gb: float) -> list[dict[str, Any]]: + preferred = _CATALOG[1] if ram_gb >= 12 else _CATALOG[0] + other = _CATALOG[0] if preferred is _CATALOG[1] else _CATALOG[1] + return [ + {**dict(preferred), "reason": "recommended_for_hardware", "install": "freeos"}, + {**dict(other), "reason": "lighter_or_stronger_alternative", "install": "freeos"}, + ] diff --git a/src/octop/infra/agents/providers/local_default.py b/src/octop/infra/agents/providers/local_default.py index 516cff80..a287eaf0 100644 --- a/src/octop/infra/agents/providers/local_default.py +++ b/src/octop/infra/agents/providers/local_default.py @@ -1,9 +1,10 @@ -"""Resolve and annotate the default local (Ollama) chat model.""" +"""Resolve and annotate models exposed by local inference runtimes.""" from __future__ import annotations from typing import Any +from octop.infra.agents.providers.llamacpp_runtime import LLAMACPP_PROVIDER_NAME from octop.infra.agents.providers.local_register import find_ollama_row, load_registered from octop.infra.agents.providers.model_flags import ( OLLAMA_PROVIDER_DISPLAY_NAME, @@ -18,33 +19,65 @@ def provider_base_url(row: Any | None) -> str | None: return getattr(row, "base_url", None) +def local_provider_rows(provider_repo: Any) -> list[Any]: + """Return enabled FreeOS-managed local providers in stable preference order.""" + rows: list[Any] = [] + ollama = find_ollama_row(provider_repo) + if ollama is not None: + rows.append(ollama) + llama = next( + ( + row + for row in provider_repo.list_all() + if str(getattr(row, "name", "")) == LLAMACPP_PROVIDER_NAME + ), + None, + ) + if llama is not None and bool(getattr(llama, "enabled", False)): + rows.append(llama) + return rows + + +def usable_local_model_refs(provider_repo: Any) -> set[tuple[str, str]]: + refs: set[tuple[str, str]] = set() + for row in local_provider_rows(provider_repo): + provider_name = str(row.name) + for model in row.get_models() if hasattr(row, "get_models") else []: + if not isinstance(model, dict) or not model.get("enabled", True): + continue + model_id = str(model.get("id") or "").strip() + if model_id: + refs.add((provider_name, model_id)) + return refs + + def usable_local_model_ids(provider_repo: Any) -> set[str]: - row = find_ollama_row(provider_repo) - if row is None: - return set() - ids: set[str] = set() + return {model_id for _, model_id in usable_local_model_refs(provider_repo)} + + +def _matching_model(row: Any, name: str) -> str | None: for model in row.get_models() if hasattr(row, "get_models") else []: if not isinstance(model, dict) or not model.get("enabled", True): continue - model_id = str(model.get("id") or "").strip() - if model_id: - ids.add(model_id) - return ids + listed = str(model.get("id") or "").strip() + if listed == name or listed.startswith(f"{name}:"): + return listed + return None -def resolve_local_model_ref(provider_repo: Any, name: str) -> tuple[str, str] | None: +def resolve_local_model_ref( + provider_repo: Any, name: str, provider_name: str | None = None +) -> tuple[str, str] | None: """Return ``(provider_name, model_id)`` when *name* is on the local provider.""" model_id = name.strip() if not model_id: return None - row = find_ollama_row(provider_repo) - if row is None: - return None - for model in row.get_models() if hasattr(row, "get_models") else []: - if not isinstance(model, dict): - continue - listed = str(model.get("id") or "").strip() - if listed == model_id or listed.startswith(f"{model_id}:"): + rows = local_provider_rows(provider_repo) + if provider_name: + rows = [row for row in rows if str(row.name) == provider_name] + for row in rows: + listed = _matching_model(row, model_id) + if listed: return str(row.name or OLLAMA_PROVIDER_DISPLAY_NAME), listed return None @@ -54,9 +87,10 @@ def resolve_registered_or_usable( provider_repo: Any, settings_repo: Any, name: str, + provider_name: str | None = None, ) -> tuple[str, str] | None: """Resolve a registered weight or an already-enabled local provider model.""" - resolved = resolve_local_model_ref(provider_repo, name) + resolved = resolve_local_model_ref(provider_repo, name, provider_name) if resolved is not None: return resolved model_id = name.strip() @@ -65,11 +99,12 @@ def resolve_registered_or_usable( for row in load_registered(settings_repo): listed = str(row.get("name") or "").strip() if listed == model_id: + stored_provider = str(row.get("provider_name") or provider_name or "").strip() + if stored_provider: + return stored_provider, listed provider = find_ollama_row(provider_repo) - provider_name = ( - str(provider.name) if provider is not None else OLLAMA_PROVIDER_DISPLAY_NAME - ) - return provider_name, listed + fallback = str(provider.name) if provider is not None else OLLAMA_PROVIDER_DISPLAY_NAME + return fallback, listed return None @@ -105,12 +140,10 @@ def annotate_local_models( user_preferences_json: str | None, ) -> dict[str, Any]: """Mark usable local models and which one is the current default.""" - row = find_ollama_row(provider_repo) - provider_name = str(row.name) if row is not None else OLLAMA_PROVIDER_DISPLAY_NAME - usable = usable_local_model_ids(provider_repo) - registered_names = { - str(item.get("name") or "") for item in load_registered(settings_repo) if item.get("name") - } + providers = local_provider_rows(provider_repo) + provider_name = str(providers[0].name) if providers else OLLAMA_PROVIDER_DISPLAY_NAME + usable_refs = usable_local_model_refs(provider_repo) + registered = load_registered(settings_repo) preferred = get_preferred_model_from_json(user_preferences_json) active_name, active_model = ("", "") getter = getattr(settings_repo, "get_active_model", None) @@ -123,14 +156,30 @@ def annotate_local_models( if not isinstance(item, dict): continue name = str(item.get("name") or "") - if name and (name in usable or name in registered_names): + path = str(item.get("path") or "") + matched_provider = next( + (provider for provider, model_id in usable_refs if model_id == name), None + ) + stored = next( + ( + row + for row in registered + if str(row.get("name") or "") == name + or (path and str(row.get("path") or "") == path) + ), + None, + ) + item_provider = str((stored or {}).get("provider_name") or matched_provider or "") + if name and (matched_provider or stored): item["registered"] = True item["registerable"] = False - item["provider_name"] = provider_name + item["provider_name"] = item_provider or provider_name elif item.get("registered"): item["provider_name"] = item.get("provider_name") or provider_name if name: - item["is_default"] = is_local_default_ref(default_ref, provider_name, name) + item["is_default"] = is_local_default_ref( + default_ref, str(item.get("provider_name") or provider_name), name + ) probe["default_ref"] = default_ref probe["default_provider_name"] = default_ref.partition("/")[0] if default_ref else "" probe["default_model"] = default_ref.partition("/")[2] if default_ref else "" diff --git a/src/octop/infra/agents/providers/local_download.py b/src/octop/infra/agents/providers/local_download.py new file mode 100644 index 00000000..0f4409e0 --- /dev/null +++ b/src/octop/infra/agents/providers/local_download.py @@ -0,0 +1,242 @@ +"""Background downloads for the pinned FreeOS GGUF catalog.""" + +from __future__ import annotations + +import hashlib +import json +import shutil +import threading +import time +import urllib.request +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from octop.infra.agents.providers.local_catalog import catalog_entry +from octop.infra.utils.paths import PathLayout + +_MAX_JOBS = 8 +_DISK_RESERVE_BYTES = 256 * 1024 * 1024 + + +@dataclass +class DownloadJob: + job_id: str + catalog_id: str + status: str = "pending" + downloaded_bytes: int = 0 + total_bytes: int = 0 + path: str = "" + 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]: + percent = ( + round(self.downloaded_bytes * 100 / self.total_bytes, 1) if self.total_bytes else 0 + ) + entry = catalog_entry(self.catalog_id) or {} + return { + "job_id": self.job_id, + "catalog_id": self.catalog_id, + "name": entry.get("name", ""), + "status": self.status, + "downloaded_bytes": self.downloaded_bytes, + "total_bytes": self.total_bytes, + "percent": percent, + "path": self.path, + "error": self.error, + "created_at": self.created_at, + "updated_at": self.updated_at, + "resumable": self.status != "completed" and Path(f"{self.path}.part").is_file() + if self.path + else False, + } + + +_lock = threading.Lock() +_jobs: dict[str, DownloadJob] = {} + + +def _state_dir() -> Path: + return PathLayout.from_env().root / "models" / ".downloads" + + +def _state_path(job_id: str) -> Path: + return _state_dir() / f"{job_id}.json" + + +def _save_job(job: DownloadJob) -> None: + directory = _state_dir() + directory.mkdir(parents=True, exist_ok=True) + target = _state_path(job.job_id) + temporary = target.with_suffix(f".json.{threading.get_ident()}.tmp") + temporary.write_text(json.dumps(job.snapshot(), ensure_ascii=False), encoding="utf-8") + temporary.replace(target) + + +def _load_job(job_id: str) -> DownloadJob | None: + path = _state_path(job_id) + if not path.is_file(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + job = DownloadJob(job_id=str(data["job_id"]), catalog_id=str(data["catalog_id"])) + job.status = str(data.get("status") or "failed") + if job.status in {"pending", "running"}: + job.status = "interrupted" + job.error = "FreeOS stopped before the download completed. Retry to continue." + else: + job.error = str(data["error"]) if data.get("error") else None + job.downloaded_bytes = int(data.get("downloaded_bytes") or 0) + job.total_bytes = int(data.get("total_bytes") or 0) + job.path = str(data.get("path") or "") + job.created_at = float(data.get("created_at") or path.stat().st_mtime) + job.updated_at = float(data.get("updated_at") or path.stat().st_mtime) + return job + except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError): + return None + + +def get_download_job(job_id: str) -> DownloadJob | None: + with _lock: + job = _jobs.get(job_id) + if job is not None: + return job + job = _load_job(job_id) + if job is not None: + _jobs[job_id] = job + return job + + +def list_download_jobs() -> list[DownloadJob]: + """Return recent jobs from memory and durable state, newest first.""" + state_ids = [path.stem for path in _state_dir().glob("*.json")] if _state_dir().is_dir() else [] + for job_id in state_ids: + get_download_job(job_id) + with _lock: + return sorted(_jobs.values(), key=lambda item: item.updated_at, reverse=True)[:_MAX_JOBS] + + +def cancel_download_job(job_id: str) -> bool: + job = get_download_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() + _save_job(job) + return True + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _run(job: DownloadJob) -> None: + entry = catalog_entry(job.catalog_id) + if entry is None: + job.status, job.error = "failed", "model is not in the trusted catalog" + return + destination = PathLayout.from_env().root / "models" / str(entry["filename"]) + partial = destination.with_suffix(destination.suffix + ".part") + job.path = str(destination) + job.total_bytes = int(entry["size"]) + job.status = "running" + job.error = None + _save_job(job) + try: + destination.parent.mkdir(parents=True, exist_ok=True) + if destination.is_file() and _sha256(destination) == entry["sha256"]: + job.downloaded_bytes = destination.stat().st_size + job.status = "completed" + return + existing = partial.stat().st_size if partial.is_file() else 0 + if existing > job.total_bytes: + partial.unlink(missing_ok=True) + existing = 0 + if existing == job.total_bytes and existing > 0: + if _sha256(partial) == entry["sha256"]: + partial.replace(destination) + job.downloaded_bytes = destination.stat().st_size + job.status = "completed" + return + partial.unlink(missing_ok=True) + existing = 0 + remaining = max(job.total_bytes - existing, 0) + free = shutil.disk_usage(destination.parent).free + if free < remaining + _DISK_RESERVE_BYTES: + raise OSError( + f"Not enough disk space: need {remaining + _DISK_RESERVE_BYTES} bytes " + f"including safety reserve, only {free} bytes available." + ) + headers = {"User-Agent": "FreeOS/0.0.7"} + if existing: + headers["Range"] = f"bytes={existing}-" + request = urllib.request.Request(str(entry["url"]), headers=headers) + with urllib.request.urlopen(request, timeout=60) as response: + resumed = existing > 0 and int(getattr(response, "status", 200)) == 206 + mode = "ab" if resumed else "wb" + if not resumed: + existing = 0 + job.downloaded_bytes = existing + header_size = int(response.headers.get("Content-Length") or 0) + if header_size: + job.total_bytes = existing + header_size + with partial.open(mode) as output: + while not job.cancel.is_set(): + chunk = response.read(1024 * 1024) + if not chunk: + break + output.write(chunk) + job.downloaded_bytes += len(chunk) + job.updated_at = time.time() + _save_job(job) + if job.cancel.is_set(): + job.status = "cancelled" + return + if _sha256(partial) != entry["sha256"]: + raise ValueError("download checksum mismatch") + partial.replace(destination) + job.downloaded_bytes = destination.stat().st_size + job.status = "completed" + except Exception as exc: # noqa: BLE001 - surfaced to the polling UI + job.status, job.error = "failed", str(exc) + finally: + job.updated_at = time.time() + _save_job(job) + + +def start_download_job(catalog_id: str) -> DownloadJob: + if catalog_entry(catalog_id) is None: + raise ValueError("model is not in the trusted catalog") + with _lock: + active = next( + ( + item + for item in _jobs.values() + if item.catalog_id == catalog_id and item.status in {"pending", "running"} + ), + None, + ) + if active is not None: + return active + job = DownloadJob(job_id=str(uuid.uuid4()), catalog_id=catalog_id) + _jobs[job.job_id] = job + terminal = sorted(_jobs.values(), key=lambda item: item.created_at) + for stale in terminal: + if len(_jobs) <= _MAX_JOBS: + break + if stale.status not in {"pending", "running"}: + _jobs.pop(stale.job_id, None) + threading.Thread( + target=_run, args=(job,), daemon=True, name=f"model-download-{job.job_id}" + ).start() + return job diff --git a/src/octop/infra/agents/providers/local_probe.py b/src/octop/infra/agents/providers/local_probe.py index 175d9037..29910e40 100644 --- a/src/octop/infra/agents/providers/local_probe.py +++ b/src/octop/infra/agents/providers/local_probe.py @@ -9,6 +9,11 @@ from pathlib import Path from typing import Any +from octop.infra.agents.providers.llamacpp_runtime import ( + find_llama_server, + is_llamacpp_reachable, +) +from octop.infra.agents.providers.local_catalog import recommended_catalog 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 @@ -91,31 +96,12 @@ def _ollama_models() -> tuple[bool, list[dict[str, Any]]]: return True, models -def recommend_models(ram_gb: float, has_gpu: bool) -> list[dict[str, str]]: - picks: list[tuple[str, str]] - if ram_gb and ram_gb < 8: - picks = [("llama3.2:1b", "1B chat model for 8 GB or less")] - elif ram_gb < 16: - picks = [ - ("llama3.2:3b", "3B chat model for 8–16 GB RAM"), - ("qwen2.5:3b", "Compact Qwen for everyday tasks"), - ] - elif ram_gb < 32: - picks = [ - ("llama3.1:8b", "8B general chat"), - ("qwen2.5:7b", "7B Qwen for Chinese + English"), - ] - else: - picks = [ - ("qwen2.5:14b", "14B when you have 32 GB+ RAM"), - ("llama3.1:8b", "8B fallback if the larger pull is too heavy"), - ] - if has_gpu and ram_gb >= 16: - picks = [("qwen2.5:14b", "GPU present — 14B is usable"), *picks] - return [{"id": mid, "reason": reason, "install": "ollama"} for mid, reason in picks] - - -def _deps(*, ollama_installed: bool, ollama_up: bool) -> list[dict[str, Any]]: +def _deps( + *, + ollama_installed: bool, + ollama_up: bool, + llamacpp_installed: bool, +) -> list[dict[str, Any]]: deps: list[dict[str, Any]] = [] plan = install_plan() if not ollama_installed: @@ -141,6 +127,16 @@ def _deps(*, ollama_installed: bool, ollama_up: bool) -> list[dict[str, Any]]: "next_step": "Ollama is installed. Start the local service to pull or chat with models.", } ) + if not llamacpp_installed: + deps.append( + { + "id": "llamacpp", + "kind": "bundled_runtime", + "automatable": False, + "method": "repair_freeos", + "next_step": "Repair or update FreeOS to install the bundled llama.cpp runtime.", + } + ) return deps @@ -149,6 +145,8 @@ def probe_local_models() -> dict[str, Any]: gpu = _gpu_name() ollama_up, ollama_models = _ollama_models() ollama_installed = ollama_is_installed() + llamacpp_binary = find_llama_server() + llamacpp_up = is_llamacpp_reachable() discovered = list(ollama_models) discovered.extend( scan_weight_roots( @@ -169,8 +167,15 @@ def probe_local_models() -> dict[str, Any]: "ollama_installed": ollama_installed, "ollama_reachable": ollama_up, "ollama_path": find_ollama_binary() or "", + "llamacpp_binary": bool(llamacpp_binary), + "llamacpp_reachable": llamacpp_up, + "llamacpp_path": str(llamacpp_binary) if llamacpp_binary else "", }, - "deps": _deps(ollama_installed=ollama_installed, ollama_up=ollama_up), + "deps": _deps( + ollama_installed=ollama_installed, + ollama_up=ollama_up, + llamacpp_installed=llamacpp_binary is not None, + ), "installed": discovered, - "recommended": recommend_models(ram, bool(gpu)), + "recommended": recommended_catalog(ram), } diff --git a/src/octop/infra/server.py b/src/octop/infra/server.py index fbc48efd..85844edd 100644 --- a/src/octop/infra/server.py +++ b/src/octop/infra/server.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import gzip import logging import os @@ -509,6 +510,11 @@ async def _boot_runtime(self, config: OctopConfig) -> None: trajectory_service=trajectory_service, history_archive=history_archive, ) + from octop.infra.agents.providers.llamacpp_runtime import restore # noqa: PLC0415 + + restored = await asyncio.to_thread(restore) + if restored is not None and not restored.get("ok"): + logger.warning("llama.cpp sidecar restore failed: %s", restored.get("error", "")) from octop.infra.knowledge.jobs import resume_pending_index_jobs # noqa: PLC0415 resume_pending_index_jobs(self.services) diff --git a/tests/unit/agents/test_llamacpp_runtime.py b/tests/unit/agents/test_llamacpp_runtime.py new file mode 100644 index 00000000..d1508a2d --- /dev/null +++ b/tests/unit/agents/test_llamacpp_runtime.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from octop.infra.agents.providers import llamacpp_runtime + + +class _ProviderRepo: + def __init__(self) -> None: + self.row = None + self.created: dict[str, object] | None = None + self.updated: dict[str, object] | None = None + + def get_by_name(self, _name: str): + return self.row + + def create(self, **values: object) -> int: + self.created = values + return 1 + + def update(self, provider_id: int, **values: object) -> None: + self.updated = {"provider_id": provider_id, **values} + + +def test_find_binary_prefers_explicit_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + binary = tmp_path / "llama-server.exe" + binary.write_bytes(b"runtime") + monkeypatch.setenv("FREEOS_LLAMA_SERVER", str(binary)) + assert llamacpp_runtime.find_llama_server() == binary.resolve() + + +def test_upsert_provider_registers_openai_compatible_model(tmp_path: Path) -> None: + model = tmp_path / "Qwen 3.gguf" + model.write_bytes(b"model") + repo = _ProviderRepo() + name = llamacpp_runtime.upsert_provider(repo, alias="Qwen 3", model_path=str(model)) + assert name == llamacpp_runtime.LLAMACPP_PROVIDER_NAME + assert repo.created is not None + assert repo.created["base_url"] == llamacpp_runtime.LLAMACPP_BASE_URL + models = json.loads(str(repo.created["models_json"])) + assert models[0]["id"] == "Qwen-3" + + +def test_start_uses_argument_vector_and_loopback( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + binary = tmp_path / "llama-server.exe" + binary.write_bytes(b"runtime") + model = tmp_path / "tiny.gguf" + model.write_bytes(b"model") + calls: list[list[str]] = [] + + class _Process: + def poll(self): + return None + + def terminate(self) -> None: + pass + + def wait(self, timeout: int): + return 0 + + def popen(args: list[str], **_kwargs: object): + calls.append(args) + return _Process() + + reachable = iter([False, True, True]) + monkeypatch.setattr(llamacpp_runtime, "find_llama_server", lambda: binary) + monkeypatch.setattr(llamacpp_runtime, "is_llamacpp_reachable", lambda: next(reachable)) + monkeypatch.setattr(llamacpp_runtime.subprocess, "Popen", popen) + monkeypatch.setenv("OCTOP_HOME", str(tmp_path / "home")) + monkeypatch.setattr(llamacpp_runtime, "_PROCESS", None) + monkeypatch.setattr(llamacpp_runtime, "_PROCESS_MODEL", None) + + result = llamacpp_runtime.start(model_path=str(model), alias="tiny") + assert result["ok"] is True + assert calls + assert calls[0][calls[0].index("--host") + 1] == "127.0.0.1" + assert calls[0][calls[0].index("--model") + 1] == str(model.resolve()) + state = json.loads( + (tmp_path / "home" / "models" / "llamacpp-state.json").read_text(encoding="utf-8") + ) + assert state["alias"] == "tiny" + assert state["model_path"] == str(model.resolve()) + + +def test_restore_restarts_saved_model(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + model = tmp_path / "tiny.gguf" + model.write_bytes(b"model") + home = tmp_path / "home" + state = home / "models" / "llamacpp-state.json" + state.parent.mkdir(parents=True) + state.write_text( + json.dumps( + { + "model_path": str(model), + "alias": "tiny", + "context_size": 4096, + "gpu_layers": 12, + } + ), + encoding="utf-8", + ) + calls: list[dict[str, object]] = [] + + monkeypatch.setenv("OCTOP_HOME", str(home)) + monkeypatch.setattr(llamacpp_runtime, "is_llamacpp_reachable", lambda: False) + monkeypatch.setattr( + llamacpp_runtime, + "start", + lambda **kwargs: calls.append(kwargs) or {"ok": True}, + ) + + assert llamacpp_runtime.restore() == {"ok": True} + assert calls == [ + { + "model_path": str(model), + "alias": "tiny", + "context_size": 4096, + "gpu_layers": 12, + } + ] + + +def test_stop_disables_restart(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + home = tmp_path / "home" + state = home / "models" / "llamacpp-state.json" + state.parent.mkdir(parents=True) + state.write_text("{}", encoding="utf-8") + monkeypatch.setenv("OCTOP_HOME", str(home)) + monkeypatch.setattr(llamacpp_runtime, "_PROCESS", None) + monkeypatch.setattr(llamacpp_runtime, "_PROCESS_MODEL", None) + monkeypatch.setattr(llamacpp_runtime, "is_llamacpp_reachable", lambda: False) + + llamacpp_runtime.stop() + + assert not state.exists() + + +def test_start_rejects_non_gguf(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + binary = tmp_path / "llama-server.exe" + binary.write_bytes(b"runtime") + model = tmp_path / "model.bin" + model.write_bytes(b"model") + monkeypatch.setattr(llamacpp_runtime, "find_llama_server", lambda: binary) + with pytest.raises(ValueError, match=".gguf"): + llamacpp_runtime.start(model_path=str(model)) diff --git a/tests/unit/agents/test_local_default.py b/tests/unit/agents/test_local_default.py index 3225f15f..d31bcaed 100644 --- a/tests/unit/agents/test_local_default.py +++ b/tests/unit/agents/test_local_default.py @@ -47,6 +47,22 @@ def list_all(self) -> list[SimpleNamespace]: return self.rows +def test_resolve_bundled_llamacpp_model() -> None: + repo = _Providers() + repo.rows.append( + SimpleNamespace( + name="FreeOS Local (llama.cpp)", + enabled=1, + base_url="http://127.0.0.1:11435/v1", + get_models=lambda: [{"id": "qwen-local", "enabled": True}], + ) + ) + assert resolve_local_model_ref(repo, "qwen-local", "FreeOS Local (llama.cpp)") == ( + "FreeOS Local (llama.cpp)", + "qwen-local", + ) + + def test_resolve_local_model_ref() -> None: repo = _Providers() assert resolve_local_model_ref(repo, "tiny") == ("Ollama (Local)", "tiny") diff --git a/tests/unit/desktop/test_stage_llamacpp_runtime.py b/tests/unit/desktop/test_stage_llamacpp_runtime.py new file mode 100644 index 00000000..c0f46e72 --- /dev/null +++ b/tests/unit/desktop/test_stage_llamacpp_runtime.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import json +import zipfile +from pathlib import Path + +import pytest + + +def _module(): + path = Path(__file__).parents[3] / "desktop" / "portable" / "stage-llamacpp-runtime.py" + spec = importlib.util.spec_from_file_location("stage_llamacpp_runtime", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _manifest(tmp_path: Path, archive: Path, digest: str) -> Path: + path = tmp_path / "runtime.json" + path.write_text( + json.dumps( + { + "version": "test", + "license": "MIT", + "source": "https://example.invalid", + "platforms": { + "windows-amd64": { + "archive": archive.name, + "url": archive.as_uri(), + "sha256": digest, + } + }, + } + ), + encoding="utf-8", + ) + return path + + +def test_stage_verifies_and_extracts_runtime(tmp_path: Path) -> None: + archive = tmp_path / "runtime.zip" + with zipfile.ZipFile(archive, "w") as bundle: + bundle.writestr("llama-server.exe", b"runtime") + bundle.writestr("LICENSE", "MIT") + digest = hashlib.sha256(archive.read_bytes()).hexdigest() + result = _module().stage( + "windows-amd64", + tmp_path / "staging", + manifest_path=_manifest(tmp_path, archive, digest), + ) + assert result is not None + assert (result / "llama-server.exe").read_bytes() == b"runtime" + metadata = json.loads((result / "FREEOS_RUNTIME.json").read_text(encoding="utf-8")) + assert metadata["sha256"] == digest + + +def test_stage_rejects_checksum_mismatch(tmp_path: Path) -> None: + archive = tmp_path / "runtime.zip" + with zipfile.ZipFile(archive, "w") as bundle: + bundle.writestr("llama-server.exe", b"runtime") + with pytest.raises(ValueError, match="checksum mismatch"): + _module().stage( + "windows-amd64", + tmp_path / "staging", + manifest_path=_manifest(tmp_path, archive, "0" * 64), + ) + + +def test_safe_extract_rejects_parent_traversal(tmp_path: Path) -> None: + archive = tmp_path / "runtime.zip" + with zipfile.ZipFile(archive, "w") as bundle: + bundle.writestr("../escape.txt", "bad") + with pytest.raises(ValueError, match="unsafe archive member"): + _module()._safe_extract(archive, tmp_path / "out") diff --git a/tests/unit/test_local_catalog_download.py b/tests/unit/test_local_catalog_download.py new file mode 100644 index 00000000..0a4e8da3 --- /dev/null +++ b/tests/unit/test_local_catalog_download.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import hashlib +import io +from pathlib import Path + +from octop.infra.agents.providers import local_catalog, local_download + + +class _Response(io.BytesIO): + def __init__(self, content: bytes, *, status: int = 200) -> None: + super().__init__(content) + self.status = status + self.headers = {"Content-Length": str(len(content))} + + def __enter__(self) -> _Response: + return self + + def __exit__(self, *_args: object) -> None: + self.close() + + +def test_catalog_recommends_smaller_model_for_low_memory() -> None: + low = local_catalog.recommended_catalog(8) + high = local_catalog.recommended_catalog(16) + + assert low[0]["id"] == "qwen2.5-1.5b-instruct-q4-k-m" + assert high[0]["id"] == "qwen2.5-3b-instruct-q4-k-m" + assert all(item["install"] == "freeos" for item in low) + + +def test_download_verifies_hash_and_moves_to_models(tmp_path: Path, monkeypatch: object) -> None: + content = b"hello gguf!" + entry = { + "id": "test-model", + "name": "test-model", + "filename": "test.gguf", + "url": "https://example.invalid/test.gguf", + "sha256": hashlib.sha256(content).hexdigest(), + "size": len(content), + } + monkeypatch.setenv("FREEOS_HOME", str(tmp_path)) # type: ignore[attr-defined] + monkeypatch.setattr(local_download, "catalog_entry", lambda _model_id: dict(entry)) # type: ignore[attr-defined] + monkeypatch.setattr( + local_download.urllib.request, "urlopen", lambda *_a, **_k: _Response(content) + ) # type: ignore[attr-defined] + job = local_download.DownloadJob(job_id="job", catalog_id="test-model") + + local_download._run(job) + + assert job.status == "completed" + assert Path(job.path).read_bytes() == content + assert not Path(f"{job.path}.part").exists() + + +def test_download_resumes_existing_partial_file(tmp_path: Path, monkeypatch: object) -> None: + content = b"hello gguf!" + prefix = b"hello " + entry = { + "id": "test-model", + "name": "test-model", + "filename": "test.gguf", + "url": "https://example.invalid/test.gguf", + "sha256": hashlib.sha256(content).hexdigest(), + "size": len(content), + } + monkeypatch.setenv("FREEOS_HOME", str(tmp_path)) # type: ignore[attr-defined] + monkeypatch.setattr(local_download, "catalog_entry", lambda _model_id: dict(entry)) # type: ignore[attr-defined] + partial = tmp_path / "models" / "test.gguf.part" + partial.parent.mkdir(parents=True) + partial.write_bytes(prefix) + seen_range: list[str | None] = [] + + def open_range(request: object, **_kwargs: object) -> _Response: + seen_range.append(getattr(request, "headers", {}).get("Range")) + return _Response(content[len(prefix) :], status=206) + + monkeypatch.setattr(local_download.urllib.request, "urlopen", open_range) # type: ignore[attr-defined] + job = local_download.DownloadJob(job_id="resume", catalog_id="test-model") + + local_download._run(job) + + assert seen_range == [f"bytes={len(prefix)}-"] + assert job.status == "completed" + assert Path(job.path).read_bytes() == content + + +def test_download_job_survives_process_memory_reset(tmp_path: Path, monkeypatch: object) -> None: + entry = { + "id": "test-model", + "name": "test-model", + "filename": "test.gguf", + "url": "https://example.invalid/test.gguf", + "sha256": "unused", + "size": 12, + } + monkeypatch.setenv("FREEOS_HOME", str(tmp_path)) # type: ignore[attr-defined] + monkeypatch.setattr(local_download, "catalog_entry", lambda _model_id: dict(entry)) # type: ignore[attr-defined] + job = local_download.DownloadJob( + job_id="persisted", catalog_id="test-model", status="running", downloaded_bytes=5 + ) + job.path = str(tmp_path / "models" / "test.gguf") + Path(f"{job.path}.part").parent.mkdir(parents=True) + Path(f"{job.path}.part").write_bytes(b"12345") + local_download._save_job(job) + local_download._jobs.clear() + + restored = local_download.get_download_job("persisted") + + assert restored is not None + assert restored.status == "interrupted" + assert restored.downloaded_bytes == 5 + assert restored.snapshot()["resumable"] is True diff --git a/tests/unit/test_local_probe.py b/tests/unit/test_local_probe.py index c7bf4d10..e2be7bdb 100644 --- a/tests/unit/test_local_probe.py +++ b/tests/unit/test_local_probe.py @@ -1,13 +1,4 @@ -from octop.infra.agents.providers.local_probe import probe_local_models, recommend_models - - -def test_recommend_models_scales_with_ram() -> None: - tiny = recommend_models(4, False) - mid = recommend_models(16, False) - big = recommend_models(64, True) - assert tiny[0]["id"] == "llama3.2:1b" - assert any(item["id"].startswith("llama3.1") or item["id"].startswith("qwen") for item in mid) - assert any("14b" in item["id"] for item in big) +from octop.infra.agents.providers.local_probe import probe_local_models def test_probe_local_models_shape() -> None: diff --git a/uv.lock b/uv.lock index 0e814928..908f0f6b 100644 --- a/uv.lock +++ b/uv.lock @@ -2503,7 +2503,7 @@ wheels = [ [[package]] name = "octop" -version = "0.0.6" +version = "0.0.7" source = { editable = "." } dependencies = [ { name = "acme" },