feat(fetch): add Ollama Cloud web fetch provider
This commit is contained in:
@@ -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
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
112
tests/unit/ollama-web-fetch-provider.test.js
Normal file
112
tests/unit/ollama-web-fetch-provider.test.js
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user