feat(fetch): add Ollama Cloud web fetch provider

This commit is contained in:
Sami Basra
2026-09-03 10:12:41 +07:00
committed by decolua
parent 6ab9ca9eb1
commit e0ffc7e2a1
6 changed files with 255 additions and 8 deletions

View File

@@ -1,4 +1,4 @@
// Web Fetch handler — dispatches to firecrawl, jina-reader, tavily, exa
// Web Fetch handler — dispatches to firecrawl, jina-reader, tavily, exa, ollama
// Returns normalized shape across all providers
const DEFAULT_TIMEOUT_MS = 15000;
@@ -56,8 +56,8 @@ function parseJinaTitle(text) {
return m ? m[1].trim() : null;
}
function buildData({ provider, url, title, format, text, costUsd, responseMs, upstreamMs }) {
return {
function buildData({ provider, url, title, format, text, links, costUsd, responseMs, upstreamMs }) {
const data = {
provider,
url,
title: title || null,
@@ -66,6 +66,8 @@ function buildData({ provider, url, title, format, text, costUsd, responseMs, up
usage: { fetch_cost_usd: costUsd ?? null },
metrics: { response_time_ms: responseMs, upstream_latency_ms: upstreamMs }
};
if (Array.isArray(links)) data.links = links;
return data;
}
async function readJsonOrText(res) {
@@ -115,6 +117,18 @@ export async function handleFetchCore({ url, format, maxCharacters, provider, pr
if (provider === "exa") {
return await runExa({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery, startedAt });
}
if (provider === "ollama") {
return await runOllama({
url,
fmt,
timeoutMs,
apiKey,
maxCharacters,
costPerQuery,
startedAt,
baseUrl: providerConfig?.baseUrl,
});
}
return { success: false, status: 400, error: `Unsupported provider: ${provider}` };
} catch (err) {
log?.("fetch handler error:", err?.message || err);
@@ -241,3 +255,56 @@ async function runExa({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery
})
};
}
async function runOllama({
url,
fmt,
timeoutMs,
apiKey,
maxCharacters,
costPerQuery,
startedAt,
baseUrl,
}) {
const upstreamStart = Date.now();
const r = await tryFetch(baseUrl, {
method: "POST",
headers: {
"content-type": "application/json",
...(apiKey ? { authorization: `Bearer ${apiKey}` } : {})
},
body: JSON.stringify({ url })
}, timeoutMs);
if (!r.ok) {
return { success: false, status: r.timeout ? 504 : 502, error: r.error };
}
const upstreamMs = Date.now() - upstreamStart;
const { json, text: responseText } = await readJsonOrText(r.res);
if (!r.res.ok) {
const error = json?.error
|| json?.message
|| responseText?.slice(0, 500)
|| `Ollama error: ${r.res.status}`;
return { success: false, status: r.res.status, error };
}
if (!json || typeof json.content !== "string") {
return { success: false, status: 502, error: "Ollama returned an empty or invalid web fetch response" };
}
const text = truncate(json.content, maxCharacters);
return {
success: true,
data: buildData({
provider: "ollama",
url,
title: json.title || null,
format: fmt,
text,
links: json.links,
costUsd: costPerQuery,
responseMs: Date.now() - startedAt,
upstreamMs
})
};
}

View File

@@ -31,7 +31,16 @@ export default {
{ id: "qwen3.5", name: "Qwen3.5" },
{ id: "minimax-m3", name: "MiniMax M3" },
],
serviceKinds: ["llm"],
serviceKinds: ["llm", "webFetch"],
fetchConfig: {
baseUrl: "https://ollama.com/api/web_fetch",
method: "POST",
authType: "apikey",
authHeader: "bearer",
formats: ["markdown"],
maxCharacters: 200000,
timeoutMs: 30000,
},
features: {
usage: true,
usageApikey: true,

View File

@@ -1,6 +1,6 @@
---
name: 9router-web-fetch
description: Fetch URL → markdown / text / HTML via 9Router /v1/web/fetch using Firecrawl / Jina Reader / Tavily Extract / Exa Contents. Use when the user wants to scrape a webpage, extract URL content, read article, or convert a URL to markdown.
description: Fetch URL → markdown / text / HTML via 9Router /v1/web/fetch using Ollama Cloud / Firecrawl / Jina Reader / Tavily Extract / Exa Contents. Use when the user wants to scrape a webpage, extract URL content, read article, or convert a URL to markdown.
---
# 9Router — Web Fetch
@@ -62,6 +62,17 @@ curl -X POST $NINEROUTER_URL/v1/web/fetch \
-d '{"model":"tavily","url":"https://example.com","format":"markdown","max_characters":0}'
```
### Ollama Cloud
Uses the API key from the existing `ollama` connection.
```bash
curl -X POST $NINEROUTER_URL/v1/web/fetch \
-H "Authorization: Bearer $NINEROUTER_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"ollama","url":"https://example.com","format":"markdown"}'
```
JS:
@@ -83,12 +94,15 @@ console.log(data.title, data.content.length);
"url": "...",
"title": "...",
"content": { "format": "markdown", "text": "...", "length": 1234 },
"links": ["https://example.com/related"],
"metadata": { "author": null, "published_at": null, "language": null },
"usage": { "fetch_cost_usd": 0 },
"metrics": { "response_time_ms": 850, "upstream_latency_ms": 700 }
}
```
`links` is included when the upstream provider returns discovered page links (currently Ollama Cloud).
## Provider quirks
| Provider | Auth | Best for |
@@ -97,3 +111,4 @@ console.log(data.title, data.content.length);
| `jina-reader` | Bearer (optional) | Free tier (~1M chars/mo); fastest plain markdown |
| `tavily` | Bearer | Bulk extract; returns `raw_content` |
| `exa` | `x-api-key` | Pre-indexed pages; fast text extraction |
| `ollama` | Bearer | Markdown plus page title and discovered links; uses the Ollama Cloud key |

View File

@@ -159,8 +159,13 @@ async function handleSingleProviderFetch(body, providerInput, request, apiKey, s
let lastError = null;
let lastStatus = null;
// Keep web-fetch failures scoped to this capability. Providers such as
// Ollama use the same connection for chat and fetch, so an upstream fetch
// failure must not take the account offline for LLM requests.
const fetchLockKey = `webfetch:${providerId}`;
while (true) {
const credentials = await getProviderCredentials(providerId, excludeConnectionIds);
const credentials = await getProviderCredentials(providerId, excludeConnectionIds, fetchLockKey);
if (!credentials || credentials.allRateLimited) {
if (credentials?.allRateLimited) {
@@ -200,13 +205,19 @@ async function handleSingleProviderFetch(body, providerInput, request, apiKey, s
});
if (result.success) {
await clearAccountError(credentials.connectionId, credentials);
await clearAccountError(credentials.connectionId, credentials, fetchLockKey);
return new Response(JSON.stringify(result.data), {
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }
});
}
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, providerId);
const { shouldFallback } = await markAccountUnavailable(
credentials.connectionId,
result.status,
result.error,
providerId,
fetchLockKey,
);
if (shouldFallback) {
log.warn("AUTH", `Account ${credentials.connectionName} unavailable (${result.status}), trying fallback`);

View File

@@ -85,7 +85,40 @@ describe("web fetch account state", () => {
expect(mocks.clearAccountError).toHaveBeenCalledWith(
"jina-connection",
expect.objectContaining({ connectionName: "Jina Test" }),
"webfetch:jina-reader",
);
expect(mocks.getProviderCredentials).toHaveBeenCalledWith(
"jina-reader",
expect.any(Set),
"webfetch:jina-reader",
);
expect(mocks.markAccountUnavailable).not.toHaveBeenCalled();
});
it("scopes provider failures to web fetch", async () => {
mocks.handleFetchCore.mockResolvedValue({
success: false,
status: 429,
error: "quota exceeded",
});
mocks.markAccountUnavailable.mockResolvedValue({ shouldFallback: false });
const response = await handleFetch(new Request("http://localhost/v1/web/fetch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: "jina-reader",
url: "https://example.com/article",
}),
}));
expect(response.status).toBe(429);
expect(mocks.markAccountUnavailable).toHaveBeenCalledWith(
"jina-connection",
429,
"quota exceeded",
"jina-reader",
"webfetch:jina-reader",
);
});
});

View File

@@ -0,0 +1,112 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import REGISTRY from "../../open-sse/providers/registry/index.js";
import { handleFetchCore } from "../../open-sse/handlers/fetch/index.js";
import { AI_PROVIDERS, getProvidersByKind } from "@/shared/constants/providers.js";
const CONFIG = {
baseUrl: "https://ollama.com/api/web_fetch",
timeoutMs: 30000,
};
afterEach(() => {
vi.unstubAllGlobals();
});
describe("Ollama Cloud web fetch provider", () => {
it("registers web fetch on the existing Ollama Cloud connection", () => {
const entry = REGISTRY.find((candidate) => candidate.id === "ollama");
expect(entry).toMatchObject({
category: "freeTier",
serviceKinds: ["llm", "webFetch"],
fetchConfig: {
baseUrl: "https://ollama.com/api/web_fetch",
method: "POST",
authHeader: "bearer",
formats: ["markdown"],
},
});
expect(AI_PROVIDERS.ollama?.fetchConfig).toEqual(entry.fetchConfig);
expect(getProvidersByKind("webFetch").map((provider) => provider.id)).toContain("ollama");
});
it("calls Ollama with bearer auth and normalizes the response", async () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({
title: "Example Domain",
content: "Hello from Ollama",
links: ["https://www.iana.org/domains/example"],
}), {
status: 200,
headers: { "Content-Type": "application/json" },
})));
const result = await handleFetchCore({
url: "https://example.com",
format: "markdown",
maxCharacters: 5,
provider: "ollama",
providerConfig: CONFIG,
credentials: { apiKey: "ollama-test-key" },
});
expect(result.success).toBe(true);
expect(global.fetch).toHaveBeenCalledTimes(1);
const [requestUrl, init] = global.fetch.mock.calls[0];
expect(requestUrl).toBe("https://ollama.com/api/web_fetch");
expect(init.method).toBe("POST");
expect(init.headers).toEqual({
"content-type": "application/json",
authorization: "Bearer ollama-test-key",
});
expect(JSON.parse(init.body)).toEqual({ url: "https://example.com" });
expect(result.data).toMatchObject({
provider: "ollama",
url: "https://example.com",
title: "Example Domain",
content: { format: "markdown", text: "Hello", length: 5 },
links: ["https://www.iana.org/domains/example"],
usage: { fetch_cost_usd: null },
});
});
it("returns the upstream status and error message", async () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response(
JSON.stringify({ error: "invalid API key" }),
{ status: 401, headers: { "Content-Type": "application/json" } },
)));
const result = await handleFetchCore({
url: "https://example.com",
provider: "ollama",
providerConfig: CONFIG,
credentials: { apiKey: "bad-key" },
});
expect(result).toMatchObject({
success: false,
status: 401,
error: "invalid API key",
});
});
it("treats an empty successful response as an upstream error", async () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response(null, {
status: 200,
headers: { "Content-Type": "application/json" },
})));
const result = await handleFetchCore({
url: "https://example.com",
provider: "ollama",
providerConfig: CONFIG,
credentials: { apiKey: "ollama-test-key" },
});
expect(result).toEqual({
success: false,
status: 502,
error: "Ollama returned an empty or invalid web fetch response",
});
});
});