Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions connectors/keenable/endpoints/fetch/endpoint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { assert, assertEquals, assertRejects } from "@std/assert";
import type { Json } from "@shared/core";
import { fromFileUrl } from "@std/path";
import {
liveSkip,
loadFixture,
runEndpoint,
testSealedUnit,
} from "@shared/testing";

const fixturesDir = fromFileUrl(new URL("../../fixtures/", import.meta.url));

Deno.test("keenable#fetch happy (synthetic): one credit; fold settles; markdown content", async () => {
const unit = await testSealedUnit("keenable#fetch");
const fixture = await loadFixture(`${fixturesDir}synthetic-fetch-ok.json`);
const result = await runEndpoint({
unit,
input: { queryParams: { url: "https://example.com" } },
mode: "replay",
fixture,
});
assertEquals(result.httpStatus, 200);
assertEquals(result.isProviderError, false);
assertEquals(result.usage, {
credits: { default: 1 },
evidence: { CALL: 1 },
});
const output = result.output as Record<string, unknown>;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
assertEquals(output.url, "https://example.com/");
assertEquals(output.title, "Example Domain");
assert(
typeof output.content === "string" &&
(output.content as string).includes("Example Domain"),
"markdown content present",
);
});

Deno.test("keenable#fetch provider error (recorded 401): data, zero usage", async () => {
const unit = await testSealedUnit("keenable#fetch");
const fixture = await loadFixture(`${fixturesDir}unauthorized-fetch.json`);
const result = await runEndpoint({
unit,
input: { queryParams: { url: "https://example.com" } },
mode: "replay",
fixture,
});
assertEquals(result.httpStatus, 401);
assertEquals(result.isProviderError, true);
assertEquals(result.usage, { credits: {}, evidence: {} });
assertEquals(result.output, {
error: "Authentication failed",
message: "Malformed API key",
});
});

Deno.test("keenable#fetch: url required; prompt bounded; unknown keys rejected", async () => {
const unit = await testSealedUnit("keenable#fetch");
const fixture = await loadFixture(`${fixturesDir}synthetic-fetch-ok.json`);
const rejected: Record<string, Json>[] = [
{},
{ url: "not-a-url" },
{ url: "https://example.com", max_chars: 0 },
{ url: "https://example.com", prompt: "x".repeat(2001) },
{ url: "https://example.com", bogus: 1 },
Comment thread
coderabbitai[bot] marked this conversation as resolved.
];
for (const queryParams of rejected) {
await assertRejects(
() =>
runEndpoint({
unit,
input: { queryParams },
mode: "replay",
fixture,
}),
Error,
"INVALID_INPUT",
JSON.stringify(queryParams),
);
}
});

Deno.test({
name: "keenable#fetch live (gated on KEENABLE_API_KEY)",
ignore: liveSkip("keenable"),
fn: async () => {
const unit = await testSealedUnit("keenable#fetch");
const result = await runEndpoint({
unit,
input: { queryParams: { url: "https://example.com" } },
mode: "live",
});
assertEquals(
result.isProviderError,
false,
JSON.stringify(result.output),
);
assertEquals(result.usage, {
credits: { default: 1 },
evidence: { CALL: 1 },
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
const output = result.output as Record<string, unknown>;
assertEquals(typeof output.content, "string");
},
});
40 changes: 40 additions & 0 deletions connectors/keenable/endpoints/fetch/endpoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { defineEndpoint } from "@shared/core";
import { zKeenableFetchQueryParams } from "./schema/inputs.ts";

/**
* GET /v1/fetch — clean markdown for a known URL.
*
* Usage falls back to the provider's PER_CALL 1 credit; estimate and
* evidence are compiler-synthesized (flat model).
*/
export default defineEndpoint({
meta: {
displayName: "Keenable Fetch",
summary: "Fetch a URL as clean, LLM-ready markdown.",
description: "Retrieve a page as clean markdown, with title, " +
"description, and author when available. By default this " +
"returns Keenable's indexed copy — a URL that is not in " +
"the index is an error. Pass 'live' to fetch directly from " +
"the source, including unindexed URLs. Cap returned " +
"content with 'max_chars', or pass 'prompt' (at most 2000 " +
"characters) so an LLM reads the page and `content` is " +
"only the instruction's output instead of the full text. " +
"Use this when you already know the URL; start from " +
"/search if you don't.",
docsUrl: "https://docs.keenable.ai/api-reference/fetch",
categories: ["web-scraping"],
notes: [
"Indexed fetch (`fetch`) and live fetch (`fetch.live`) are " +
"separate SKUs. Keenable publishes that a fetch costs one " +
"credit and that live 'draws more than one' without a " +
"number, and REST responses carry no usage receipt, so " +
"this doc bills 1 credit per successful call either way " +
"(design D4).",
],
},
/** PUBLIC identity (design D22): request.path is `/v1/fetch`; pin
* `/fetch` so the catalog id is `keenable#fetch`. */
endpoint: "/fetch",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
request: { method: "GET", path: "/v1/fetch" },
input: { schema: { queryParams: zKeenableFetchQueryParams } },
});
37 changes: 37 additions & 0 deletions connectors/keenable/endpoints/fetch/schema/inputs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { z } from "zod";

/**
* GET /v1/fetch query params — OpenAPI fetch parameters
* (docs.keenable.ai api-reference/openapi.json, 2026-09-16). Mirror
* carries optionality only (D25): `max_chars` vendor default 50000
* and `live` vendor default false are behaviour knobs the estimate
* does not read (the model is PER_CALL), so they stay optional.
* `.strict()`: unknown keys fail INVALID_INPUT.
*/
export const zKeenableFetchQueryParams = z.object({
url: z.url({ protocol: /^https?$/ }).describe(
"URL to fetch. By default only URLs in Keenable's index are " +
"supported; a miss is an error. Pass live=true to fetch " +
"directly from the source, including unindexed URLs.",
),
max_chars: z.number().int().min(1).optional().describe(
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
"Maximum number of characters of content to return. Longer " +
"content is truncated and a notice is appended after the " +
"cut, so the response runs slightly past this number. " +
"Vendor default 50000.",
),
live: z.boolean().optional().describe(
"Fetch the page live from the source instead of returning " +
"Keenable's indexed copy. Enables fetching URLs that are " +
"not indexed. Vendor default false. Live fetch is a " +
"separate SKU (fetch.live); this doc still bills 1 credit " +
"per call (design D4).",
),
prompt: z.string().min(1).max(2000).optional().describe(
"Optional extraction instruction, at most 2000 characters. " +
"When set, an LLM reads the fetched page and `content` " +
"contains only the output for this instruction instead of " +
"the full page. Example: 'List all pricing tiers with " +
"their monthly prices'.",
),
}).strict();
169 changes: 169 additions & 0 deletions connectors/keenable/endpoints/search/endpoint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { assert, assertEquals, assertRejects } from "@std/assert";
import type { Json } from "@shared/core";
import { fromFileUrl } from "@std/path";
import {
liveSkip,
loadFixture,
runEndpoint,
testBundle,
testSealedUnit,
} from "@shared/testing";

const fixturesDir = fromFileUrl(new URL("../../fixtures/", import.meta.url));

Deno.test("keenable: both docs inherit PER_CALL 1, one auth fn, synthesized quantities, no consolidate", async () => {
const bundle = await testBundle();
const ids = Object.keys(bundle.endpoints).filter((id) =>
id.startsWith("keenable#")
).sort();
assertEquals(ids, ["keenable#fetch", "keenable#search"]);

const search = bundle.endpoints["keenable#search"];
const fetchDoc = bundle.endpoints["keenable#fetch"];
assertEquals(search.request.method, "POST");
assertEquals(search.request.url, "https://api.keenable.ai/v1/search");
assertEquals(fetchDoc.request.method, "GET");
assertEquals(fetchDoc.request.url, "https://api.keenable.ai/v1/fetch");

assertEquals(search.auth.inject.$fn.key, fetchDoc.auth.inject.$fn.key);
assertEquals(search.usage.consolidate, undefined);
assertEquals(fetchDoc.usage.consolidate, undefined);
assertEquals(search.input.toRequest, undefined);
assertEquals(fetchDoc.input.toRequest, undefined);

// flat PER_CALL: compiler-synthesized empty quantities (tinyfish/pdl
// enrich posture); engine appends CALL at settle
const synthesizedKey = search.usage.evidence.$fn.key;
assertEquals(search.usage.estimate.$fn.key, synthesizedKey);
assertEquals(fetchDoc.usage.evidence.$fn.key, synthesizedKey);
assertEquals(fetchDoc.usage.estimate.$fn.key, synthesizedKey);
assertEquals(
bundle.fnTable[synthesizedKey].provenance,
"core#usage.synthesizedEmpty",
);

for (const id of ids) {
const doc = bundle.endpoints[id];
assertEquals(Object.keys(doc.usage.credits), ["default"], id);
const model = doc.usage.model;
assertEquals(model.kind, "PER_CALL", id);
assertEquals(
"consumes" in model ? model.consumes : undefined,
{ credit: "default", amount: 1 },
id,
);
}
});

Deno.test("keenable#search happy (synthetic): one credit; fold settles; mode rides the payload", async () => {
const unit = await testSealedUnit("keenable#search");
const fixture = await loadFixture(`${fixturesDir}synthetic-search-ok.json`);
const result = await runEndpoint({
unit,
input: { body: { query: "typescript best practices", max_results: 2 } },
mode: "replay",
fixture,
});
assertEquals(result.httpStatus, 200);
assertEquals(result.isProviderError, false);
// no consolidate ⇒ no claim ⇒ the DERIVED fold is the bill (D2)
assertEquals(result.usage, {
credits: { default: 1 },
evidence: { CALL: 1 },
});
const output = result.output as Record<string, unknown>;
assertEquals(output.query, "typescript best practices");
assertEquals(output.mode, "pro");
assertEquals((output.results as unknown[]).length, 2);
});

Deno.test("keenable#search provider error (recorded 401): data, zero usage", async () => {
const unit = await testSealedUnit("keenable#search");
const fixture = await loadFixture(`${fixturesDir}unauthorized-search.json`);
const result = await runEndpoint({
unit,
input: { body: { query: "anything" } },
mode: "replay",
fixture,
});
assertEquals(result.httpStatus, 401);
assertEquals(result.isProviderError, true);
assertEquals(result.usage, { credits: {}, evidence: {} });
assertEquals(result.output, {
error: "Authentication failed",
message: "Malformed API key",
});
});

Deno.test("keenable#search: query required; bounds; mode is not a request field", async () => {
const unit = await testSealedUnit("keenable#search");
const fixture = await loadFixture(`${fixturesDir}synthetic-search-ok.json`);
const rejected: Json[] = [
{},
{ query: "" },
{ query: "x", max_results: 0 },
{ query: "x", max_results: 51 },
{ query: "x", snippet_max_length: 179 },
{ query: "x", snippet_max_length: 10001 },
// design D3: mode is not on the REST body; .strict() rejects it
{ query: "x", mode: "pro" },
{ query: "x", bogus: 1 },
];
for (const body of rejected) {
await assertRejects(
() =>
runEndpoint({ unit, input: { body }, mode: "replay", fixture }),
Error,
"INVALID_INPUT",
JSON.stringify(body),
);
}
// date filters and site are optional and pass through
const ok = await runEndpoint({
unit,
input: {
body: {
query: "typescript best practices",
site: "arxiv.org",
published_after: "2026-01-01",
max_results: 2,
},
},
mode: "replay",
fixture,
});
assertEquals(ok.isProviderError, false);
});

Deno.test({
name: "keenable#search live (gated on KEENABLE_API_KEY)",
ignore: liveSkip("keenable"),
fn: async () => {
const unit = await testSealedUnit("keenable#search");
const result = await runEndpoint({
unit,
input: {
body: {
query: "deno 2 workspace monorepo guide",
max_results: 2,
},
},
mode: "live",
});
assertEquals(
result.isProviderError,
false,
JSON.stringify(result.output),
);
assertEquals(result.usage, {
credits: { default: 1 },
evidence: { CALL: 1 },
});
const output = result.output as Record<string, unknown>;
assert(
Array.isArray(output.results),
"results array present",
);
assertEquals(output.query, "deno 2 workspace monorepo guide");
},
});
40 changes: 40 additions & 0 deletions connectors/keenable/endpoints/search/endpoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { defineEndpoint } from "@shared/core";
import { zKeenableSearchBody } from "./schema/inputs.ts";

/**
* POST /v1/search — ranked web results with extracted snippets.
*
* Usage falls back to the provider's PER_CALL 1 credit; estimate and
* evidence are compiler-synthesized (flat model).
*/
export default defineEndpoint({
meta: {
displayName: "Keenable Search",
summary: "Search the web and get ranked results with page snippets.",
description: "Search Keenable's web index and return ranked " +
"results with title, URL, description, and an extracted " +
"snippet of page text. Restrict to a site, filter by " +
"publication date or when the page was indexed, search the " +
"index as it stood at a point in time (`query_time`), cap " +
"snippet length (180–10000 characters) and result count " +
"(1–50, default 10). Phrase the query as a description of " +
"the page you want. Pipe result URLs into Keenable /fetch " +
"when you need the full page as markdown.",
docsUrl: "https://docs.keenable.ai/api-reference/search",
categories: ["web-search"],
notes: [
"Search mode (realtime vs pro) is decided per call by " +
"Keenable, not a request parameter. The REST body does not " +
"accept `mode`; a response may echo the mode that was " +
"served. MCP integrators can pin it via " +
'`_meta["keenable/overrides"]`, which this connector ' +
"does not expose.",
],
},
/** PUBLIC identity (design D22): request.path is `/v1/search`; pin
* `/search` so the catalog id is `keenable#search` like exa/tinyfish
* rather than leaking the vendor version prefix. */
endpoint: "/search",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
request: { method: "POST", path: "/v1/search" },
input: { schema: { body: zKeenableSearchBody } },
});
Loading