Repository navigation
feat(connectors): add keenable connector (2 endpoints) #27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
ec03f4f
feat(connectors): add keenable connector (2 endpoints)
KGKallasmaa 20bf408
fix(keenable): address CodeRabbit review on identity, schemas, and li…
KGKallasmaa 18cf969
Merge remote-tracking branch 'origin/main' into pr27-keenable
ooctoo777 181979d
feat(keenable): search mode, live fetch, empty search bills 0
ooctoo777 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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>; | ||
| 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 }, | ||
|
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 }, | ||
| }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| const output = result.output as Record<string, unknown>; | ||
| assertEquals(typeof output.content, "string"); | ||
| }, | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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", | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| request: { method: "GET", path: "/v1/fetch" }, | ||
| input: { schema: { queryParams: zKeenableFetchQueryParams } }, | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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( | ||
|
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(); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"); | ||
| }, | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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", | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| request: { method: "POST", path: "/v1/search" }, | ||
| input: { schema: { body: zKeenableSearchBody } }, | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.