fix: quiet unconfigured ollama discovery

This commit is contained in:
Peter Steinberger
2026-04-07 07:59:31 +01:00
parent 38a673b688
commit cf2fc4fdbb
6 changed files with 253 additions and 19 deletions

View File

@@ -139,6 +139,125 @@ describe("ollama plugin", () => {
expect(buildOllamaProviderMock).not.toHaveBeenCalled();
});
it("keeps empty default-ish provider stubs quiet", async () => {
const provider = registerProvider();
buildOllamaProviderMock.mockResolvedValueOnce({
baseUrl: "http://127.0.0.1:11434",
api: "ollama",
models: [],
});
const result = await provider.discovery.run({
config: {
models: {
providers: {
ollama: {
baseUrl: "http://127.0.0.1:11434",
api: "ollama",
models: [],
},
},
},
},
env: { NODE_ENV: "development" },
resolveProviderApiKey: () => ({ apiKey: "" }),
} as never);
expect(result).toBeNull();
expect(buildOllamaProviderMock).toHaveBeenCalledWith("http://127.0.0.1:11434", {
quiet: true,
});
});
it("treats non-default baseUrl as explicit discovery config", async () => {
const provider = registerProvider();
buildOllamaProviderMock.mockResolvedValueOnce({
baseUrl: "http://remote-ollama:11434",
api: "ollama",
models: [],
});
const result = await provider.discovery.run({
config: {
models: {
providers: {
ollama: {
baseUrl: "http://remote-ollama:11434",
api: "ollama",
models: [],
},
},
},
},
env: { NODE_ENV: "development" },
resolveProviderApiKey: () => ({ apiKey: "" }),
} as never);
expect(result).toBeNull();
expect(buildOllamaProviderMock).toHaveBeenCalledWith("http://remote-ollama:11434", {
quiet: false,
});
});
it("keeps stored ollama-local marker auth on the quiet ambient path", async () => {
const provider = registerProvider();
buildOllamaProviderMock.mockResolvedValueOnce({
baseUrl: "http://127.0.0.1:11434",
api: "ollama",
models: [],
});
const result = await provider.discovery.run({
config: {},
env: { NODE_ENV: "development" },
resolveProviderApiKey: () => ({ apiKey: "ollama-local" }),
} as never);
expect(result).toMatchObject({
provider: {
baseUrl: "http://127.0.0.1:11434",
api: "ollama",
apiKey: "ollama-local",
models: [],
},
});
expect(buildOllamaProviderMock).toHaveBeenCalledWith(undefined, {
quiet: true,
});
});
it("does not mint synthetic auth for empty default-ish provider stubs", () => {
const provider = registerProvider();
const auth = provider.resolveSyntheticAuth?.({
providerConfig: {
baseUrl: "http://127.0.0.1:11434",
api: "ollama",
models: [],
},
});
expect(auth).toBeUndefined();
});
it("mints synthetic auth for non-default explicit ollama config", () => {
const provider = registerProvider();
const auth = provider.resolveSyntheticAuth?.({
providerConfig: {
baseUrl: "http://remote-ollama:11434",
api: "ollama",
models: [],
},
});
expect(auth).toEqual({
apiKey: "ollama-local",
source: "models.providers.ollama (synthetic local key)",
mode: "api-key",
});
});
it("wraps OpenAI-compatible payloads with num_ctx for Ollama compat routes", () => {
const provider = registerProvider();
let payloadSeen: Record<string, unknown> | undefined;

View File

@@ -6,7 +6,10 @@ import {
type ProviderAuthResult,
type ProviderDiscoveryContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared";
import {
buildProviderReplayFamilyHooks,
type ModelProviderConfig,
} from "openclaw/plugin-sdk/provider-model-shared";
import { readStringValue } from "openclaw/plugin-sdk/text-runtime";
import {
buildOllamaProvider,
@@ -40,6 +43,8 @@ type OllamaPluginConfig = {
};
};
type OllamaProviderLikeConfig = ModelProviderConfig;
function resolveOllamaDiscoveryApiKey(params: {
env: NodeJS.ProcessEnv;
explicitApiKey?: string;
@@ -55,6 +60,43 @@ function shouldSkipAmbientOllamaDiscovery(env: NodeJS.ProcessEnv): boolean {
return Boolean(env.VITEST) || env.NODE_ENV === "test";
}
function hasMeaningfulExplicitOllamaConfig(
providerConfig: OllamaProviderLikeConfig | undefined,
): boolean {
if (!providerConfig) {
return false;
}
if (Array.isArray(providerConfig.models) && providerConfig.models.length > 0) {
return true;
}
if (typeof providerConfig.baseUrl === "string" && providerConfig.baseUrl.trim()) {
return resolveOllamaApiBase(providerConfig.baseUrl) !== OLLAMA_DEFAULT_BASE_URL;
}
if (readStringValue(providerConfig.apiKey)) {
return true;
}
if (providerConfig.auth) {
return true;
}
if (typeof providerConfig.authHeader === "boolean") {
return true;
}
if (
providerConfig.headers &&
typeof providerConfig.headers === "object" &&
Object.keys(providerConfig.headers).length > 0
) {
return true;
}
if (providerConfig.request) {
return true;
}
if (typeof providerConfig.injectNumCtxForOpenAICompat === "boolean") {
return true;
}
return false;
}
export default definePluginEntry({
id: "ollama",
name: "Ollama Provider",
@@ -113,12 +155,17 @@ export default definePluginEntry({
run: async (ctx: ProviderDiscoveryContext) => {
const explicit = ctx.config.models?.providers?.ollama;
const hasExplicitModels = Array.isArray(explicit?.models) && explicit.models.length > 0;
const hasMeaningfulExplicitConfig = hasMeaningfulExplicitOllamaConfig(explicit);
const discoveryEnabled =
pluginConfig.discovery?.enabled ?? ctx.config.models?.ollamaDiscovery?.enabled;
if (!hasExplicitModels && discoveryEnabled === false) {
return null;
}
const ollamaKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey;
const hasRealOllamaKey =
typeof ollamaKey === "string" &&
ollamaKey.trim().length > 0 &&
ollamaKey.trim() !== DEFAULT_API_KEY;
const explicitApiKey = readStringValue(explicit?.apiKey);
if (hasExplicitModels && explicit) {
return {
@@ -137,12 +184,16 @@ export default definePluginEntry({
},
};
}
if (!ollamaKey && !explicit && shouldSkipAmbientOllamaDiscovery(ctx.env)) {
if (
!hasRealOllamaKey &&
!hasMeaningfulExplicitConfig &&
shouldSkipAmbientOllamaDiscovery(ctx.env)
) {
return null;
}
const provider = await buildOllamaProvider(explicit?.baseUrl, {
quiet: !ollamaKey && !explicit,
quiet: !hasRealOllamaKey && !hasMeaningfulExplicitConfig,
});
if (provider.models.length === 0 && !ollamaKey && !explicit?.apiKey) {
return null;
@@ -210,11 +261,7 @@ export default definePluginEntry({
/\bollama\b.*(?:context length|too many tokens|context window)/i.test(errorMessage) ||
/\btruncating input\b.*\btoo long\b/i.test(errorMessage),
resolveSyntheticAuth: ({ providerConfig }) => {
const hasApiConfig =
Boolean(providerConfig?.api?.trim()) ||
Boolean(providerConfig?.baseUrl?.trim()) ||
(Array.isArray(providerConfig?.models) && providerConfig.models.length > 0);
if (!hasApiConfig) {
if (!hasMeaningfulExplicitOllamaConfig(providerConfig)) {
return undefined;
}
return {

View File

@@ -41,11 +41,18 @@ vi.mock("../plugins/provider-runtime.js", async () => {
return undefined;
}
const providerConfig = params.context.providerConfig;
const hasApiConfig =
Boolean(providerConfig?.api?.trim()) ||
Boolean(providerConfig?.baseUrl?.trim()) ||
(Array.isArray(providerConfig?.models) && providerConfig.models.length > 0);
if (!hasApiConfig) {
const hasMeaningfulOllamaConfig =
params.provider !== "ollama"
? Boolean(providerConfig?.api?.trim()) ||
Boolean(providerConfig?.baseUrl?.trim()) ||
(Array.isArray(providerConfig?.models) && providerConfig.models.length > 0)
: (Array.isArray(providerConfig?.models) && providerConfig.models.length > 0) ||
Boolean(providerConfig?.api?.trim() && providerConfig.api.trim() !== "ollama") ||
Boolean(
providerConfig?.baseUrl?.trim() &&
providerConfig.baseUrl.trim().replace(/\/+$/, "") !== "http://127.0.0.1:11434",
);
if (!hasMeaningfulOllamaConfig) {
return undefined;
}
return {
@@ -410,6 +417,28 @@ describe("getApiKeyForModel", () => {
});
});
it("does not mint synthetic local auth for default-ish ollama stubs", async () => {
await withEnvAsync({ OLLAMA_API_KEY: undefined }, async () => {
await expect(
resolveApiKeyForProvider({
provider: "ollama",
store: { version: 1, profiles: {} },
cfg: {
models: {
providers: {
ollama: {
baseUrl: "http://127.0.0.1:11434",
api: "ollama",
models: [],
},
},
},
},
}),
).rejects.toThrow(/No API key found for provider "ollama"/);
});
});
it("prefers explicit OLLAMA_API_KEY over synthetic local key", async () => {
await withEnvAsync({ [envVar("OLLAMA", "API", "KEY")]: "env-ollama-key" }, async () => {
// pragma: allowlist secret

View File

@@ -95,11 +95,14 @@ vi.mock("../plugins/provider-runtime.js", async () => {
return undefined;
}
const providerConfig = params.context.providerConfig;
const hasApiConfig =
Boolean(providerConfig?.api?.trim()) ||
Boolean(providerConfig?.baseUrl?.trim()) ||
(Array.isArray(providerConfig?.models) && providerConfig.models.length > 0);
if (!hasApiConfig) {
const hasMeaningfulOllamaConfig =
(Array.isArray(providerConfig?.models) && providerConfig.models.length > 0) ||
Boolean(providerConfig?.api?.trim() && providerConfig.api.trim() !== "ollama") ||
Boolean(
providerConfig?.baseUrl?.trim() &&
providerConfig.baseUrl.trim().replace(/\/+$/, "") !== "http://127.0.0.1:11434",
);
if (!hasMeaningfulOllamaConfig) {
return undefined;
}
return {

View File

@@ -138,7 +138,7 @@ describe("Ollama auto-discovery", () => {
await runOllamaCatalog({
explicitProviders: {
ollama: {
baseUrl: "http://127.0.0.1:11434/v1",
baseUrl: "http://gpu-node-server:11434/v1",
api: "openai-completions",
models: [],
},

View File

@@ -416,6 +416,42 @@ export function describeOllamaProviderDiscoveryContract() {
).resolves.toBeNull();
expect(buildOllamaProviderMock).toHaveBeenCalledWith(undefined, { quiet: true });
});
it("keeps empty default-ish provider stubs on the quiet ambient path", async () => {
buildOllamaProviderMock.mockResolvedValueOnce({
baseUrl: "http://127.0.0.1:11434",
api: "ollama",
models: [],
});
await expect(
runCatalog(state, {
provider: state.ollamaProvider!,
config: {
models: {
providers: {
ollama: {
baseUrl: "http://127.0.0.1:11434",
api: "ollama",
models: [],
},
},
},
},
env: {} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({ apiKey: undefined }),
resolveProviderAuth: () => ({
apiKey: undefined,
discoveryApiKey: undefined,
mode: "none",
source: "none",
}),
}),
).resolves.toBeNull();
expect(buildOllamaProviderMock).toHaveBeenCalledWith("http://127.0.0.1:11434", {
quiet: true,
});
});
});
}