Fix Ollama cloud API key discovery (#85091)

Summary:
- The branch teaches Ollama discovery to use resolved `discoveryApiKey` values for non-local cloud providers, preserves local marker auth, and adds focused provider-discovery regressions plus a changelog entry.
- Reproducibility: yes. from source inspection: current main can return the `OLLAMA_API_KEY` marker instead of ... ential for documented Ollama Cloud config. I did not run executable tests because this review is read-only.

Automerge notes:
- PR branch already contained follow-up commit before automerge: ci: allowlist qa lab fixtures
- PR branch already contained follow-up commit before automerge: Fix Ollama cloud API key discovery
- PR branch already contained follow-up commit before automerge: fix(clawsweeper): address review for automerge-openclaw-openclaw-8503…

Validation:
- ClawSweeper review passed for head cb6b658819.
- Required merge gates passed before the squash merge.

Prepared head SHA: cb6b658819
Review: https://github.com/openclaw/openclaw/pull/85091#issuecomment-4512647237

Co-authored-by: Anup Sharma <anupnewsmail@gmail.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
This commit is contained in:
clawsweeper[bot]
2026-05-21 20:54:59 +00:00
committed by GitHub
parent 48bb3b0a74
commit 5f5e3b4511
3 changed files with 160 additions and 17 deletions

View File

@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Infra/json: retry transient `File changed during read` races while loading JSON state so config and state reads recover instead of failing the turn. (#84285)
- Providers/Ollama: resolve configured Ollama Cloud `OLLAMA_API_KEY` markers to the real discovery key so cloud provider entries keep authenticated model catalog access. (#85037)
- Gateway/sessions: preserve compatible session auth profile overrides when switching models within the same provider, including provider-auth aliases. Fixes #81837. (#81886) Thanks @TurboTheTurtle.
- Gateway/status: surface inbound delivery telemetry counters and transport-liveness warnings in `openclaw status --all`. Fixes #49577. (#72724)
- Docker: prune package-excluded plugin source workspaces and dependency closures so runtime images do not keep packages for plugins that were not opted in.

View File

@@ -57,7 +57,11 @@ describe("Ollama provider", () => {
}
}
async function runOllamaCatalog(params: { config?: OpenClawConfig; env?: NodeJS.ProcessEnv }) {
async function runOllamaCatalog(params: {
config?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
resolveProviderApiKey?: () => { apiKey: string | undefined; discoveryApiKey?: string };
}) {
const env: NodeJS.ProcessEnv = {
...process.env,
VITEST: "1",
@@ -68,9 +72,11 @@ describe("Ollama provider", () => {
config: params.config ?? {},
agentDir: createAgentDir(),
env,
resolveProviderApiKey: () => ({
apiKey: env.OLLAMA_API_KEY?.trim() ? env.OLLAMA_API_KEY : undefined,
}),
resolveProviderApiKey:
params.resolveProviderApiKey ??
(() => ({
apiKey: env.OLLAMA_API_KEY?.trim() ? env.OLLAMA_API_KEY : undefined,
})),
resolveProviderAuth: () => ({
apiKey: env.OLLAMA_API_KEY?.trim() ? env.OLLAMA_API_KEY : undefined,
mode: env.OLLAMA_API_KEY?.trim() ? "api_key" : "none",
@@ -480,6 +486,131 @@ describe("Ollama provider", () => {
});
});
it("uses resolved discovery api key when configured cloud apiKey is an env marker", async () => {
await withoutAmbientOllamaEnv(async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
const provider = await runOllamaCatalog({
config: {
models: {
providers: {
ollama: {
baseUrl: "https://ollama.com/v1",
models: [
{
id: "gpt-oss:20b",
name: "GPT-OSS 20B",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 81920,
},
],
apiKey: "OLLAMA_API_KEY",
},
},
},
},
env: { OLLAMA_API_KEY: "real-secret", VITEST: "", NODE_ENV: "development" },
resolveProviderApiKey: () => ({
apiKey: "OLLAMA_API_KEY",
discoveryApiKey: "real-secret",
}),
});
expect(fetchMock).not.toHaveBeenCalled();
expect(provider?.baseUrl).toBe("https://ollama.com");
expect(provider?.api).toBe("ollama");
expect(provider?.apiKey).toBe("real-secret");
expect(provider?.models).toHaveLength(1);
});
});
it("uses resolved discovery api key for configured cloud providers without apiKey", async () => {
await withoutAmbientOllamaEnv(async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
const provider = await runOllamaCatalog({
config: {
models: {
providers: {
ollama: {
baseUrl: "https://ollama.com/v1",
models: [
{
id: "gpt-oss:20b",
name: "GPT-OSS 20B",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 81920,
},
],
},
},
},
},
env: { OLLAMA_API_KEY: "real-secret", VITEST: "", NODE_ENV: "development" },
resolveProviderApiKey: () => ({
apiKey: "OLLAMA_API_KEY",
discoveryApiKey: "real-secret",
}),
});
expect(fetchMock).not.toHaveBeenCalled();
expect(provider?.baseUrl).toBe("https://ollama.com");
expect(provider?.api).toBe("ollama");
expect(provider?.apiKey).toBe("real-secret");
expect(provider?.models).toHaveLength(1);
});
});
it("keeps synthetic local auth when a local provider also has a discovery key", async () => {
await withoutAmbientOllamaEnv(async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
const provider = await runOllamaCatalog({
config: {
models: {
providers: {
ollama: {
baseUrl: "http://127.0.0.1:11434/v1",
models: [
{
id: "gpt-oss:20b",
name: "GPT-OSS 20B",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 81920,
},
],
apiKey: "OLLAMA_API_KEY",
},
},
},
},
env: { OLLAMA_API_KEY: "real-secret", VITEST: "", NODE_ENV: "development" },
resolveProviderApiKey: () => ({
apiKey: "OLLAMA_API_KEY",
discoveryApiKey: "real-secret",
}),
});
expect(fetchMock).not.toHaveBeenCalled();
expect(provider?.baseUrl).toBe("http://127.0.0.1:11434");
expect(provider?.api).toBe("ollama");
expect(provider?.apiKey).toBe(OLLAMA_LOCAL_AUTH_MARKER);
expect(provider?.models).toHaveLength(1);
});
});
it("should preserve explicit apiKey from configured remote providers", async () => {
await withoutAmbientOllamaEnv(async () => {
const fetchMock = vi.fn(async (input: unknown) => {

View File

@@ -23,7 +23,10 @@ type OllamaDiscoveryContext = {
};
};
env: NodeJS.ProcessEnv;
resolveProviderApiKey: (providerId: string) => { apiKey?: unknown };
resolveProviderApiKey: (providerId: string) => {
apiKey?: unknown;
discoveryApiKey?: unknown;
};
};
function normalizeOptionalString(value: unknown): string | undefined {
@@ -40,27 +43,34 @@ function readStringValue(value: unknown): string | undefined {
return undefined;
}
function isOllamaApiKeyMarker(value: string): boolean {
return value === "OLLAMA_API_KEY" || value === OLLAMA_DEFAULT_API_KEY;
}
function resolveOllamaDiscoveryApiKey(params: {
env: NodeJS.ProcessEnv;
baseUrl?: string;
explicitApiKey?: string;
hasDeclaredApiKey?: boolean;
resolvedApiKey?: unknown;
resolvedDiscoveryApiKey?: unknown;
}): string | undefined {
const envValue = normalizeOptionalString(params.env.OLLAMA_API_KEY);
const envApiKey = envValue ? "OLLAMA_API_KEY" : undefined;
const resolvedApiKey = normalizeOptionalString(params.resolvedApiKey);
const resolvedDiscoveryApiKey = normalizeOptionalString(params.resolvedDiscoveryApiKey);
const explicitApiKey = normalizeOptionalString(params.explicitApiKey);
if (explicitApiKey) {
if (explicitApiKey && !isOllamaApiKeyMarker(explicitApiKey)) {
return explicitApiKey;
}
if (params.hasDeclaredApiKey && resolvedApiKey) {
return resolvedApiKey;
}
if (!isLocalOllamaBaseUrl(params.baseUrl)) {
return envApiKey ?? (resolvedApiKey !== OLLAMA_DEFAULT_API_KEY ? resolvedApiKey : undefined);
if (resolvedDiscoveryApiKey) {
return resolvedDiscoveryApiKey;
}
if (resolvedApiKey && !isOllamaApiKeyMarker(resolvedApiKey)) {
return resolvedApiKey;
}
return envValue && envValue !== OLLAMA_DEFAULT_API_KEY ? envValue : undefined;
}
if (resolvedApiKey && resolvedApiKey !== envValue && resolvedApiKey !== OLLAMA_DEFAULT_API_KEY) {
if (resolvedApiKey && resolvedApiKey !== envValue && !isOllamaApiKeyMarker(resolvedApiKey)) {
return resolvedApiKey;
}
return OLLAMA_DEFAULT_API_KEY;
@@ -237,22 +247,23 @@ export async function resolveOllamaDiscoveryResult(params: {
if (!hasExplicitModels && discoveryEnabled === false) {
return null;
}
const ollamaKey = params.ctx.resolveProviderApiKey(OLLAMA_PROVIDER_ID).apiKey;
const resolvedOllamaAuth = params.ctx.resolveProviderApiKey(OLLAMA_PROVIDER_ID);
const ollamaKey = resolvedOllamaAuth.apiKey;
const ollamaDiscoveryKey = resolvedOllamaAuth.discoveryApiKey;
const hasOllamaDiscoveryOptIn = typeof ollamaKey === "string" && ollamaKey.trim().length > 0;
const hasRealOllamaKey =
typeof ollamaKey === "string" &&
ollamaKey.trim().length > 0 &&
ollamaKey.trim() !== OLLAMA_DEFAULT_API_KEY;
const explicitApiKey = readStringValue(explicit?.apiKey);
const hasDeclaredApiKey = explicit?.apiKey !== undefined;
if (hasExplicitModels && explicit) {
const baseUrl = resolveOllamaApiBase(readProviderBaseUrl(explicit) ?? OLLAMA_DEFAULT_BASE_URL);
const apiKey = resolveOllamaDiscoveryApiKey({
env: params.ctx.env,
baseUrl,
explicitApiKey,
hasDeclaredApiKey,
resolvedApiKey: ollamaKey,
resolvedDiscoveryApiKey: ollamaDiscoveryKey,
});
return {
provider: {
@@ -299,8 +310,8 @@ export async function resolveOllamaDiscoveryResult(params: {
env: params.ctx.env,
baseUrl: provider.baseUrl ?? configuredBaseUrl,
explicitApiKey,
hasDeclaredApiKey,
resolvedApiKey: ollamaKey,
resolvedDiscoveryApiKey: ollamaDiscoveryKey,
});
return {
provider: {