From ec03f4fe1cb3163d2c4af041da4188351a4caad4 Mon Sep 17 00:00:00 2001 From: Karl-Gustav Kallasmaa <29797627+KGKallasmaa@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:40:56 +0100 Subject: [PATCH 1/3] feat(connectors): add keenable connector (2 endpoints) Add search and fetch as a PER_CALL search provider against the authenticated Keenable REST API. --- .../keenable/endpoints/fetch/endpoint.test.ts | 104 +++++++++++ .../keenable/endpoints/fetch/endpoint.ts | 40 +++++ .../keenable/endpoints/fetch/schema/inputs.ts | 37 ++++ .../endpoints/search/endpoint.test.ts | 169 ++++++++++++++++++ .../keenable/endpoints/search/endpoint.ts | 40 +++++ .../endpoints/search/schema/inputs.ts | 57 ++++++ .../keenable/fixtures/synthetic-fetch-ok.json | 21 +++ .../fixtures/synthetic-search-ok.json | 40 +++++ .../keenable/fixtures/unauthorized-fetch.json | 19 ++ .../fixtures/unauthorized-search.json | 20 +++ connectors/keenable/provider.ts | 53 ++++++ connectors/keenable/schema/common.ts | 18 ++ .../changes/add-connector-keenable/design.md | 81 +++++++++ .../add-connector-keenable/proposal.md | 50 ++++++ .../specs/keenable-connector/spec.md | 47 +++++ .../changes/add-connector-keenable/tasks.md | 33 ++++ 16 files changed, 829 insertions(+) create mode 100644 connectors/keenable/endpoints/fetch/endpoint.test.ts create mode 100644 connectors/keenable/endpoints/fetch/endpoint.ts create mode 100644 connectors/keenable/endpoints/fetch/schema/inputs.ts create mode 100644 connectors/keenable/endpoints/search/endpoint.test.ts create mode 100644 connectors/keenable/endpoints/search/endpoint.ts create mode 100644 connectors/keenable/endpoints/search/schema/inputs.ts create mode 100644 connectors/keenable/fixtures/synthetic-fetch-ok.json create mode 100644 connectors/keenable/fixtures/synthetic-search-ok.json create mode 100644 connectors/keenable/fixtures/unauthorized-fetch.json create mode 100644 connectors/keenable/fixtures/unauthorized-search.json create mode 100644 connectors/keenable/provider.ts create mode 100644 connectors/keenable/schema/common.ts create mode 100644 openspec/changes/add-connector-keenable/design.md create mode 100644 openspec/changes/add-connector-keenable/proposal.md create mode 100644 openspec/changes/add-connector-keenable/specs/keenable-connector/spec.md create mode 100644 openspec/changes/add-connector-keenable/tasks.md diff --git a/connectors/keenable/endpoints/fetch/endpoint.test.ts b/connectors/keenable/endpoints/fetch/endpoint.test.ts new file mode 100644 index 00000000..0e0f7eb9 --- /dev/null +++ b/connectors/keenable/endpoints/fetch/endpoint.test.ts @@ -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; + 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[] = [ + {}, + { 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 }, + ]; + 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 }, + }); + const output = result.output as Record; + assertEquals(typeof output.content, "string"); + }, +}); diff --git a/connectors/keenable/endpoints/fetch/endpoint.ts b/connectors/keenable/endpoints/fetch/endpoint.ts new file mode 100644 index 00000000..54548630 --- /dev/null +++ b/connectors/keenable/endpoints/fetch/endpoint.ts @@ -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", + request: { method: "GET", path: "/v1/fetch" }, + input: { schema: { queryParams: zKeenableFetchQueryParams } }, +}); diff --git a/connectors/keenable/endpoints/fetch/schema/inputs.ts b/connectors/keenable/endpoints/fetch/schema/inputs.ts new file mode 100644 index 00000000..86e51f00 --- /dev/null +++ b/connectors/keenable/endpoints/fetch/schema/inputs.ts @@ -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( + "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(); diff --git a/connectors/keenable/endpoints/search/endpoint.test.ts b/connectors/keenable/endpoints/search/endpoint.test.ts new file mode 100644 index 00000000..809d6403 --- /dev/null +++ b/connectors/keenable/endpoints/search/endpoint.test.ts @@ -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; + 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; + assert( + Array.isArray(output.results), + "results array present", + ); + assertEquals(output.query, "deno 2 workspace monorepo guide"); + }, +}); diff --git a/connectors/keenable/endpoints/search/endpoint.ts b/connectors/keenable/endpoints/search/endpoint.ts new file mode 100644 index 00000000..f4045624 --- /dev/null +++ b/connectors/keenable/endpoints/search/endpoint.ts @@ -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", + request: { method: "POST", path: "/v1/search" }, + input: { schema: { body: zKeenableSearchBody } }, +}); diff --git a/connectors/keenable/endpoints/search/schema/inputs.ts b/connectors/keenable/endpoints/search/schema/inputs.ts new file mode 100644 index 00000000..2eee0a52 --- /dev/null +++ b/connectors/keenable/endpoints/search/schema/inputs.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; +import { + TIME_BOUND_FORMAT, + zKeenableTimeBound, +} from "../../../schema/common.ts"; + +/** + * POST /v1/search body — OpenAPI SearchRequest (docs.keenable.ai + * api-reference/openapi.json, 2026-09-16). Mirror carries optionality + * only (D25): `max_results` vendor default 10 is not a billing knob + * (the model is PER_CALL), so it stays optional. `.strict()`: `mode` + * is decided per call by Keenable and is not a REST request field + * (credits docs; design D3) — a pasted SDK payload that sends it + * fails INVALID_INPUT rather than riding through. + */ +export const zKeenableSearchBody = z.object({ + query: z.string().min(1).describe( + "The search query. Natural language; describe the page you want.", + ), + site: z.string().min(1).optional().describe( + "Restrict results to a specific site, e.g. 'techcrunch.com' or " + + "'arxiv.org'.", + ), + acquired_after: zKeenableTimeBound.optional().describe( + "Filter to pages Keenable acquired/indexed at or after this " + + "instant. " + TIME_BOUND_FORMAT, + ), + acquired_before: zKeenableTimeBound.optional().describe( + "Filter to pages Keenable acquired/indexed at or before this " + + "instant. " + TIME_BOUND_FORMAT, + ), + published_after: zKeenableTimeBound.optional().describe( + "Filter to pages published at or after this instant. " + + TIME_BOUND_FORMAT, + ), + published_before: zKeenableTimeBound.optional().describe( + "Filter to pages published at or before this instant. " + + TIME_BOUND_FORMAT, + ), + query_time: zKeenableTimeBound.optional().describe( + "Search the index as it stood at this instant: pages acquired " + + "after it are excluded. A date resolves to 00:00:00 UTC (not " + + "the end of the day). Relative deltas on the other date " + + "filters resolve against this instant instead of now. " + + TIME_BOUND_FORMAT, + ), + snippet_max_length: z.number().int().min(180).max(10000).optional() + .describe( + "Maximum length, in characters, of the snippet returned per " + + "result (180–10000). When omitted, a default snippet " + + "length is used.", + ), + max_results: z.number().int().min(1).max(50).optional().describe( + "Maximum number of results to return (1–50). When omitted, up " + + "to 10 results are returned.", + ), +}).strict(); diff --git a/connectors/keenable/fixtures/synthetic-fetch-ok.json b/connectors/keenable/fixtures/synthetic-fetch-ok.json new file mode 100644 index 00000000..975abaa7 --- /dev/null +++ b/connectors/keenable/fixtures/synthetic-fetch-ok.json @@ -0,0 +1,21 @@ +{ + "name": "synthetic-fetch-ok", + "description": "SYNTHETIC happy chain for keenable#fetch. Shape taken from a real 2026-09-16 GET /v1/fetch/public?url=https://example.com response (url, title, content, description); URL rewritten to the authenticated /v1/fetch the doc calls, with the query string the engine emits. Replace via `deno task record` when KEENABLE_API_KEY exists.", + "calls": [ + { + "req": { + "method": "GET", + "url": "https://api.keenable.ai/v1/fetch?url=https%3A%2F%2Fexample.com" + }, + "res": { + "status": 200, + "body": { + "url": "https://example.com/", + "title": "Example Domain", + "content": "# Example Domain\n\nThis domain is for use in documentation examples without needing permission. Avoid use in operations.", + "description": "" + } + } + } + ] +} diff --git a/connectors/keenable/fixtures/synthetic-search-ok.json b/connectors/keenable/fixtures/synthetic-search-ok.json new file mode 100644 index 00000000..bb15b9a2 --- /dev/null +++ b/connectors/keenable/fixtures/synthetic-search-ok.json @@ -0,0 +1,40 @@ +{ + "name": "synthetic-search-ok", + "description": "SYNTHETIC happy chain for keenable#search. Shape taken from a real 2026-09-16 POST /v1/search/public response (query, mode, results[].title/url/description/snippet/published_at/acquired_at); URL rewritten to the authenticated /v1/search the doc calls. Snippets hand-truncated. Replace via `deno task record` when KEENABLE_API_KEY exists.", + "calls": [ + { + "req": { + "method": "POST", + "url": "{{request.url}}", + "body": { + "query": "typescript best practices", + "max_results": 2 + } + }, + "res": { + "status": 200, + "body": { + "query": "typescript best practices", + "mode": "pro", + "results": [ + { + "title": "TypeScript Best Practices", + "url": "https://www.w3schools.com/typescript/typescript_best_practices.php", + "description": "", + "snippet": "Project Configuration Enable Strict Mode Always enable strict mode in your tsconfig.json for maximum type safety.", + "acquired_at": "2026-09-13T02:42:52Z" + }, + { + "title": "TypeScript Best Practices for Production Code in 2026", + "url": "https://devplaybook.cc/blog/typescript-best-practices-2026", + "description": "", + "snippet": "15 TypeScript best practices for writing safer, more maintainable production code in 2026.", + "published_at": "2026-03-24T15:39:39Z", + "acquired_at": "2026-05-19T15:39:16Z" + } + ] + } + } + } + ] +} diff --git a/connectors/keenable/fixtures/unauthorized-fetch.json b/connectors/keenable/fixtures/unauthorized-fetch.json new file mode 100644 index 00000000..a3c1ff69 --- /dev/null +++ b/connectors/keenable/fixtures/unauthorized-fetch.json @@ -0,0 +1,19 @@ +{ + "name": "unauthorized-fetch", + "description": "Recorded 2026-09-16 against GET https://api.keenable.ai/v1/fetch?url=https://example.com with a malformed key (X-API-Key: keen_invalid). Same 401 envelope as search. Query string is the engine's URLSearchParams encoding so replay matches.", + "calls": [ + { + "req": { + "method": "GET", + "url": "https://api.keenable.ai/v1/fetch?url=https%3A%2F%2Fexample.com" + }, + "res": { + "status": 401, + "body": { + "error": "Authentication failed", + "message": "Malformed API key" + } + } + } + ] +} diff --git a/connectors/keenable/fixtures/unauthorized-search.json b/connectors/keenable/fixtures/unauthorized-search.json new file mode 100644 index 00000000..3340d07f --- /dev/null +++ b/connectors/keenable/fixtures/unauthorized-search.json @@ -0,0 +1,20 @@ +{ + "name": "unauthorized-search", + "description": "Recorded 2026-09-16 against POST https://api.keenable.ai/v1/search with a malformed key (X-API-Key: keen_invalid). Vendor 401 `{error, message}` is DATA: zero usage. Docs table lists malformed keys as 400; the live keyed endpoint answered 401 Authentication failed / Malformed API key.", + "calls": [ + { + "req": { + "method": "POST", + "url": "{{request.url}}", + "body": { "query": "anything" } + }, + "res": { + "status": 401, + "body": { + "error": "Authentication failed", + "message": "Malformed API key" + } + } + } + ] +} diff --git a/connectors/keenable/provider.ts b/connectors/keenable/provider.ts new file mode 100644 index 00000000..afc8fd57 --- /dev/null +++ b/connectors/keenable/provider.ts @@ -0,0 +1,53 @@ +import { defineProvider, presets, UsageModelKind } from "@shared/core"; + +/** + * Keenable (keenable.ai) — web search and page fetch for AI agents. + * Two synchronous REST endpoints against `https://api.keenable.ai/v1`, + * auth `X-API-Key`. + * + * The catalog surface is the authenticated pair (`POST /v1/search`, + * `GET /v1/fetch`). The keyless `/public` twins exist to evaluate the + * API without an account (shared per-IP pool, `X-Keenable-Title`, no + * credits) and are not exposed — design D1. + * + * Billing: Keenable meters authenticated usage in credits, 100,000 + * requests/month free, and publishes that search and fetch each cost + * one (credits docs, 2026-09-16). REST responses carry no usage + * receipt — MCP reports `_meta["keenable/usage"]`, which this HTTP + * connector never sees — so there is no `usage.consolidate` and the + * derived fold settles (design D2; same posture as pdl). One pool + * `default`, PER_CALL 1 on the provider, inherited by both docs. + */ +export default defineProvider({ + name: "keenable", + meta: { + displayName: "Keenable", + summary: "Low-latency web search and clean page fetch for AI agents.", + description: "Web search and page fetch built for AI agents — " + + "ranked results that already carry extracted page text, plus a " + + "fetch that returns any indexed URL as clean markdown (or a " + + "live fetch from the source). Filter search by site, " + + "publication date, and when Keenable indexed the page; fetch " + + "can optionally run an extraction instruction instead of " + + "returning the whole page.", + homepageUrl: "https://keenable.ai", + docsUrl: "https://docs.keenable.ai", + categories: ["web-search"], + }, + auth: { inject: presets.auth.header("X-API-Key") }, + request: { baseUrl: "https://api.keenable.ai" }, + timeouts: { requestMs: 30_000, runMs: 30_000 }, + usage: { + /** THE credit system (design D26): Keenable's native meter is + * credits, one per search or fetch. Search mode (realtime vs + * pro) and live fetch are separate SKUs, but the published + * rule is still one credit per call (design D2/D4). No + * `consolidate`: no REST body carries a receipt. */ + credits: { default: { label: "Keenable credits" } }, + model: { + kind: UsageModelKind.PER_CALL, + label: "call", + consumes: { credit: "default", amount: 1 }, + }, + }, +}); diff --git a/connectors/keenable/schema/common.ts b/connectors/keenable/schema/common.ts new file mode 100644 index 00000000..98b3a557 --- /dev/null +++ b/connectors/keenable/schema/common.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +/** + * Date/time bounds Keenable accepts on search filters (OpenAPI + * SearchRequest + docs "Date and time filters", 2026-09-16). A single + * string: the three spellings cannot be a JSON-Schema union without + * dropping the relative form, so the formats live in the describe and + * Keenable itself 400s a bad value. + */ +export const TIME_BOUND_FORMAT = + "YYYY-MM-DD (whole day UTC: an _after bound starts at 00:00:00, a " + + "_before bound ends at 23:59:59.999), an ISO 8601 timestamp (no " + + "offset means UTC), or a relative delta such as 7d, 30min, 6mo, 1y " + + "(units: min, h, d, mo, y; truncated to the minute)."; + +export const zKeenableTimeBound = z.string().min(1).describe( + TIME_BOUND_FORMAT, +); diff --git a/openspec/changes/add-connector-keenable/design.md b/openspec/changes/add-connector-keenable/design.md new file mode 100644 index 00000000..262f77e8 --- /dev/null +++ b/openspec/changes/add-connector-keenable/design.md @@ -0,0 +1,81 @@ +# Design: add-connector-keenable + +Decision record for the Keenable connector (new; not a v1 port). Only the +choices the declarative model forced are recorded; the input schemas +mirror OpenAPI SearchRequest / fetch parameters (docs.keenable.ai +api-reference/openapi.json, 2026-09-16). + +## D1 — Authenticated REST only + +Keenable ships each operation twice: a keyed path (`POST /v1/search`, +`GET /v1/fetch`, `X-API-Key`) and a keyless `/public` twin +(`X-Keenable-Title`, shared 1,000 req/hour per IP, no credits). The +catalog is a metered, keyed surface. The public twins are an evaluation +tier with a different auth header, different rate-limit identity, and +no usage — exposing them would be a second provider with a different +auth inject and a FREE model, which is its own change if anyone wants +it. Both docs call the keyed paths. Public identity is pinned +(`endpoint: "/search"` / `"/fetch"`) so the catalog ids are +`keenable#search` and `keenable#fetch` — the `/v1` prefix is a vendor +URL fact, not a caller-facing name (tinyfish's D22 form). + +## D2 — One pool, PER_CALL 1, no consolidate + +Credits docs (2026-09-16): authenticated usage is metered in credits; +"search and fetch each cost one"; 100,000 requests/month free, then +purchased packs. REST JSON carries no usage object. MCP reports +`_meta["keenable/usage"]` (`sku`, `amount`, `credits`, `paid`) beside +the tool result, which this HTTP connector never sees. Response headers +on the public twin were rate-limit only (`X-RateLimit-*`, +`X-Request-Id`); no usage header is documented for REST. + +So there is no vendor claim to pluck (no `usage.consolidate`) and the +derived fold settles — the pdl posture. One pool `default` ("Keenable +credits"), a provider-level `PER_CALL` of 1 inherited by both docs. +Quantities fns are compiler-synthesized. Search mode SKUs +(`search.realtime` / `search.pro`) are not request-selectable (D3) and +are published as one credit either way. + +Rejected: two pools keyed on SKU (the caller cannot choose, and REST +does not report which SKU ran); a FREE model (authenticated calls draw +the monthly allowance). + +## D3 — Search `mode` is not a request field + +Credits: "Search mode is not a request parameter. Over MCP an +integrator can pin it with `_meta[\"keenable/overrides\"]`; otherwise +the mode is decided per call." OpenAPI SearchRequest has no `mode`. +The Python SDK documents a `mode` argument; that is not the HTTP +surface this connector speaks. + +The search body is `.strict()`, so a payload that includes `mode` +fails INVALID_INPUT rather than being dropped on the floor or sent +and 400'd upstream. The response may echo `mode` (`"pro"` observed on +the public twin 2026-09-16); it rides the payload, unused by billing. + +## D4 — `fetch.live` stays PER_CALL 1 + +`live=true` is a documented query param and a separate SKU +(`fetch.live`) that "draws more than one credit per call". No number is +published; prices are "per-organization"; REST has no receipt. Inventing +a surcharge would make every live estimate a guess that cannot +mismatch-detect. The doc bills 1 on every 2xx, indexed or live, and +says so in `meta.notes`. A published rate or a REST usage field is a +follow-up (tasks 3.3). + +## D5 — Fixtures: recorded 401, synthetic happy + +A malformed key against the keyed paths (2026-09-16, +`X-API-Key: keen_invalid`) returned HTTP 401 +`{error: "Authentication failed", message: "Malformed API key"}` on +both endpoints. Auth docs table lists malformed keys as 400; the live +keyed endpoint answered 401. Those two chains are recordings. + +Happy chains are `synthetic-`: the response shape was read off the +public twins the same day (search: `query`/`mode`/`results[]` with +`title`/`url`/`description`/`snippet`/`published_at`/`acquired_at`; +fetch: `url`/`title`/`content`/`description`) and the request URL was +rewritten to the authenticated path the doc calls. Replay matches +method+URL; search uses `{{request.url}}`, fetch pins the engine's +`URLSearchParams` encoding of `url=https://example.com`. Replace via +`deno task record` when `KEENABLE_API_KEY` exists. diff --git a/openspec/changes/add-connector-keenable/proposal.md b/openspec/changes/add-connector-keenable/proposal.md new file mode 100644 index 00000000..728d0600 --- /dev/null +++ b/openspec/changes/add-connector-keenable/proposal.md @@ -0,0 +1,50 @@ +# Proposal: add-connector-keenable + +## Why + +Keenable (keenable.ai) is another search-and-fetch provider in the same +class as Exa, Firecrawl, TinyFish, and Octen: ranked web search plus a +page-content fetch, one API key, REST. Two synchronous endpoints, no new +engine capability. The published surface is small and stable +(`POST /v1/search`, `GET /v1/fetch`; OpenAPI 2026-09-16). + +## What Changes + +- **connectors/keenable** — 2 endpoints, `X-API-Key` auth, 30 s timeouts: + - `keenable#search` (`POST /v1/search`, identity pinned `/search`): + ranked results with title, URL, description, snippet, publication + and index timestamps; site / date / point-in-time filters; snippet + length and result-count caps. PER_CALL, 1 Keenable credit. + - `keenable#fetch` (`GET /v1/fetch`, identity pinned `/fetch`): + markdown for a known URL (indexed copy by default; `live=true` + fetches from the source); optional `max_chars` and a `prompt` + extraction instruction. PER_CALL, 1 Keenable credit. +- **One credit pool** `default` ("Keenable credits") and a provider-level + `PER_CALL` model inherited by both docs. No `usage.consolidate`: REST + responses carry no usage receipt (MCP's `_meta["keenable/usage"]` is + not on this surface). +- Fixtures: search/fetch 401s are RECORDED (malformed key against the + keyed paths, 2026-09-16). Happy paths are `synthetic-`, shaped from + real public-twin traffic the same day, URL rewritten to the + authenticated paths the docs call. + +## Capabilities + +- `keenable-connector`. + +## Non-goals + +- The keyless `/v1/search/public` and `/v1/fetch/public` twins + (`X-Keenable-Title`, shared per-IP pool, unmetered) — evaluation-only. +- Search `mode` (realtime vs pro) as a request field — Keenable decides + it per call; MCP `_meta["keenable/overrides"]` is not an HTTP + parameter. +- A `fetch.live` surcharge — published as a different SKU that "draws + more than one" credit without a number, and REST has no receipt to + settle against. + +## Impact + +New connector tree. No schema/engine contract change — `ENGINE_VERSION` +unchanged, no new `Unit`, no new preset, no new hook, no new category +leaf (`web-search` / `web-scraping` already exist). diff --git a/openspec/changes/add-connector-keenable/specs/keenable-connector/spec.md b/openspec/changes/add-connector-keenable/specs/keenable-connector/spec.md new file mode 100644 index 00000000..04d40542 --- /dev/null +++ b/openspec/changes/add-connector-keenable/specs/keenable-connector/spec.md @@ -0,0 +1,47 @@ +# keenable-connector (delta) + +## ADDED Requirements + +### Requirement: Keenable provider definition +The keenable provider SHALL declare name `keenable`, `request.baseUrl` +`https://api.keenable.ai`, auth `presets.auth.header("X-API-Key")`, +timeouts 30 s / 30 s, one credit pool `default` ("Keenable credits"), a +provider-level `usage.model` of PER_CALL 1 Keenable credit, and NO +`usage.consolidate` (REST responses carry no usage receipt). + +#### Scenario: A search consumes one credit +- **WHEN** `POST /v1/search` returns 200 with two results +- **THEN** usage is `{credits: {default: 1}, evidence: {CALL: 1}}` +- **AND** the payload still carries `query`, `results`, and the + vendor-echoed `mode` (not a request field) + +#### Scenario: A fetch consumes one credit +- **WHEN** `GET /v1/fetch?url=https://example.com` returns 200 +- **THEN** usage is `{credits: {default: 1}, evidence: {CALL: 1}}` +- **AND** output carries `url`, `title`, and markdown `content` + +#### Scenario: A 401 is data, zero usage +- **WHEN** either endpoint is called with a malformed API key +- **THEN** the run completes as HTTP 401, `isProviderError` true, + usage `{credits: {}, evidence: {}}`, and the vendor envelope + `{error, message}` rides through + +### Requirement: Two authenticated endpoints +The connector SHALL provide `POST /v1/search` (`keenable#search`, +`web-search`) and `GET /v1/fetch` (`keenable#fetch`, `web-scraping`). +Search input SHALL be a strict body: `query` required; `site`, +`acquired_after`/`acquired_before`, `published_after`/`published_before`, +`query_time`, `snippet_max_length` (180–10000), `max_results` (1–50) +optional; `mode` SHALL fail INVALID_INPUT. Fetch input SHALL be strict +query params: `url` required (URI); `max_chars` (≥1), `live` +(boolean), `prompt` (1–2000 characters) optional. The keyless `/public` +twins SHALL NOT be exposed. + +#### Scenario: Search mode is not a request field +- **WHEN** `POST /v1/search` is called with a body carrying `mode` +- **THEN** the run fails INVALID_INPUT + +#### Scenario: Fetch url is required +- **WHEN** `GET /v1/fetch` is called without `url`, or with a string + that is not a URI +- **THEN** the run fails INVALID_INPUT diff --git a/openspec/changes/add-connector-keenable/tasks.md b/openspec/changes/add-connector-keenable/tasks.md new file mode 100644 index 00000000..e9d0fe29 --- /dev/null +++ b/openspec/changes/add-connector-keenable/tasks.md @@ -0,0 +1,33 @@ +# Tasks: add-connector-keenable + +## 1. Provider + schemas + +- [x] 1.1 provider.ts: X-API-Key auth, baseUrl, 30 s timeouts, one + `default` pool, provider-level PER_CALL 1 (no consolidate) +- [x] 1.2 schema/common.ts: shared time-bound string + format describe + +## 2. Endpoints (2) + +- [x] 2.1 search: POST /v1/search, strict body, web-search, inherits + PER_CALL; `mode` rejected (D3) +- [x] 2.2 fetch: GET /v1/fetch, strict queryParams, web-scraping, + inherits PER_CALL; live SKU noted (D4) + +## 3. Fixtures + tests + +- [x] 3.1 Recorded 401 on both keyed paths (malformed key, 2026-09-16) +- [x] 3.2 Synthetic happy: search (shape from /search/public) and fetch + (shape from /fetch/public), URLs rewritten to keyed paths +- [x] 3.3 Tests: provenance + pool + wire form, happy / 401 / schema + gates (mode rejected, url required), live gated on + KEENABLE_API_KEY +- [ ] 3.4 Replace synthetic happy fixtures via `deno task record` when + KEENABLE_API_KEY exists; confirm authenticated REST still carries + no usage receipt, and whether `fetch.live` actually draws more + than 1 (D4 follow-up) + +## 4. Wiring + docs + +- [x] 4.1 OpenSpec proposal / design / spec +- [x] 4.2 Verify: fmt · lint · check · test · double-compile · + version:check · catalog smoke From 20bf408c91cf6ca4fcc4c99c3e0a369a26ab16ba Mon Sep 17 00:00:00 2001 From: Karl-Gustav Kallasmaa <29797627+KGKallasmaa@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:43:41 +0100 Subject: [PATCH 2/3] fix(keenable): address CodeRabbit review on identity, schemas, and live SKU Drop the authored endpoint pin and fetch.live until a published amount exists, move vendor-mirror tightening to the binding, and tighten tests to the compiler path-instructions. --- .../keenable/endpoints/fetch/endpoint.test.ts | 64 +++++++++++++---- .../keenable/endpoints/fetch/endpoint.ts | 40 ++++++----- .../keenable/endpoints/fetch/schema/inputs.ts | 28 +++----- .../endpoints/search/endpoint.test.ts | 72 +++++++++++-------- .../keenable/endpoints/search/endpoint.ts | 19 +++-- .../endpoints/search/schema/inputs.ts | 38 +++++----- .../fixtures/synthetic-fetch-max-chars.json | 21 ++++++ .../keenable/fixtures/synthetic-fetch-ok.json | 2 +- .../fixtures/synthetic-fetch-prompt.json | 21 ++++++ .../fixtures/synthetic-search-ok.json | 2 +- .../keenable/fixtures/unauthorized-fetch.json | 2 +- .../fixtures/unauthorized-search.json | 2 +- connectors/keenable/provider.ts | 18 ++--- connectors/keenable/schema/common.ts | 7 +- .../changes/add-connector-keenable/design.md | 32 ++++----- .../add-connector-keenable/proposal.md | 21 +++--- .../specs/keenable-connector/spec.md | 19 +++-- .../changes/add-connector-keenable/tasks.md | 14 ++-- 18 files changed, 264 insertions(+), 158 deletions(-) create mode 100644 connectors/keenable/fixtures/synthetic-fetch-max-chars.json create mode 100644 connectors/keenable/fixtures/synthetic-fetch-prompt.json diff --git a/connectors/keenable/endpoints/fetch/endpoint.test.ts b/connectors/keenable/endpoints/fetch/endpoint.test.ts index 0e0f7eb9..c32ee7a1 100644 --- a/connectors/keenable/endpoints/fetch/endpoint.test.ts +++ b/connectors/keenable/endpoints/fetch/endpoint.test.ts @@ -10,8 +10,8 @@ import { 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"); +Deno.test("keenable#v1/fetch happy (synthetic): one credit; fold settles; markdown content", async () => { + const unit = await testSealedUnit("keenable#v1/fetch"); const fixture = await loadFixture(`${fixturesDir}synthetic-fetch-ok.json`); const result = await runEndpoint({ unit, @@ -26,6 +26,9 @@ Deno.test("keenable#fetch happy (synthetic): one credit; fold settles; markdown evidence: { CALL: 1 }, }); const output = result.output as Record; + assertEquals("usage" in output, false); + assertEquals("credits" in output, false); + assertEquals("costDollars" in output, false); assertEquals(output.url, "https://example.com/"); assertEquals(output.title, "Example Domain"); assert( @@ -35,8 +38,8 @@ Deno.test("keenable#fetch happy (synthetic): one credit; fold settles; markdown ); }); -Deno.test("keenable#fetch provider error (recorded 401): data, zero usage", async () => { - const unit = await testSealedUnit("keenable#fetch"); +Deno.test("keenable#v1/fetch provider error (recorded 401): data, zero usage", async () => { + const unit = await testSealedUnit("keenable#v1/fetch"); const fixture = await loadFixture(`${fixturesDir}unauthorized-fetch.json`); const result = await runEndpoint({ unit, @@ -53,14 +56,15 @@ Deno.test("keenable#fetch provider error (recorded 401): data, zero usage", asyn }); }); -Deno.test("keenable#fetch: url required; prompt bounded; unknown keys rejected", async () => { - const unit = await testSealedUnit("keenable#fetch"); +Deno.test("keenable#v1/fetch: url required; prompt bounded; unknown keys rejected", async () => { + const unit = await testSealedUnit("keenable#v1/fetch"); const fixture = await loadFixture(`${fixturesDir}synthetic-fetch-ok.json`); const rejected: Record[] = [ {}, { url: "not-a-url" }, { url: "https://example.com", max_chars: 0 }, { url: "https://example.com", prompt: "x".repeat(2001) }, + { url: "https://example.com", live: true }, { url: "https://example.com", bogus: 1 }, ]; for (const queryParams of rejected) { @@ -77,13 +81,51 @@ Deno.test("keenable#fetch: url required; prompt bounded; unknown keys rejected", JSON.stringify(queryParams), ); } + // passing near-twins. Extra query params change the GET URL, so each + // bound twin has its own fixture. + const okUrl = await runEndpoint({ + unit, + input: { queryParams: { url: "https://example.com" } }, + mode: "replay", + fixture, + }); + assertEquals(okUrl.isProviderError, false); + + const maxCharsFixture = await loadFixture( + `${fixturesDir}synthetic-fetch-max-chars.json`, + ); + const okMax = await runEndpoint({ + unit, + input: { + queryParams: { url: "https://example.com", max_chars: 1 }, + }, + mode: "replay", + fixture: maxCharsFixture, + }); + assertEquals(okMax.isProviderError, false); + + const promptFixture = await loadFixture( + `${fixturesDir}synthetic-fetch-prompt.json`, + ); + const okPrompt = await runEndpoint({ + unit, + input: { + queryParams: { + url: "https://example.com", + prompt: "x".repeat(2000), + }, + }, + mode: "replay", + fixture: promptFixture, + }); + assertEquals(okPrompt.isProviderError, false); }); Deno.test({ - name: "keenable#fetch live (gated on KEENABLE_API_KEY)", + name: "keenable#v1/fetch live (gated on KEENABLE_API_KEY)", ignore: liveSkip("keenable"), fn: async () => { - const unit = await testSealedUnit("keenable#fetch"); + const unit = await testSealedUnit("keenable#v1/fetch"); const result = await runEndpoint({ unit, input: { queryParams: { url: "https://example.com" } }, @@ -94,10 +136,8 @@ Deno.test({ false, JSON.stringify(result.output), ); - assertEquals(result.usage, { - credits: { default: 1 }, - evidence: { CALL: 1 }, - }); + assertEquals(Object.keys(result.usage.evidence).sort(), ["CALL"]); + assertEquals(typeof result.usage.credits.default, "number"); const output = result.output as Record; assertEquals(typeof output.content, "string"); }, diff --git a/connectors/keenable/endpoints/fetch/endpoint.ts b/connectors/keenable/endpoints/fetch/endpoint.ts index 54548630..303ced1c 100644 --- a/connectors/keenable/endpoints/fetch/endpoint.ts +++ b/connectors/keenable/endpoints/fetch/endpoint.ts @@ -12,29 +12,31 @@ export default defineEndpoint({ 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.", + "description, and author when available. This returns " + + "Keenable's indexed copy — a URL that is not in the index " + + "is an error. 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).", + "Indexed fetch (`fetch`) costs one credit. Live fetch " + + "(`fetch.live`) is a separate SKU that 'draws more than " + + "one' without a published number, so `live` is not a " + + "request parameter on this doc (design D4).", ], }, - /** PUBLIC identity (design D22): request.path is `/v1/fetch`; pin - * `/fetch` so the catalog id is `keenable#fetch`. */ - endpoint: "/fetch", request: { method: "GET", path: "/v1/fetch" }, - input: { schema: { queryParams: zKeenableFetchQueryParams } }, + input: { + schema: { + queryParams: zKeenableFetchQueryParams.extend({ + max_chars: zKeenableFetchQueryParams.shape.max_chars + .unwrap().min(1).optional(), + prompt: zKeenableFetchQueryParams.shape.prompt.unwrap() + .min(1).max(2000).optional(), + }), + }, + }, }); diff --git a/connectors/keenable/endpoints/fetch/schema/inputs.ts b/connectors/keenable/endpoints/fetch/schema/inputs.ts index 86e51f00..805a8d9e 100644 --- a/connectors/keenable/endpoints/fetch/schema/inputs.ts +++ b/connectors/keenable/endpoints/fetch/schema/inputs.ts @@ -4,34 +4,28 @@ 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. + * is a behaviour knob the estimate does not read (the model is + * PER_CALL), so it stays optional. Bounds live at the binding in + * endpoint.ts. `live` is not on this surface (design D4): the + * `fetch.live` SKU has no published amount, so exposing it would + * undercount. */ 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.", + "URL to fetch. Only URLs in Keenable's index are supported; " + + "a miss is an error.", ), - max_chars: z.number().int().min(1).optional().describe( + max_chars: z.number().int().describe( "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(), + prompt: z.string().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'.", - ), + ).optional(), }).strict(); diff --git a/connectors/keenable/endpoints/search/endpoint.test.ts b/connectors/keenable/endpoints/search/endpoint.test.ts index 809d6403..a422c047 100644 --- a/connectors/keenable/endpoints/search/endpoint.test.ts +++ b/connectors/keenable/endpoints/search/endpoint.test.ts @@ -16,10 +16,10 @@ Deno.test("keenable: both docs inherit PER_CALL 1, one auth fn, synthesized quan const ids = Object.keys(bundle.endpoints).filter((id) => id.startsWith("keenable#") ).sort(); - assertEquals(ids, ["keenable#fetch", "keenable#search"]); + assertEquals(ids, ["keenable#v1/fetch", "keenable#v1/search"]); - const search = bundle.endpoints["keenable#search"]; - const fetchDoc = bundle.endpoints["keenable#fetch"]; + const search = bundle.endpoints["keenable#v1/search"]; + const fetchDoc = bundle.endpoints["keenable#v1/fetch"]; assertEquals(search.request.method, "POST"); assertEquals(search.request.url, "https://api.keenable.ai/v1/search"); assertEquals(fetchDoc.request.method, "GET"); @@ -55,8 +55,8 @@ Deno.test("keenable: both docs inherit PER_CALL 1, one auth fn, synthesized quan } }); -Deno.test("keenable#search happy (synthetic): one credit; fold settles; mode rides the payload", async () => { - const unit = await testSealedUnit("keenable#search"); +Deno.test("keenable#v1/search happy (synthetic): one credit; fold settles; mode rides the payload", async () => { + const unit = await testSealedUnit("keenable#v1/search"); const fixture = await loadFixture(`${fixturesDir}synthetic-search-ok.json`); const result = await runEndpoint({ unit, @@ -72,13 +72,16 @@ Deno.test("keenable#search happy (synthetic): one credit; fold settles; mode rid evidence: { CALL: 1 }, }); const output = result.output as Record; + assertEquals("usage" in output, false); + assertEquals("credits" in output, false); + assertEquals("costDollars" in output, false); 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"); +Deno.test("keenable#v1/search provider error (recorded 401): data, zero usage", async () => { + const unit = await testSealedUnit("keenable#v1/search"); const fixture = await loadFixture(`${fixturesDir}unauthorized-search.json`); const result = await runEndpoint({ unit, @@ -95,8 +98,8 @@ Deno.test("keenable#search provider error (recorded 401): data, zero usage", asy }); }); -Deno.test("keenable#search: query required; bounds; mode is not a request field", async () => { - const unit = await testSealedUnit("keenable#search"); +Deno.test("keenable#v1/search: query required; bounds; mode is not a request field", async () => { + const unit = await testSealedUnit("keenable#v1/search"); const fixture = await loadFixture(`${fixturesDir}synthetic-search-ok.json`); const rejected: Json[] = [ {}, @@ -105,9 +108,8 @@ Deno.test("keenable#search: query required; bounds; mode is not a request field" { 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 + // design D3: mode is not on the REST body; z.never() rejects it { query: "x", mode: "pro" }, - { query: "x", bogus: 1 }, ]; for (const body of rejected) { await assertRejects( @@ -118,28 +120,38 @@ Deno.test("keenable#search: query required; bounds; mode is not a request field" 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, - }, + // passing near-twins of the bounds above; extra keys ride through + // (looseObject). Replay matches method+URL, not body. + const passing: Json[] = [ + { query: "x" }, + { query: "x", max_results: 1 }, + { query: "x", max_results: 50 }, + { query: "x", snippet_max_length: 180 }, + { query: "x", snippet_max_length: 10000 }, + { query: "x", bogus: 1 }, + { + query: "typescript best practices", + site: "arxiv.org", + published_after: "2026-01-01", + max_results: 2, }, - mode: "replay", - fixture, - }); - assertEquals(ok.isProviderError, false); + ]; + for (const body of passing) { + const ok = await runEndpoint({ + unit, + input: { body }, + mode: "replay", + fixture, + }); + assertEquals(ok.isProviderError, false, JSON.stringify(body)); + } }); Deno.test({ - name: "keenable#search live (gated on KEENABLE_API_KEY)", + name: "keenable#v1/search live (gated on KEENABLE_API_KEY)", ignore: liveSkip("keenable"), fn: async () => { - const unit = await testSealedUnit("keenable#search"); + const unit = await testSealedUnit("keenable#v1/search"); const result = await runEndpoint({ unit, input: { @@ -155,10 +167,8 @@ Deno.test({ false, JSON.stringify(result.output), ); - assertEquals(result.usage, { - credits: { default: 1 }, - evidence: { CALL: 1 }, - }); + assertEquals(Object.keys(result.usage.evidence).sort(), ["CALL"]); + assertEquals(typeof result.usage.credits.default, "number"); const output = result.output as Record; assert( Array.isArray(output.results), diff --git a/connectors/keenable/endpoints/search/endpoint.ts b/connectors/keenable/endpoints/search/endpoint.ts index f4045624..bb171929 100644 --- a/connectors/keenable/endpoints/search/endpoint.ts +++ b/connectors/keenable/endpoints/search/endpoint.ts @@ -31,10 +31,19 @@ export default defineEndpoint({ "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", request: { method: "POST", path: "/v1/search" }, - input: { schema: { body: zKeenableSearchBody } }, + input: { + schema: { + body: zKeenableSearchBody.extend({ + query: zKeenableSearchBody.shape.query.min(1), + site: zKeenableSearchBody.shape.site.unwrap().min(1) + .optional(), + snippet_max_length: zKeenableSearchBody.shape + .snippet_max_length.unwrap().min(180).max(10000) + .optional(), + max_results: zKeenableSearchBody.shape.max_results + .unwrap().min(1).max(50).optional(), + }), + }, + }, }); diff --git a/connectors/keenable/endpoints/search/schema/inputs.ts b/connectors/keenable/endpoints/search/schema/inputs.ts index 2eee0a52..3f58ce0a 100644 --- a/connectors/keenable/endpoints/search/schema/inputs.ts +++ b/connectors/keenable/endpoints/search/schema/inputs.ts @@ -8,19 +8,22 @@ import { * POST /v1/search body — OpenAPI SearchRequest (docs.keenable.ai * api-reference/openapi.json, 2026-09-16). Mirror carries optionality * only (D25): `max_results` vendor default 10 is not a billing knob - * (the model is PER_CALL), so it stays optional. `.strict()`: `mode` - * is decided per call by Keenable and is not a REST request field - * (credits docs; design D3) — a pasted SDK payload that sends it - * fails INVALID_INPUT rather than riding through. + * (the model is PER_CALL), so it stays optional. Bounds (`.min`/`.max`) + * live at the binding in endpoint.ts. + * + * `z.looseObject`: unspecified vendor-forward keys ride through. + * `mode` is decided per call by Keenable and is not a REST request + * field (credits docs; design D3) — `z.never()` rejects a pasted SDK + * payload that sends it. */ -export const zKeenableSearchBody = z.object({ - query: z.string().min(1).describe( +export const zKeenableSearchBody = z.looseObject({ + query: z.string().describe( "The search query. Natural language; describe the page you want.", ), - site: z.string().min(1).optional().describe( + site: z.string().describe( "Restrict results to a specific site, e.g. 'techcrunch.com' or " + "'arxiv.org'.", - ), + ).optional(), acquired_after: zKeenableTimeBound.optional().describe( "Filter to pages Keenable acquired/indexed at or after this " + "instant. " + TIME_BOUND_FORMAT, @@ -44,14 +47,17 @@ export const zKeenableSearchBody = z.object({ "filters resolve against this instant instead of now. " + TIME_BOUND_FORMAT, ), - snippet_max_length: z.number().int().min(180).max(10000).optional() - .describe( - "Maximum length, in characters, of the snippet returned per " + - "result (180–10000). When omitted, a default snippet " + - "length is used.", - ), - max_results: z.number().int().min(1).max(50).optional().describe( + snippet_max_length: z.number().int().describe( + "Maximum length, in characters, of the snippet returned per " + + "result (180–10000). When omitted, a default snippet " + + "length is used.", + ).optional(), + max_results: z.number().int().describe( "Maximum number of results to return (1–50). When omitted, up " + "to 10 results are returned.", + ).optional(), + mode: z.never().optional().describe( + "Not a REST request field. Keenable decides search mode per " + + "call; a pasted SDK `mode` fails INVALID_INPUT.", ), -}).strict(); +}); diff --git a/connectors/keenable/fixtures/synthetic-fetch-max-chars.json b/connectors/keenable/fixtures/synthetic-fetch-max-chars.json new file mode 100644 index 00000000..b8e5fcb1 --- /dev/null +++ b/connectors/keenable/fixtures/synthetic-fetch-max-chars.json @@ -0,0 +1,21 @@ +{ + "name": "synthetic-fetch-max-chars", + "description": "SYNTHETIC bounded fetch for keenable#v1/fetch with max_chars=1 (schema-gate near-twin of max_chars: 0). Response shape matches the indexed-fetch public twin; query string is the engine's URLSearchParams encoding.", + "calls": [ + { + "req": { + "method": "GET", + "url": "https://api.keenable.ai/v1/fetch?url=https%3A%2F%2Fexample.com&max_chars=1" + }, + "res": { + "status": 200, + "body": { + "url": "https://example.com/", + "title": "Example Domain", + "content": "#", + "description": "" + } + } + } + ] +} diff --git a/connectors/keenable/fixtures/synthetic-fetch-ok.json b/connectors/keenable/fixtures/synthetic-fetch-ok.json index 975abaa7..7f538e6f 100644 --- a/connectors/keenable/fixtures/synthetic-fetch-ok.json +++ b/connectors/keenable/fixtures/synthetic-fetch-ok.json @@ -1,6 +1,6 @@ { "name": "synthetic-fetch-ok", - "description": "SYNTHETIC happy chain for keenable#fetch. Shape taken from a real 2026-09-16 GET /v1/fetch/public?url=https://example.com response (url, title, content, description); URL rewritten to the authenticated /v1/fetch the doc calls, with the query string the engine emits. Replace via `deno task record` when KEENABLE_API_KEY exists.", + "description": "SYNTHETIC happy chain for keenable#v1/fetch. Shape taken from a real 2026-09-16 GET /v1/fetch/public?url=https://example.com response (url, title, content, description); URL rewritten to the authenticated /v1/fetch the doc calls, with the query string the engine emits. Replace via `deno task record` when KEENABLE_API_KEY exists.", "calls": [ { "req": { diff --git a/connectors/keenable/fixtures/synthetic-fetch-prompt.json b/connectors/keenable/fixtures/synthetic-fetch-prompt.json new file mode 100644 index 00000000..2c7e7220 --- /dev/null +++ b/connectors/keenable/fixtures/synthetic-fetch-prompt.json @@ -0,0 +1,21 @@ +{ + "name": "synthetic-fetch-prompt", + "description": "SYNTHETIC bounded fetch for keenable#v1/fetch with a 2000-character prompt (schema-gate near-twin of prompt length 2001). Response shape matches the indexed-fetch public twin; query string is the engine's URLSearchParams encoding.", + "calls": [ + { + "req": { + "method": "GET", + "url": "https://api.keenable.ai/v1/fetch?url=https%3A%2F%2Fexample.com&prompt=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + "res": { + "status": 200, + "body": { + "url": "https://example.com/", + "title": "Example Domain", + "content": "x", + "description": "" + } + } + } + ] +} diff --git a/connectors/keenable/fixtures/synthetic-search-ok.json b/connectors/keenable/fixtures/synthetic-search-ok.json index bb15b9a2..a01f6c33 100644 --- a/connectors/keenable/fixtures/synthetic-search-ok.json +++ b/connectors/keenable/fixtures/synthetic-search-ok.json @@ -1,6 +1,6 @@ { "name": "synthetic-search-ok", - "description": "SYNTHETIC happy chain for keenable#search. Shape taken from a real 2026-09-16 POST /v1/search/public response (query, mode, results[].title/url/description/snippet/published_at/acquired_at); URL rewritten to the authenticated /v1/search the doc calls. Snippets hand-truncated. Replace via `deno task record` when KEENABLE_API_KEY exists.", + "description": "SYNTHETIC happy chain for keenable#v1/search. Shape taken from a real 2026-09-16 POST /v1/search/public response (query, mode, results[].title/url/description/snippet/published_at/acquired_at); URL rewritten to the authenticated /v1/search the doc calls. Snippets hand-truncated. Replace via `deno task record` when KEENABLE_API_KEY exists.", "calls": [ { "req": { diff --git a/connectors/keenable/fixtures/unauthorized-fetch.json b/connectors/keenable/fixtures/unauthorized-fetch.json index a3c1ff69..793d159d 100644 --- a/connectors/keenable/fixtures/unauthorized-fetch.json +++ b/connectors/keenable/fixtures/unauthorized-fetch.json @@ -1,6 +1,6 @@ { "name": "unauthorized-fetch", - "description": "Recorded 2026-09-16 against GET https://api.keenable.ai/v1/fetch?url=https://example.com with a malformed key (X-API-Key: keen_invalid). Same 401 envelope as search. Query string is the engine's URLSearchParams encoding so replay matches.", + "description": "Recorded 2026-09-16 against GET https://api.keenable.ai/v1/fetch?url=https://example.com with a malformed API key. Same 401 envelope as search. Query string is the engine's URLSearchParams encoding so replay matches.", "calls": [ { "req": { diff --git a/connectors/keenable/fixtures/unauthorized-search.json b/connectors/keenable/fixtures/unauthorized-search.json index 3340d07f..d06f5a9a 100644 --- a/connectors/keenable/fixtures/unauthorized-search.json +++ b/connectors/keenable/fixtures/unauthorized-search.json @@ -1,6 +1,6 @@ { "name": "unauthorized-search", - "description": "Recorded 2026-09-16 against POST https://api.keenable.ai/v1/search with a malformed key (X-API-Key: keen_invalid). Vendor 401 `{error, message}` is DATA: zero usage. Docs table lists malformed keys as 400; the live keyed endpoint answered 401 Authentication failed / Malformed API key.", + "description": "Recorded 2026-09-16 against POST https://api.keenable.ai/v1/search with a malformed API key. Vendor 401 `{error, message}` is DATA: zero usage. Docs table lists malformed keys as 400; the live keyed endpoint answered 401 Authentication failed / Malformed API key.", "calls": [ { "req": { diff --git a/connectors/keenable/provider.ts b/connectors/keenable/provider.ts index afc8fd57..8b1e4090 100644 --- a/connectors/keenable/provider.ts +++ b/connectors/keenable/provider.ts @@ -25,11 +25,10 @@ export default defineProvider({ summary: "Low-latency web search and clean page fetch for AI agents.", description: "Web search and page fetch built for AI agents — " + "ranked results that already carry extracted page text, plus a " + - "fetch that returns any indexed URL as clean markdown (or a " + - "live fetch from the source). Filter search by site, " + - "publication date, and when Keenable indexed the page; fetch " + - "can optionally run an extraction instruction instead of " + - "returning the whole page.", + "fetch that returns any indexed URL as clean markdown. Filter " + + "search by site, publication date, and when Keenable indexed " + + "the page; fetch can optionally run an extraction instruction " + + "instead of returning the whole page.", homepageUrl: "https://keenable.ai", docsUrl: "https://docs.keenable.ai", categories: ["web-search"], @@ -39,10 +38,11 @@ export default defineProvider({ timeouts: { requestMs: 30_000, runMs: 30_000 }, usage: { /** THE credit system (design D26): Keenable's native meter is - * credits, one per search or fetch. Search mode (realtime vs - * pro) and live fetch are separate SKUs, but the published - * rule is still one credit per call (design D2/D4). No - * `consolidate`: no REST body carries a receipt. */ + * credits, one per search or indexed fetch. Search mode + * (realtime vs pro) is not request-selectable (D3). Live + * fetch is a separate unpublished-amount SKU, so `live` is + * not on the catalog (D4). No `consolidate`: no REST body + * carries a receipt. */ credits: { default: { label: "Keenable credits" } }, model: { kind: UsageModelKind.PER_CALL, diff --git a/connectors/keenable/schema/common.ts b/connectors/keenable/schema/common.ts index 98b3a557..2a35cf42 100644 --- a/connectors/keenable/schema/common.ts +++ b/connectors/keenable/schema/common.ts @@ -5,7 +5,8 @@ import { z } from "zod"; * SearchRequest + docs "Date and time filters", 2026-09-16). A single * string: the three spellings cannot be a JSON-Schema union without * dropping the relative form, so the formats live in the describe and - * Keenable itself 400s a bad value. + * Keenable itself 400s a bad value. Tightening (`.min`) lives at the + * binding, not on this mirror. */ export const TIME_BOUND_FORMAT = "YYYY-MM-DD (whole day UTC: an _after bound starts at 00:00:00, a " + @@ -13,6 +14,4 @@ export const TIME_BOUND_FORMAT = "offset means UTC), or a relative delta such as 7d, 30min, 6mo, 1y " + "(units: min, h, d, mo, y; truncated to the minute)."; -export const zKeenableTimeBound = z.string().min(1).describe( - TIME_BOUND_FORMAT, -); +export const zKeenableTimeBound = z.string().describe(TIME_BOUND_FORMAT); diff --git a/openspec/changes/add-connector-keenable/design.md b/openspec/changes/add-connector-keenable/design.md index 262f77e8..28b33b82 100644 --- a/openspec/changes/add-connector-keenable/design.md +++ b/openspec/changes/add-connector-keenable/design.md @@ -14,10 +14,9 @@ catalog is a metered, keyed surface. The public twins are an evaluation tier with a different auth header, different rate-limit identity, and no usage — exposing them would be a second provider with a different auth inject and a FREE model, which is its own change if anyone wants -it. Both docs call the keyed paths. Public identity is pinned -(`endpoint: "/search"` / `"/fetch"`) so the catalog ids are -`keenable#search` and `keenable#fetch` — the `/v1` prefix is a vendor -URL fact, not a caller-facing name (tinyfish's D22 form). +it. Both docs call the keyed paths. Identity is inferred from +`request.path` (design D22): `keenable#v1/search` and +`keenable#v1/fetch`. No authored `endpoint:` pin. ## D2 — One pool, PER_CALL 1, no consolidate @@ -48,25 +47,26 @@ the mode is decided per call." OpenAPI SearchRequest has no `mode`. The Python SDK documents a `mode` argument; that is not the HTTP surface this connector speaks. -The search body is `.strict()`, so a payload that includes `mode` -fails INVALID_INPUT rather than being dropped on the floor or sent -and 400'd upstream. The response may echo `mode` (`"pro"` observed on -the public twin 2026-09-16); it rides the payload, unused by billing. +The search body is `z.looseObject` so unspecified vendor-forward keys +ride through, plus `mode: z.never().optional()` so a payload that +includes `mode` fails INVALID_INPUT rather than being dropped on the +floor or sent and 400'd upstream. The response may echo `mode` (`"pro"` +observed on the public twin 2026-09-16); it rides the payload, unused +by billing. -## D4 — `fetch.live` stays PER_CALL 1 +## D4 — `fetch.live` is not on the catalog `live=true` is a documented query param and a separate SKU (`fetch.live`) that "draws more than one credit per call". No number is -published; prices are "per-organization"; REST has no receipt. Inventing -a surcharge would make every live estimate a guess that cannot -mismatch-detect. The doc bills 1 on every 2xx, indexed or live, and -says so in `meta.notes`. A published rate or a REST usage field is a -follow-up (tasks 3.3). +published; prices are "per-organization"; REST has no receipt. Exposing +`live` under the provider PER_CALL of 1 would undercount every live +fetch. The catalog fetch is indexed-only: `live` is not a request +parameter. A published rate or a REST usage field is a follow-up +(tasks 3.4). ## D5 — Fixtures: recorded 401, synthetic happy -A malformed key against the keyed paths (2026-09-16, -`X-API-Key: keen_invalid`) returned HTTP 401 +A malformed key against the keyed paths (2026-09-16) returned HTTP 401 `{error: "Authentication failed", message: "Malformed API key"}` on both endpoints. Auth docs table lists malformed keys as 400; the live keyed endpoint answered 401. Those two chains are recordings. diff --git a/openspec/changes/add-connector-keenable/proposal.md b/openspec/changes/add-connector-keenable/proposal.md index 728d0600..a870a0b4 100644 --- a/openspec/changes/add-connector-keenable/proposal.md +++ b/openspec/changes/add-connector-keenable/proposal.md @@ -11,14 +11,13 @@ engine capability. The published surface is small and stable ## What Changes - **connectors/keenable** — 2 endpoints, `X-API-Key` auth, 30 s timeouts: - - `keenable#search` (`POST /v1/search`, identity pinned `/search`): - ranked results with title, URL, description, snippet, publication - and index timestamps; site / date / point-in-time filters; snippet - length and result-count caps. PER_CALL, 1 Keenable credit. - - `keenable#fetch` (`GET /v1/fetch`, identity pinned `/fetch`): - markdown for a known URL (indexed copy by default; `live=true` - fetches from the source); optional `max_chars` and a `prompt` - extraction instruction. PER_CALL, 1 Keenable credit. + - `keenable#v1/search` (`POST /v1/search`): ranked results with title, + URL, description, snippet, publication and index timestamps; site / + date / point-in-time filters; snippet length and result-count caps. + PER_CALL, 1 Keenable credit. + - `keenable#v1/fetch` (`GET /v1/fetch`): markdown for a known indexed + URL; optional `max_chars` and a `prompt` extraction instruction. + PER_CALL, 1 Keenable credit. Live fetch is not exposed (D4). - **One credit pool** `default` ("Keenable credits") and a provider-level `PER_CALL` model inherited by both docs. No `usage.consolidate`: REST responses carry no usage receipt (MCP's `_meta["keenable/usage"]` is @@ -39,9 +38,9 @@ engine capability. The published surface is small and stable - Search `mode` (realtime vs pro) as a request field — Keenable decides it per call; MCP `_meta["keenable/overrides"]` is not an HTTP parameter. -- A `fetch.live` surcharge — published as a different SKU that "draws - more than one" credit without a number, and REST has no receipt to - settle against. +- Live fetch (`live=true` / `fetch.live`) — a different SKU that "draws + more than one" credit without a published number, and REST has no + receipt to settle against. ## Impact diff --git a/openspec/changes/add-connector-keenable/specs/keenable-connector/spec.md b/openspec/changes/add-connector-keenable/specs/keenable-connector/spec.md index 04d40542..51b34035 100644 --- a/openspec/changes/add-connector-keenable/specs/keenable-connector/spec.md +++ b/openspec/changes/add-connector-keenable/specs/keenable-connector/spec.md @@ -27,15 +27,16 @@ provider-level `usage.model` of PER_CALL 1 Keenable credit, and NO `{error, message}` rides through ### Requirement: Two authenticated endpoints -The connector SHALL provide `POST /v1/search` (`keenable#search`, -`web-search`) and `GET /v1/fetch` (`keenable#fetch`, `web-scraping`). -Search input SHALL be a strict body: `query` required; `site`, +The connector SHALL provide `POST /v1/search` (`keenable#v1/search`, +`web-search`) and `GET /v1/fetch` (`keenable#v1/fetch`, `web-scraping`). +Search input SHALL be a loose body: `query` required; `site`, `acquired_after`/`acquired_before`, `published_after`/`published_before`, `query_time`, `snippet_max_length` (180–10000), `max_results` (1–50) -optional; `mode` SHALL fail INVALID_INPUT. Fetch input SHALL be strict -query params: `url` required (URI); `max_chars` (≥1), `live` -(boolean), `prompt` (1–2000 characters) optional. The keyless `/public` -twins SHALL NOT be exposed. +optional; unspecified keys SHALL pass through; `mode` SHALL fail +INVALID_INPUT. Fetch input SHALL be strict query params: `url` required +(URI); `max_chars` (≥1), `prompt` (1–2000 characters) optional; `live` +SHALL NOT be a request field. The keyless `/public` twins SHALL NOT be +exposed. #### Scenario: Search mode is not a request field - **WHEN** `POST /v1/search` is called with a body carrying `mode` @@ -45,3 +46,7 @@ twins SHALL NOT be exposed. - **WHEN** `GET /v1/fetch` is called without `url`, or with a string that is not a URI - **THEN** the run fails INVALID_INPUT + +#### Scenario: Live fetch is not exposed +- **WHEN** `GET /v1/fetch` is called with `live` +- **THEN** the run fails INVALID_INPUT diff --git a/openspec/changes/add-connector-keenable/tasks.md b/openspec/changes/add-connector-keenable/tasks.md index e9d0fe29..b769e6d6 100644 --- a/openspec/changes/add-connector-keenable/tasks.md +++ b/openspec/changes/add-connector-keenable/tasks.md @@ -8,10 +8,10 @@ ## 2. Endpoints (2) -- [x] 2.1 search: POST /v1/search, strict body, web-search, inherits - PER_CALL; `mode` rejected (D3) +- [x] 2.1 search: POST /v1/search, loose body, web-search, inherits + PER_CALL; `mode` rejected via `z.never()` (D3) - [x] 2.2 fetch: GET /v1/fetch, strict queryParams, web-scraping, - inherits PER_CALL; live SKU noted (D4) + inherits PER_CALL; `live` not exposed (D4) ## 3. Fixtures + tests @@ -19,12 +19,12 @@ - [x] 3.2 Synthetic happy: search (shape from /search/public) and fetch (shape from /fetch/public), URLs rewritten to keyed paths - [x] 3.3 Tests: provenance + pool + wire form, happy / 401 / schema - gates (mode rejected, url required), live gated on - KEENABLE_API_KEY + gates (mode rejected, url required, live rejected, bound + near-twins), live gated on KEENABLE_API_KEY (shape, not amounts) - [ ] 3.4 Replace synthetic happy fixtures via `deno task record` when KEENABLE_API_KEY exists; confirm authenticated REST still carries - no usage receipt, and whether `fetch.live` actually draws more - than 1 (D4 follow-up) + no usage receipt. Revisit `fetch.live` only if a published amount + or REST receipt appears (D4) ## 4. Wiring + docs From 181979d7e8c4b90c183b81fe9c9469612fa5d3be Mon Sep 17 00:00:00 2001 From: ZuoyouDang Date: Fri, 9 Oct 2026 10:09:59 -0700 Subject: [PATCH 3/3] feat(keenable): search mode, live fetch, empty search bills 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align with Monid's production Keenable adaptor and its drills (2026-09-28 / 2026-10-01): - search: `mode` ("pro" | "realtime") is accepted by the REST body and each mode draws one credit — now an optional string instead of z.never() (design D3). - fetch: `live` measured at one credit, same as an indexed fetch — now an optional boolean (design D4). - search: `results: []` still draws a vendor credit but bills the buyer 0 — PER_UNIT RESULT with 0|1 evidence, the litescrape empty-success shape (design D2). - time bounds: `w` unit and Unix epoch (as a string) for `query_time` documented; empty strings rejected at the binding. New synthetic fixtures for a live fetch and an empty search; tests for both plus mode / bound twins. Main merged in. --- .../keenable/endpoints/fetch/endpoint.test.ts | 19 +++++- .../keenable/endpoints/fetch/endpoint.ts | 13 ++-- .../keenable/endpoints/fetch/schema/inputs.ts | 14 ++-- .../endpoints/search/endpoint.test.ts | 51 ++++++++++---- .../keenable/endpoints/search/endpoint.ts | 57 ++++++++++++---- .../endpoints/search/schema/inputs.ts | 20 +++--- .../fixtures/synthetic-fetch-live.json | 21 ++++++ .../fixtures/synthetic-search-empty.json | 23 +++++++ connectors/keenable/provider.ts | 30 ++++----- connectors/keenable/schema/common.ts | 7 +- .../changes/add-connector-keenable/design.md | 67 +++++++++---------- .../add-connector-keenable/proposal.md | 20 +++--- .../specs/keenable-connector/spec.md | 37 +++++----- .../changes/add-connector-keenable/tasks.md | 14 ++-- 14 files changed, 257 insertions(+), 136 deletions(-) create mode 100644 connectors/keenable/fixtures/synthetic-fetch-live.json create mode 100644 connectors/keenable/fixtures/synthetic-search-empty.json diff --git a/connectors/keenable/endpoints/fetch/endpoint.test.ts b/connectors/keenable/endpoints/fetch/endpoint.test.ts index c32ee7a1..4f921e89 100644 --- a/connectors/keenable/endpoints/fetch/endpoint.test.ts +++ b/connectors/keenable/endpoints/fetch/endpoint.test.ts @@ -56,7 +56,7 @@ Deno.test("keenable#v1/fetch provider error (recorded 401): data, zero usage", a }); }); -Deno.test("keenable#v1/fetch: url required; prompt bounded; unknown keys rejected", async () => { +Deno.test("keenable#v1/fetch: url required; prompt bounded; live accepted; unknown keys rejected", async () => { const unit = await testSealedUnit("keenable#v1/fetch"); const fixture = await loadFixture(`${fixturesDir}synthetic-fetch-ok.json`); const rejected: Record[] = [ @@ -64,7 +64,7 @@ Deno.test("keenable#v1/fetch: url required; prompt bounded; unknown keys rejecte { url: "not-a-url" }, { url: "https://example.com", max_chars: 0 }, { url: "https://example.com", prompt: "x".repeat(2001) }, - { url: "https://example.com", live: true }, + { url: "https://example.com", live: "yes" }, { url: "https://example.com", bogus: 1 }, ]; for (const queryParams of rejected) { @@ -119,6 +119,21 @@ Deno.test("keenable#v1/fetch: url required; prompt bounded; unknown keys rejecte fixture: promptFixture, }); assertEquals(okPrompt.isProviderError, false); + + const liveFixture = await loadFixture( + `${fixturesDir}synthetic-fetch-live.json`, + ); + const okLive = await runEndpoint({ + unit, + input: { queryParams: { url: "https://example.com", live: true } }, + mode: "replay", + fixture: liveFixture, + }); + assertEquals(okLive.isProviderError, false); + assertEquals(okLive.usage, { + credits: { default: 1 }, + evidence: { CALL: 1 }, + }); }); Deno.test({ diff --git a/connectors/keenable/endpoints/fetch/endpoint.ts b/connectors/keenable/endpoints/fetch/endpoint.ts index 303ced1c..832ce103 100644 --- a/connectors/keenable/endpoints/fetch/endpoint.ts +++ b/connectors/keenable/endpoints/fetch/endpoint.ts @@ -12,9 +12,10 @@ export default defineEndpoint({ 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. This returns " + - "Keenable's indexed copy — a URL that is not in the index " + - "is an error. Cap returned content with 'max_chars', or " + + "description, and author when available. By default this " + + "returns Keenable's indexed copy — a URL that is not in the " + + "index is an error; set 'live' to fetch it from the source " + + "instead. 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 " + @@ -22,10 +23,8 @@ export default defineEndpoint({ docsUrl: "https://docs.keenable.ai/api-reference/fetch", categories: ["web-scraping"], notes: [ - "Indexed fetch (`fetch`) costs one credit. Live fetch " + - "(`fetch.live`) is a separate SKU that 'draws more than " + - "one' without a published number, so `live` is not a " + - "request parameter on this doc (design D4).", + "One credit per page, indexed or live, with or without a " + + "prompt. Errors are free.", ], }, request: { method: "GET", path: "/v1/fetch" }, diff --git a/connectors/keenable/endpoints/fetch/schema/inputs.ts b/connectors/keenable/endpoints/fetch/schema/inputs.ts index 805a8d9e..aaf8d131 100644 --- a/connectors/keenable/endpoints/fetch/schema/inputs.ts +++ b/connectors/keenable/endpoints/fetch/schema/inputs.ts @@ -6,14 +6,13 @@ import { z } from "zod"; * carries optionality only (D25): `max_chars` vendor default 50000 * is a behaviour knob the estimate does not read (the model is * PER_CALL), so it stays optional. Bounds live at the binding in - * endpoint.ts. `live` is not on this surface (design D4): the - * `fetch.live` SKU has no published amount, so exposing it would - * undercount. + * endpoint.ts. `live` draws one credit like an indexed fetch (MCP + * `_meta["keenable/usage"]`, drill 2026-09-28; design D4). */ export const zKeenableFetchQueryParams = z.object({ url: z.url({ protocol: /^https?$/ }).describe( - "URL to fetch. Only URLs in Keenable's index are supported; " + - "a miss is an error.", + "URL to fetch. Without `live`, Keenable's indexed copy; a URL " + + "not in the index is an error.", ), max_chars: z.number().int().describe( "Maximum number of characters of content to return. Longer " + @@ -21,6 +20,11 @@ export const zKeenableFetchQueryParams = z.object({ "cut, so the response runs slightly past this number. " + "Vendor default 50000.", ).optional(), + live: z.boolean().describe( + "Fetch the page live from the source instead of Keenable's " + + "indexed copy (default false). Same price; slower (a few " + + "seconds).", + ).optional(), prompt: z.string().describe( "Optional extraction instruction, at most 2000 characters. " + "When set, an LLM reads the fetched page and `content` " + diff --git a/connectors/keenable/endpoints/search/endpoint.test.ts b/connectors/keenable/endpoints/search/endpoint.test.ts index a422c047..ab2a43ad 100644 --- a/connectors/keenable/endpoints/search/endpoint.test.ts +++ b/connectors/keenable/endpoints/search/endpoint.test.ts @@ -11,7 +11,7 @@ import { 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 () => { +Deno.test("keenable: fetch inherits PER_CALL 1, search counts RESULT, one auth fn, no consolidate", async () => { const bundle = await testBundle(); const ids = Object.keys(bundle.endpoints).filter((id) => id.startsWith("keenable#") @@ -31,22 +31,26 @@ Deno.test("keenable: both docs inherit PER_CALL 1, one auth fn, synthesized quan 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); + // fetch: flat PER_CALL, compiler-synthesized empty quantities + // (tinyfish/pdl enrich posture); engine appends CALL at settle + const synthesizedKey = fetchDoc.usage.evidence.$fn.key; assertEquals(fetchDoc.usage.estimate.$fn.key, synthesizedKey); assertEquals( bundle.fnTable[synthesizedKey].provenance, "core#usage.synthesizedEmpty", ); + // search: its own 0|1 evidence (empty result bills 0) + assert(search.usage.evidence.$fn.key !== synthesizedKey); + const kinds: Record = { + "keenable#v1/fetch": "PER_CALL", + "keenable#v1/search": "PER_UNIT", + }; 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(model.kind, kinds[id], id); assertEquals( "consumes" in model ? model.consumes : undefined, { credit: "default", amount: 1 }, @@ -55,7 +59,7 @@ Deno.test("keenable: both docs inherit PER_CALL 1, one auth fn, synthesized quan } }); -Deno.test("keenable#v1/search happy (synthetic): one credit; fold settles; mode rides the payload", async () => { +Deno.test("keenable#v1/search happy (synthetic): one credit; fold settles; mode echoed", async () => { const unit = await testSealedUnit("keenable#v1/search"); const fixture = await loadFixture(`${fixturesDir}synthetic-search-ok.json`); const result = await runEndpoint({ @@ -69,7 +73,7 @@ Deno.test("keenable#v1/search happy (synthetic): one credit; fold settles; mode // no consolidate ⇒ no claim ⇒ the DERIVED fold is the bill (D2) assertEquals(result.usage, { credits: { default: 1 }, - evidence: { CALL: 1 }, + evidence: { RESULT: 1 }, }); const output = result.output as Record; assertEquals("usage" in output, false); @@ -80,6 +84,23 @@ Deno.test("keenable#v1/search happy (synthetic): one credit; fold settles; mode assertEquals((output.results as unknown[]).length, 2); }); +Deno.test("keenable#v1/search empty (synthetic): results [] bills 0", async () => { + const unit = await testSealedUnit("keenable#v1/search"); + const fixture = await loadFixture( + `${fixturesDir}synthetic-search-empty.json`, + ); + const result = await runEndpoint({ + unit, + input: { body: { query: "zzqxv no such page" } }, + mode: "replay", + fixture, + }); + assertEquals(result.httpStatus, 200); + assertEquals(result.isProviderError, false); + assertEquals(result.usage, { credits: {}, evidence: { RESULT: 0 } }); + assertEquals((result.output as Record).results, []); +}); + Deno.test("keenable#v1/search provider error (recorded 401): data, zero usage", async () => { const unit = await testSealedUnit("keenable#v1/search"); const fixture = await loadFixture(`${fixturesDir}unauthorized-search.json`); @@ -98,7 +119,7 @@ Deno.test("keenable#v1/search provider error (recorded 401): data, zero usage", }); }); -Deno.test("keenable#v1/search: query required; bounds; mode is not a request field", async () => { +Deno.test("keenable#v1/search: query required; bounds; mode accepted", async () => { const unit = await testSealedUnit("keenable#v1/search"); const fixture = await loadFixture(`${fixturesDir}synthetic-search-ok.json`); const rejected: Json[] = [ @@ -108,8 +129,8 @@ Deno.test("keenable#v1/search: query required; bounds; mode is not a request fie { 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; z.never() rejects it - { query: "x", mode: "pro" }, + { query: "x", published_after: "" }, + { query: "x", query_time: 1790000000 }, ]; for (const body of rejected) { await assertRejects( @@ -129,6 +150,10 @@ Deno.test("keenable#v1/search: query required; bounds; mode is not a request fie { query: "x", snippet_max_length: 180 }, { query: "x", snippet_max_length: 10000 }, { query: "x", bogus: 1 }, + { query: "x", mode: "pro" }, + { query: "x", mode: "realtime" }, + { query: "x", published_after: "2w" }, + { query: "x", query_time: "1790000000" }, { query: "typescript best practices", site: "arxiv.org", @@ -167,7 +192,7 @@ Deno.test({ false, JSON.stringify(result.output), ); - assertEquals(Object.keys(result.usage.evidence).sort(), ["CALL"]); + assertEquals(Object.keys(result.usage.evidence).sort(), ["RESULT"]); assertEquals(typeof result.usage.credits.default, "number"); const output = result.output as Record; assert( diff --git a/connectors/keenable/endpoints/search/endpoint.ts b/connectors/keenable/endpoints/search/endpoint.ts index bb171929..d0a5ab61 100644 --- a/connectors/keenable/endpoints/search/endpoint.ts +++ b/connectors/keenable/endpoints/search/endpoint.ts @@ -1,11 +1,15 @@ -import { defineEndpoint } from "@shared/core"; +import { defineEndpoint, Unit, UsageModelKind } from "@shared/core"; import { zKeenableSearchBody } from "./schema/inputs.ts"; +const timeBound = zKeenableSearchBody.shape.query_time.unwrap().min(1) + .optional(); + /** * 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). + * Usage overrides the provider's PER_CALL: a search that found nothing + * (`results: []`) still draws a Keenable credit (console balance delta, + * drill 2026-09-28) but bills 0, the v1 posture (design D2). */ export default defineEndpoint({ meta: { @@ -17,18 +21,15 @@ export default defineEndpoint({ "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.", + "(1–50, default 10), and pick a `pro` (deeper) or " + + "`realtime` (fastest) mode. 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.", + "One credit per search in either mode. A search that returns " + + "no results is free.", ], }, request: { method: "POST", path: "/v1/search" }, @@ -38,6 +39,11 @@ export default defineEndpoint({ query: zKeenableSearchBody.shape.query.min(1), site: zKeenableSearchBody.shape.site.unwrap().min(1) .optional(), + acquired_after: timeBound, + acquired_before: timeBound, + published_after: timeBound, + published_before: timeBound, + query_time: timeBound, snippet_max_length: zKeenableSearchBody.shape .snippet_max_length.unwrap().min(180).max(10000) .optional(), @@ -46,4 +52,31 @@ export default defineEndpoint({ }), }, }, + usage: { + model: { + kind: UsageModelKind.PER_UNIT, + unit: Unit.RESULT, + label: "searches with results", + description: "searches whose response carried at least one " + + "result", + consumes: { credit: "default", amount: 1 }, + }, + /** The hold promises one search. */ + estimate: () => ({ counts: { RESULT: 1 } }), + /** v1 `searchActuals`: 0 for `results: []` (the vendor still + * deducts the credit, the buyer is not charged) or a non-object + * body, 1 for any other 2xx object. */ + evidence: ({ data }) => { + const body = data.output; + if ( + typeof body !== "object" || body === null || + Array.isArray(body) + ) { + return { counts: { RESULT: 0 } }; + } + const results = (body as Record).results; + const empty = Array.isArray(results) && results.length === 0; + return { counts: { RESULT: empty ? 0 : 1 } }; + }, + }, }); diff --git a/connectors/keenable/endpoints/search/schema/inputs.ts b/connectors/keenable/endpoints/search/schema/inputs.ts index 3f58ce0a..b4f0190b 100644 --- a/connectors/keenable/endpoints/search/schema/inputs.ts +++ b/connectors/keenable/endpoints/search/schema/inputs.ts @@ -12,9 +12,8 @@ import { * live at the binding in endpoint.ts. * * `z.looseObject`: unspecified vendor-forward keys ride through. - * `mode` is decided per call by Keenable and is not a REST request - * field (credits docs; design D3) — `z.never()` rejects a pasted SDK - * payload that sends it. + * `mode` is not in the OpenAPI but the REST body takes it: `pro` and + * `realtime` each drew one credit (drill 2026-09-28; design D3). */ export const zKeenableSearchBody = z.looseObject({ query: z.string().describe( @@ -40,12 +39,15 @@ export const zKeenableSearchBody = z.looseObject({ "Filter to pages published at or before this instant. " + TIME_BOUND_FORMAT, ), + // The Unix epoch must be a string: a JSON number is a 400 upstream + // (drill 2026-10-01). query_time: zKeenableTimeBound.optional().describe( "Search the index as it stood at this instant: pages acquired " + "after it are excluded. A date resolves to 00:00:00 UTC (not " + "the end of the day). Relative deltas on the other date " + - "filters resolve against this instant instead of now. " + - TIME_BOUND_FORMAT, + "filters resolve against this instant instead of now. Also " + + "accepts a Unix epoch in seconds as a string (e.g. " + + "'1790000000'). " + TIME_BOUND_FORMAT, ), snippet_max_length: z.number().int().describe( "Maximum length, in characters, of the snippet returned per " + @@ -56,8 +58,8 @@ export const zKeenableSearchBody = z.looseObject({ "Maximum number of results to return (1–50). When omitted, up " + "to 10 results are returned.", ).optional(), - mode: z.never().optional().describe( - "Not a REST request field. Keenable decides search mode per " + - "call; a pasted SDK `mode` fails INVALID_INPUT.", - ), + mode: z.string().describe( + "Search mode: 'pro' (default) for deeper retrieval, 'realtime' " + + "for the fastest results. Same price.", + ).optional(), }); diff --git a/connectors/keenable/fixtures/synthetic-fetch-live.json b/connectors/keenable/fixtures/synthetic-fetch-live.json new file mode 100644 index 00000000..4124fe73 --- /dev/null +++ b/connectors/keenable/fixtures/synthetic-fetch-live.json @@ -0,0 +1,21 @@ +{ + "name": "synthetic-fetch-live", + "description": "SYNTHETIC live fetch for keenable#v1/fetch with live=true. Response shape matches the indexed fetch (synthetic-fetch-ok); query string is the engine's URLSearchParams encoding of the boolean.", + "calls": [ + { + "req": { + "method": "GET", + "url": "https://api.keenable.ai/v1/fetch?url=https%3A%2F%2Fexample.com&live=true" + }, + "res": { + "status": 200, + "body": { + "url": "https://example.com/", + "title": "Example Domain", + "content": "# Example Domain\n\nThis domain is for use in documentation examples without needing permission. Avoid use in operations.", + "description": "" + } + } + } + ] +} diff --git a/connectors/keenable/fixtures/synthetic-search-empty.json b/connectors/keenable/fixtures/synthetic-search-empty.json new file mode 100644 index 00000000..be9add0e --- /dev/null +++ b/connectors/keenable/fixtures/synthetic-search-empty.json @@ -0,0 +1,23 @@ +{ + "name": "synthetic-search-empty", + "description": "SYNTHETIC empty search for keenable#v1/search: a 200 with results: [] (the shape v1 drilled 2026-09-28; the vendor still deducts a credit). Envelope as synthetic-search-ok.", + "calls": [ + { + "req": { + "method": "POST", + "url": "{{request.url}}", + "body": { + "query": "zzqxv no such page" + } + }, + "res": { + "status": 200, + "body": { + "query": "zzqxv no such page", + "mode": "pro", + "results": [] + } + } + } + ] +} diff --git a/connectors/keenable/provider.ts b/connectors/keenable/provider.ts index 8b1e4090..a6a36e2d 100644 --- a/connectors/keenable/provider.ts +++ b/connectors/keenable/provider.ts @@ -11,12 +11,13 @@ import { defineProvider, presets, UsageModelKind } from "@shared/core"; * credits) and are not exposed — design D1. * * Billing: Keenable meters authenticated usage in credits, 100,000 - * requests/month free, and publishes that search and fetch each cost - * one (credits docs, 2026-09-16). REST responses carry no usage - * receipt — MCP reports `_meta["keenable/usage"]`, which this HTTP - * connector never sees — so there is no `usage.consolidate` and the - * derived fold settles (design D2; same posture as pdl). One pool - * `default`, PER_CALL 1 on the provider, inherited by both docs. + * requests/month free; every SKU either doc can select (`search.pro`, + * `search.realtime`, `fetch`, `fetch.live`, with or without `prompt`) + * drew one credit (MCP `_meta["keenable/usage"]`, drill 2026-09-28). + * REST responses carry no usage receipt, so there is no + * `usage.consolidate` and the derived fold settles (design D2; same + * posture as pdl). One pool `default`, PER_CALL 1 on the provider; + * search overrides it so an empty result bills 0. */ export default defineProvider({ name: "keenable", @@ -25,10 +26,11 @@ export default defineProvider({ summary: "Low-latency web search and clean page fetch for AI agents.", description: "Web search and page fetch built for AI agents — " + "ranked results that already carry extracted page text, plus a " + - "fetch that returns any indexed URL as clean markdown. Filter " + - "search by site, publication date, and when Keenable indexed " + - "the page; fetch can optionally run an extraction instruction " + - "instead of returning the whole page.", + "fetch that returns any URL as clean markdown, from the index " + + "or live from the source. Filter search by site, publication " + + "date, and when Keenable indexed the page; fetch can " + + "optionally run an extraction instruction instead of " + + "returning the whole page.", homepageUrl: "https://keenable.ai", docsUrl: "https://docs.keenable.ai", categories: ["web-search"], @@ -38,11 +40,9 @@ export default defineProvider({ timeouts: { requestMs: 30_000, runMs: 30_000 }, usage: { /** THE credit system (design D26): Keenable's native meter is - * credits, one per search or indexed fetch. Search mode - * (realtime vs pro) is not request-selectable (D3). Live - * fetch is a separate unpublished-amount SKU, so `live` is - * not on the catalog (D4). No `consolidate`: no REST body - * carries a receipt. */ + * credits, one per search (either mode, D3) or fetch (indexed + * or live, D4). No `consolidate`: no REST body carries a + * receipt. */ credits: { default: { label: "Keenable credits" } }, model: { kind: UsageModelKind.PER_CALL, diff --git a/connectors/keenable/schema/common.ts b/connectors/keenable/schema/common.ts index 2a35cf42..6b7b9960 100644 --- a/connectors/keenable/schema/common.ts +++ b/connectors/keenable/schema/common.ts @@ -2,7 +2,8 @@ import { z } from "zod"; /** * Date/time bounds Keenable accepts on search filters (OpenAPI - * SearchRequest + docs "Date and time filters", 2026-09-16). A single + * SearchRequest + docs "Date and time filters", 2026-09-16; the `w` unit + * drilled 2026-10-01). A single * string: the three spellings cannot be a JSON-Schema union without * dropping the relative form, so the formats live in the describe and * Keenable itself 400s a bad value. Tightening (`.min`) lives at the @@ -11,7 +12,7 @@ import { z } from "zod"; export const TIME_BOUND_FORMAT = "YYYY-MM-DD (whole day UTC: an _after bound starts at 00:00:00, a " + "_before bound ends at 23:59:59.999), an ISO 8601 timestamp (no " + - "offset means UTC), or a relative delta such as 7d, 30min, 6mo, 1y " + - "(units: min, h, d, mo, y; truncated to the minute)."; + "offset means UTC), or a relative delta such as 7d, 2w, 30min, 6mo, " + + "1y (units: min, h, d, w, mo, y; truncated to the minute)."; export const zKeenableTimeBound = z.string().describe(TIME_BOUND_FORMAT); diff --git a/openspec/changes/add-connector-keenable/design.md b/openspec/changes/add-connector-keenable/design.md index 28b33b82..6fc9fa68 100644 --- a/openspec/changes/add-connector-keenable/design.md +++ b/openspec/changes/add-connector-keenable/design.md @@ -18,51 +18,48 @@ it. Both docs call the keyed paths. Identity is inferred from `request.path` (design D22): `keenable#v1/search` and `keenable#v1/fetch`. No authored `endpoint:` pin. -## D2 — One pool, PER_CALL 1, no consolidate +## D2 — One pool, PER_CALL 1, no consolidate; empty search bills 0 Credits docs (2026-09-16): authenticated usage is metered in credits; "search and fetch each cost one"; 100,000 requests/month free, then -purchased packs. REST JSON carries no usage object. MCP reports -`_meta["keenable/usage"]` (`sku`, `amount`, `credits`, `paid`) beside -the tool result, which this HTTP connector never sees. Response headers -on the public twin were rate-limit only (`X-RateLimit-*`, -`X-Request-Id`); no usage header is documented for REST. +purchased packs. Monid's v1 drill (2026-09-28, MCP +`_meta["keenable/usage"]` per SKU) measured every SKU a doc can select +— `search.pro`, `search.realtime`, `fetch`, `fetch.live`, with or +without `prompt` — at one credit for Monid's organization. REST JSON +carries no usage object, so there is no vendor claim to pluck (no +`usage.consolidate`) and the derived fold settles — the pdl posture. -So there is no vendor claim to pluck (no `usage.consolidate`) and the -derived fold settles — the pdl posture. One pool `default` ("Keenable -credits"), a provider-level `PER_CALL` of 1 inherited by both docs. -Quantities fns are compiler-synthesized. Search mode SKUs -(`search.realtime` / `search.pro`) are not request-selectable (D3) and -are published as one credit either way. +One pool `default` ("Keenable credits"), a provider-level `PER_CALL` of +1. Fetch inherits it. Search overrides it with `PER_UNIT` `RESULT` and +its own evidence: `results: []` still draws a credit (console balance +delta, drill 2026-09-28) but bills 0 — v1's posture (`searchActuals`), +the same shape as litescrape's empty success. The estimate promises one. -Rejected: two pools keyed on SKU (the caller cannot choose, and REST -does not report which SKU ran); a FREE model (authenticated calls draw -the monthly allowance). +A fetch the target refuses (403/404/422/500) also draws a credit +(drill 2026-09-28 / 2026-10-01); here it settles as a provider error +with zero usage, as every non-2xx does. v1 records that credit as +Monid's cost; the declarative model has no seam for it and the buyer +pays nothing either way. -## D3 — Search `mode` is not a request field +Rejected: two pools keyed on SKU (every SKU is one credit); a FREE +model (authenticated calls draw the monthly allowance). -Credits: "Search mode is not a request parameter. Over MCP an -integrator can pin it with `_meta[\"keenable/overrides\"]`; otherwise -the mode is decided per call." OpenAPI SearchRequest has no `mode`. -The Python SDK documents a `mode` argument; that is not the HTTP -surface this connector speaks. +## D3 — Search `mode` is a request field -The search body is `z.looseObject` so unspecified vendor-forward keys -ride through, plus `mode: z.never().optional()` so a payload that -includes `mode` fails INVALID_INPUT rather than being dropped on the -floor or sent and 400'd upstream. The response may echo `mode` (`"pro"` -observed on the public twin 2026-09-16); it rides the payload, unused -by billing. +OpenAPI SearchRequest has no `mode`, but the REST body accepts it: the +v1 drill (2026-09-28) sent `mode: "pro"` and `mode: "realtime"` to +`POST /v1/search` and each drew one credit. So `mode` is an optional +string — the values (`pro` default, `realtime`) live in the describe, +not a `z.enum` (vendor enumerated strings stay `z.string`, repo +convention). The response echoes the mode served. -## D4 — `fetch.live` is not on the catalog +## D4 — `live` fetch is on the catalog -`live=true` is a documented query param and a separate SKU -(`fetch.live`) that "draws more than one credit per call". No number is -published; prices are "per-organization"; REST has no receipt. Exposing -`live` under the provider PER_CALL of 1 would undercount every live -fetch. The catalog fetch is indexed-only: `live` is not a request -parameter. A published rate or a REST usage field is a follow-up -(tasks 3.4). +`live=true` is a documented query param (`fetch.live` SKU). The credits +docs say it "draws more than one credit" without a number; the v1 drill +(2026-09-28) measured one for Monid's organization, the same as an +indexed fetch. So `live` is an optional boolean under the same PER_CALL +of 1. ## D5 — Fixtures: recorded 401, synthetic happy diff --git a/openspec/changes/add-connector-keenable/proposal.md b/openspec/changes/add-connector-keenable/proposal.md index a870a0b4..81cd5f9e 100644 --- a/openspec/changes/add-connector-keenable/proposal.md +++ b/openspec/changes/add-connector-keenable/proposal.md @@ -13,13 +13,15 @@ engine capability. The published surface is small and stable - **connectors/keenable** — 2 endpoints, `X-API-Key` auth, 30 s timeouts: - `keenable#v1/search` (`POST /v1/search`): ranked results with title, URL, description, snippet, publication and index timestamps; site / - date / point-in-time filters; snippet length and result-count caps. - PER_CALL, 1 Keenable credit. - - `keenable#v1/fetch` (`GET /v1/fetch`): markdown for a known indexed - URL; optional `max_chars` and a `prompt` extraction instruction. - PER_CALL, 1 Keenable credit. Live fetch is not exposed (D4). + date / point-in-time filters; snippet length and result-count caps; + `pro` / `realtime` mode. 1 Keenable credit per search with results; + an empty result bills 0. + - `keenable#v1/fetch` (`GET /v1/fetch`): markdown for a URL, indexed + or `live`; optional `max_chars` and a `prompt` extraction + instruction. PER_CALL, 1 Keenable credit. - **One credit pool** `default` ("Keenable credits") and a provider-level - `PER_CALL` model inherited by both docs. No `usage.consolidate`: REST + `PER_CALL` model; search overrides it so an empty result bills 0 + (v1 posture). No `usage.consolidate`: REST responses carry no usage receipt (MCP's `_meta["keenable/usage"]` is not on this surface). - Fixtures: search/fetch 401s are RECORDED (malformed key against the @@ -35,12 +37,6 @@ engine capability. The published surface is small and stable - The keyless `/v1/search/public` and `/v1/fetch/public` twins (`X-Keenable-Title`, shared per-IP pool, unmetered) — evaluation-only. -- Search `mode` (realtime vs pro) as a request field — Keenable decides - it per call; MCP `_meta["keenable/overrides"]` is not an HTTP - parameter. -- Live fetch (`live=true` / `fetch.live`) — a different SKU that "draws - more than one" credit without a published number, and REST has no - receipt to settle against. ## Impact diff --git a/openspec/changes/add-connector-keenable/specs/keenable-connector/spec.md b/openspec/changes/add-connector-keenable/specs/keenable-connector/spec.md index 51b34035..23b359c4 100644 --- a/openspec/changes/add-connector-keenable/specs/keenable-connector/spec.md +++ b/openspec/changes/add-connector-keenable/specs/keenable-connector/spec.md @@ -7,13 +7,19 @@ The keenable provider SHALL declare name `keenable`, `request.baseUrl` `https://api.keenable.ai`, auth `presets.auth.header("X-API-Key")`, timeouts 30 s / 30 s, one credit pool `default` ("Keenable credits"), a provider-level `usage.model` of PER_CALL 1 Keenable credit, and NO -`usage.consolidate` (REST responses carry no usage receipt). +`usage.consolidate` (REST responses carry no usage receipt). Search +SHALL override the model with PER_UNIT `RESULT`, 1 credit, counting 0 +for `results: []`. #### Scenario: A search consumes one credit - **WHEN** `POST /v1/search` returns 200 with two results -- **THEN** usage is `{credits: {default: 1}, evidence: {CALL: 1}}` +- **THEN** usage is `{credits: {default: 1}, evidence: {RESULT: 1}}` - **AND** the payload still carries `query`, `results`, and the - vendor-echoed `mode` (not a request field) + vendor-echoed `mode` + +#### Scenario: An empty search is free +- **WHEN** `POST /v1/search` returns 200 with `results: []` +- **THEN** usage is `{credits: {}, evidence: {RESULT: 0}}` #### Scenario: A fetch consumes one credit - **WHEN** `GET /v1/fetch?url=https://example.com` returns 200 @@ -31,22 +37,21 @@ The connector SHALL provide `POST /v1/search` (`keenable#v1/search`, `web-search`) and `GET /v1/fetch` (`keenable#v1/fetch`, `web-scraping`). Search input SHALL be a loose body: `query` required; `site`, `acquired_after`/`acquired_before`, `published_after`/`published_before`, -`query_time`, `snippet_max_length` (180–10000), `max_results` (1–50) -optional; unspecified keys SHALL pass through; `mode` SHALL fail -INVALID_INPUT. Fetch input SHALL be strict query params: `url` required -(URI); `max_chars` (≥1), `prompt` (1–2000 characters) optional; `live` -SHALL NOT be a request field. The keyless `/public` twins SHALL NOT be -exposed. - -#### Scenario: Search mode is not a request field -- **WHEN** `POST /v1/search` is called with a body carrying `mode` -- **THEN** the run fails INVALID_INPUT +`query_time` (non-empty strings), `mode`, `snippet_max_length` +(180–10000), `max_results` (1–50) optional; unspecified keys SHALL pass +through. Fetch input SHALL be strict query params: `url` required +(URI); `max_chars` (≥1), `live` (boolean), `prompt` (1–2000 +characters) optional. The keyless `/public` twins SHALL NOT be exposed. + +#### Scenario: Search mode is a request field +- **WHEN** `POST /v1/search` is called with `mode: "realtime"` +- **THEN** the body is accepted and sent #### Scenario: Fetch url is required - **WHEN** `GET /v1/fetch` is called without `url`, or with a string that is not a URI - **THEN** the run fails INVALID_INPUT -#### Scenario: Live fetch is not exposed -- **WHEN** `GET /v1/fetch` is called with `live` -- **THEN** the run fails INVALID_INPUT +#### Scenario: Live fetch is exposed +- **WHEN** `GET /v1/fetch` is called with `live: true` +- **THEN** the request carries `live=true` and settles one credit diff --git a/openspec/changes/add-connector-keenable/tasks.md b/openspec/changes/add-connector-keenable/tasks.md index b769e6d6..583ae943 100644 --- a/openspec/changes/add-connector-keenable/tasks.md +++ b/openspec/changes/add-connector-keenable/tasks.md @@ -8,10 +8,10 @@ ## 2. Endpoints (2) -- [x] 2.1 search: POST /v1/search, loose body, web-search, inherits - PER_CALL; `mode` rejected via `z.never()` (D3) +- [x] 2.1 search: POST /v1/search, loose body, web-search, `mode` + string (D3); PER_UNIT RESULT, empty result bills 0 (D2) - [x] 2.2 fetch: GET /v1/fetch, strict queryParams, web-scraping, - inherits PER_CALL; `live` not exposed (D4) + inherits PER_CALL; `live` boolean (D4) ## 3. Fixtures + tests @@ -19,12 +19,12 @@ - [x] 3.2 Synthetic happy: search (shape from /search/public) and fetch (shape from /fetch/public), URLs rewritten to keyed paths - [x] 3.3 Tests: provenance + pool + wire form, happy / 401 / schema - gates (mode rejected, url required, live rejected, bound - near-twins), live gated on KEENABLE_API_KEY (shape, not amounts) + gates (mode accepted, url required, live accepted, bound + near-twins), empty search bills 0, live gated on KEENABLE_API_KEY + (shape, not amounts) - [ ] 3.4 Replace synthetic happy fixtures via `deno task record` when KEENABLE_API_KEY exists; confirm authenticated REST still carries - no usage receipt. Revisit `fetch.live` only if a published amount - or REST receipt appears (D4) + no usage receipt ## 4. Wiring + docs