mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
feat: add TinyFish Fetch support to web-fetch provider and update related documentation (#6349)
add TinyFish web-fetch/search provider + tool (#6349). Tests green (tinyfish suites + count-guard 170->171). Integrated into release/v3.8.46.
This commit is contained in:
@@ -31,6 +31,7 @@
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **feat(providers):** add **TinyFish** web-fetch/search support — a `tinyfish-fetch` executor + `/v1/web/fetch` route + MCP web-fetch tool, registered as a specialty-media provider with request-validation and a search-provider catalog entry. Regression guards: `tests/unit/executor-tinyfish-fetch.test.ts`, `web-fetch-handler.test.ts`, `mcp-web-fetch-tool.test.ts`, `provider-validation-tinyfish.test.ts`. (thanks @dtybnrj)
|
||||
- **fix(cli):** `omniroute launch-codex` now spawns `codex.cmd` through a shell on Windows (the npm `.cmd` shim is unresolvable by bare `spawn` → ENOENT), mirroring the qodercli Windows fix (#6263). Regression guard: `tests/unit/launch-codex-windows-spawn-6312.test.ts`. (thanks @swingtempo)
|
||||
- **fix(codex):** isolate the **Spark** quota from the shared Codex quota and stabilize the quota UI ordering / hydration so per-scope limits render consistently. Regression guards: `tests/unit/codex-quota-selection-hydration.test.ts`, `provider-limits-ui.test.ts` + 3 more. (thanks @xz-dev)
|
||||
- **feat(api):** add a `hidePaidModels` setting that filters paid-only models out of the `/v1/models` catalog. Regression guard: `tests/unit/models-catalog-hide-paid.test.ts`. (thanks @chirag127)
|
||||
|
||||
@@ -57,7 +57,7 @@ New tab for extracting content from a URL via `POST /v1/web/fetch` (created in p
|
||||
- Submit → fetch → render `ScrapeResult.tsx`.
|
||||
- `ScrapeResult` renders markdown preview + raw toggle.
|
||||
- Cap: if response body > **256 KB**, UI shows `(truncated, view raw)` and opens raw in a Monaco modal (D21).
|
||||
- Metadata panel: provider (firecrawl/jina-reader/tavily-search), latency, cost, response size, links count.
|
||||
- Metadata panel: provider (firecrawl/jina-reader/tavily-search/tinyfish), latency, cost, response size, links count.
|
||||
- Uses `useScrapeFetch.ts` hook.
|
||||
|
||||
### Compare Tab
|
||||
@@ -108,7 +108,7 @@ Runs the same query/URL across up to **4 providers in parallel** (D22):
|
||||
| Field | Source |
|
||||
| ------------------------------ | ------------------------------------------------------------------------------------------ |
|
||||
| `id`, `name` | `searchRegistry.ts` |
|
||||
| `kind` | `"search"` (12 providers) or `"fetch"` (firecrawl, jina-reader, tavily-search) |
|
||||
| `kind` | `"search"` (12 providers) or `"fetch"` (firecrawl, jina-reader, tavily-search, tinyfish) |
|
||||
| `costPerQuery` | Registry data |
|
||||
| `freeMonthlyQuota` | Registry data |
|
||||
| `searchTypes` / `fetchFormats` | Registry data |
|
||||
@@ -136,7 +136,7 @@ Only one backend change was needed for this feature:
|
||||
|
||||
`src/app/api/search/providers/route.ts` was extended to:
|
||||
|
||||
- Include all 3 fetch providers (`firecrawl`, `jina-reader`, `tavily-search`) in the array.
|
||||
- Include all 4 fetch providers (`firecrawl`, `jina-reader`, `tavily-search`, `tinyfish`) in the array.
|
||||
- Add `kind: "search" | "fetch"` to every item.
|
||||
- Add `status: "configured" | "missing" | "rate_limited"` derived from live credential state.
|
||||
- Maintain backward compatibility — existing fields (`id`, `name`, etc.) unchanged.
|
||||
|
||||
131
open-sse/executors/tinyfish-fetch.ts
Normal file
131
open-sse/executors/tinyfish-fetch.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* TinyFish Fetch Executor
|
||||
*
|
||||
* Fetches content from a URL using the TinyFish Fetch API.
|
||||
* POST https://api.fetch.tinyfish.ai
|
||||
*
|
||||
* "Fetch does not use credits" per TinyFish docs — no explicit pricing tier.
|
||||
* Docs: https://docs.tinyfish.ai/fetch-api
|
||||
*
|
||||
* Unlike Firecrawl, TinyFish has no "links" or "screenshot" output modes —
|
||||
* only markdown, html, and json. Requests for those formats fall back to
|
||||
* markdown and return an empty links array / null screenshot, mirroring how
|
||||
* jina-reader-fetch.ts and tavily-fetch.ts handle formats they don't support.
|
||||
*/
|
||||
|
||||
import { sanitizeErrorMessage, buildErrorBody } from "../utils/error.ts";
|
||||
import type { WebFetchResult, WebFetchFormat, WebFetchCredentials } from "../handlers/webFetch.ts";
|
||||
|
||||
const TINYFISH_FETCH_URL = "https://api.fetch.tinyfish.ai";
|
||||
const TINYFISH_TIMEOUT_MS = 30_000;
|
||||
|
||||
function mapFormat(format: WebFetchFormat): "markdown" | "html" {
|
||||
return format === "html" ? "html" : "markdown";
|
||||
}
|
||||
|
||||
interface TinyFishFetchOptions {
|
||||
url: string;
|
||||
format: WebFetchFormat;
|
||||
includeMetadata: boolean;
|
||||
credentials: WebFetchCredentials;
|
||||
}
|
||||
|
||||
interface TinyFishResultEntry {
|
||||
url?: string;
|
||||
final_url?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
interface TinyFishErrorEntry {
|
||||
url?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a TinyFish Fetch API request.
|
||||
*/
|
||||
export async function tinyfishFetch(opts: TinyFishFetchOptions): Promise<WebFetchResult> {
|
||||
const { url, format, includeMetadata, credentials } = opts;
|
||||
|
||||
if (!credentials.apiKey) {
|
||||
const body = buildErrorBody(401, "TinyFish API key required");
|
||||
return { success: false, status: 401, error: body.error.message };
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
urls: [url],
|
||||
format: mapFormat(format),
|
||||
ttl: 0,
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), TINYFISH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(TINYFISH_FETCH_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": credentials.apiKey,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const rawError = await response.text().catch(() => `HTTP ${response.status}`);
|
||||
const msg = sanitizeErrorMessage(`TinyFish error ${response.status}: ${rawError}`);
|
||||
const body = buildErrorBody(response.status, msg);
|
||||
return { success: false, status: response.status, error: body.error.message };
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
results?: TinyFishResultEntry[];
|
||||
errors?: TinyFishErrorEntry[];
|
||||
};
|
||||
|
||||
const result = data.results?.[0];
|
||||
|
||||
if (!result) {
|
||||
const errorEntry = data.errors?.[0];
|
||||
const msg = sanitizeErrorMessage(
|
||||
errorEntry?.message ?? errorEntry?.error ?? "TinyFish could not fetch the requested URL"
|
||||
);
|
||||
const body = buildErrorBody(502, msg);
|
||||
return { success: false, status: 502, error: body.error.message };
|
||||
}
|
||||
|
||||
const metadata = includeMetadata
|
||||
? {
|
||||
title: result.title != null ? String(result.title) : null,
|
||||
description: result.description != null ? String(result.description) : null,
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
provider: "tinyfish",
|
||||
url,
|
||||
content: String(result.text ?? ""),
|
||||
links: [],
|
||||
metadata,
|
||||
screenshot_url: null,
|
||||
},
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
const body = buildErrorBody(504, "TinyFish request timed out");
|
||||
return { success: false, status: 504, error: body.error.message };
|
||||
}
|
||||
const msg =
|
||||
err instanceof Error ? sanitizeErrorMessage(err.message) : sanitizeErrorMessage(String(err));
|
||||
const body = buildErrorBody(502, msg);
|
||||
return { success: false, status: 502, error: body.error.message };
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,12 @@
|
||||
* Web Fetch Handler
|
||||
*
|
||||
* Handles POST /v1/web/fetch requests.
|
||||
* Dispatches to a web-fetch provider executor (Firecrawl, Jina Reader, or Tavily).
|
||||
* Dispatches to a web-fetch provider executor (Firecrawl, Jina Reader, Tavily, or TinyFish).
|
||||
*
|
||||
* Request format:
|
||||
* {
|
||||
* "url": "https://example.com",
|
||||
* "provider": "firecrawl" | "jina-reader" | "tavily-search", // optional
|
||||
* "provider": "firecrawl" | "jina-reader" | "tavily-search" | "tinyfish", // optional
|
||||
* "format": "markdown" | "html" | "links" | "screenshot",
|
||||
* "depth": 0 | 1 | 2,
|
||||
* "wait_for_selector": "main",
|
||||
@@ -19,12 +19,13 @@ import { buildErrorBody, sanitizeErrorMessage } from "../utils/error.ts";
|
||||
import { firecrawlFetch } from "../executors/firecrawl-fetch.ts";
|
||||
import { jinaReaderFetch } from "../executors/jina-reader-fetch.ts";
|
||||
import { tavilyFetch } from "../executors/tavily-fetch.ts";
|
||||
import { tinyfishFetch } from "../executors/tinyfish-fetch.ts";
|
||||
|
||||
export type WebFetchFormat = "markdown" | "html" | "links" | "screenshot";
|
||||
|
||||
export interface WebFetchRequest {
|
||||
url: string;
|
||||
provider?: "firecrawl" | "jina-reader" | "tavily-search";
|
||||
provider?: "firecrawl" | "jina-reader" | "tavily-search" | "tinyfish";
|
||||
format?: WebFetchFormat;
|
||||
depth?: 0 | 1 | 2;
|
||||
wait_for_selector?: string;
|
||||
@@ -51,7 +52,7 @@ export interface WebFetchCredentials {
|
||||
apiKey?: string;
|
||||
}
|
||||
|
||||
const WEB_FETCH_PROVIDERS = ["firecrawl", "jina-reader", "tavily-search"] as const;
|
||||
const WEB_FETCH_PROVIDERS = ["firecrawl", "jina-reader", "tavily-search", "tinyfish"] as const;
|
||||
type WebFetchProviderId = (typeof WEB_FETCH_PROVIDERS)[number];
|
||||
|
||||
/**
|
||||
@@ -99,6 +100,14 @@ export async function handleWebFetch(
|
||||
credentials,
|
||||
});
|
||||
|
||||
case "tinyfish":
|
||||
return await tinyfishFetch({
|
||||
url: req.url,
|
||||
format,
|
||||
includeMetadata,
|
||||
credentials,
|
||||
});
|
||||
|
||||
default: {
|
||||
const _exhaustive: never = provider;
|
||||
return {
|
||||
|
||||
@@ -453,7 +453,7 @@ export const webFetchInput = z.object({
|
||||
.min(1, "URL is required")
|
||||
.describe("The URL to fetch content from"),
|
||||
provider: z
|
||||
.enum(["firecrawl", "jina-reader", "tavily-search"])
|
||||
.enum(["firecrawl", "jina-reader", "tavily-search", "tinyfish"])
|
||||
.optional()
|
||||
.describe("Specific fetch provider to use (default: first available)"),
|
||||
format: z
|
||||
@@ -496,7 +496,7 @@ export const webFetchOutput = z.object({
|
||||
export const webFetchTool: McpToolDefinition<typeof webFetchInput, typeof webFetchOutput> = {
|
||||
name: "omniroute_web_fetch",
|
||||
description:
|
||||
"Fetches and extracts content from a URL using OmniRoute's web fetch gateway. Supports multiple providers (Firecrawl, Jina Reader, Tavily) with automatic failover. Returns the page content as markdown, HTML, links, or screenshot, along with metadata.",
|
||||
"Fetches and extracts content from a URL using OmniRoute's web fetch gateway. Supports multiple providers (Firecrawl, Jina Reader, Tavily, TinyFish) with automatic failover. Returns the page content as markdown, HTML, links, or screenshot, along with metadata.",
|
||||
inputSchema: webFetchInput,
|
||||
outputSchema: webFetchOutput,
|
||||
scopes: ["execute:search"],
|
||||
|
||||
@@ -578,7 +578,7 @@ async function handleWebSearch(args: {
|
||||
|
||||
async function handleWebFetch(args: {
|
||||
url: string;
|
||||
provider?: "firecrawl" | "jina-reader" | "tavily-search";
|
||||
provider?: "firecrawl" | "jina-reader" | "tavily-search" | "tinyfish";
|
||||
format?: "markdown" | "html" | "links" | "screenshot";
|
||||
include_metadata?: boolean;
|
||||
depth?: number;
|
||||
@@ -866,7 +866,16 @@ export function createMcpServer(): McpServer {
|
||||
)
|
||||
);
|
||||
|
||||
server.registerTool("omniroute_pick_fastest_model", { description: "Picks the fastest reliable provider-model pair from live telemetry.", inputSchema: pickFastestModelInput }, withScopeEnforcement("omniroute_pick_fastest_model", (args) => handlePickFastestModel(pickFastestModelInput.parse(args))));
|
||||
server.registerTool(
|
||||
"omniroute_pick_fastest_model",
|
||||
{
|
||||
description: "Picks the fastest reliable provider-model pair from live telemetry.",
|
||||
inputSchema: pickFastestModelInput,
|
||||
},
|
||||
withScopeEnforcement("omniroute_pick_fastest_model", (args) =>
|
||||
handlePickFastestModel(pickFastestModelInput.parse(args))
|
||||
)
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"omniroute_get_session_snapshot",
|
||||
@@ -1073,17 +1082,21 @@ export function createMcpServer(): McpServer {
|
||||
// @ts-ignore: dynamic zod access
|
||||
inputSchema: toolDef.inputSchema,
|
||||
},
|
||||
withScopeEnforcement(toolDef.name, async (args) => {
|
||||
try {
|
||||
const parsedArgs = toolDef.inputSchema.parse(args ?? {});
|
||||
// @ts-expect-error - handler type lost through dynamic Object.values() access
|
||||
const result = await toolDef.handler(parsedArgs);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
}, toolDef.scopes)
|
||||
withScopeEnforcement(
|
||||
toolDef.name,
|
||||
async (args) => {
|
||||
try {
|
||||
const parsedArgs = toolDef.inputSchema.parse(args ?? {});
|
||||
// @ts-expect-error - handler type lost through dynamic Object.values() access
|
||||
const result = await toolDef.handler(parsedArgs);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
},
|
||||
toolDef.scopes
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import * as log from "@/sse/utils/logger";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fetch provider metadata (hardcoded — no registry for these 3)
|
||||
// Fetch provider metadata (hardcoded — no registry for these 4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FetchProviderDef {
|
||||
@@ -47,6 +47,13 @@ const FETCH_PROVIDERS: FetchProviderDef[] = [
|
||||
freeMonthlyQuota: 1000,
|
||||
fetchFormats: ["markdown", "text"],
|
||||
},
|
||||
{
|
||||
id: "tinyfish",
|
||||
name: "TinyFish Fetch",
|
||||
costPerQuery: 0,
|
||||
freeMonthlyQuota: 0,
|
||||
fetchFormats: ["markdown", "html"],
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -115,10 +122,7 @@ async function resolveProviderStatus(
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json(
|
||||
buildErrorBody(401, "Unauthorized"),
|
||||
{ status: 401 }
|
||||
);
|
||||
return NextResponse.json(buildErrorBody(401, "Unauthorized"), { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -133,19 +137,21 @@ export async function GET(request: Request) {
|
||||
)
|
||||
);
|
||||
|
||||
const searchItems: SearchProviderCatalogItem[] = searchProviderStatuses.map(({ p, status }) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
kind: "search" as const,
|
||||
costPerQuery: p.costPerQuery,
|
||||
freeMonthlyQuota: p.freeMonthlyQuota,
|
||||
searchTypes: p.searchTypes,
|
||||
status,
|
||||
configureHref: "/dashboard/providers",
|
||||
}));
|
||||
const searchItems: SearchProviderCatalogItem[] = searchProviderStatuses.map(
|
||||
({ p, status }) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
kind: "search" as const,
|
||||
costPerQuery: p.costPerQuery,
|
||||
freeMonthlyQuota: p.freeMonthlyQuota,
|
||||
searchTypes: p.searchTypes,
|
||||
status,
|
||||
configureHref: "/dashboard/providers",
|
||||
})
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 2. Build fetch providers (3 hardcoded)
|
||||
// 2. Build fetch providers (4 hardcoded)
|
||||
// -----------------------------------------------------------------------
|
||||
const fetchProviderStatuses = await Promise.all(
|
||||
FETCH_PROVIDERS.map((fp) =>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* POST /v1/web/fetch
|
||||
*
|
||||
* Extract content from a URL using a configured web-fetch provider.
|
||||
* Supports Firecrawl, Jina Reader, and Tavily Extract.
|
||||
* Supports Firecrawl, Jina Reader, Tavily Extract, and TinyFish Fetch.
|
||||
*
|
||||
* Request: { url, provider?, format?, depth?, wait_for_selector?, include_metadata? }
|
||||
* Response: { provider, url, content, links, metadata, screenshot_url }
|
||||
@@ -23,7 +23,7 @@ const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
};
|
||||
|
||||
const WEB_FETCH_PROVIDERS = ["firecrawl", "jina-reader", "tavily-search"] as const;
|
||||
const WEB_FETCH_PROVIDERS = ["firecrawl", "jina-reader", "tavily-search", "tinyfish"] as const;
|
||||
type WebFetchProviderId = (typeof WEB_FETCH_PROVIDERS)[number];
|
||||
|
||||
export async function OPTIONS() {
|
||||
|
||||
@@ -1968,7 +1968,7 @@
|
||||
"scrapeProvider": "Provider",
|
||||
"scrapeSize": "Size",
|
||||
"scrapeEmptyState": "Enter a URL to extract its content",
|
||||
"scrapeProvidersAvailable": "Available providers: Firecrawl, Jina Reader, Tavily.",
|
||||
"scrapeProvidersAvailable": "Available providers: Firecrawl, Jina Reader, Tavily, TinyFish.",
|
||||
"compareRun": "Compare",
|
||||
"compareRunning": "Comparing…",
|
||||
"autoProvider": "Auto (cheapest)",
|
||||
|
||||
@@ -188,4 +188,12 @@ export const SEARCH_VALIDATOR_CONFIGS: Record<
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
},
|
||||
}),
|
||||
tinyfish: (apiKey) => ({
|
||||
url: "https://api.fetch.tinyfish.ai",
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-API-Key": apiKey },
|
||||
body: JSON.stringify({ urls: ["https://example.com"], format: "markdown" }),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -248,4 +248,19 @@ export const APIKEY_PROVIDERS_SPECIALTY = {
|
||||
},
|
||||
serviceKinds: ["webFetch"],
|
||||
},
|
||||
tinyfish: {
|
||||
id: "tinyfish",
|
||||
alias: "tf",
|
||||
name: "TinyFish Fetch",
|
||||
icon: "language",
|
||||
color: "#0891B2",
|
||||
textIcon: "TF",
|
||||
website: "https://docs.tinyfish.ai/fetch-api",
|
||||
notice: {
|
||||
text: "Fetch does not use TinyFish credits. Submit up to 10 URLs per request (OmniRoute fetches one URL per call).",
|
||||
apiKeyUrl: "https://agent.tinyfish.ai/api-keys",
|
||||
},
|
||||
authHint: "X-API-Key from agent.tinyfish.ai/api-keys",
|
||||
serviceKinds: ["webFetch"],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -289,7 +289,7 @@ export const v1BatchCreateSchema = z.object({
|
||||
|
||||
export const v1WebFetchSchema = z.object({
|
||||
url: z.string().url("url must be a valid URL (http/https)"),
|
||||
provider: z.enum(["firecrawl", "jina-reader", "tavily-search"]).optional(),
|
||||
provider: z.enum(["firecrawl", "jina-reader", "tavily-search", "tinyfish"]).optional(),
|
||||
format: z.enum(["markdown", "html", "links", "screenshot"]).default("markdown"),
|
||||
depth: z.union([z.literal(0), z.literal(1), z.literal(2)]).default(0),
|
||||
wait_for_selector: z.string().max(256).optional(),
|
||||
|
||||
@@ -27,9 +27,7 @@ import {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Isolated temp DB for this test suite
|
||||
// ---------------------------------------------------------------------------
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "omniroute-search-providers-catalog-")
|
||||
);
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-search-providers-catalog-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = "test-api-key-secret-search-catalog";
|
||||
// Disable dashboard password requirement by default
|
||||
@@ -54,7 +52,7 @@ const route = await import("../../src/app/api/search/providers/route.ts");
|
||||
// linkup, searchapi, youcom, searxng, ollama, zai + duckduckgo-free (added in the
|
||||
// v3.8.27 cycle, registry open-sse/config/searchRegistry.ts).
|
||||
const EXPECTED_SEARCH_COUNT = 13;
|
||||
const EXPECTED_FETCH_COUNT = 3;
|
||||
const EXPECTED_FETCH_COUNT = 4;
|
||||
const EXPECTED_TOTAL = EXPECTED_SEARCH_COUNT + EXPECTED_FETCH_COUNT;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -271,11 +269,7 @@ test("search-providers-catalog: back-compat data field has legacy shape", async
|
||||
|
||||
// Legacy shape: { id, object, created, name, search_types }
|
||||
assert.ok(Array.isArray(body.data), "`data` array must be present for back-compat");
|
||||
assert.equal(
|
||||
body.data.length,
|
||||
EXPECTED_TOTAL,
|
||||
"data array should have same length as providers"
|
||||
);
|
||||
assert.equal(body.data.length, EXPECTED_TOTAL, "data array should have same length as providers");
|
||||
|
||||
for (const item of body.data) {
|
||||
assert.ok(typeof item.id === "string", "data item must have id");
|
||||
@@ -296,6 +290,7 @@ test("search-providers-catalog: fetch providers have correct metadata", async ()
|
||||
assert.ok(ids.includes("firecrawl"), "firecrawl must be present");
|
||||
assert.ok(ids.includes("jina-reader"), "jina-reader must be present");
|
||||
assert.ok(ids.includes("tavily-search"), "tavily-search must be present");
|
||||
assert.ok(ids.includes("tinyfish"), "tinyfish must be present");
|
||||
|
||||
const firecrawl = fetchProviders.find((p: { id: string }) => p.id === "firecrawl");
|
||||
assert.equal(firecrawl.name, "Firecrawl");
|
||||
@@ -319,6 +314,14 @@ test("search-providers-catalog: fetch providers have correct metadata", async ()
|
||||
const tavily = fetchProviders.find((p: { id: string }) => p.id === "tavily-search");
|
||||
assert.equal(tavily.name, "Tavily Extract");
|
||||
assert.equal(tavily.costPerQuery, 0.001);
|
||||
|
||||
const tinyfish = fetchProviders.find((p: { id: string }) => p.id === "tinyfish");
|
||||
assert.equal(tinyfish.name, "TinyFish Fetch");
|
||||
assert.equal(tinyfish.costPerQuery, 0);
|
||||
assert.ok(
|
||||
tinyfish.fetchFormats.includes("markdown"),
|
||||
"tinyfish fetchFormats must include markdown"
|
||||
);
|
||||
});
|
||||
|
||||
test("search-providers-catalog: search providers have correct fields", async () => {
|
||||
@@ -331,10 +334,7 @@ test("search-providers-catalog: search providers have correct fields", async ()
|
||||
assert.ok(typeof item.id === "string", "search item must have id");
|
||||
assert.ok(typeof item.name === "string", "search item must have name");
|
||||
assert.ok(typeof item.costPerQuery === "number", "search item must have costPerQuery");
|
||||
assert.ok(
|
||||
typeof item.freeMonthlyQuota === "number",
|
||||
"search item must have freeMonthlyQuota"
|
||||
);
|
||||
assert.ok(typeof item.freeMonthlyQuota === "number", "search item must have freeMonthlyQuota");
|
||||
assert.ok(Array.isArray(item.searchTypes), "search item must have searchTypes array");
|
||||
assert.equal(
|
||||
item.configureHref,
|
||||
@@ -355,9 +355,8 @@ test("search-providers-catalog: response validates against SearchProviderCatalog
|
||||
const res = await route.GET(req);
|
||||
const body = await res.json();
|
||||
|
||||
const { SearchProviderCatalogResponseSchema } = await import(
|
||||
"../../src/shared/schemas/searchTools.ts"
|
||||
);
|
||||
const { SearchProviderCatalogResponseSchema } =
|
||||
await import("../../src/shared/schemas/searchTools.ts");
|
||||
|
||||
const result = SearchProviderCatalogResponseSchema.safeParse({ providers: body.providers });
|
||||
assert.ok(
|
||||
|
||||
254
tests/unit/executor-tinyfish-fetch.test.ts
Normal file
254
tests/unit/executor-tinyfish-fetch.test.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { tinyfishFetch } = await import("../../open-sse/executors/tinyfish-fetch.ts");
|
||||
|
||||
// ── tinyfishFetch tests ─────────────────────────────────────────────────────
|
||||
|
||||
test("tinyfishFetch posts to api.fetch.tinyfish.ai with X-API-Key auth and a urls array", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let captured: { url: string; init: RequestInit } = { url: "", init: {} };
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
captured = { url: String(url), init: init as RequestInit };
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
results: [{ url: "https://example.com", text: "# Hello from TinyFish" }],
|
||||
errors: [],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await tinyfishFetch({
|
||||
url: "https://example.com",
|
||||
format: "markdown",
|
||||
includeMetadata: false,
|
||||
credentials: { apiKey: "tf-test-key" },
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(captured.url, "https://api.fetch.tinyfish.ai");
|
||||
assert.equal(captured.init.method, "POST");
|
||||
const headers = captured.init.headers as Record<string, string>;
|
||||
assert.equal(headers["X-API-Key"], "tf-test-key");
|
||||
const body = JSON.parse(String(captured.init.body));
|
||||
assert.deepEqual(body.urls, ["https://example.com"]);
|
||||
assert.equal(body.format, "markdown");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("tinyfishFetch returns 401 error when no API key", async () => {
|
||||
const result = await tinyfishFetch({
|
||||
url: "https://example.com",
|
||||
format: "markdown",
|
||||
includeMetadata: false,
|
||||
credentials: {},
|
||||
});
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.status, 401);
|
||||
assert.ok(!result.error?.includes("at /"), "error must not contain stack trace");
|
||||
});
|
||||
|
||||
test("tinyfishFetch propagates non-200 status without stack trace", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
new Response("Forbidden", { status: 403, headers: { "content-type": "text/plain" } });
|
||||
|
||||
try {
|
||||
const result = await tinyfishFetch({
|
||||
url: "https://example.com",
|
||||
format: "markdown",
|
||||
includeMetadata: false,
|
||||
credentials: { apiKey: "bad-key" },
|
||||
});
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.status, 403);
|
||||
assert.ok(result.error, "should have error message");
|
||||
assert.ok(!result.error.includes("at /"), "error must not contain stack trace");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("tinyfishFetch parses results[0].text as content and includes metadata when requested", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
results: [
|
||||
{
|
||||
url: "https://example.com",
|
||||
final_url: "https://example.com/",
|
||||
title: "Example Domain",
|
||||
description: "An example page",
|
||||
text: "# Example content",
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await tinyfishFetch({
|
||||
url: "https://example.com",
|
||||
format: "markdown",
|
||||
includeMetadata: true,
|
||||
credentials: { apiKey: "tf-key" },
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.ok(result.data, "should have data");
|
||||
assert.equal(result.data.provider, "tinyfish");
|
||||
assert.ok(result.data.content.includes("Example content"));
|
||||
assert.equal(result.data.metadata?.title, "Example Domain");
|
||||
assert.equal(result.data.metadata?.description, "An example page");
|
||||
assert.equal(result.data.screenshot_url, null);
|
||||
assert.deepEqual(result.data.links, []);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("tinyfishFetch omits metadata when includeMetadata is false", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
results: [{ url: "https://example.com", title: "Example", text: "content" }],
|
||||
errors: [],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await tinyfishFetch({
|
||||
url: "https://example.com",
|
||||
format: "markdown",
|
||||
includeMetadata: false,
|
||||
credentials: { apiKey: "tf-key" },
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.data?.metadata, null);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("tinyfishFetch maps 'html' format to the html request format", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let capturedBody: Record<string, unknown> = {};
|
||||
|
||||
globalThis.fetch = async (_url, init = {}) => {
|
||||
capturedBody = JSON.parse(String((init as RequestInit).body));
|
||||
return new Response(
|
||||
JSON.stringify({ results: [{ url: "https://example.com", text: "<html></html>" }] }),
|
||||
{ status: 200 }
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
await tinyfishFetch({
|
||||
url: "https://example.com",
|
||||
format: "html",
|
||||
includeMetadata: false,
|
||||
credentials: { apiKey: "tf-key" },
|
||||
});
|
||||
|
||||
assert.equal(capturedBody.format, "html");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("tinyfishFetch falls back to markdown for unsupported 'links'/'screenshot' formats", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let capturedBody: Record<string, unknown> = {};
|
||||
|
||||
globalThis.fetch = async (_url, init = {}) => {
|
||||
capturedBody = JSON.parse(String((init as RequestInit).body));
|
||||
return new Response(
|
||||
JSON.stringify({ results: [{ url: "https://example.com", text: "content" }] }),
|
||||
{ status: 200 }
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await tinyfishFetch({
|
||||
url: "https://example.com",
|
||||
format: "links",
|
||||
includeMetadata: false,
|
||||
credentials: { apiKey: "tf-key" },
|
||||
});
|
||||
|
||||
assert.equal(capturedBody.format, "markdown");
|
||||
assert.equal(result.success, true);
|
||||
assert.deepEqual(result.data?.links, []);
|
||||
assert.equal(result.data?.screenshot_url, null);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("tinyfishFetch returns a failure when the URL is only present in the errors[] array", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
results: [],
|
||||
errors: [{ url: "https://example.com", message: "could not reach host" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await tinyfishFetch({
|
||||
url: "https://example.com",
|
||||
format: "markdown",
|
||||
includeMetadata: false,
|
||||
credentials: { apiKey: "tf-key" },
|
||||
});
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.status, 502);
|
||||
assert.ok(result.error?.includes("could not reach host"));
|
||||
assert.ok(!result.error?.includes("at /"), "error must not contain stack trace");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("tinyfishFetch maps AbortError to a 504 timeout", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async () => {
|
||||
const err = new Error("aborted");
|
||||
err.name = "AbortError";
|
||||
throw err;
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await tinyfishFetch({
|
||||
url: "https://example.com",
|
||||
format: "markdown",
|
||||
includeMetadata: false,
|
||||
credentials: { apiKey: "tf-key" },
|
||||
});
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.status, 504);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -27,14 +27,8 @@ test("webFetchTool has the required McpToolDefinition shape", () => {
|
||||
|
||||
test("webFetchTool is registered in MCP_TOOLS and MCP_TOOL_MAP", () => {
|
||||
const toolNames = MCP_TOOLS.map((t) => t.name);
|
||||
assert.ok(
|
||||
toolNames.includes("omniroute_web_fetch"),
|
||||
"webFetchTool must be in MCP_TOOLS array"
|
||||
);
|
||||
assert.ok(
|
||||
"omniroute_web_fetch" in MCP_TOOL_MAP,
|
||||
"webFetchTool must be in MCP_TOOL_MAP"
|
||||
);
|
||||
assert.ok(toolNames.includes("omniroute_web_fetch"), "webFetchTool must be in MCP_TOOLS array");
|
||||
assert.ok("omniroute_web_fetch" in MCP_TOOL_MAP, "webFetchTool must be in MCP_TOOL_MAP");
|
||||
});
|
||||
|
||||
// ── Scope mapping ──
|
||||
@@ -103,6 +97,11 @@ test("webFetchInput accepts depth values 0, 1, 2", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("webFetchInput accepts provider=tinyfish", () => {
|
||||
const parsed = webFetchInput.parse({ url: "https://example.com", provider: "tinyfish" });
|
||||
assert.equal(parsed.provider, "tinyfish");
|
||||
});
|
||||
|
||||
test("webFetchInput rejects invalid provider", () => {
|
||||
assert.throws(
|
||||
() => webFetchInput.parse({ url: "https://example.com", provider: "unknown-provider" }),
|
||||
|
||||
47
tests/unit/provider-validation-tinyfish.test.ts
Normal file
47
tests/unit/provider-validation-tinyfish.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// TinyFish Fetch API added as a webFetch-kind provider (docs.tinyfish.ai/fetch-api).
|
||||
// These tests pin the validator dispatch (tinyfish -> POST api.fetch.tinyfish.ai with
|
||||
// X-API-Key auth) and the auth-failure mapping, mirroring the firecrawl/jina-reader
|
||||
// coverage in provider-validation-webfetch-4401.test.ts.
|
||||
|
||||
const { validateProviderApiKey } = await import("../../src/lib/providers/validation.ts");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
test.afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function headerValue(init: RequestInit | undefined, name: string): string | undefined {
|
||||
const headers = (init?.headers || {}) as Record<string, string>;
|
||||
return headers[name];
|
||||
}
|
||||
|
||||
test("tinyfish validator probes api.fetch.tinyfish.ai with X-API-Key auth and accepts a 200", async () => {
|
||||
const calls: { url: string; init: RequestInit }[] = [];
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
calls.push({ url: String(url), init });
|
||||
return new Response(JSON.stringify({ results: [{}], errors: [] }), { status: 200 });
|
||||
};
|
||||
|
||||
const result = await validateProviderApiKey({ provider: "tinyfish", apiKey: "tf-test-key" });
|
||||
|
||||
assert.equal(result.valid, true);
|
||||
assert.equal(result.unsupported ?? false, false);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0].url, /^https:\/\/api\.fetch\.tinyfish\.ai\/?$/);
|
||||
assert.equal(calls[0].init.method, "POST");
|
||||
assert.equal(headerValue(calls[0].init, "X-API-Key"), "tf-test-key");
|
||||
});
|
||||
|
||||
test("tinyfish validator maps 401/403 to an invalid-key error", async () => {
|
||||
globalThis.fetch = async () =>
|
||||
new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 });
|
||||
|
||||
const result = await validateProviderApiKey({ provider: "tinyfish", apiKey: "bad" });
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.equal(result.error, "Invalid API key");
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
// Characterization of the providers.ts catalog split (god-file decomposition): the host became a
|
||||
// barrel that re-exports 10 data catalogs now living under constants/providers/*, and APIKEY is
|
||||
// merged from 6 semantic family files (apikey/<family>.ts). Locks: the public surface (every catalog
|
||||
// + helpers still exported), the spread-merge integrity (170 APIKEY entries, no loss/dup), and that
|
||||
// + helpers still exported), the spread-merge integrity (171 APIKEY entries, no loss/dup), and that
|
||||
// load-time Zod validation still runs. Pure-data move → behavior must be identical.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
@@ -31,12 +31,12 @@ test("barrel still exports every catalog + key helpers", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("APIKEY_PROVIDERS merges the 6 family files into 170 entries (no loss / no dup)", async () => {
|
||||
test("APIKEY_PROVIDERS merges the 6 family files into 171 entries (no loss / no dup)", async () => {
|
||||
const keys = Object.keys((P as Record<string, object>).APIKEY_PROVIDERS);
|
||||
assert.equal(keys.length, 170);
|
||||
assert.equal(new Set(keys).size, 170, "duplicate keys after spread-merge");
|
||||
assert.equal(keys.length, 171);
|
||||
assert.equal(new Set(keys).size, 171, "duplicate keys after spread-merge");
|
||||
// the merged object's entry-count equals the sum of the 6 semantic family files; families are a
|
||||
// strict partition (every provider in exactly one), so the sum must be exactly 170.
|
||||
// strict partition (every provider in exactly one), so the sum must be exactly 171.
|
||||
const families: [string, string][] = [
|
||||
["gateways", "APIKEY_PROVIDERS_GATEWAYS"],
|
||||
["frontier-labs", "APIKEY_PROVIDERS_FRONTIER"],
|
||||
@@ -56,7 +56,7 @@ test("APIKEY_PROVIDERS merges the 6 family files into 170 entries (no loss / no
|
||||
seen.add(k);
|
||||
}
|
||||
}
|
||||
assert.equal(famTotal, 170, "families must partition all 170 providers");
|
||||
assert.equal(famTotal, 171, "families must partition all 171 providers");
|
||||
});
|
||||
|
||||
test("AI_PROVIDERS Proxy aggregates all sections; lookups resolve", () => {
|
||||
|
||||
@@ -70,6 +70,33 @@ test("handleWebFetch routes to jina-reader when provider=jina-reader", async ()
|
||||
}
|
||||
});
|
||||
|
||||
test("handleWebFetch routes to tinyfish when provider=tinyfish", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
results: [{ url: "https://example.com", title: "Test", text: "# TinyFish content" }],
|
||||
errors: [],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await handleWebFetch(
|
||||
{ url: "https://example.com", format: "markdown" },
|
||||
{ apiKey: "tf-key" },
|
||||
"tinyfish"
|
||||
);
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.data?.provider, "tinyfish");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("handleWebFetch returns error 401 when no apiKey for firecrawl", async () => {
|
||||
const result = await handleWebFetch({ url: "https://example.com" }, {}, "firecrawl");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user