mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
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:
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user