mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
Merge branch 'feat/search-providers-catalog-F4' into feat/playground-search-i18n-docs-F9
This commit is contained in:
@@ -3,47 +3,198 @@ import {
|
||||
SEARCH_PROVIDERS,
|
||||
SEARCH_CREDENTIAL_FALLBACKS,
|
||||
} from "@omniroute/open-sse/config/searchRegistry.ts";
|
||||
import { getDbInstance } from "@/lib/db/core";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { getProviderCredentials } from "@/sse/services/auth";
|
||||
import { isAllRateLimitedCredentials } from "@/app/api/v1/_shared/rateLimit";
|
||||
import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts";
|
||||
import {
|
||||
SearchProviderCatalogResponseSchema,
|
||||
type SearchProviderCatalogItem,
|
||||
} from "@/shared/schemas/searchTools";
|
||||
import * as log from "@/sse/utils/logger";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fetch provider metadata (hardcoded — no registry for these 3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FetchProviderDef {
|
||||
id: string;
|
||||
name: string;
|
||||
costPerQuery: number;
|
||||
freeMonthlyQuota: number;
|
||||
fetchFormats: string[];
|
||||
}
|
||||
|
||||
const FETCH_PROVIDERS: FetchProviderDef[] = [
|
||||
{
|
||||
id: "firecrawl",
|
||||
name: "Firecrawl",
|
||||
costPerQuery: 0.002,
|
||||
freeMonthlyQuota: 500,
|
||||
fetchFormats: ["markdown", "html", "links", "screenshot"],
|
||||
},
|
||||
{
|
||||
id: "jina-reader",
|
||||
name: "Jina Reader",
|
||||
costPerQuery: 0.0005,
|
||||
freeMonthlyQuota: 1000,
|
||||
fetchFormats: ["markdown", "text"],
|
||||
},
|
||||
{
|
||||
id: "tavily-search",
|
||||
name: "Tavily Extract",
|
||||
costPerQuery: 0.001,
|
||||
freeMonthlyQuota: 1000,
|
||||
fetchFormats: ["markdown", "text"],
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Credential status resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ProviderStatus = "configured" | "missing" | "rate_limited";
|
||||
|
||||
/**
|
||||
* Determine credential status for a provider (search or fetch).
|
||||
* - "configured" : at least one active key is available
|
||||
* - "rate_limited": all keys exist but are currently rate-limited
|
||||
* - "missing" : no credentials found
|
||||
*
|
||||
* Respects SEARCH_CREDENTIAL_FALLBACKS (e.g. perplexity-search → perplexity).
|
||||
*/
|
||||
async function resolveProviderStatus(
|
||||
providerId: string,
|
||||
useCredentialFallback = true
|
||||
): Promise<ProviderStatus> {
|
||||
try {
|
||||
const credentials = await getProviderCredentials(providerId).catch(() => null);
|
||||
|
||||
// Active credentials available
|
||||
if (credentials && !isAllRateLimitedCredentials(credentials)) {
|
||||
return "configured";
|
||||
}
|
||||
|
||||
// All rate limited — check fallback before returning rate_limited
|
||||
if (isAllRateLimitedCredentials(credentials)) {
|
||||
if (useCredentialFallback) {
|
||||
const fallbackId = SEARCH_CREDENTIAL_FALLBACKS[providerId];
|
||||
if (fallbackId) {
|
||||
const fallbackCreds = await getProviderCredentials(fallbackId).catch(() => null);
|
||||
if (fallbackCreds && !isAllRateLimitedCredentials(fallbackCreds)) {
|
||||
return "configured";
|
||||
}
|
||||
}
|
||||
}
|
||||
return "rate_limited";
|
||||
}
|
||||
|
||||
// null → no credentials; try fallback
|
||||
if (useCredentialFallback) {
|
||||
const fallbackId = SEARCH_CREDENTIAL_FALLBACKS[providerId];
|
||||
if (fallbackId) {
|
||||
const fallbackCreds = await getProviderCredentials(fallbackId).catch(() => null);
|
||||
if (fallbackCreds && !isAllRateLimitedCredentials(fallbackCreds)) {
|
||||
return "configured";
|
||||
}
|
||||
if (isAllRateLimitedCredentials(fallbackCreds)) {
|
||||
return "rate_limited";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "missing";
|
||||
} catch {
|
||||
return "missing";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
return NextResponse.json(
|
||||
buildErrorBody(401, "Unauthorized"),
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const db = getDbInstance();
|
||||
const providers = Object.values(SEARCH_PROVIDERS).map((p) => {
|
||||
let status: "active" | "no_credentials" = "no_credentials";
|
||||
try {
|
||||
const cred = db
|
||||
.prepare(
|
||||
"SELECT id FROM provider_connections WHERE provider = ? AND is_active = 1 LIMIT 1"
|
||||
)
|
||||
.get(p.id);
|
||||
// Use canonical fallback mapping (e.g. perplexity-search → perplexity)
|
||||
const fallbackId = SEARCH_CREDENTIAL_FALLBACKS[p.id];
|
||||
const fallbackCred =
|
||||
!cred && fallbackId
|
||||
? db
|
||||
.prepare(
|
||||
"SELECT id FROM provider_connections WHERE provider = ? AND is_active = 1 LIMIT 1"
|
||||
)
|
||||
.get(fallbackId)
|
||||
: null;
|
||||
if (cred || fallbackCred) status = "active";
|
||||
} catch {
|
||||
// DB error — report as no_credentials
|
||||
}
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
status,
|
||||
cost_per_query: p.costPerQuery,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ providers });
|
||||
try {
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 1. Build search providers (12 from registry)
|
||||
// -----------------------------------------------------------------------
|
||||
const searchProviderStatuses = await Promise.all(
|
||||
Object.values(SEARCH_PROVIDERS).map((p) =>
|
||||
resolveProviderStatus(p.id).then((status) => ({ p, status }))
|
||||
)
|
||||
);
|
||||
|
||||
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)
|
||||
// -----------------------------------------------------------------------
|
||||
const fetchProviderStatuses = await Promise.all(
|
||||
FETCH_PROVIDERS.map((fp) =>
|
||||
resolveProviderStatus(fp.id, false).then((status) => ({ fp, status }))
|
||||
)
|
||||
);
|
||||
|
||||
const fetchItems: SearchProviderCatalogItem[] = fetchProviderStatuses.map(({ fp, status }) => ({
|
||||
id: fp.id,
|
||||
name: fp.name,
|
||||
kind: "fetch" as const,
|
||||
costPerQuery: fp.costPerQuery,
|
||||
freeMonthlyQuota: fp.freeMonthlyQuota,
|
||||
fetchFormats: fp.fetchFormats,
|
||||
status,
|
||||
configureHref: "/dashboard/providers",
|
||||
}));
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 3. Combine: search first, then fetch
|
||||
// -----------------------------------------------------------------------
|
||||
const providers: SearchProviderCatalogItem[] = [...searchItems, ...fetchItems];
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 4. Defensive schema validation — log warning but still return on failure
|
||||
// -----------------------------------------------------------------------
|
||||
const parseResult = SearchProviderCatalogResponseSchema.safeParse({ providers });
|
||||
if (!parseResult.success) {
|
||||
log.warn(
|
||||
"SEARCH_PROVIDERS",
|
||||
`Response schema validation warning: ${parseResult.error.message}`
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 5. Back-compat: include legacy `data` array for callers using old shape
|
||||
// Old shape: { id, object, created, name, search_types }
|
||||
// -----------------------------------------------------------------------
|
||||
const data = providers.map((p) => ({
|
||||
id: p.id,
|
||||
object: "search_provider",
|
||||
created: timestamp,
|
||||
name: p.name,
|
||||
search_types: p.searchTypes ?? [],
|
||||
}));
|
||||
|
||||
return NextResponse.json({ providers, data });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Failed to list providers" }, { status: 500 });
|
||||
log.error("SEARCH_PROVIDERS", "Failed to list providers", error);
|
||||
return NextResponse.json(buildErrorBody(500, "Failed to list providers"), { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
397
tests/integration/search-providers-catalog.test.ts
Normal file
397
tests/integration/search-providers-catalog.test.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* Integration tests for GET /api/search/providers — extended catalog (F4).
|
||||
*
|
||||
* Tests:
|
||||
* - Returns 15 items total (12 search + 3 fetch providers).
|
||||
* - Each item carries the correct `kind` field.
|
||||
* - Status reflects actual DB credential state:
|
||||
* - "configured" when an active, non-rate-limited connection exists.
|
||||
* - "missing" when no connection exists for the provider.
|
||||
* - "rate_limited" when all connections are rate-limited (rateLimitedUntil in future).
|
||||
* - Back-compat: `data` field is present and uses legacy shape
|
||||
* {id, object, created, name, search_types}.
|
||||
* - Unauthenticated requests receive 401.
|
||||
* - Error responses do not leak stack traces (Hard Rule #12).
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
createManagementSessionHeaders,
|
||||
TEST_MANAGEMENT_JWT_SECRET,
|
||||
} from "../helpers/managementSession.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Isolated temp DB for this test suite
|
||||
// ---------------------------------------------------------------------------
|
||||
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
|
||||
process.env.INITIAL_PASSWORD = "";
|
||||
process.env.DASHBOARD_PASSWORD = "";
|
||||
process.env.JWT_SECRET = TEST_MANAGEMENT_JWT_SECRET;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module imports (after env setup so DB initialises in the right dir)
|
||||
// ---------------------------------------------------------------------------
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
|
||||
// Import route AFTER env is configured
|
||||
const route = await import("../../src/app/api/search/providers/route.ts");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EXPECTED_SEARCH_COUNT = 12;
|
||||
const EXPECTED_FETCH_COUNT = 3;
|
||||
const EXPECTED_TOTAL = EXPECTED_SEARCH_COUNT + EXPECTED_FETCH_COUNT;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Build a Request with valid management session cookie. */
|
||||
async function buildAuthRequest(url = "http://localhost/api/search/providers"): Promise<Request> {
|
||||
const headers = await createManagementSessionHeaders();
|
||||
return new Request(url, { method: "GET", headers });
|
||||
}
|
||||
|
||||
/** Build an unauthenticated request. */
|
||||
function buildUnauthRequest(url = "http://localhost/api/search/providers"): Request {
|
||||
return new Request(url, { method: "GET" });
|
||||
}
|
||||
|
||||
/** Seed an active provider connection. */
|
||||
async function seedActiveConnection(provider: string) {
|
||||
return providersDb.createProviderConnection({
|
||||
provider,
|
||||
authType: "apikey",
|
||||
name: `${provider}-test`,
|
||||
apiKey: `sk-test-${provider}-${Date.now()}`,
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
rateLimitedUntil: null,
|
||||
providerSpecificData: {},
|
||||
});
|
||||
}
|
||||
|
||||
/** Seed a rate-limited provider connection (rateLimitedUntil in future). */
|
||||
async function seedRateLimitedConnection(provider: string) {
|
||||
const future = new Date(Date.now() + 60_000).toISOString();
|
||||
return providersDb.createProviderConnection({
|
||||
provider,
|
||||
authType: "apikey",
|
||||
name: `${provider}-ratelimited`,
|
||||
apiKey: `sk-test-${provider}-rl-${Date.now()}`,
|
||||
isActive: false, // rate-limited connections are set inactive
|
||||
testStatus: "unavailable",
|
||||
rateLimitedUntil: future,
|
||||
providerSpecificData: {},
|
||||
});
|
||||
}
|
||||
|
||||
/** Reset DB state between tests. */
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
await resetStorage();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("search-providers-catalog: returns 401 for unauthenticated requests when auth is required", async () => {
|
||||
// Enable auth: set a password in DB settings and INITIAL_PASSWORD so isAuthRequired() → true
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
await settingsDb.updateSettings({ requireLogin: true, password: "hashed-pw-test" });
|
||||
|
||||
const req = buildUnauthRequest();
|
||||
const res = await route.GET(req);
|
||||
|
||||
assert.equal(res.status, 401);
|
||||
const body = await res.json();
|
||||
// Hard Rule #12: error body should not leak stack traces
|
||||
const bodyStr = JSON.stringify(body);
|
||||
assert.ok(!bodyStr.includes(" at /"), "error body must not contain stack trace");
|
||||
});
|
||||
|
||||
test("search-providers-catalog: returns 15 providers (12 search + 3 fetch)", async () => {
|
||||
const req = await buildAuthRequest();
|
||||
const res = await route.GET(req);
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
|
||||
assert.ok(Array.isArray(body.providers), "`providers` array must be present");
|
||||
assert.equal(
|
||||
body.providers.length,
|
||||
EXPECTED_TOTAL,
|
||||
`Expected ${EXPECTED_TOTAL} providers, got ${body.providers.length}`
|
||||
);
|
||||
});
|
||||
|
||||
test("search-providers-catalog: correct count of search and fetch kinds", async () => {
|
||||
const req = await buildAuthRequest();
|
||||
const res = await route.GET(req);
|
||||
const body = await res.json();
|
||||
|
||||
const searchItems = body.providers.filter((p: { kind: string }) => p.kind === "search");
|
||||
const fetchItems = body.providers.filter((p: { kind: string }) => p.kind === "fetch");
|
||||
|
||||
assert.equal(
|
||||
searchItems.length,
|
||||
EXPECTED_SEARCH_COUNT,
|
||||
`Expected ${EXPECTED_SEARCH_COUNT} search-kind items, got ${searchItems.length}`
|
||||
);
|
||||
assert.equal(
|
||||
fetchItems.length,
|
||||
EXPECTED_FETCH_COUNT,
|
||||
`Expected ${EXPECTED_FETCH_COUNT} fetch-kind items, got ${fetchItems.length}`
|
||||
);
|
||||
});
|
||||
|
||||
test("search-providers-catalog: every item has a valid kind value", async () => {
|
||||
const req = await buildAuthRequest();
|
||||
const res = await route.GET(req);
|
||||
const body = await res.json();
|
||||
|
||||
for (const item of body.providers) {
|
||||
assert.ok(
|
||||
item.kind === "search" || item.kind === "fetch",
|
||||
`item.kind must be 'search' or 'fetch', got '${item.kind}' for id=${item.id}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("search-providers-catalog: status=missing when no DB credentials exist", async () => {
|
||||
// No connections seeded — all providers should be "missing"
|
||||
const req = await buildAuthRequest();
|
||||
const res = await route.GET(req);
|
||||
const body = await res.json();
|
||||
|
||||
for (const item of body.providers) {
|
||||
assert.equal(
|
||||
item.status,
|
||||
"missing",
|
||||
`provider ${item.id} should be 'missing', got '${item.status}'`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("search-providers-catalog: status=configured when active credentials seeded", async () => {
|
||||
// Seed an active connection for serper-search
|
||||
await seedActiveConnection("serper-search");
|
||||
|
||||
const req = await buildAuthRequest();
|
||||
const res = await route.GET(req);
|
||||
const body = await res.json();
|
||||
|
||||
const serper = body.providers.find((p: { id: string }) => p.id === "serper-search");
|
||||
assert.ok(serper, "serper-search must be in response");
|
||||
assert.equal(
|
||||
serper.status,
|
||||
"configured",
|
||||
`serper-search should be 'configured' after seeding active credentials`
|
||||
);
|
||||
|
||||
// Other providers (no creds) should still be "missing"
|
||||
const missingItems = body.providers.filter(
|
||||
(p: { id: string; status: string }) => p.id !== "serper-search" && p.id !== "perplexity-search"
|
||||
);
|
||||
for (const item of missingItems) {
|
||||
assert.equal(item.status, "missing", `${item.id} should be 'missing'`);
|
||||
}
|
||||
});
|
||||
|
||||
test("search-providers-catalog: status=rate_limited when all connections are rate-limited", async () => {
|
||||
// Seed a rate-limited connection for brave-search (no active connections)
|
||||
await seedRateLimitedConnection("brave-search");
|
||||
|
||||
const req = await buildAuthRequest();
|
||||
const res = await route.GET(req);
|
||||
const body = await res.json();
|
||||
|
||||
const brave = body.providers.find((p: { id: string }) => p.id === "brave-search");
|
||||
assert.ok(brave, "brave-search must be in response");
|
||||
assert.equal(
|
||||
brave.status,
|
||||
"rate_limited",
|
||||
`brave-search should be 'rate_limited' when all connections have future rateLimitedUntil`
|
||||
);
|
||||
});
|
||||
|
||||
test("search-providers-catalog: mixed status across providers", async () => {
|
||||
// serper → configured, brave → rate_limited, rest → missing
|
||||
await seedActiveConnection("serper-search");
|
||||
await seedRateLimitedConnection("brave-search");
|
||||
|
||||
const req = await buildAuthRequest();
|
||||
const res = await route.GET(req);
|
||||
const body = await res.json();
|
||||
|
||||
const serper = body.providers.find((p: { id: string }) => p.id === "serper-search");
|
||||
assert.equal(serper?.status, "configured", "serper-search should be configured");
|
||||
|
||||
const brave = body.providers.find((p: { id: string }) => p.id === "brave-search");
|
||||
assert.equal(brave?.status, "rate_limited", "brave-search should be rate_limited");
|
||||
|
||||
const exa = body.providers.find((p: { id: string }) => p.id === "exa-search");
|
||||
assert.equal(exa?.status, "missing", "exa-search should be missing");
|
||||
});
|
||||
|
||||
test("search-providers-catalog: back-compat data field has legacy shape", async () => {
|
||||
const req = await buildAuthRequest();
|
||||
const res = await route.GET(req);
|
||||
const body = await res.json();
|
||||
|
||||
// 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"
|
||||
);
|
||||
|
||||
for (const item of body.data) {
|
||||
assert.ok(typeof item.id === "string", "data item must have id");
|
||||
assert.equal(item.object, "search_provider", "data item object must be 'search_provider'");
|
||||
assert.ok(typeof item.created === "number", "data item must have numeric created timestamp");
|
||||
assert.ok(typeof item.name === "string", "data item must have name");
|
||||
assert.ok(Array.isArray(item.search_types), "data item must have search_types array");
|
||||
}
|
||||
});
|
||||
|
||||
test("search-providers-catalog: fetch providers have correct metadata", async () => {
|
||||
const req = await buildAuthRequest();
|
||||
const res = await route.GET(req);
|
||||
const body = await res.json();
|
||||
|
||||
const fetchProviders = body.providers.filter((p: { kind: string }) => p.kind === "fetch");
|
||||
const ids = fetchProviders.map((p: { id: string }) => p.id);
|
||||
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");
|
||||
|
||||
const firecrawl = fetchProviders.find((p: { id: string }) => p.id === "firecrawl");
|
||||
assert.equal(firecrawl.name, "Firecrawl");
|
||||
assert.equal(firecrawl.costPerQuery, 0.002);
|
||||
assert.equal(firecrawl.freeMonthlyQuota, 500);
|
||||
assert.ok(Array.isArray(firecrawl.fetchFormats), "fetchFormats must be an array");
|
||||
assert.ok(
|
||||
firecrawl.fetchFormats.includes("markdown"),
|
||||
"firecrawl fetchFormats must include markdown"
|
||||
);
|
||||
assert.ok(
|
||||
firecrawl.fetchFormats.includes("screenshot"),
|
||||
"firecrawl fetchFormats must include screenshot"
|
||||
);
|
||||
|
||||
const jina = fetchProviders.find((p: { id: string }) => p.id === "jina-reader");
|
||||
assert.equal(jina.name, "Jina Reader");
|
||||
assert.equal(jina.costPerQuery, 0.0005);
|
||||
assert.ok(jina.fetchFormats.includes("text"), "jina fetchFormats must include text");
|
||||
|
||||
const tavily = fetchProviders.find((p: { id: string }) => p.id === "tavily-search");
|
||||
assert.equal(tavily.name, "Tavily Extract");
|
||||
assert.equal(tavily.costPerQuery, 0.001);
|
||||
});
|
||||
|
||||
test("search-providers-catalog: search providers have correct fields", async () => {
|
||||
const req = await buildAuthRequest();
|
||||
const res = await route.GET(req);
|
||||
const body = await res.json();
|
||||
|
||||
const searchProviders = body.providers.filter((p: { kind: string }) => p.kind === "search");
|
||||
for (const item of searchProviders) {
|
||||
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(Array.isArray(item.searchTypes), "search item must have searchTypes array");
|
||||
assert.equal(
|
||||
item.configureHref,
|
||||
"/dashboard/providers",
|
||||
"configureHref must be /dashboard/providers"
|
||||
);
|
||||
}
|
||||
|
||||
// Spot check: serper-search
|
||||
const serper = searchProviders.find((p: { id: string }) => p.id === "serper-search");
|
||||
assert.ok(serper, "serper-search must be in search providers");
|
||||
assert.ok(serper.searchTypes.includes("web"), "serper must support web search");
|
||||
assert.equal(serper.kind, "search");
|
||||
});
|
||||
|
||||
test("search-providers-catalog: response validates against SearchProviderCatalogResponseSchema", async () => {
|
||||
const req = await buildAuthRequest();
|
||||
const res = await route.GET(req);
|
||||
const body = await res.json();
|
||||
|
||||
const { SearchProviderCatalogResponseSchema } = await import(
|
||||
"../../src/shared/schemas/searchTools.ts"
|
||||
);
|
||||
|
||||
const result = SearchProviderCatalogResponseSchema.safeParse({ providers: body.providers });
|
||||
assert.ok(
|
||||
result.success,
|
||||
`Schema validation failed: ${(result as { error?: { message: string } }).error?.message ?? "unknown"}`
|
||||
);
|
||||
});
|
||||
|
||||
test("search-providers-catalog: fetch providers have configureHref set", async () => {
|
||||
const req = await buildAuthRequest();
|
||||
const res = await route.GET(req);
|
||||
const body = await res.json();
|
||||
|
||||
const fetchProviders = body.providers.filter((p: { kind: string }) => p.kind === "fetch");
|
||||
for (const item of fetchProviders) {
|
||||
assert.equal(
|
||||
item.configureHref,
|
||||
"/dashboard/providers",
|
||||
`fetch provider ${item.id} must have configureHref='/dashboard/providers'`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("search-providers-catalog: perplexity-search uses credential fallback", async () => {
|
||||
// perplexity-search can use perplexity (chat) credentials as fallback
|
||||
// Seed the fallback provider "perplexity" (not "perplexity-search")
|
||||
await seedActiveConnection("perplexity");
|
||||
|
||||
const req = await buildAuthRequest();
|
||||
const res = await route.GET(req);
|
||||
const body = await res.json();
|
||||
|
||||
const perplexity = body.providers.find((p: { id: string }) => p.id === "perplexity-search");
|
||||
assert.ok(perplexity, "perplexity-search must be in response");
|
||||
assert.equal(
|
||||
perplexity.status,
|
||||
"configured",
|
||||
"perplexity-search should be 'configured' via fallback to perplexity credentials"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user