diff --git a/open-sse/handlers/fetch/index.js b/open-sse/handlers/fetch/index.js index 187bd60e..9bad7e18 100644 --- a/open-sse/handlers/fetch/index.js +++ b/open-sse/handlers/fetch/index.js @@ -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 + }) + }; +} diff --git a/open-sse/providers/registry/ollama.js b/open-sse/providers/registry/ollama.js index 89fec43c..6965484a 100644 --- a/open-sse/providers/registry/ollama.js +++ b/open-sse/providers/registry/ollama.js @@ -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, diff --git a/skills/9router-web-fetch/SKILL.md b/skills/9router-web-fetch/SKILL.md index 69c41ac3..c9c5af1b 100644 --- a/skills/9router-web-fetch/SKILL.md +++ b/skills/9router-web-fetch/SKILL.md @@ -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 | diff --git a/src/sse/handlers/fetch.js b/src/sse/handlers/fetch.js index 509656fd..b9b8f631 100644 --- a/src/sse/handlers/fetch.js +++ b/src/sse/handlers/fetch.js @@ -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`); diff --git a/tests/unit/fetch-success-clears-account.test.js b/tests/unit/fetch-success-clears-account.test.js index 2dc0ad19..79fec6eb 100644 --- a/tests/unit/fetch-success-clears-account.test.js +++ b/tests/unit/fetch-success-clears-account.test.js @@ -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", + ); + }); }); diff --git a/tests/unit/ollama-web-fetch-provider.test.js b/tests/unit/ollama-web-fetch-provider.test.js new file mode 100644 index 00000000..c9bdc7f4 --- /dev/null +++ b/tests/unit/ollama-web-fetch-provider.test.js @@ -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", + }); + }); +});