fix(ollama): align web search endpoint routing

This commit is contained in:
Peter Steinberger
2026-04-27 01:10:23 +01:00
parent b825c8d34b
commit 0f672dcc73
4 changed files with 92 additions and 30 deletions

View File

@@ -21,7 +21,7 @@ Docs: https://docs.openclaw.ai
- Providers/Ollama: expose native Ollama thinking effort levels so `/think max` is accepted for reasoning-capable Ollama models and maps to Ollama's highest supported `think` effort. Fixes #71584. Thanks @g0st1n.
- Providers/Ollama: strip the active custom Ollama provider prefix before native chat and embedding requests, so custom provider ids like `ollama-spark/qwen3:32b` reach Ollama as the real model name. Fixes #72353. Thanks @maximus-dss and @hclsys.
- Providers/Ollama: move memory embeddings to Ollama's current `/api/embed` endpoint with batched `input` requests while preserving vector normalization and custom provider auth/header overrides. Fixes #39983. Thanks @sskkcc and @LiudengZhang.
- Providers/Ollama: try both current and legacy Ollama web-search endpoints and use `OLLAMA_API_KEY` only for the `ollama.com` cloud fallback, keeping local signed-in hosts keyless. Fixes #69132. Thanks @yoon1012 and @hyspacex.
- 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.
- 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.
- Agents/Ollama: validate explicit `--thinking max` against catalog-discovered Ollama reasoning metadata so local agent runs accept the same native thinking levels shown in the model catalog. Fixes #71584. Thanks @g0st1n.
- Docker/QA: add observability coverage to the normal Docker aggregate so QA-lab OTEL and Prometheus diagnostics run inside Docker. Thanks @vincentkoc.

View File

@@ -92,8 +92,10 @@ for requests to that configured host.
it does not block selection.
- Runtime auto-detect can fall back to Ollama Web Search when no higher-priority
credentialed provider is configured.
- The provider tries Ollama's `/api/web_search` endpoint first, then the legacy
`/api/experimental/web_search` endpoint for older hosts.
- Local Ollama daemon hosts use the local proxy endpoint
`/api/experimental/web_search`, which signs and forwards to Ollama Cloud.
- `https://ollama.com` hosts use the public hosted endpoint
`/api/web_search` directly with bearer API-key auth.
## Related

View File

@@ -125,7 +125,7 @@ describe("ollama web search provider", () => {
).toBe("https://ollama.com");
});
it("maps generic search args into the Ollama search endpoint", async () => {
it("maps generic search args into the local Ollama proxy endpoint", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(
@@ -157,7 +157,7 @@ describe("ollama web search provider", () => {
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "http://ollama.local:11434/api/web_search",
url: "http://ollama.local:11434/api/experimental/web_search",
auditContext: "ollama-web-search.search",
}),
);
@@ -184,7 +184,7 @@ describe("ollama web search provider", () => {
expect(release).toHaveBeenCalledTimes(1);
});
it("falls back to the legacy Ollama web search endpoint when /api/web_search is missing", async () => {
it("tries the future local direct endpoint when the local proxy endpoint is missing", async () => {
fetchWithSsrFGuardMock
.mockResolvedValueOnce({
response: new Response("not found", { status: 404 }),
@@ -211,11 +211,42 @@ describe("ollama web search provider", () => {
});
expect(fetchWithSsrFGuardMock.mock.calls.map((call) => call[0].url)).toEqual([
"http://ollama.local:11434/api/web_search",
"http://ollama.local:11434/api/experimental/web_search",
"http://ollama.local:11434/api/web_search",
]);
});
it("uses only the hosted endpoint for Ollama Cloud base URLs", async () => {
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: new Response(
JSON.stringify({
results: [{ title: "Cloud", url: "https://example.com", content: "result" }],
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
release: vi.fn(async () => {}),
});
await expect(
runOllamaWebSearch({
config: createOllamaConfig({
baseUrl: "https://ollama.com",
apiKey: "cloud-config-secret",
}),
query: "openclaw",
}),
).resolves.toMatchObject({ count: 1 });
expect(fetchWithSsrFGuardMock.mock.calls).toHaveLength(1);
expect(fetchWithSsrFGuardMock.mock.calls[0]?.[0].url).toBe("https://ollama.com/api/web_search");
expect(fetchWithSsrFGuardMock.mock.calls[0]?.[0].init?.headers).toMatchObject({
Authorization: "Bearer cloud-config-secret",
});
});
it("uses an env Ollama key only for the cloud fallback from a local host", async () => {
const original = process.env.OLLAMA_API_KEY;
try {
@@ -256,6 +287,11 @@ describe("ollama web search provider", () => {
| undefined;
expect(firstHeaders?.Authorization).toBeUndefined();
expect(cloudHeaders?.Authorization).toBe("Bearer cloud-secret");
expect(fetchWithSsrFGuardMock.mock.calls.map((call) => call[0].url)).toEqual([
"http://ollama.local:11434/api/experimental/web_search",
"http://ollama.local:11434/api/web_search",
"https://ollama.com/api/web_search",
]);
expect(fetchWithSsrFGuardMock.mock.calls[2]?.[0].url).toBe(
"https://ollama.com/api/web_search",
);

View File

@@ -41,8 +41,8 @@ const OLLAMA_WEB_SEARCH_SCHEMA = Type.Object(
{ additionalProperties: false },
);
const OLLAMA_WEB_SEARCH_PATH = "/api/web_search";
const OLLAMA_LEGACY_WEB_SEARCH_PATH = "/api/experimental/web_search";
const OLLAMA_HOSTED_WEB_SEARCH_PATH = "/api/web_search";
const OLLAMA_LOCAL_WEB_SEARCH_PROXY_PATH = "/api/experimental/web_search";
const OLLAMA_CLOUD_BASE_URL = "https://ollama.com";
const DEFAULT_OLLAMA_WEB_SEARCH_COUNT = 5;
const DEFAULT_OLLAMA_WEB_SEARCH_TIMEOUT_MS = 15_000;
@@ -58,6 +58,12 @@ type OllamaWebSearchResponse = {
results?: OllamaWebSearchResult[];
};
type OllamaWebSearchAttempt = {
baseUrl: string;
path: string;
apiKey?: string;
};
function isOllamaCloudBaseUrl(baseUrl: string): boolean {
try {
const parsed = new URL(baseUrl);
@@ -111,6 +117,43 @@ function normalizeOllamaWebSearchResult(
};
}
function buildOllamaWebSearchAttempts(params: {
baseUrl: string;
configuredApiKey?: string;
envApiKey?: string;
}): OllamaWebSearchAttempt[] {
if (isOllamaCloudBaseUrl(params.baseUrl)) {
return [
{
baseUrl: params.baseUrl,
path: OLLAMA_HOSTED_WEB_SEARCH_PATH,
apiKey: params.configuredApiKey ?? params.envApiKey,
},
];
}
const attempts: OllamaWebSearchAttempt[] = [
{
baseUrl: params.baseUrl,
path: OLLAMA_LOCAL_WEB_SEARCH_PROXY_PATH,
apiKey: params.configuredApiKey,
},
{
baseUrl: params.baseUrl,
path: OLLAMA_HOSTED_WEB_SEARCH_PATH,
apiKey: params.configuredApiKey,
},
];
if (params.envApiKey) {
attempts.push({
baseUrl: OLLAMA_CLOUD_BASE_URL,
path: OLLAMA_HOSTED_WEB_SEARCH_PATH,
apiKey: params.envApiKey,
});
}
return attempts;
}
export async function runOllamaWebSearch(params: {
config?: OpenClawConfig;
query: string;
@@ -127,27 +170,7 @@ export async function runOllamaWebSearch(params: {
const count = resolveSearchCount(params.count, DEFAULT_OLLAMA_WEB_SEARCH_COUNT);
const startedAt = Date.now();
const body = JSON.stringify({ query, max_results: count });
const attempts = [
{
baseUrl,
path: OLLAMA_WEB_SEARCH_PATH,
apiKey: isOllamaCloudBaseUrl(baseUrl) ? (configuredApiKey ?? envApiKey) : configuredApiKey,
},
{
baseUrl,
path: OLLAMA_LEGACY_WEB_SEARCH_PATH,
apiKey: isOllamaCloudBaseUrl(baseUrl) ? (configuredApiKey ?? envApiKey) : configuredApiKey,
},
...(!isOllamaCloudBaseUrl(baseUrl) && envApiKey
? [
{
baseUrl: OLLAMA_CLOUD_BASE_URL,
path: OLLAMA_WEB_SEARCH_PATH,
apiKey: envApiKey,
},
]
: []),
];
const attempts = buildOllamaWebSearchAttempts({ baseUrl, configuredApiKey, envApiKey });
let payload: OllamaWebSearchResponse | undefined;
let lastError: Error | undefined;
@@ -305,6 +328,7 @@ export function createOllamaWebSearchProvider(): WebSearchProviderPlugin {
}
export const __testing = {
buildOllamaWebSearchAttempts,
normalizeOllamaWebSearchResult,
resolveConfiguredOllamaWebSearchApiKey,
resolveEnvOllamaWebSearchApiKey,