Merge pull request #118183 from openclaw/fix/ollama-loaded-setup-20260802

fix(ollama): avoid loading idle models during guided setup
This commit is contained in:
Vincent Koc
2026-08-03 12:38:00 +08:00
committed by GitHub
5 changed files with 210 additions and 48 deletions
+86 -2
View File
@@ -31,6 +31,7 @@ const ensureOllamaModelPulledMock = vi.hoisted(() => vi.fn(async () => {}));
const checkOllamaCloudAuthMock = vi.hoisted(() => vi.fn());
const configureOllamaNonInteractiveMock = vi.hoisted(() => vi.fn());
const fetchOllamaModelsMock = vi.hoisted(() => vi.fn());
const fetchLoadedOllamaModelNamesMock = vi.hoisted(() => vi.fn());
const buildOllamaProviderMock = vi.hoisted(() => vi.fn());
const queryOllamaModelShowInfoMock = vi.hoisted(() => vi.fn());
const resolveConfiguredSecretInputStringMock = vi.hoisted(() => vi.fn());
@@ -69,6 +70,11 @@ vi.mock("./api.js", () => ({
buildOllamaModelDefinition: buildOllamaModelDefinitionMock,
}));
vi.mock("./src/provider-models.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./src/provider-models.js")>()),
fetchLoadedOllamaModelNames: fetchLoadedOllamaModelNamesMock,
}));
vi.mock("openclaw/plugin-sdk/secret-input-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/secret-input-runtime")>();
return {
@@ -100,6 +106,11 @@ beforeEach(() => {
checkOllamaCloudAuthMock.mockResolvedValue({ signedIn: true });
configureOllamaNonInteractiveMock.mockReset();
fetchOllamaModelsMock.mockReset();
fetchLoadedOllamaModelNamesMock.mockReset();
fetchLoadedOllamaModelNamesMock.mockResolvedValue({
reachable: true,
models: ["qwen-tool", "qwen3.5:4b", "llama3.3:70b", "nomic-embed-text", "unknown-tools"],
});
buildOllamaProviderMock.mockReset();
queryOllamaModelShowInfoMock.mockReset();
resolveConfiguredSecretInputStringMock.mockClear();
@@ -483,7 +494,7 @@ describe("ollama plugin", () => {
expect(result.defaultModel).toBeUndefined();
});
it("discovers and prepares an installed tool-capable model without pulling it", async () => {
it("discovers and prepares a loaded tool-capable model without pulling it", async () => {
const provider = registerProvider();
const guided = provider.auth[0].appGuidedSetup;
buildOllamaProviderMock.mockResolvedValue({
@@ -532,7 +543,74 @@ describe("ollama plugin", () => {
expect(ensureOllamaModelPulledMock).not.toHaveBeenCalled();
});
it("prefers the strongest tool-calling family among installed models", async () => {
it("does not auto-detect installed models that are not loaded", async () => {
const provider = registerProvider();
fetchLoadedOllamaModelNamesMock.mockResolvedValue({ reachable: true, models: [] });
await expect(
provider.auth[0].appGuidedSetup?.detect({ config: {}, env: {} }),
).resolves.toBeNull();
expect(buildOllamaProviderMock).not.toHaveBeenCalled();
expect(queryOllamaModelShowInfoMock).not.toHaveBeenCalled();
});
it("selects only from loaded models when stronger installed models are idle", async () => {
const provider = registerProvider();
fetchLoadedOllamaModelNamesMock.mockResolvedValue({
reachable: true,
models: ["llama3.3:70b"],
});
buildOllamaProviderMock.mockResolvedValue({
baseUrl: "http://127.0.0.1:11434",
api: "ollama",
models: [
{ id: "llama3.3:70b", name: "llama3.3:70b", compat: { supportsTools: true } },
{ id: "qwen3.5:4b", name: "qwen3.5:4b", compat: { supportsTools: true } },
],
});
await expect(provider.auth[0].appGuidedSetup?.detect({ config: {}, env: {} })).resolves.toEqual(
{
modelRef: "ollama/llama3.3:70b",
detail: "llama3.3:70b at http://127.0.0.1:11434",
},
);
expect(queryOllamaModelShowInfoMock).toHaveBeenCalledTimes(1);
expect(queryOllamaModelShowInfoMock).toHaveBeenCalledWith(
"http://127.0.0.1:11434",
"llama3.3:70b",
undefined,
);
});
it("rechecks loaded state before preparing the detected route", async () => {
const provider = registerProvider();
buildOllamaProviderMock.mockResolvedValue({
baseUrl: "http://127.0.0.1:11434",
api: "ollama",
models: [{ id: "qwen-tool", name: "qwen-tool", compat: { supportsTools: true } }],
});
await expect(provider.auth[0].appGuidedSetup?.detect({ config: {}, env: {} })).resolves.toEqual(
{
modelRef: "ollama/qwen-tool",
detail: "qwen-tool at http://127.0.0.1:11434",
},
);
fetchLoadedOllamaModelNamesMock.mockResolvedValue({ reachable: true, models: [] });
await expect(
provider.auth[0].appGuidedSetup?.prepare({
config: {},
env: {},
modelRef: "ollama/qwen-tool",
}),
).resolves.toBeNull();
expect(buildOllamaProviderMock).toHaveBeenCalledTimes(1);
});
it("prefers the strongest tool-calling family among loaded models", async () => {
const provider = registerProvider();
buildOllamaProviderMock.mockResolvedValue({
baseUrl: "http://127.0.0.1:11434",
@@ -625,6 +703,10 @@ describe("ollama plugin", () => {
"https://ollama.example.com",
expect.objectContaining(providerAccess),
);
expect(fetchLoadedOllamaModelNamesMock).toHaveBeenCalledWith(
"https://ollama.example.com",
providerAccess,
);
});
it("keeps environment-backed Ollama access for the completion proposal", async () => {
@@ -677,6 +759,7 @@ describe("ollama plugin", () => {
| { apiKey?: string; quiet?: boolean }
| undefined;
expect(options?.apiKey).toBeUndefined();
expect(fetchLoadedOllamaModelNamesMock).toHaveBeenCalledWith("http://127.0.0.1:11434", {});
});
it("honors the Ollama discovery opt-out during app-guided detection", async () => {
@@ -690,6 +773,7 @@ describe("ollama plugin", () => {
env: {},
}),
).resolves.toBeNull();
expect(fetchLoadedOllamaModelNamesMock).not.toHaveBeenCalled();
expect(buildOllamaProviderMock).not.toHaveBeenCalled();
});
+15 -2
View File
@@ -78,8 +78,10 @@ import {
buildDefaultOllamaCloudModelDefinition,
capLocalOllamaModelContext,
capLocalOllamaProviderContext,
fetchLoadedOllamaModelNames,
isOllamaCloudModel,
} from "./src/provider-models.js";
import { findAvailableOllamaModelName } from "./src/setup-model-selection.js";
import {
OLLAMA_INCOMPLETE_STREAM_ERROR,
createConfiguredOllamaCompatStreamWrapper,
@@ -239,12 +241,23 @@ async function discoverAppGuidedOllamaModel(ctx: ProviderAppGuidedSetupContext)
});
const accessValue = await resolveAppGuidedOllamaApiKey(ctx, existing);
const discoveryAccess = accessValue ? { apiKey: accessValue } : {};
const provider = await buildOllamaProvider(readProviderBaseUrl(existing), {
const baseUrl = resolveOllamaApiBase(readProviderBaseUrl(existing));
// App-guided setup must not turn an installed-but-idle model into a surprise
// memory allocation. Only /api/ps owns the currently resident model set.
const loaded = await fetchLoadedOllamaModelNames(baseUrl, discoveryAccess);
if (!loaded.reachable || loaded.models.length === 0) {
return null;
}
const provider = await buildOllamaProvider(baseUrl, {
quiet: true,
...discoveryAccess,
});
const toolModels =
provider.models?.filter((candidate) => candidate.compat?.supportsTools === true) ?? [];
provider.models?.filter(
(candidate) =>
candidate.compat?.supportsTools === true &&
findAvailableOllamaModelName(candidate.id, loaded.models) !== undefined,
) ?? [];
// Automatic setup needs measured /api/show facts. The catalog fallback is
// intentionally optimistic for manual use and must not qualify a weak route.
let model: ModelDefinitionConfig | undefined;
+4 -27
View File
@@ -24,6 +24,7 @@ import {
buildOllamaBaseUrlSsrFPolicy,
enrichOllamaCompletionModels,
enrichOllamaModelsWithContext,
fetchLoadedOllamaModelNames,
fetchOllamaModels,
isOllamaCloudModel,
resolveOllamaApiBase,
@@ -156,32 +157,6 @@ async function requestOllamaJson<T>(params: {
}
}
async function fetchLoadedModelNames(baseUrl: string, signal?: AbortSignal): Promise<Set<string>> {
try {
const data = await requestOllamaJson<{ models?: Array<{ name?: unknown; model?: unknown }> }>({
baseUrl,
path: "/api/ps",
timeoutMs: 5000,
...(signal ? { signal } : {}),
});
return new Set(
(data.models ?? [])
.map((model) =>
typeof model.name === "string"
? model.name.trim()
: typeof model.model === "string"
? model.model.trim()
: "",
)
.filter(Boolean),
);
} catch {
throwIfOllamaRequestAborted(signal);
// Model discovery still works against Ollama versions without /api/ps.
return new Set();
}
}
async function discoverOllamaNodeModels(
baseUrl = OLLAMA_DEFAULT_BASE_URL,
signal?: AbortSignal,
@@ -194,7 +169,9 @@ async function discoverOllamaNodeModels(
const localModels = discovered.models.filter(
(model) => !model.remote_host?.trim() && !isOllamaCloudModel(model.name),
);
const loadedNames = await fetchLoadedModelNames(apiBase, signal);
const loaded = await fetchLoadedOllamaModelNames(apiBase, signal ? { signal } : undefined);
// Model discovery still works against Ollama versions without /api/ps.
const loadedNames = new Set(loaded.models);
// Probe loaded models before the bounded catalog can hide already-runnable node models.
const prioritizedModels = localModels.toSorted(
(left, right) => Number(loadedNames.has(right.name)) - Number(loadedNames.has(left.name)),
@@ -10,6 +10,7 @@ import {
buildOllamaModelDefinition,
capLocalOllamaProviderContext,
enrichOllamaModelsWithContext,
fetchLoadedOllamaModelNames,
isOllamaCloudModel,
fetchOllamaModels,
queryOllamaModelShowInfo,
@@ -161,6 +162,26 @@ describe("ollama provider models", () => {
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("reads loaded models from /api/ps with remote auth", async () => {
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
expect(requestUrl(input)).toBe("https://ollama.example.com/api/ps");
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer private-key");
return jsonResponse({
models: [{ name: "qwen3.5:4b" }, { model: "llama3.3:70b" }, { name: " " }, {}],
});
});
vi.stubGlobal("fetch", fetchMock);
await expect(
fetchLoadedOllamaModelNames("https://ollama.example.com/v1", {
apiKey: "private-key",
}),
).resolves.toEqual({
reachable: true,
models: ["qwen3.5:4b", "llama3.3:70b"],
});
});
it("discovers a chat model after 200 embedding-only catalog entries", async () => {
const embeddingModels = Array.from({ length: 200 }, (_, index) => ({
name: `embedding-${index}:latest`,
@@ -533,6 +554,18 @@ describe("ollama provider models", () => {
});
expect(tagsResponse.wasCanceled()).toBe(true);
const psResponse = cancelTrackedResponse("process listing unavailable", { status: 503 });
vi.stubGlobal(
"fetch",
vi.fn(async () => psResponse.response),
);
await expect(fetchLoadedOllamaModelNames("http://127.0.0.1:11434")).resolves.toEqual({
reachable: true,
models: [],
});
expect(psResponse.wasCanceled()).toBe(true);
const showResponse = cancelTrackedResponse("model unavailable", { status: 503 });
vi.stubGlobal(
"fetch",
+72 -17
View File
@@ -34,6 +34,13 @@ export type OllamaTagsResponse = {
models?: OllamaTagModel[];
};
type OllamaRunningModel = {
name?: unknown;
model?: unknown;
};
type OllamaModelRow = OllamaTagModel | OllamaRunningModel;
export type OllamaModelWithContext = OllamaTagModel & {
contextWindow?: number;
capabilities?: string[];
@@ -420,25 +427,29 @@ type OllamaModelsFetchDeps = {
lookupFn?: LookupFn;
};
export async function fetchOllamaModels(
baseUrl: string,
opts?: OllamaModelRequestOptions,
deps?: OllamaModelsFetchDeps,
): Promise<{ reachable: boolean; models: OllamaTagModel[] }> {
async function fetchOllamaModelRows(params: {
baseUrl: string;
endpoint: "ps" | "tags";
opts?: OllamaModelRequestOptions;
deps?: OllamaModelsFetchDeps;
}): Promise<{ reachable: boolean; models: OllamaModelRow[] }> {
try {
const apiBase = resolveOllamaApiBase(baseUrl);
const apiBase = resolveOllamaApiBase(params.baseUrl);
const auditContext = `ollama-provider-models.${params.endpoint}`;
const { response, release } = await fetchWithSsrFGuard({
url: `${apiBase}/api/tags`,
url: `${apiBase}/api/${params.endpoint}`,
init: {
headers: opts?.apiKey ? { Authorization: `Bearer ${opts.apiKey}` } : undefined,
headers: params.opts?.apiKey
? { Authorization: `Bearer ${params.opts.apiKey}` }
: undefined,
},
// Guard-owned timeoutMs also bounds DNS/proxy preflight; init.signal does not.
timeoutMs: Math.min(opts?.timeoutMs ?? OLLAMA_TAGS_TIMEOUT_MS, OLLAMA_TAGS_TIMEOUT_MS),
...(opts?.signal ? { signal: opts.signal } : {}),
timeoutMs: Math.min(params.opts?.timeoutMs ?? OLLAMA_TAGS_TIMEOUT_MS, OLLAMA_TAGS_TIMEOUT_MS),
...(params.opts?.signal ? { signal: params.opts.signal } : {}),
policy: buildOllamaBaseUrlSsrFPolicy(apiBase),
auditContext: "ollama-provider-models.tags",
...(deps?.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}),
...(deps?.lookupFn ? { lookupFn: deps.lookupFn } : {}),
auditContext,
...(params.deps?.fetchImpl ? { fetchImpl: params.deps.fetchImpl } : {}),
...(params.deps?.lookupFn ? { lookupFn: params.deps.lookupFn } : {}),
});
try {
if (!response.ok) {
@@ -447,21 +458,65 @@ export async function fetchOllamaModels(
void response.body?.cancel().catch(() => undefined);
return { reachable: true, models: [] };
}
const data = await readProviderJsonResponse<OllamaTagsResponse>(
const data = await readProviderJsonResponse<{ models?: OllamaModelRow[] }>(
response,
"ollama-provider-models.tags",
auditContext,
);
const models = (data.models ?? []).filter((m) => m.name);
const models = Array.isArray(data.models) ? data.models : [];
return { reachable: true, models };
} finally {
await release();
}
} catch {
throwIfOllamaRequestAborted(opts?.signal);
throwIfOllamaRequestAborted(params.opts?.signal);
return { reachable: false, models: [] };
}
}
export async function fetchOllamaModels(
baseUrl: string,
opts?: OllamaModelRequestOptions,
deps?: OllamaModelsFetchDeps,
): Promise<{ reachable: boolean; models: OllamaTagModel[] }> {
const result = await fetchOllamaModelRows({
baseUrl,
endpoint: "tags",
opts,
deps,
});
return {
reachable: result.reachable,
models: result.models.filter(
(model): model is OllamaTagModel => typeof model.name === "string" && Boolean(model.name),
),
};
}
export async function fetchLoadedOllamaModelNames(
baseUrl: string,
opts?: OllamaModelRequestOptions,
deps?: OllamaModelsFetchDeps,
): Promise<{ reachable: boolean; models: string[] }> {
const result = await fetchOllamaModelRows({
baseUrl,
endpoint: "ps",
opts,
deps,
});
return {
reachable: result.reachable,
models: result.models
.map((model) =>
typeof model.name === "string"
? model.name.trim()
: "model" in model && typeof model.model === "string"
? model.model.trim()
: "",
)
.filter(Boolean),
};
}
export async function buildOllamaProvider(
configuredBaseUrl?: string,
opts?: { apiKey?: string; quiet?: boolean },