mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-05-01 06:36:23 +08:00
fix(ollama): resolve custom local provider auth
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user