fix(ollama): resolve custom local provider auth

This commit is contained in:
Peter Steinberger
2026-04-27 04:33:05 +01:00
parent f60378519c
commit 67650c4c0a
7 changed files with 167 additions and 26 deletions

View File

@@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai
- Providers/Ollama: route local web search through Ollama's signed `/api/experimental/web_search` daemon proxy, use hosted `/api/web_search` directly for `ollama.com`, and keep `OLLAMA_API_KEY` scoped to cloud fallback auth. Fixes #69132. Thanks @yoon1012 and @hyspacex.
- Providers/Ollama: accept OpenAI SDK-style `baseURL` as an alias for `baseUrl` across discovery, streaming, setup pulls, embeddings, and web search so remote Ollama hosts are not silently ignored. Fixes #62533; supersedes #62549. Thanks @Julien-BKK and @Linux2010.
- Providers/Ollama: scope synthetic local auth and embedding bearer headers to declared Ollama host boundaries so cloud keys are not sent to local/self-hosted embedding endpoints and remote/cloud Ollama endpoints no longer receive the `ollama-local` marker as if it were a real token. Supersedes #69261 and #69857; refs #43945. Thanks @hyspacex, @maxramsay, and @Meli73.
- Providers/Ollama: resolve custom-named local Ollama providers such as `ollama-remote` through the Ollama synthetic-auth hook so subagents no longer miss `ollama-local` auth and silently fall back to cloud models. Fixes #43945. Thanks @Meli73 and @maxramsay.
- Providers/PDF/Ollama: add bounded network timeouts for Ollama model pulls and native Anthropic/Gemini PDF analysis requests so unresponsive provider endpoints no longer hang sessions indefinitely. Fixes #54142; supersedes #54144 and #54145. Thanks @jinduwang1001-max and @arkyu2077.
- Memory/doctor: treat Ollama memory embeddings as key-optional so `openclaw doctor` no longer warns about a missing API key when the gateway reports embeddings are ready. Fixes #46584. Thanks @fengly78.
- Agents/Ollama: apply provider-owned replay turn normalization to native Ollama chat so Cloud models no longer reject non-alternating replay history in agent/Gateway runs. Fixes #71697. Thanks @ismael-81.

View File

@@ -17,6 +17,8 @@ Ollama provider config uses `baseUrl` as the canonical key. OpenClaw also accept
Local and LAN Ollama hosts do not need a real bearer token; OpenClaw uses the local `ollama-local` marker only for loopback, private-network, `.local`, and bare-hostname Ollama base URLs. Remote public hosts and Ollama Cloud (`https://ollama.com`) require a real credential through `OLLAMA_API_KEY`, an auth profile, or the provider's `apiKey`.
Custom provider ids that set `api: "ollama"` use the same auth rules. For example, an `ollama-remote` provider that points at a private LAN Ollama host can use `apiKey: "ollama-local"` and sub-agents will resolve that marker through the Ollama provider hook instead of treating it as a missing credential.
When Ollama is used for memory embeddings, bearer auth is scoped to the host where it was declared. A provider-level key is sent only to that provider's Ollama host; `agents.*.memorySearch.remote.apiKey` is sent only to its remote embedding host; and a pure `OLLAMA_API_KEY` env value is treated as the Ollama Cloud convention rather than being sent to local/self-hosted hosts by default.
## Getting started

View File

@@ -198,13 +198,13 @@ export default definePluginEntry({
matchesContextOverflowError: ({ errorMessage }) =>
/\bollama\b.*(?:context length|too many tokens|context window)/i.test(errorMessage) ||
/\btruncating input\b.*\btoo long\b/i.test(errorMessage),
resolveSyntheticAuth: ({ providerConfig }) => {
resolveSyntheticAuth: ({ provider, providerConfig }) => {
if (!shouldUseSyntheticOllamaAuth(providerConfig)) {
return undefined;
}
return {
apiKey: OLLAMA_DEFAULT_API_KEY,
source: "models.providers.ollama (synthetic local key)",
source: `models.providers.${provider ?? OLLAMA_PROVIDER_ID} (synthetic local key)`,
mode: "api-key",
};
},

View File

@@ -15,7 +15,7 @@ type OllamaProviderPlugin = {
docsPath: string;
envVars: string[];
auth: [];
resolveSyntheticAuth: (ctx: { providerConfig?: ModelProviderConfig }) =>
resolveSyntheticAuth: (ctx: { provider?: string; providerConfig?: ModelProviderConfig }) =>
| {
apiKey: string;
source: string;
@@ -50,13 +50,13 @@ export const ollamaProviderDiscovery: OllamaProviderPlugin = {
docsPath: "/providers/ollama",
envVars: ["OLLAMA_API_KEY"],
auth: [],
resolveSyntheticAuth: ({ providerConfig }) => {
resolveSyntheticAuth: ({ provider, providerConfig }) => {
if (!shouldUseSyntheticOllamaAuth(providerConfig)) {
return undefined;
}
return {
apiKey: OLLAMA_DEFAULT_API_KEY,
source: "models.providers.ollama (synthetic local key)",
source: `models.providers.${provider ?? OLLAMA_PROVIDER_ID} (synthetic local key)`,
mode: "api-key",
};
},

View File

@@ -14,7 +14,7 @@ vi.mock("../plugins/plugin-registry.js", () => ({
plugins: [
{
origin: "bundled",
nonSecretAuthMarkers: ["gcp-vertex-credentials"],
nonSecretAuthMarkers: ["gcp-vertex-credentials", "ollama-local"],
},
],
}),
@@ -98,6 +98,16 @@ vi.mock("../plugins/provider-runtime.js", async () => {
mode: "oauth" as const,
};
}
if (
params.context.providerConfig?.api === "ollama" &&
params.context.providerConfig.baseUrl?.startsWith("http://192.168.")
) {
return {
apiKey: "ollama-local",
source: `models.providers.${params.provider} (synthetic local key)`,
mode: "api-key" as const,
};
}
return undefined;
},
};
@@ -867,6 +877,41 @@ describe("resolveApiKeyForProvider synthetic local auth for custom providers
).rejects.toThrow("No API key found");
});
it("resolves custom named Ollama providers with explicit local marker auth", async () => {
const auth = await resolveApiKeyForProvider({
provider: "ollama-remote",
cfg: {
models: {
providers: {
"ollama-remote": {
baseUrl: "http://192.168.178.122:11434",
api: "ollama",
apiKey: "ollama-local",
models: [
{
id: "qwen3.5:27b",
name: "Qwen 3.5 27B",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 4096,
},
],
},
},
},
},
store: { version: 1, profiles: {} },
});
expect(auth).toMatchObject({
apiKey: "ollama-local",
source: "models.providers.ollama-remote (synthetic local key)",
mode: "api-key",
});
});
it("does not synthesize local auth when apiKey is explicitly configured but unresolved", async () => {
const previous = process.env.OPENAI_API_KEY;
delete process.env.OPENAI_API_KEY;

View File

@@ -13,6 +13,25 @@ const resolvePluginDiscoveryProvidersRuntime = vi.hoisted(() =>
mode: "api-key" as const,
}),
},
{
id: "ollama",
label: "Ollama",
auth: [],
resolveSyntheticAuth: ({
provider,
providerConfig,
}: {
provider: string;
providerConfig?: { api?: string; baseUrl?: string };
}) =>
providerConfig?.api === "ollama" && providerConfig.baseUrl?.startsWith("http://10.")
? {
apiKey: "ollama-local",
source: `models.providers.${provider} (synthetic local key)`,
mode: "api-key" as const,
}
: undefined,
},
]),
);
@@ -39,7 +58,13 @@ vi.mock("./providers.js", () => ({
resolveCatalogHookProviderPluginIds: vi.fn(() => []),
resolveExternalAuthProfileCompatFallbackPluginIds: vi.fn(() => []),
resolveExternalAuthProfileProviderPluginIds: vi.fn(() => []),
resolveOwningPluginIdsForProvider: vi.fn(() => ["anthropic-vertex"]),
resolveOwningPluginIdsForProvider: vi.fn(({ provider }: { provider: string }) =>
provider === "ollama"
? ["ollama"]
: provider === "anthropic-vertex"
? ["anthropic-vertex"]
: [],
),
}));
import { resolveProviderSyntheticAuthWithPlugin } from "./provider-runtime.js";
@@ -63,4 +88,26 @@ describe("resolveProviderSyntheticAuthWithPlugin", () => {
expect(resolveProviderRuntimePlugin).not.toHaveBeenCalled();
expect(resolvePluginDiscoveryProvidersRuntime).toHaveBeenCalled();
});
it("uses the configured provider api as the synthetic-auth hook owner", () => {
expect(
resolveProviderSyntheticAuthWithPlugin({
provider: "ollama-remote",
context: {
config: undefined,
provider: "ollama-remote",
providerConfig: {
api: "ollama",
baseUrl: "http://10.0.0.8:11434",
apiKey: "ollama-local",
models: [],
},
},
}),
).toEqual({
apiKey: "ollama-local",
source: "models.providers.ollama-remote (synthetic local key)",
mode: "api-key",
});
});
});

View File

@@ -108,6 +108,19 @@ function matchesProviderPluginRef(provider: ProviderPlugin, providerId: string):
);
}
function resolveProviderHookRefs(provider: string, providerConfig?: ModelProviderConfig): string[] {
const refs = [provider];
const apiRef = normalizeOptionalString(providerConfig?.api);
if (apiRef && normalizeProviderId(apiRef) !== normalizeProviderId(provider)) {
refs.push(apiRef);
}
return [...new Set(refs)];
}
function matchesAnyProviderPluginRef(provider: ProviderPlugin, providerRefs: readonly string[]) {
return providerRefs.some((providerRef) => matchesProviderPluginRef(provider, providerRef));
}
function hasExplicitProviderRuntimePluginActivation(params: {
provider: string;
config?: OpenClawConfig;
@@ -930,13 +943,20 @@ export function resolveProviderSyntheticAuthWithPlugin(params: {
env?: NodeJS.ProcessEnv;
context: ProviderResolveSyntheticAuthContext;
}) {
const discoveryPluginIds =
resolveOwningPluginIdsForProvider({
provider: params.provider,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
}) ?? [];
const providerRefs = resolveProviderHookRefs(params.provider, params.context.providerConfig);
const discoveryPluginIds = [
...new Set(
providerRefs.flatMap(
(provider) =>
resolveOwningPluginIdsForProvider({
provider,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
}) ?? [],
),
),
];
const discoveryProvider = (
discoveryPluginIds.length > 0
? resolvePluginDiscoveryProvidersRuntime({
@@ -947,7 +967,7 @@ export function resolveProviderSyntheticAuthWithPlugin(params: {
discoveryEntriesOnly: true,
})
: []
).find((provider) => matchesProviderPluginRef(provider, params.provider));
).find((provider) => matchesAnyProviderPluginRef(provider, providerRefs));
if (typeof discoveryProvider?.resolveSyntheticAuth === "function") {
return discoveryProvider.resolveSyntheticAuth(params.context) ?? undefined;
}
@@ -961,13 +981,32 @@ export function resolveProviderSyntheticAuthWithPlugin(params: {
if (runtimeResolved) {
return runtimeResolved;
}
return resolvePluginDiscoveryProvidersRuntime({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
})
.find((provider) => provider.id === params.provider)
?.resolveSyntheticAuth?.(params.context);
for (const providerRef of providerRefs) {
if (normalizeProviderId(providerRef) === normalizeProviderId(params.provider)) {
continue;
}
const runtimeProviderResolved = resolveProviderRuntimePlugin({
...params,
provider: providerRef,
applyAutoEnable: false,
bundledProviderAllowlistCompat: false,
bundledProviderVitestCompat: false,
installBundledRuntimeDeps: false,
})?.resolveSyntheticAuth?.(params.context);
if (runtimeProviderResolved) {
return runtimeProviderResolved;
}
}
if (providerRefs.length === 1) {
return resolvePluginDiscoveryProvidersRuntime({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
})
.find((provider) => matchesAnyProviderPluginRef(provider, providerRefs))
?.resolveSyntheticAuth?.(params.context);
}
return undefined;
}
export function resolveExternalAuthProfilesWithPlugins(params: {
@@ -1040,10 +1079,17 @@ export function shouldDeferProviderSyntheticProfileAuthWithPlugin(params: {
env?: NodeJS.ProcessEnv;
context: ProviderDeferSyntheticProfileAuthContext;
}) {
return (
resolveProviderRuntimePlugin(params)?.shouldDeferSyntheticProfileAuth?.(params.context) ??
undefined
);
const providerRefs = resolveProviderHookRefs(params.provider, params.context.providerConfig);
for (const providerRef of providerRefs) {
const resolved = resolveProviderRuntimePlugin({
...params,
provider: providerRef,
})?.shouldDeferSyntheticProfileAuth?.(params.context);
if (resolved !== undefined) {
return resolved;
}
}
return undefined;
}
export function resolveProviderBuiltInModelSuppression(params: {