From 10ee2d4d7bef39f616f822f5c2bb3842eb820c71 Mon Sep 17 00:00:00 2001 From: Eric Date: Mon, 3 Aug 2026 13:15:01 -0700 Subject: [PATCH 1/6] feat!: realign types with Tako's current API and gate drift in CI The API moved on between 2026-06 and 2026-08; this SDK's types did not. Six fields were wrong on the wire. Every claim below is verified against Tako's published OpenAPI document and against tako-sdk, Tako's official generated client, both of which now run as CI oracles. Wire fixes: - Remove `sources.data.deferDataRetrieval`. The API deleted the field and its data-source settings forbid unknown properties, so setting the option made the whole request fail rather than being ignored. - Replace `contents_total_cost` with `usage` ({ total_cost_usd, compute, data }). The old field is absent from the spec and the official client, so it read `undefined` at runtime while TypeScript promised `number`. - Rename `ResultContent.format` to `content_format` and correct its enum to csv | json_records | json_compact. `"text"` no longer exists: web page text is signalled by a null or absent format. Adds the `records`, `dataset`, `export_pricing` and `manifest` payload fields. - Collapse the source taxonomy to `TakoSourceIndex = "data" | "web"` and delete `TakoCardSourceIndexSegment` / `TakoCardSourcePrivateIndex`, which never existed in the API. Comparing against "tako" silently never matched. - Guarantee `cards`, `web_results` and `contents`. The API promises only `request_id`, so these were typed present but could be absent; the tools now normalize them to [] instead of pushing optionality onto callers. - Add the card fields the API already sent: `exportable`, `data_freshness`, `relevance_score`, `nodes`, `metric_definitions`. Also corrects `TakoKnowledgeCardMethodology` to required-but-nullable keys, validates the contents `url` input as a url so a malformed value costs no API call, and fixes docs that claimed inline contents cap at 1000 rows (the real behaviour is a 20-row default against a 2,000-row ceiling). Verification lives in tests/contract/: the vendored spec validates every request body via ajv, and types.conformance.ts must compile clean against tako-sdk. tako-sdk is a devDependency only, so the package keeps zero runtime dependencies. BREAKING CHANGE: response and config types no longer match 2.x. See MIGRATING.md for the field-by-field migration. `deferDataRetrieval`, `contents_total_cost`, `ResultContent.format`, `TakoCardSourceIndexSegment` and `TakoCardSourcePrivateIndex` are removed; `TakoKnowledgeCardSource` and `TakoCardSourceIndex` remain as deprecated aliases. --- MIGRATING.md | 97 + README.md | 42 +- examples/contents.ts | 46 +- package.json | 8 +- pnpm-lock.yaml | 98 +- src/index.ts | 32 +- src/request.ts | 39 +- src/tools/answer.ts | 52 +- src/tools/contents.ts | 60 +- src/tools/search.ts | 54 +- src/types.ts | 244 +- tests/answer.test.ts | 2 +- tests/contents.test.ts | 67 +- tests/contract/openapi.yaml | 4005 ++++++++++++++++++++++ tests/contract/request.contract.test.ts | 107 + tests/contract/response.contract.test.ts | 183 + tests/contract/spec.ts | 60 + tests/contract/types.conformance.test.ts | 44 + tests/contract/types.conformance.ts | 108 + tests/index.test.ts | 2 +- tests/search.test.ts | 36 +- tests/types.test.ts | 96 +- tsconfig.check.json | 6 +- tsconfig.conformance.json | 5 + 24 files changed, 5328 insertions(+), 165 deletions(-) create mode 100644 MIGRATING.md create mode 100644 tests/contract/openapi.yaml create mode 100644 tests/contract/request.contract.test.ts create mode 100644 tests/contract/response.contract.test.ts create mode 100644 tests/contract/spec.ts create mode 100644 tests/contract/types.conformance.test.ts create mode 100644 tests/contract/types.conformance.ts create mode 100644 tsconfig.conformance.json diff --git a/MIGRATING.md b/MIGRATING.md new file mode 100644 index 0000000..4c67439 --- /dev/null +++ b/MIGRATING.md @@ -0,0 +1,97 @@ +# Migrating + +## 2.x → 3.0 + +Tako's API moved on between June and August 2026 and this SDK's types did not follow. 3.0 realigns them. Every change below is a case where 2.x described something the API no longer does — so if code depended on it, it was already broken at runtime, whatever TypeScript said. + +`tests/contract/` now validates these types against Tako's published OpenAPI document and against [`tako-sdk`](https://www.npmjs.com/package/tako-sdk), Tako's official generated client, so this class of drift fails CI from now on. + +### Config + +| 2.x | 3.0 | +| --- | --- | +| `sources.data.deferDataRetrieval` | **Removed.** No replacement. | + +The API removed `defer_data_retrieval` from its data-source settings, and those settings forbid unknown properties — so any request that set this option was **rejected outright**, not silently ignored. Delete the option; the request starts working. + +### Responses + +| 2.x | 3.0 | +| --- | --- | +| `result.contents_total_cost: number` | `result.usage?.total_cost_usd` | +| `content.format` | `content.content_format` | +| `TakoContentFormat = 'csv' \| 'text'` | `'csv' \| 'json_records' \| 'json_compact'` | + +**Cost.** `contents_total_cost` no longer exists. `usage` replaces it and adds a breakdown: + +```ts +// 2.x +const cost = result.contents_total_cost; // undefined at runtime + +// 3.0 +const cost = result.usage?.total_cost_usd ?? 0; +const compute = result.usage?.compute?.cost_usd; // running the operation +const data = result.usage?.data?.cost_usd; // inline data delivered +``` + +**Content format.** The field was renamed *and* its values changed. `'text'` is gone: web page text is now signalled by `content_format === null`. + +```ts +// 2.x +if (item.format === 'csv') parseCsv(item.data); +else if (item.format === 'text') readProse(item.data); // both branches dead + +// 3.0 +if (item.content_format === null) readProse(item.data); // web page text +else parseCsv(item.data); // card data +``` + +Three payload fields were also missing and are now typed: `records` (for `json_records`), `dataset` (for `json_compact`), plus `export_pricing` and `manifest`. + +### Source taxonomy + +| 2.x | 3.0 | +| --- | --- | +| `TakoCardSourceIndex = 'tako' \| 'web' \| 'connected_data' \| 'tako_deep_v2'` | `TakoSourceIndex = 'data' \| 'web'` | +| `TakoCardSourceIndexSegment` | **Removed** — never existed in the API | +| `TakoCardSourcePrivateIndex` | **Removed** — never existed in the API | +| `TakoKnowledgeCardSource` | `TakoCardSource` (old name kept as a deprecated alias) | + +The curated Tako source is `'data'`, not `'tako'`. This one fails silently, so it's worth grepping for: + +```ts +if (src.source_index === 'tako') // never matches — compiles fine, never runs +if (src.source_index === 'data') // correct +``` + +`source_index` is also a plain string now. 2.x modelled it as a union that could be an object (`{ index_type, segment_id }`), which the API has never sent on this surface — any code narrowing on that shape can be deleted. + +### Guaranteed collections + +The API guarantees only `request_id` (plus `answer` on the answer surface) and omits empty collections rather than sending `[]`. 2.x typed `cards`, `web_results` and `contents` as always-present, so `result.cards.length` could throw on a perfectly valid response. + +3.0 keeps them non-optional **and makes it true**: the tools normalize absent collections to `[]` before returning. No caller changes needed, and no `?.` required. + +If you want the unnormalized wire shape, import `TakoSearchResponse`, `TakoAnswerResponse` or `TakoContentsResponse`. + +### New card fields + +`TakoCard` gained five fields the API was already sending: + +- **`exportable`** — whether `takoContents` can download this card's data. `false` means the call returns 403, so skip it. `true` is eligibility, not a guarantee. +- **`data_freshness`** — `{ data_as_of, last_updated }`. +- **`relevance_score`** — 1.0–5.0, populated for entitled accounts. +- **`nodes`** — the graph entities and metrics behind the card. +- **`metric_definitions`** — definitions of the metrics displayed. + +`exportable` is the practical one; filtering on it avoids calls that cannot succeed: + +```ts +const downloadable = result.cards.filter((c) => c.exportable); +``` + +### Also corrected + +`TakoKnowledgeCardMethodology.methodology_name` and `.methodology_description` are required keys with nullable values (`string | null`), not optional — matching the spec. + +Documentation fixes: 2.x claimed inline contents were "capped at 1000 rows". The real behaviour is a 20-row default against a 2,000-row ceiling. Raising it needs `max_rows`, which this SDK does not expose yet. diff --git a/README.md b/README.md index 78f81bb..c96bcf3 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ npm install @takoviz/ai-sdk ai ## Setup -Get an API key from the [Tako developer console](https://developer.tako.com/console/api-keys) and set it as an environment variable: +Get an API key from the [Tako developer console](https://tako.com/console/api-keys) and set it as an environment variable: ```bash export TAKO_API_KEY=your_api_key_here @@ -63,7 +63,7 @@ takoSearch({ baseUrl: 'https://tako.com', // optional; override for staging effort: 'fast', // 'fast' (default) | 'instant' | 'deep' sources: { // a source is searched iff its key is present; omit to search both - data: { count: 5, includeContents: false, deferDataRetrieval: false }, // legacy alias: tako + data: { count: 5, includeContents: false }, // legacy alias: tako web: { count: 5, includeContents: false }, }, countryCode: 'US', // default 'US' @@ -96,12 +96,38 @@ The LLM supplies only the dynamic input: `{ query }` for `takoSearch`/`takoAnswe { cards: TakoCard[]; // Tako knowledge cards (title, description, image_url, webpage_url, sources, ...) web_results: TakoWebResult[]; - contents_total_cost: number; request_id: string; + usage?: TakoUsage | null; // { total_cost_usd, compute?, data? } } ``` -`takoAnswer` additionally includes `answer: string` (with `cards[0]` as the lead card). `takoContents` resolves to `{ contents: TakoContentItem[]; request_id: string }`, where each item has a `format` (`'csv'` | `'text'`), a `cost`, and either a presigned `url`/`expires_at` (url mode) or inline `data`/`total_rows`/`truncated` (inline mode). +`takoAnswer` additionally includes `answer: string` (with `cards[0]` as the lead card). `takoContents` resolves to `{ contents: TakoContentItem[]; request_id: string; usage? }`. + +The API guarantees only `request_id` and omits empty collections, so the tools normalize: `cards`, `web_results` and `contents` are **always arrays**. No `?.` needed. + +### Reading a card + +Two fields are worth knowing about: + +- **`exportable`** — whether `takoContents` can download that card's data. `false` means don't bother; the call returns 403. `true` is eligibility, not a guarantee, so still handle errors. +- **`data_freshness`** — `{ data_as_of, last_updated }`, so you can tell how current a number is. + +### Reading a contents item + +Each item carries a `cost` (USD) and either a presigned `url` + `expires_at` (url mode) or an inline payload (inline mode). `content_format` tells you what you got: + +| `content_format` | Payload field | Meaning | +| --- | --- | --- | +| `null` *or absent* | `data` | A web page's extracted text | +| `'csv'` | `data` | Card data as CSV | +| `'json_records'` | `records` | Card data as row objects | +| `'json_compact'` | `dataset` | Card data as typed columns + positional rows | + +`total_rows` and `truncated` tell you whether the card held more rows than were returned. + +Which format you get depends on the surface: `takoContents` returns `'csv'` for cards and no format for web pages, while a card inlined by `sources.data.includeContents` arrives as `'json_compact'` (a `dataset`). Requesting a specific format is not configurable yet. + +`content_format` is optional as well as nullable, so branch on it loosely — `content_format == null` means web text; `=== null` misses the absent case. Full type definitions ship with the package. @@ -115,17 +141,25 @@ import type { TakoAnswerResult, TakoContentsResult, TakoCard, + TakoCardSource, TakoWebResult, TakoContentItem, + TakoDataset, + TakoUsage, } from '@takoviz/ai-sdk'; ``` +The types mirror Tako's published OpenAPI document. `tests/contract/` validates them against a vendored copy of that spec and against [`tako-sdk`](https://www.npmjs.com/package/tako-sdk), Tako's official generated client, so API drift fails CI rather than shipping. + +If you need the raw wire shapes (where collections are optional, before the tools normalize them), import `TakoSearchResponse`, `TakoAnswerResponse` or `TakoContentsResponse`. + ## License MIT ## Links +- [Migrating from 2.x](./MIGRATING.md) - [Tako documentation](https://docs.tako.com) - [Vercel AI SDK](https://sdk.vercel.ai/docs) - [GitHub repository](https://github.com/TakoData/ai-sdk) diff --git a/examples/contents.ts b/examples/contents.ts index b583511..87678d5 100644 --- a/examples/contents.ts +++ b/examples/contents.ts @@ -1,4 +1,5 @@ import { takoSearch, takoContents } from "../src/index"; +import type { TakoContentsResult, TakoSearchResult } from "../src/index"; // Run: pnpm exec tsx --env-file=.env examples/contents.ts // Cast: calling a tool's execute() directly (outside generateText) needs a @@ -10,26 +11,49 @@ async function main() { const result = (await search.execute!( { query: "Nvidia full-time employees since 2013" }, opts, - )) as any; + )) as TakoSearchResult; - // Candidate URLs to drill into: card data first, then web results. Some Tako - // cards come from protected sources whose data can't be exported (the API - // returns 403) — so try each until one resolves, the way an agent would. + // `cards` and `web_results` are always arrays — the tools normalize the + // API's optional collections, so no guard is needed here. + console.log(`${result.cards.length} cards, ${result.web_results.length} web results`); + + // Only exportable cards can be downloaded; a non-exportable card returns 403, + // so filtering on the flag avoids a call that cannot succeed. Web results are + // always downloadable as text. const candidates: string[] = [ - ...(result.cards ?? []).map((c: any) => c.webpage_url), - ...(result.web_results ?? []).map((w: any) => w.url), - ].filter(Boolean); + ...result.cards.filter((c) => c.exportable).map((c) => c.webpage_url), + ...result.web_results.map((w) => w.url), + ].filter((url): url is string => Boolean(url)); + + const skipped = result.cards.filter((c) => !c.exportable).length; + if (skipped) console.log(`Skipped ${skipped} card(s) whose data is not exportable.`); const contents = takoContents({ mode: "inline" }); for (const url of candidates) { try { - const downloaded = (await contents.execute!({ url }, opts)) as any; - const item = downloaded.contents?.[0]; + const downloaded = (await contents.execute!({ url }, opts)) as TakoContentsResult; + const item = downloaded.contents[0]; + if (!item) continue; + console.log("Source:", url); - console.log("Format:", item?.format, "| cost:", item?.cost); - console.log("Data (first 500 chars):\n", item?.data?.slice(0, 500)); + // content_format names the serialization for a card's tabular data. For web + // page text it is null — or absent entirely, since the field is optional — + // so compare loosely rather than with `=== null`. + console.log( + item.content_format == null + ? "Web page text" + : `Card data (${item.content_format})`, + "| cost:", + item.cost, + ); + if (item.total_rows != null) { + console.log(`Rows: ${item.total_rows}${item.truncated ? " (truncated)" : ""}`); + } + console.log("Data (first 500 chars):\n", item.data?.slice(0, 500)); + console.log("Request cost (USD):", downloaded.usage?.total_cost_usd ?? 0); return; } catch (err) { + // `exportable: true` is eligibility, not a guarantee — still fall back. console.log(`Skip ${url}: ${(err as Error).message}`); } } diff --git a/package.json b/package.json index 3dd6415..0d82519 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "typecheck": "tsc --noEmit -p tsconfig.check.json", "test": "vitest run", "test:watch": "vitest", + "test:contract": "vitest run tests/contract", + "spec:refresh": "curl -fsSL https://docs.tako.com/api-reference/openapi.yaml -o tests/contract/openapi.yaml", "prepublishOnly": "pnpm build" }, "keywords": [ @@ -50,9 +52,13 @@ "@ai-sdk/openai": "^4.0.0", "@types/node": "^24.10.1", "ai": "^7.0.0", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "tako-sdk": "^1.1.10", "tsup": "^8.5.0", "tsx": "^4.20.6", "typescript": "^5.9.3", - "vitest": "^3.0.0" + "vitest": "^3.0.0", + "yaml": "^2.9.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 671039b..e916ccb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,9 +21,18 @@ importers: ai: specifier: ^7.0.0 version: 7.0.0(zod@4.4.3) + ajv: + specifier: ^8.20.0 + version: 8.20.0 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.20.0) + tako-sdk: + specifier: ^1.1.10 + version: 1.1.10 tsup: specifier: ^8.5.0 - version: 8.5.1(postcss@8.5.15)(tsx@4.22.4)(typescript@5.9.3) + version: 8.5.1(postcss@8.5.15)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0) tsx: specifier: ^4.20.6 version: 4.22.4 @@ -32,7 +41,10 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.0 - version: 3.2.6(@types/node@24.13.2)(tsx@4.22.4) + version: 3.2.6(@types/node@24.13.2)(tsx@4.22.4)(yaml@2.9.0) + yaml: + specifier: ^2.9.0 + version: 2.9.0 packages: @@ -583,6 +595,17 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -660,6 +683,12 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -684,6 +713,9 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} @@ -769,6 +801,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -803,6 +839,10 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + tako-sdk@1.1.10: + resolution: {integrity: sha512-g7W/rSEIeorCqOMOqeGgbAnEbgs8iGbnqk5Fs2ttvjUsov+uc/N7zHzgOYhRcxZxLiDJitG9ktbNNBLlKBvOhg==} + engines: {node: '>=18'} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -952,6 +992,11 @@ packages: engines: {node: '>=8'} hasBin: true + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -1252,13 +1297,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.6(vite@7.3.5(@types/node@24.13.2)(tsx@4.22.4))': + '@vitest/mocker@3.2.6(vite@7.3.5(@types/node@24.13.2)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@vitest/spy': 3.2.6 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.5(@types/node@24.13.2)(tsx@4.22.4) + vite: 7.3.5(@types/node@24.13.2)(tsx@4.22.4)(yaml@2.9.0) '@vitest/pretty-format@3.2.6': dependencies: @@ -1297,6 +1342,17 @@ snapshots: '@ai-sdk/provider-utils': 5.0.0(zod@4.4.3) zod: 4.4.3 + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.5 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + any-promise@1.3.0: {} assertion-error@2.0.1: {} @@ -1402,6 +1458,10 @@ snapshots: expect-type@1.3.0: {} + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.5: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -1419,6 +1479,8 @@ snapshots: js-tokens@9.0.1: {} + json-schema-traverse@1.0.0: {} + json-schema@0.4.0: {} lilconfig@3.1.3: {} @@ -1468,12 +1530,13 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 - postcss-load-config@6.0.1(postcss@8.5.15)(tsx@4.22.4): + postcss-load-config@6.0.1(postcss@8.5.15)(tsx@4.22.4)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 optionalDependencies: postcss: 8.5.15 tsx: 4.22.4 + yaml: 2.9.0 postcss@8.5.15: dependencies: @@ -1483,6 +1546,8 @@ snapshots: readdirp@4.1.2: {} + require-from-string@2.0.2: {} + resolve-from@5.0.0: {} rollup@4.62.2: @@ -1540,6 +1605,8 @@ snapshots: tinyglobby: 0.2.17 ts-interface-checker: 0.1.13 + tako-sdk@1.1.10: {} + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -1567,7 +1634,7 @@ snapshots: ts-interface-checker@0.1.13: {} - tsup@8.5.1(postcss@8.5.15)(tsx@4.22.4)(typescript@5.9.3): + tsup@8.5.1(postcss@8.5.15)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) cac: 6.7.14 @@ -1578,7 +1645,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(postcss@8.5.15)(tsx@4.22.4) + postcss-load-config: 6.0.1(postcss@8.5.15)(tsx@4.22.4)(yaml@2.9.0) resolve-from: 5.0.0 rollup: 4.62.2 source-map: 0.7.6 @@ -1607,13 +1674,13 @@ snapshots: undici-types@7.18.2: {} - vite-node@3.2.4(@types/node@24.13.2)(tsx@4.22.4): + vite-node@3.2.4(@types/node@24.13.2)(tsx@4.22.4)(yaml@2.9.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.5(@types/node@24.13.2)(tsx@4.22.4) + vite: 7.3.5(@types/node@24.13.2)(tsx@4.22.4)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - jiti @@ -1628,7 +1695,7 @@ snapshots: - tsx - yaml - vite@7.3.5(@types/node@24.13.2)(tsx@4.22.4): + vite@7.3.5(@types/node@24.13.2)(tsx@4.22.4)(yaml@2.9.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) @@ -1640,12 +1707,13 @@ snapshots: '@types/node': 24.13.2 fsevents: 2.3.3 tsx: 4.22.4 + yaml: 2.9.0 - vitest@3.2.6(@types/node@24.13.2)(tsx@4.22.4): + vitest@3.2.6(@types/node@24.13.2)(tsx@4.22.4)(yaml@2.9.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.6 - '@vitest/mocker': 3.2.6(vite@7.3.5(@types/node@24.13.2)(tsx@4.22.4)) + '@vitest/mocker': 3.2.6(vite@7.3.5(@types/node@24.13.2)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/pretty-format': 3.2.6 '@vitest/runner': 3.2.6 '@vitest/snapshot': 3.2.6 @@ -1663,8 +1731,8 @@ snapshots: tinyglobby: 0.2.17 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.5(@types/node@24.13.2)(tsx@4.22.4) - vite-node: 3.2.4(@types/node@24.13.2)(tsx@4.22.4) + vite: 7.3.5(@types/node@24.13.2)(tsx@4.22.4)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@24.13.2)(tsx@4.22.4)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.2 @@ -1687,4 +1755,6 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + yaml@2.9.0: {} + zod@4.4.3: {} diff --git a/src/index.ts b/src/index.ts index becf3d2..22f7670 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,24 +3,48 @@ export { takoAnswer } from "./tools/answer"; export { takoContents } from "./tools/contents"; export type { + // Config TakoBaseConfig, TakoRetrievalConfig, TakoContentsConfig, TakoSourceOptions, TakoCardSourceOptions, + // Enums / unions TakoSearchEffort, TakoContentsMode, TakoContentFormat, + TakoSourceIndex, TakoCardSourceIndex, TakoKnowledgeCardRelevance, + TakoGraphNodeType, + TakoDatasetColumnType, + // Usage + TakoUsage, + TakoUsageCompute, + TakoUsageData, + // Content payloads TakoResultContent, - TakoCardSourceIndexSegment, - TakoCardSourcePrivateIndex, + TakoContentItem, + TakoDataset, + TakoDatasetCell, + TakoDatasetColumn, + TakoDatasetSource, + TakoExportPricing, + TakoColumnDescriptor, + // Cards and web results + TakoCard, + TakoCardSource, TakoKnowledgeCardSource, TakoKnowledgeCardMethodology, - TakoCard, + TakoCardNode, + TakoMetricDefinition, + TakoDataFreshness, TakoWebResult, - TakoContentItem, + // Wire responses (what the API sends; only request_id is guaranteed) + TakoSearchResponse, + TakoAnswerResponse, + TakoContentsResponse, + // Tool results (normalized: collections always present) TakoSearchResult, TakoAnswerResult, TakoContentsResult, diff --git a/src/request.ts b/src/request.ts index c454a74..93c8794 100644 --- a/src/request.ts +++ b/src/request.ts @@ -1,4 +1,12 @@ -import type { TakoRetrievalConfig } from "./types"; +import type { + TakoAnswerResponse, + TakoAnswerResult, + TakoContentsResponse, + TakoContentsResult, + TakoRetrievalConfig, + TakoSearchResponse, + TakoSearchResult, +} from "./types"; const DEFAULT_BASE_URL = "https://tako.com"; @@ -16,7 +24,7 @@ export interface SearchRequestBody { country_code: string; locale: string; sources?: { - data?: { count?: number; include_contents?: boolean; defer_data_retrieval?: boolean }; + data?: { count?: number; include_contents?: boolean }; web?: { count?: number; include_contents?: boolean }; }; timezone?: string; @@ -40,7 +48,6 @@ export function buildSearchRequestBody(config: TakoRetrievalConfig, query: strin const data: NonNullable["data"]> = {}; if (dataSource.count !== undefined) data.count = dataSource.count; if (dataSource.includeContents !== undefined) data.include_contents = dataSource.includeContents; - if (dataSource.deferDataRetrieval !== undefined) data.defer_data_retrieval = dataSource.deferDataRetrieval; sources.data = data; } if (config.sources.web) { @@ -63,3 +70,29 @@ export function buildSearchRequestBody(config: TakoRetrievalConfig, query: strin return body; } + +// ----- Response normalizers ----- +// +// The API guarantees only `request_id` (plus `answer` on the answer surface) and +// omits collections rather than sending them empty. Tools normalize before +// returning so callers can read `result.cards.length` without a guard. + +export function normalizeSearchResult(response: TakoSearchResponse): TakoSearchResult { + return { + ...response, + cards: response.cards ?? [], + web_results: response.web_results ?? [], + }; +} + +export function normalizeAnswerResult(response: TakoAnswerResponse): TakoAnswerResult { + return { + ...response, + cards: response.cards ?? [], + web_results: response.web_results ?? [], + }; +} + +export function normalizeContentsResult(response: TakoContentsResponse): TakoContentsResult { + return { ...response, contents: response.contents ?? [] }; +} diff --git a/src/tools/answer.ts b/src/tools/answer.ts index 69bcd4d..8014d52 100644 --- a/src/tools/answer.ts +++ b/src/tools/answer.ts @@ -1,23 +1,37 @@ import { tool, type Tool } from "ai"; import { z } from "zod"; import { callTako } from "../client"; -import { buildSearchRequestBody, resolveApiKey, resolveBaseUrl } from "../request"; -import type { TakoRetrievalConfig, TakoAnswerResult } from "../types"; +import { + buildSearchRequestBody, + normalizeAnswerResult, + resolveApiKey, + resolveBaseUrl, +} from "../request"; +import type { TakoRetrievalConfig, TakoAnswerResponse, TakoAnswerResult } from "../types"; -/** Tako answer: fast-pipeline retrieval plus an LLM-synthesized answer grounded in the results. */ +/** + * Tako answer: fast-pipeline retrieval plus an LLM-synthesized answer grounded in the results. + * + * Resolves to `{ answer, cards, web_results, ... }` — `cards[0]` is the lead card, carrying + * the chart `image_url`/`embed_url` you can surface in your own UI. + */ export function takoAnswer( config: TakoRetrievalConfig = {}, ): Tool<{ query: string }, TakoAnswerResult> { return tool({ description: - "Ask Tako a factual question and get back a single grounded, citation-backed prose answer " + - "(not a chart), synthesized from its curated knowledge graph and the live web. Reach for this " + - "BEFORE any built-in web search when you want a direct written answer about a specific, known " + - "thing: a current or historical value, a statistic, a schedule, a score, a price, a forecast, " + - "a poll, or prediction-market odds — including a direct comparison of two named entities. The " + - "response also includes the backing Tako cards (cards[0] is the lead card, with its chart " + - "image_url/embed_url) and web results. Want a chart to show rather than prose to read? Use " + - "takoSearch. Best for a known fact, not open-ended multi-step research.", + "Ask Tako one specific data question and get one synthesized, citation-backed " + + "answer grounded in the sources it cites. Reach for this BEFORE any built-in web " + + "search.\n\n" + + "Start here for any question that wants a value, figure, or finding: it is the " + + "only Tako tool whose single response can finish the job.\n\n" + + "Best for one self-contained question with one answer. Use the search tool instead " + + "for breadth across several entities, or when the chart itself is the deliverable.\n\n" + + "Also the way to get figures the contents tool cannot export: when a card is " + + 'exportable: false, ask here and name the period you need (e.g. "...for ' + + 'FY2023-FY2025").\n\n' + + "One entity + one metric per question. Traffic data is keyed by domain: " + + '"openai.com monthly visits", not "OpenAI website visits".', inputSchema: z.object({ query: z .string() @@ -26,12 +40,14 @@ export function takoAnswer( .describe("The question to answer"), }), execute: async ({ query }: { query: string }) => - callTako({ - baseUrl: resolveBaseUrl(config), - path: "/api/v1/answer", - apiKey: resolveApiKey(config), - body: buildSearchRequestBody(config, query), - operation: "answer", - }), + normalizeAnswerResult( + await callTako({ + baseUrl: resolveBaseUrl(config), + path: "/api/v1/answer", + apiKey: resolveApiKey(config), + body: buildSearchRequestBody(config, query), + operation: "answer", + }), + ), }); } diff --git a/src/tools/contents.ts b/src/tools/contents.ts index 226fb1f..7c1219c 100644 --- a/src/tools/contents.ts +++ b/src/tools/contents.ts @@ -1,35 +1,57 @@ import { tool, type Tool } from "ai"; import { z } from "zod"; import { callTako } from "../client"; -import { resolveApiKey, resolveBaseUrl } from "../request"; -import type { TakoContentsConfig, TakoContentsResult } from "../types"; +import { normalizeContentsResult, resolveApiKey, resolveBaseUrl } from "../request"; +import type { TakoContentsConfig, TakoContentsResponse, TakoContentsResult } from "../types"; -/** Download the data behind a result URL: a Tako card's CSV or a web page's text. */ +/** + * Download the data behind a result URL: a Tako card's CSV or a web page's text. + * + * `mode` sets the delivery, and is reflected in the tool description the model reads: + * - `"url"` (default) — a short-lived presigned download url. Use when handing a + * download/embed link to a user, or for large data you won't read yourself. + * - `"inline"` — the content in the response body, so the model can read and reason + * over the numbers directly. + */ export function takoContents( config: TakoContentsConfig = {}, ): Tool<{ url: string }, TakoContentsResult> { + const mode = config.mode ?? "url"; return tool({ description: - "Fetch the underlying data behind a result URL — a Tako card's webpage_url yields a CSV " + - "of the card's data; any other URL (a web result's url) yields the page's extracted full " + - "text. Pass a single url taken from a prior takoSearch/takoAnswer result. Delivery is set " + - 'at construction via `mode`: the default "url" returns a short-lived presigned download_url ' + - '(no row cap) for handing over a download/embed link or for large data you won\'t read ' + - 'yourself; "inline" instead returns the content in the response (CSV capped at 1000 rows, ' + - "with total_rows/truncated, or web text) so you can read and reason over the numbers directly.", + "Fetch the real data behind a result url — a Tako card's webpage_url yields its " + + "rows; any other url (a web result's) yields the page's full extracted text. Only " + + "call this on a url returned by a prior search or answer call, which gives you a " + + "caption and a chart but not the rows.\n\n" + + (mode === "inline" + ? "Returns the content in the response body — read and compute over the numbers " + + "directly.\n\n" + : "Returns a short-lived presigned download url, NOT the data itself: surface the " + + "link, do not parse it or call again expecting rows.\n\n") + + "Only cards whose exportable field is true can be downloaded; a non-exportable card " + + "always returns 403 and retrying will not change that — get its figures from the " + + "answer tool instead, naming the period you need. Web urls always work, so this is " + + "also the fallback when a search surfaced a relevant web result but no fitting data " + + "card.\n\n" + + "On each returned item, content_format names the serialization for card data and is " + + "null or absent for web page text; total_rows and truncated tell you whether the card " + + "had more rows than were returned.", inputSchema: z.object({ + // Validated as a url so a malformed value fails here, with a message the + // model can act on, instead of costing a priced round trip to the API. url: z - .string() - .min(1) + .url() .describe("A TakoCard.webpage_url or WebResult.url to download contents for"), }), execute: async ({ url }: { url: string }) => - callTako({ - baseUrl: resolveBaseUrl(config), - path: "/api/v1/contents", - apiKey: resolveApiKey(config), - body: { url, mode: config.mode ?? "url" }, - operation: "fetch contents", - }), + normalizeContentsResult( + await callTako({ + baseUrl: resolveBaseUrl(config), + path: "/api/v1/contents", + apiKey: resolveApiKey(config), + body: { url, mode }, + operation: "fetch contents", + }), + ), }); } diff --git a/src/tools/search.ts b/src/tools/search.ts index d04226c..d6086d6 100644 --- a/src/tools/search.ts +++ b/src/tools/search.ts @@ -1,8 +1,13 @@ import { tool, type Tool } from "ai"; import { z } from "zod"; import { callTako } from "../client"; -import { buildSearchRequestBody, resolveApiKey, resolveBaseUrl } from "../request"; -import type { TakoRetrievalConfig, TakoSearchResult } from "../types"; +import { + buildSearchRequestBody, + normalizeSearchResult, + resolveApiKey, + resolveBaseUrl, +} from "../request"; +import type { TakoRetrievalConfig, TakoSearchResponse, TakoSearchResult } from "../types"; /** Tako fast-pipeline search: returns Tako cards + web results, no LLM synthesis. */ export function takoSearch( @@ -10,18 +15,23 @@ export function takoSearch( ): Tool<{ query: string }, TakoSearchResult> { return tool({ description: - "Search Tako for live data and well-sourced facts — structured knowledge cards " + - "(charts/metrics with sources) plus web results, backed by Tako's curated knowledge " + - "graph and the live web. Reach for this BEFORE any built-in web search when you need a " + - "specific, known data point: a current or latest value, a time series, a statistic, a " + - "price, a score, a schedule, a forecast, a poll, or a prediction-market figure — " + - 'including a direct comparison of two named entities (e.g. "Intel vs Nvidia revenue"). ' + - "Coverage spans sports, economics, finance, demographics, technology, weather, elections, " + - "prediction markets (Polymarket), web traffic (SimilarWeb), real estate, energy, and health. " + - "Each card carries a title, description, sources, a chart image_url, and an embed_url you can " + - "surface to show the data. Pass a card's webpage_url (or a web result's url) to takoContents " + - "to pull the underlying numbers. Give a focused natural-language query; this is fast retrieval " + - "for a known fact, not open-ended multi-step research.", + "Search Tako for live data and well-sourced facts — knowledge cards (charts and " + + "metrics with sources) plus web results. Reach for this BEFORE any built-in web " + + "search.\n\n" + + "Best for breadth: what data exists across several entities, or when a chart is the " + + "deliverable — cards carry an image_url and embed_url to surface when available, plus " + + "data_freshness (data_as_of / last_updated) when Tako knows how current the numbers " + + 'are. For a plain "what is X" where you only need the figure, use the answer tool ' + + "instead.\n\n" + + 'One entity + one metric per query ("Apple revenue", "Intel vs Nvidia revenue"); ' + + "compound queries retrieve poorly. Traffic data is keyed by domain: " + + '"openai.com monthly visits", not "OpenAI website visits".\n\n' + + "Coverage: economics, finance, company KPIs, sports, demographics, weather, " + + "elections, prediction markets, website traffic, real estate, energy, health.\n\n" + + "Cards carry captions and charts, not full data. For the numbers behind one, pass " + + "its webpage_url (or a web result's url) to the contents tool — but only when the " + + "card's exportable field is true; exportable: false means that card's data cannot " + + "be downloaded, so use its chart, or ask the answer tool for the figures.", inputSchema: z.object({ query: z .string() @@ -30,12 +40,14 @@ export function takoSearch( .describe("Natural-language description of what you're looking for"), }), execute: async ({ query }: { query: string }) => - callTako({ - baseUrl: resolveBaseUrl(config), - path: "/api/v3/search", - apiKey: resolveApiKey(config), - body: buildSearchRequestBody(config, query), - operation: "search", - }), + normalizeSearchResult( + await callTako({ + baseUrl: resolveBaseUrl(config), + path: "/api/v3/search", + apiKey: resolveApiKey(config), + body: buildSearchRequestBody(config, query), + operation: "search", + }), + ), }); } diff --git a/src/types.ts b/src/types.ts index 2f54f81..88b9988 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,11 +1,24 @@ -// ----- Enums / unions (mirror the backend StrEnums) ----- +// Types mirror Tako's published OpenAPI document. `tests/contract/` validates +// them against a vendored copy of that spec and against `tako-sdk`, Tako's +// official generated client, so drift fails CI. Refresh with `pnpm spec:refresh`. + +// ----- Enums / unions ----- export type TakoSearchEffort = "fast" | "instant" | "deep"; export type TakoContentsMode = "url" | "inline"; -export type TakoContentFormat = "csv" | "text"; -/** CardSourceIndex on the response surface. */ -export type TakoCardSourceIndex = "tako" | "web" | "connected_data" | "tako_deep_v2"; + +/** Serialization of tabular (Tako card) data. Web text carries no format. */ +export type TakoContentFormat = "csv" | "json_records" | "json_compact"; + +/** Public source taxonomy for the card surfaces. */ +export type TakoSourceIndex = "data" | "web"; + +/** @deprecated Renamed to {@link TakoSourceIndex}. */ +export type TakoCardSourceIndex = TakoSourceIndex; + export type TakoKnowledgeCardRelevance = "High" | "Medium" | "Low"; +export type TakoGraphNodeType = "metric" | "entity"; +export type TakoDatasetColumnType = "string" | "number" | "boolean" | "date" | "datetime"; // ----- Config (developer-facing, camelCase) ----- @@ -23,10 +36,13 @@ export interface TakoSourceOptions { includeContents?: boolean; } -export interface TakoCardSourceOptions extends TakoSourceOptions { - /** Defer data retrieval (faster, less detail). Mutually exclusive with includeContents. */ - deferDataRetrieval?: boolean; -} +/** + * Options for the curated Tako data source. + * + * The API's `DataSourceSettings` also carries `mode`, `content_format`, + * `node_ids` and `strict`; those are not surfaced yet. + */ +export interface TakoCardSourceOptions extends TakoSourceOptions {} export interface TakoRetrievalConfig extends TakoBaseConfig { /** "fast" (default) | "instant" | "deep". */ @@ -57,41 +73,162 @@ export interface TakoContentsConfig extends TakoBaseConfig { mode?: TakoContentsMode; } -// ----- Response types (mirror the API wire shape, snake_case) ----- +// ----- Usage / billing ----- + +export interface TakoUsageCompute { + /** USD cost of running the operation. */ + cost_usd: number; +} + +export interface TakoUsageData { + /** USD cost of the inline data delivered in the response. */ + cost_usd: number; + /** Number of billed data units (datasets) in the response. */ + datasets: number; +} +/** + * Usage for one metered request. `total_cost_usd` always equals the sum of + * whichever breakdown components are present. + */ +export interface TakoUsage { + /** Total quoted USD cost of this request. */ + total_cost_usd: number; + /** Compute breakdown. Absent on surfaces with no compute step (contents). */ + compute?: TakoUsageCompute | null; + /** Inline-data breakdown. Present only when billable inline data was emitted. */ + data?: TakoUsageData | null; +} + +// ----- Content payloads ----- + +export interface TakoDatasetColumn { + name: string; + type: TakoDatasetColumnType; + /** Structured unit, e.g. "USD billions", "%". Null when unitless. */ + unit?: string | null; +} + +export interface TakoDatasetSource { + /** Human-readable source name, e.g. "FRED". */ + name: string; + index?: TakoSourceIndex; +} + +export type TakoDatasetCell = string | number | boolean | null; + +/** Exact retrieved rows as positional arrays in `columns` order. */ +export interface TakoDataset { + columns: TakoDatasetColumn[]; + rows: TakoDatasetCell[][]; + total_rows: number; + truncated: boolean; + /** Source URL the dataset was derived from. */ + ref: string; + sources: TakoDatasetSource[]; + provenance?: "query" | "web_extraction"; +} + +/** + * Rate card for a card export, so cost can be computed before fetching: + * `baseline_usd + row_cpm_usd * max(0, rows - free_rows) / 1000`. + */ +export interface TakoExportPricing { + baseline_usd: number; + row_cpm_usd: number; + free_rows: number; + max_rows_ceiling: number; +} + +/** Per-column metadata; entry i describes column i. */ +export interface TakoColumnDescriptor { + name?: string | null; + metric?: string | null; + entity?: string | null; + unit?: string | null; + dtype?: TakoDatasetColumnType | null; +} + +/** + * Describes the downloadable content behind a result. + * + * Exactly one payload group is populated once contents are delivered: `data` + * (CSV or web text), `records` (verbose JSON), `dataset` (compact), or + * `url` + `expires_at` (presigned download). When every payload field is unset + * this is just a price quote. + * + * `content_format` distinguishes a web page's extracted text from a card's + * tabular data, but it is optional as well as nullable — web text may arrive as + * either `null` or an absent key. Test it loosely (`content_format == null`), + * never with `=== null`. + */ export interface TakoResultContent { - format: TakoContentFormat; - cost: number; + content_format?: TakoContentFormat | null; + /** USD price of this item. On search/answer cards this is a prospective /contents quote. */ + cost?: number; + /** Inline payload as text: CSV card data, or a web page's extracted text. */ data?: string | null; + /** Inline card data as row objects keyed by column name ("json_records"). */ + records?: Record[] | null; + /** Inline card data as a compact dataset ("json_compact"). */ + dataset?: TakoDataset | null; + /** Presigned download URL ("url" delivery mode). */ + url?: string | null; + expires_at?: string | null; + /** True total rows in the card's data, independent of how many were returned. */ total_rows?: number | null; truncated?: boolean; + export_pricing?: TakoExportPricing | null; + manifest?: TakoColumnDescriptor[] | null; } -export interface TakoCardSourceIndexSegment { - index_type: TakoCardSourceIndex; - segment_id: string; +export interface TakoContentItem extends TakoResultContent { + /** The originating result URL from the request. */ + source_url: string; } -export interface TakoCardSourcePrivateIndex { - index_type: TakoCardSourceIndex; - private_index_id: string; - /** Optional for private indexes. */ - segment_id?: string | null; -} +// ----- Cards and web results ----- -export interface TakoKnowledgeCardSource { - source_name: string | null; - source_description: string | null; - source_index: TakoCardSourceIndex | TakoCardSourceIndexSegment | TakoCardSourcePrivateIndex; - url: string | null; +export interface TakoCardSource { + source_name?: string | null; + source_description?: string | null; + source_index: TakoSourceIndex; + url?: string | null; + /** Raw excerpts from the source page. Present for web sources; null for data. */ source_text?: string | null; } +/** @deprecated Renamed to {@link TakoCardSource}. */ +export type TakoKnowledgeCardSource = TakoCardSource; + +/** Both keys are always present on the wire, though either value may be null. */ export interface TakoKnowledgeCardMethodology { methodology_name: string | null; methodology_description: string | null; } +/** Graph node (entity or metric) behind a card. */ +export interface TakoCardNode { + /** Opaque public id (`ent::…` / `mt::…`). Not durable across graph rebuilds. */ + id: string; + type: TakoGraphNodeType; + name: string; + description?: string | null; +} + +export interface TakoMetricDefinition { + name: string; + definition: string; +} + +/** Freshness dates for a card's data. */ +export interface TakoDataFreshness { + /** Coverage date of the data. */ + data_as_of?: string | null; + /** Date the data was last refreshed. */ + last_updated?: string | null; +} + export interface TakoCard { card_id?: string | null; title?: string | null; @@ -100,35 +237,75 @@ export interface TakoCard { webpage_url?: string | null; image_url?: string | null; embed_url?: string | null; - sources?: TakoKnowledgeCardSource[] | null; + sources?: TakoCardSource[] | null; methodologies?: TakoKnowledgeCardMethodology[] | null; - source_indexes?: (TakoCardSourceIndex | TakoCardSourceIndexSegment)[] | null; + source_indexes?: TakoSourceIndex[] | null; card_type?: string | null; relevance?: TakoKnowledgeCardRelevance | null; content?: TakoResultContent | null; + /** + * Whether /contents can download this card's data. `false` means the export is + * unavailable — don't call takoContents on it. `true` is eligible but not + * guaranteed (a 403 is still possible), so fall back to the inline preview. + */ + exportable?: boolean; + /** Relevance on a 1.0–5.0 scale. Only populated for entitled accounts. */ + relevance_score?: number | null; + /** Graph nodes behind this card. Absent for web-only cards. */ + nodes?: TakoCardNode[] | null; + metric_definitions?: TakoMetricDefinition[] | null; + data_freshness?: TakoDataFreshness | null; } export interface TakoWebResult { title: string; url: string; + /** Excerpt(s) from the page that matched the query. */ snippet?: string | null; source_name?: string | null; publish_date?: string | null; content?: TakoResultContent | null; + /** 1-based citation number for inline [N] markers. Null on raw retrieval. */ citation_number?: number | null; } -export interface TakoContentItem extends TakoResultContent { - source_url: string; - url?: string | null; - expires_at?: string | null; +// ----- Wire responses (exactly what the API sends) ----- + +/** + * The raw `POST /api/v3/search` body. Only `request_id` is guaranteed; the + * collections are absent rather than empty when there is nothing to report. + * Tools return the normalized {@link TakoSearchResult} instead. + */ +export interface TakoSearchResponse { + cards?: TakoCard[]; + web_results?: TakoWebResult[]; + request_id: string; + usage?: TakoUsage | null; } +/** The raw `POST /api/v1/answer` body. */ +export interface TakoAnswerResponse { + answer: string; + cards?: TakoCard[]; + web_results?: TakoWebResult[]; + request_id: string; + usage?: TakoUsage | null; +} + +/** The raw `POST /api/v1/contents` body. */ +export interface TakoContentsResponse { + contents?: TakoContentItem[]; + request_id: string; + usage?: TakoUsage | null; +} + +// ----- Tool results (normalized: collections always present) ----- + export interface TakoSearchResult { cards: TakoCard[]; web_results: TakoWebResult[]; - contents_total_cost: number; request_id: string; + usage?: TakoUsage | null; } export interface TakoAnswerResult { @@ -137,11 +314,12 @@ export interface TakoAnswerResult { /** Backing cards; cards[0] is the lead card. */ cards: TakoCard[]; web_results: TakoWebResult[]; - contents_total_cost: number; request_id: string; + usage?: TakoUsage | null; } export interface TakoContentsResult { contents: TakoContentItem[]; request_id: string; + usage?: TakoUsage | null; } diff --git a/tests/answer.test.ts b/tests/answer.test.ts index aa015ad..c282988 100644 --- a/tests/answer.test.ts +++ b/tests/answer.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, afterEach, vi } from "vitest"; import { takoAnswer } from "../src/tools/answer"; import { stubFetch, runTool } from "./_helpers"; -const OK = JSON.stringify({ answer: "AMD grew faster.", cards: [], web_results: [], contents_total_cost: 0, request_id: "r" }); +const OK = JSON.stringify({ answer: "AMD grew faster.", cards: [], web_results: [], request_id: "r" }); afterEach(() => vi.unstubAllGlobals()); diff --git a/tests/contents.test.ts b/tests/contents.test.ts index d7b2251..03eda45 100644 --- a/tests/contents.test.ts +++ b/tests/contents.test.ts @@ -3,7 +3,16 @@ import { takoContents } from "../src/tools/contents"; import { stubFetch, runTool } from "./_helpers"; const OK = JSON.stringify({ - contents: [{ source_url: "https://tako.com/card/x", url: "https://signed", expires_at: "2026-01-01T00:00:00Z", format: "csv", cost: 0, truncated: false }], + contents: [ + { + source_url: "https://tako.com/card/x", + url: "https://signed", + expires_at: "2026-01-01T00:00:00Z", + content_format: "csv", + cost: 0, + truncated: false, + }, + ], request_id: "r", }); @@ -17,7 +26,61 @@ describe("takoContents", () => { const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(url).toBe("https://tako.com/api/v1/contents"); expect(JSON.parse(init.body as string)).toEqual({ url: "https://tako.com/card/x", mode: "url" }); - expect((res as any).contents[0].format).toBe("csv"); + expect((res as any).contents[0].content_format).toBe("csv"); + }); + + it("rejects a non-url input before any request is made", () => { + // The AI SDK validates inputSchema before calling execute, so a malformed + // url costs nothing. Asserted on the schema directly, since runTool() + // bypasses that layer. + const schema = takoContents({ apiKey: "key" }).inputSchema as { + safeParse: (v: unknown) => { success: boolean }; + }; + for (const url of ["https://tako.com/card/x", "https://e.com/a?b=1#c"]) { + expect(schema.safeParse({ url }).success).toBe(true); + } + for (const url of ["not a url", "", "card/x"]) { + expect(schema.safeParse({ url }).success).toBe(false); + } + }); + + it("normalizes an absent contents collection to an empty array", async () => { + stubFetch(200, JSON.stringify({ request_id: "r" })); + const res = (await runTool(takoContents({ apiKey: "key" }), { url: "https://tako.com/card/x" })) as any; + expect(res.contents).toEqual([]); + }); + + it("surfaces web text when content_format is absent entirely", async () => { + // content_format is optional as well as nullable, so a web-text item may + // omit the key rather than send null. Consumers must branch loosely. + stubFetch( + 200, + JSON.stringify({ + contents: [{ source_url: "https://e.com/a", data: "prose" }], + request_id: "r", + }), + ); + const res = (await runTool(takoContents({ apiKey: "key", mode: "inline" }), { + url: "https://e.com/a", + })) as any; + expect(res.contents[0].content_format).toBeUndefined(); + expect(res.contents[0].content_format == null).toBe(true); + expect(res.contents[0].data).toBe("prose"); + }); + + it("surfaces web text, which carries a null content_format", async () => { + stubFetch( + 200, + JSON.stringify({ + contents: [{ source_url: "https://e.com/a", content_format: null, data: "prose" }], + request_id: "r", + }), + ); + const res = (await runTool(takoContents({ apiKey: "key", mode: "inline" }), { + url: "https://e.com/a", + })) as any; + expect(res.contents[0].content_format).toBeNull(); + expect(res.contents[0].data).toBe("prose"); }); it("uses inline mode when configured", async () => { diff --git a/tests/contract/openapi.yaml b/tests/contract/openapi.yaml new file mode 100644 index 0000000..badddc6 --- /dev/null +++ b/tests/contract/openapi.yaml @@ -0,0 +1,4005 @@ +# GENERATED - do not edit; synced from the Tako monorepo. +# Produced by the Tako monorepo OpenAPI sync workflows. +openapi: 3.1.0 +info: + title: Knowledge Search API + version: 1.0.0 +servers: +- url: https://tako.com/api/ + description: Tako Production API Server +paths: + /v3/search: + post: + tags: + - tako + summary: Search + description: Fast-pipeline knowledge search. Returns Tako cards (and web results + when requested) with no LLM synthesis. + operationId: search + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SearchRequest' + responses: + '200': + description: Fast-pipeline search results (Tako cards + web results) + content: + application/json: + schema: + $ref: '#/components/schemas/SearchResponse' + '400': + description: Invalid request data (validation or malformed body). + content: + application/json: + schema: + $ref: '#/components/schemas/BaseAPIError' + '401': + description: Missing or invalid API key. + content: + application/json: + schema: + $ref: '#/components/schemas/BaseAPIError' + '408': + description: The request exceeded the processing time limit. + content: + application/json: + schema: + $ref: '#/components/schemas/BaseAPIError' + '529': + description: 'Tako is overloaded and did not process this request. Do not + retry automatically. The response carries the header x-should-retry: false. + Send the request again later.' + content: + application/json: + schema: + $ref: '#/components/schemas/BaseAPIError' + security: + - apiKey: [] + /v1/answer: + post: + tags: + - tako + summary: Answer + description: Fast-pipeline retrieval plus an LLM-synthesized answer. Tako may + omit sources that cannot provide usable text. This endpoint returns 3 web + results when you omit sources.web.count. The shared request schema shows a + default of 5, which applies to POST /v3/search. + operationId: answer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SearchRequest' + responses: + '200': + description: Synthesized answer grounded in Tako results, web results, or + both + content: + application/json: + schema: + $ref: '#/components/schemas/AnswerResponse' + '400': + description: Invalid request data (validation or malformed body). + content: + application/json: + schema: + $ref: '#/components/schemas/BaseAPIError' + '401': + description: Missing or invalid API key. + content: + application/json: + schema: + $ref: '#/components/schemas/BaseAPIError' + '408': + description: The request exceeded the processing time limit. + content: + application/json: + schema: + $ref: '#/components/schemas/BaseAPIError' + '529': + description: 'Tako is overloaded and did not process this request. Do not + retry automatically. The response carries the header x-should-retry: false. + Send the request again later.' + content: + application/json: + schema: + $ref: '#/components/schemas/BaseAPIError' + security: + - apiKey: [] + /v1/graph/search: + get: + tags: + - tako + summary: Search the data graph + description: 'Resolve a metric or entity in Tako''s data graph by name. Use + this to discover and confirm what data exists before you query. For example, + resolve the metric and the entity here, then ask /v3/search or /v1/answer + for that entity + metric combination. Requires an API key. Note: Tako does + not yet filter results to the production-ready inventory. A returned metric + or entity without a production fact table can 404 when you pass it to /v1/graph/related. + Pass label to prefer a NER label (a ranking boost, not a filter); by default + Tako infers the label from q. This endpoint was previously at /beta/graph/search. + That path still works until 1 February 2027. Move to /v1/graph/search.' + operationId: graphSearch + parameters: + - description: Search text (min 2 chars). + required: true + schema: + type: string + name: q + in: query + - description: 'Comma-separated facets: metric,entity.' + required: false + schema: + type: string + name: types + in: query + - description: Max results (default 20, max 50). + required: false + schema: + type: integer + name: limit + in: query + - description: Prefer results with this NER label (boost, not a filter — matching + nodes rank higher; others still return). Supplying label disables inference. + required: false + schema: + type: string + enum: + - PERSON + - ORG + - GPE + - LOC + - PRODUCT + - EVENT + - LANGUAGE + - MONEY + - METRIC + - STOCK_TICKER + - WEBSITE + name: label + in: query + - description: When true, Tako NER infers the label and grounded-node boosts + from q. Set false to disable. Tako ignores this parameter when you supply + label. Default true. + required: false + schema: + type: boolean + name: infer_label + in: query + responses: + '200': + description: Matching metrics and entities + content: + application/json: + schema: + $ref: '#/components/schemas/GraphSearchResponse' + '400': + description: Invalid request data (validation or malformed body). + content: + application/json: + schema: + $ref: '#/components/schemas/BaseAPIError' + '401': + description: Missing or invalid API key. + content: + application/json: + schema: + $ref: '#/components/schemas/BaseAPIError' + '503': + description: A backing data store is temporarily unavailable. + content: + application/json: + schema: + $ref: '#/components/schemas/BaseAPIError' + security: + - apiKey: [] + /v1/graph/related: + get: + tags: + - tako + summary: Get a graph node's related nodes + description: 'Explore what a node connects to. The overview returns an ordered + `relations` list: named relationships (for example `rel:competes_with`, `rel:in_industry`) + first, then a node''s metrics and entities, then similar nodes and membership + (part_of, members). The metrics and entities are the entity + metric combinations + Tako covers — query /v3/search or /v1/answer for one. Each group carries a + stable `key`. Pass `relation`= plus cursor to paginate that one group. + Pass the optional q to narrow by a case-insensitive substring on names and + aliases (for example, the metrics of an entity that match ''gdp''). Requires + an API key. This endpoint was previously at /beta/graph/related. That path + still works until 1 February 2027. Move to /v1/graph/related.' + operationId: graphRelated + parameters: + - description: Opaque public id of the node. + required: true + schema: + type: string + name: node_id + in: query + - description: Relation key to paginate, for example `rel:competes_with`, `metrics`, + `entities`, `siblings`, or `members`. + required: false + schema: + type: string + name: relation + in: query + - description: 'Deprecated: use `relation`. A legacy facet name (metric, entity, + sibling, or member) that maps to a key.' + required: false + deprecated: true + schema: + type: string + name: relation_type + in: query + - description: Optional case-insensitive substring filter on the related nodes' + names and aliases. It filters every group of the overview, or the single + paginated group when you set `relation`. + required: false + schema: + type: string + name: q + in: query + - description: Opaque pagination cursor. + required: false + schema: + type: string + name: cursor + in: query + - description: Page size (default 50, max 100). + required: false + schema: + type: integer + name: limit + in: query + - description: 'Prefer related nodes with this NER label. This is a boost, not + a filter: matching nodes rank higher within each relation, and totals do + not change. Supplying label disables inference.' + required: false + schema: + type: string + enum: + - PERSON + - ORG + - GPE + - LOC + - PRODUCT + - EVENT + - LANGUAGE + - MONEY + - METRIC + - STOCK_TICKER + - WEBSITE + name: label + in: query + - description: When true, Tako NER infers the label and grounded-node boosts + from q. Set false to disable. Tako ignores this parameter when you supply + label. It applies only when q is present. Default true. + required: false + schema: + type: boolean + name: infer_label + in: query + responses: + '200': + description: A node's details plus related nodes by facet + content: + application/json: + schema: + $ref: '#/components/schemas/GraphRelatedResponse' + '400': + description: Invalid request data (validation or malformed body). + content: + application/json: + schema: + $ref: '#/components/schemas/BaseAPIError' + '401': + description: Missing or invalid API key. + content: + application/json: + schema: + $ref: '#/components/schemas/BaseAPIError' + '404': + description: The requested resource does not exist or has no exportable + data. + content: + application/json: + schema: + $ref: '#/components/schemas/BaseAPIError' + '503': + description: A backing data store is temporarily unavailable. + content: + application/json: + schema: + $ref: '#/components/schemas/BaseAPIError' + security: + - apiKey: [] + /v1/graph/node/{id}: + get: + tags: + - tako + summary: Get a graph node by id + description: Resolve a single node by its opaque public id. Search cards, /v1/graph/search, + and /v1/graph/related return these ids. Returns the node's name, type, aliases, + and description. Requires an API key. This endpoint was previously at /beta/graph/node/{id}. + That path still works until 1 February 2027. Move to /v1/graph/node/{id}. + operationId: graphNode + parameters: + - description: Opaque public id of the node. + required: true + schema: + type: string + name: id + in: path + responses: + '200': + description: A single graph node + content: + application/json: + schema: + $ref: '#/components/schemas/GraphNode' + '401': + description: Missing or invalid API key. + content: + application/json: + schema: + $ref: '#/components/schemas/BaseAPIError' + '404': + description: The requested resource does not exist or has no exportable + data. + content: + application/json: + schema: + $ref: '#/components/schemas/BaseAPIError' + '503': + description: A backing data store is temporarily unavailable. + content: + application/json: + schema: + $ref: '#/components/schemas/BaseAPIError' + security: + - apiKey: [] + /v1/contents: + post: + tags: + - tako + summary: Download content + description: 'Download the content behind a search result: a CSV of a Tako card''s + underlying data, or the full text of a web page. Returns a short-lived presigned + download URL. Protected-source cards (data export not available) return 403. + Send `quote_only: true` to get only the export''s price (`cost` + `export_pricing`) + with an empty payload. A quote is free: Tako fetches nothing and charges nothing.' + operationId: contents + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ContentsRequest' + responses: + '200': + description: 'Downloadable content for a result: a presigned URL plus format + and cost metadata. For a `quote_only` request the item instead carries + only the price (`cost` + `export_pricing`) with all payload and url fields + null.' + content: + application/json: + schema: + $ref: '#/components/schemas/ContentsResponse' + '400': + description: Invalid request data (validation or malformed body). + content: + application/json: + schema: + $ref: '#/components/schemas/BaseAPIError' + '401': + description: Missing or invalid API key. + content: + application/json: + schema: + $ref: '#/components/schemas/BaseAPIError' + '403': + description: Action not permitted for this resource (for example, a protected-source + export). + content: + application/json: + schema: + $ref: '#/components/schemas/BaseAPIError' + '404': + description: The requested resource does not exist or has no exportable + data. + content: + application/json: + schema: + $ref: '#/components/schemas/BaseAPIError' + security: + - apiKey: [] + /v1/thin_viz/create/: + post: + tags: + - tako + description: 'Create a visualization card directly from component configurations. + Supported component types: header, generic_timeseries, categorical_bar, stock_boxes, + financial_boxes, table.' + operationId: createCard + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CreateCardRequest' + responses: + '200': + description: Card created successfully from schema + content: + application/json: + schema: + $ref: '#/components/schemas/ThinVizCard' + '400': + description: Bad request - validation error or component mismatch + content: + application/json: + schema: + properties: + error: + type: string + type: object + required: + - error + '404': + description: Schema not found + content: + application/json: + schema: + properties: + error: + type: string + type: object + required: + - error + '500': + description: Internal server error + content: + application/json: + schema: + properties: + error: + type: string + type: object + required: + - error + security: + - apiKey: [] + /v1/agent/answer/runs: + get: + tags: + - agent + summary: List answer agent runs + description: List the authenticated caller's answer agent runs, newest first. + Returns trimmed run summaries. Fetch full detail via GET /v1/agent/answer/runs/{run_id}. + operationId: listAnswerAgentRuns + parameters: + - description: Opaque pagination cursor from a previous response's next_cursor. + required: false + schema: + type: string + name: cursor + in: query + - description: Max runs to return (default 20, max 100). + required: false + schema: + type: integer + maximum: 100.0 + minimum: 1.0 + name: limit + in: query + responses: + '200': + description: A page of the caller's answer agent runs, newest first. + content: + application/json: + schema: + $ref: '#/components/schemas/AnswerAgentRunList' + '400': + description: Invalid pagination parameter (limit or cursor). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorObject' + '401': + description: Missing or invalid API key. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorObject' + security: + - apiKey: [] + post: + tags: + - agent + summary: Dispatch an answer agent run + description: Dispatch an answer agent run. Returns 202 with an AnswerAgentRun + object. Poll GET /v1/agent/answer/runs/{run_id} until status is 'completed' + or 'failed'. + operationId: createAnswerAgentRun + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AnswerAgentRunRequest' + responses: + '202': + description: 'Run dispatched. With Accept: application/json, poll GET /v1/agent/answer/runs/{run_id} + for the run status. With Accept: text/event-stream, the response is an + SSE stream of AnswerAgentStreamEnvelope events. The stream ends at stream_done. + If the stream ends without an agent_result event, poll GET /v1/agent/answer/runs/{run_id} + for the terminal status.' + content: + application/json: + schema: + $ref: '#/components/schemas/AnswerAgentRun' + text/event-stream: + schema: + $ref: '#/components/schemas/AnswerAgentStreamEnvelope' + '400': + description: Invalid request (for example, a blank query or a malformed + body). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorObject' + '401': + description: Missing or invalid API key. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorObject' + '402': + description: Insufficient API credit balance (PAYG pre-dispatch gate). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorObject' + '404': + description: thread_id does not exist or is not owned by the caller. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorObject' + '409': + description: 'Conflict. code is one of: ''conflict'' (the thread already + has a run in flight); ''thread_product_mismatch'' (the thread belongs + to a different agent product); ''source_indexes_mismatch'' (a follow-up + changed the thread''s pinned source_indexes).' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorObject' + '500': + description: Failed to dispatch the agent run. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorObject' + security: + - apiKey: [] + /v1/agent/answer/runs/{run_id}: + get: + tags: + - agent + summary: Poll an answer agent run + description: Retrieve the current state of an answer agent run. Poll until status + is 'completed' or 'failed'. The response includes result when status is 'completed'. + operationId: getAnswerAgentRun + parameters: + - description: The run ID from the POST /v1/agent/answer/runs response. + required: true + schema: + type: string + name: run_id + in: path + - description: 'SSE resume cursor (Accept: text/event-stream only). The stream + replays events with seq greater than this value. It is equivalent to the + Last-Event-ID header.' + required: false + schema: + type: integer + minimum: 0.0 + name: starting_after + in: query + responses: + '200': + description: 'Current state of the agent run. With Accept: text/event-stream, + the response replays and then follows the run as an SSE stream of AnswerAgentStreamEnvelope + events. Use starting_after or Last-Event-ID to resume. If the stream ends + without an agent_result event, poll this endpoint with Accept: application/json + for the terminal status.' + content: + application/json: + schema: + $ref: '#/components/schemas/AnswerAgentRun' + text/event-stream: + schema: + $ref: '#/components/schemas/AnswerAgentStreamEnvelope' + '401': + description: Missing or invalid API key. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorObject' + '403': + description: The run is not owned by the caller. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorObject' + '404': + description: Run not found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorObject' + security: + - apiKey: [] + /v1/agent/retrieval/runs: + get: + tags: + - agent + summary: List retrieval agent runs + description: List the authenticated caller's retrieval agent runs, newest first. + Returns trimmed run summaries. Fetch full detail via GET /v1/agent/retrieval/runs/{run_id}. + operationId: listRetrievalAgentRuns + parameters: + - description: Opaque pagination cursor from a previous response's next_cursor. + required: false + schema: + type: string + name: cursor + in: query + - description: Max runs to return (default 20, max 100). + required: false + schema: + type: integer + maximum: 100.0 + minimum: 1.0 + name: limit + in: query + responses: + '200': + description: A page of the caller's retrieval agent runs, newest first. + content: + application/json: + schema: + $ref: '#/components/schemas/RetrievalAgentRunList' + '400': + description: Invalid pagination parameter (limit or cursor). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorObject' + '401': + description: Missing or invalid API key. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorObject' + security: + - apiKey: [] + post: + tags: + - agent + summary: Dispatch a retrieval agent run + description: Dispatch a retrieval agent run. Returns 202 with a RetrievalAgentRun + object. Poll GET /v1/agent/retrieval/runs/{run_id} until status is 'completed' + or 'failed'. + operationId: createRetrievalAgentRun + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RetrievalAgentRunRequest' + responses: + '202': + description: 'Run dispatched. With Accept: application/json, poll GET /v1/agent/retrieval/runs/{run_id} + for the run status. With Accept: text/event-stream, the response is an + SSE stream of RetrievalAgentStreamEnvelope events. The stream ends at + stream_done. If the stream ends without an agent_result event, poll GET + /v1/agent/retrieval/runs/{run_id} for the terminal status.' + content: + application/json: + schema: + $ref: '#/components/schemas/RetrievalAgentRun' + text/event-stream: + schema: + $ref: '#/components/schemas/RetrievalAgentStreamEnvelope' + '400': + description: Invalid request (for example, a blank query or a malformed + body). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorObject' + '401': + description: Missing or invalid API key. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorObject' + '402': + description: Insufficient API credit balance (PAYG pre-dispatch gate). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorObject' + '404': + description: thread_id does not exist or is not owned by the caller. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorObject' + '409': + description: 'Conflict. code is one of: ''conflict'' (the thread already + has a run in flight); ''thread_product_mismatch'' (the thread belongs + to a different agent product); ''source_indexes_mismatch'' (a follow-up + changed the thread''s pinned source_indexes).' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorObject' + '500': + description: Failed to dispatch the agent run. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorObject' + security: + - apiKey: [] + /v1/agent/retrieval/runs/{run_id}: + get: + tags: + - agent + summary: Poll a retrieval agent run + description: Retrieve the current state of a retrieval agent run. Poll until + status is 'completed' or 'failed'. The response includes result when status + is 'completed'. + operationId: getRetrievalAgentRun + parameters: + - description: The run ID from the POST /v1/agent/retrieval/runs response. + required: true + schema: + type: string + name: run_id + in: path + - description: 'SSE resume cursor (Accept: text/event-stream only). The stream + replays events with seq greater than this value. It is equivalent to the + Last-Event-ID header.' + required: false + schema: + type: integer + minimum: 0.0 + name: starting_after + in: query + responses: + '200': + description: 'Current state of the agent run. With Accept: text/event-stream, + the response replays and then follows the run as an SSE stream of RetrievalAgentStreamEnvelope + events. Use starting_after or Last-Event-ID to resume. If the stream ends + without an agent_result event, poll this endpoint with Accept: application/json + for the terminal status.' + content: + application/json: + schema: + $ref: '#/components/schemas/RetrievalAgentRun' + text/event-stream: + schema: + $ref: '#/components/schemas/RetrievalAgentStreamEnvelope' + '401': + description: Missing or invalid API key. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorObject' + '403': + description: The run is not owned by the caller. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorObject' + '404': + description: Run not found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorObject' + security: + - apiKey: [] +components: + schemas: + TakoDataset: + properties: + columns: + items: + $ref: '#/components/schemas/TakoDatasetColumn' + type: array + title: Columns + description: Ordered column headers (name + type), one per position in every + row. + rows: + items: + items: + anyOf: + - type: string + - type: number + - type: integer + - type: boolean + - type: 'null' + type: array + type: array + title: Rows + description: Row data as positional cell arrays aligned to `columns` order. + Cells are string/number/boolean/null; nulls are preserved, never coerced. + total_rows: + type: integer + title: Total Rows + description: True total number of rows in the underlying data, before any + truncation. + truncated: + type: boolean + title: Truncated + description: True when `rows` was capped and total_rows exceeds the number + returned. + ref: + type: string + title: Ref + description: Source URL the dataset was derived from (e.g. the Tako card + URL). + sources: + items: + $ref: '#/components/schemas/TakoDatasetSource' + type: array + title: Sources + description: 'Provenance for the dataset: the sources the rows were drawn + from.' + provenance: + type: string + enum: + - query + - web_extraction + title: Provenance + description: 'How the rows were produced: ''query'' (Tako data) or ''web_extraction''.' + default: query + type: object + required: + - columns + - rows + - total_rows + - truncated + - ref + - sources + title: TakoDataset + description: 'The dataset-slot envelope: exact retrieved rows as positional + arrays + + in `columns` order. The rows come directly from the data source; the LLM + + never transcribes them.' + APIErrorType: + type: string + enum: + - BAD_REQUEST + - AUTHENTICATION_ERROR + - INTERNAL_SERVER_ERROR + - RELEVANT_RESULTS_NOT_FOUND + - RATE_LIMIT_EXCEEDED + - PAYMENT_REQUIRED + - REQUEST_TIMEOUT + - FORBIDDEN + - NOT_FOUND + - SERVICE_UNAVAILABLE + - SERVICE_OVERLOADED + title: APIErrorType + AgentAnswerAssumption: + properties: + title: + type: string + title: Title + description: + type: string + title: Description + category: + anyOf: + - type: string + - type: 'null' + title: Category + source_ref: + anyOf: + - type: integer + - type: 'null' + title: Source Ref + type: object + required: + - title + - description + title: AgentAnswerAssumption + AgentAnswerCitation: + properties: + index: + type: integer + title: Index + title: + type: string + title: Title + url: + anyOf: + - type: string + - type: 'null' + title: Url + source_name: + anyOf: + - type: string + - type: 'null' + title: Source Name + source_index: + anyOf: + - $ref: '#/components/schemas/TakoSourceIndex' + - type: 'null' + excerpt: + anyOf: + - type: string + - type: 'null' + title: Excerpt + publish_date: + anyOf: + - type: string + - type: 'null' + title: Publish Date + content: + anyOf: + - $ref: '#/components/schemas/ResultContent' + - type: 'null' + type: object + required: + - index + - title + title: AgentAnswerCitation + description: 'One indexed source behind the answer. Every inline [n] marker + in the + + answer joins to a citation''s index. A citation can also back the answer + + without a surviving inline marker — markers map to citations, not 1:1. + + This covers web and Tako sources alike, and it is the single shared + + citation object across both agent products. The extended fields + + (source_index, excerpt, publish_date, content) are additive and nullable. + + The Answer Agent populates source_index and leaves the rest null. The + + Retrieval Agent also populates excerpt and publish_date for web + + sources.' + AgentAnswerDefinition: + properties: + term: + type: string + title: Term + definition: + type: string + title: Definition + source_ref: + anyOf: + - type: integer + - type: 'null' + title: Source Ref + type: object + required: + - term + - definition + title: AgentAnswerDefinition + AgentAnswerMetadata: + properties: + citations: + anyOf: + - items: + $ref: '#/components/schemas/AgentAnswerCitation' + type: array + - type: 'null' + title: Citations + definitions: + anyOf: + - items: + $ref: '#/components/schemas/AgentAnswerDefinition' + type: array + - type: 'null' + title: Definitions + assumptions: + anyOf: + - items: + $ref: '#/components/schemas/AgentAnswerAssumption' + type: array + - type: 'null' + title: Assumptions + methodology: + anyOf: + - items: + $ref: '#/components/schemas/AgentAnswerMethodologyNote' + type: array + - type: 'null' + title: Methodology + type: object + title: AgentAnswerMetadata + description: 'Supplementary answer metadata. All fields are optional — population + + varies by effort and engine (low fills citations only today). Tako adds + + new components here additively.' + AgentAnswerMethodologyNote: + properties: + title: + type: string + title: Title + description: + type: string + title: Description + type: object + required: + - title + - description + title: AgentAnswerMethodologyNote + AgentOutputSettings: + properties: + image_dark_mode: + anyOf: + - type: boolean + - type: 'null' + title: Image Dark Mode + description: Render card preview images in dark mode. Omit to use the default + (dark). + type: object + title: AgentOutputSettings + AgentRunStatus: + type: string + enum: + - queued + - running + - completed + - failed + title: AgentRunStatus + AnswerAgentEffort: + type: string + enum: + - medium + title: AnswerAgentEffort + description: 'Effort taxonomy for the Answer Agent (POST /v1/agent/answer/runs). + + + The Answer Agent shares the effort vocabulary with the Retrieval Agent, + + but only ''medium'' is available today. Unsupported values return 400. + + Effort does not select an engine; it is a forward-compatible quality and + + pricing control.' + AnswerAgentMetadata: + properties: + definitions: + anyOf: + - items: + $ref: '#/components/schemas/AgentAnswerDefinition' + type: array + - type: 'null' + title: Definitions + assumptions: + anyOf: + - items: + $ref: '#/components/schemas/AgentAnswerAssumption' + type: array + - type: 'null' + title: Assumptions + methodology: + anyOf: + - items: + $ref: '#/components/schemas/AgentAnswerMethodologyNote' + type: array + - type: 'null' + title: Methodology + type: object + title: AnswerAgentMetadata + description: 'Answer-agent metadata. Citations live in the top-level `citations` + + registry, so this carries only definitions, assumptions, and methodology. + + Distinct from the shared AgentAnswerMetadata, which retains citations for + + the legacy AgentResult.' + AnswerAgentResult: + properties: + answer: + anyOf: + - type: string + - type: 'null' + title: Answer + cards: + items: + $ref: '#/components/schemas/TakoCard' + type: array + title: Cards + citations: + items: + $ref: '#/components/schemas/AgentAnswerCitation' + type: array + title: Citations + metadata: + anyOf: + - $ref: '#/components/schemas/AnswerAgentMetadata' + - type: 'null' + request_id: + anyOf: + - type: string + - type: 'null' + title: Request Id + type: object + title: AnswerAgentResult + description: 'Final answer-agent output. answer is markdown prose with [n] citation + + markers. citations is the unified top-level registry that the [n] markers + + join. cards reuse the sibling TakoCard. metadata carries definitions, + + assumptions, and methodology. There is no inline data, no structured + + output, and no web_results — ever. A prose-only result (empty cards) is + + legitimate.' + AnswerAgentResultEvent: + properties: + kind: + type: string + const: agent_result + title: Kind + default: agent_result + id: + type: string + title: Id + data: + $ref: '#/components/schemas/AnswerAgentResult' + type: object + required: + - id + - data + title: AnswerAgentResultEvent + AnswerAgentRun: + properties: + run_id: + type: string + title: Run Id + object: + type: string + const: agent.run + title: Object + default: agent.run + thread_id: + anyOf: + - type: string + - type: 'null' + title: Thread Id + status: + $ref: '#/components/schemas/AgentRunStatus' + created_at: + type: string + title: Created At + completed_at: + anyOf: + - type: string + - type: 'null' + title: Completed At + result: + anyOf: + - $ref: '#/components/schemas/AnswerAgentResult' + - type: 'null' + error: + anyOf: + - $ref: '#/components/schemas/ErrorObject' + - type: 'null' + usage: + anyOf: + - $ref: '#/components/schemas/Usage' + - type: 'null' + request: + anyOf: + - $ref: '#/components/schemas/AnswerAgentRunRequest' + - type: 'null' + type: object + required: + - run_id + - status + - created_at + title: AnswerAgentRun + description: The answer-agent run resource returned by dispatch (202) and poll + (GET). + AnswerAgentRunList: + properties: + object: + type: string + const: list + title: Object + default: list + data: + items: + $ref: '#/components/schemas/AnswerAgentRunSummary' + type: array + title: Data + has_more: + type: boolean + title: Has More + default: false + next_cursor: + anyOf: + - type: string + - type: 'null' + title: Next Cursor + type: object + required: + - data + title: AnswerAgentRunList + description: 'List envelope for GET /v1/agent/answer/runs. The shape follows + the + + OpenAI and Exa list convention.' + AnswerAgentRunRequest: + properties: + query: + type: string + title: Query + description: Natural-language request for the answer agent. + examples: + - How have American Airlines' margins held up against fuel shocks? + thread_id: + anyOf: + - type: string + format: uuid + - type: 'null' + title: Thread Id + description: Existing thread to continue (follow-up). Omit to start a new + thread. + effort: + $ref: '#/components/schemas/AnswerAgentEffort' + description: Answer-agent effort. Only 'medium' is currently supported. + default: medium + source_indexes: + items: + type: string + enum: + - data + - web + type: array + title: Source Indexes + description: 'Which sources the agent may use: ''data'' (curated knowledge), + ''web'' (open-web search), or both. Defaults to [''data'', ''web'']. Tako + accepts the legacy value ''tako'' as a synonym for ''data''.' + examples: + - - data + - - web + - - data + - web + locale: + type: string + title: Locale + description: BCP-47 locale. Drives the language of the agent's answer and + the locale used when rendering card preview images. Defaults to en-US. + default: en-US + timezone: + anyOf: + - type: string + - type: 'null' + title: Timezone + description: IANA timezone (for example, 'America/New_York'). The agent + uses it to render dates and times in card preview images. It does not + affect the returned data. + output_settings: + anyOf: + - $ref: '#/components/schemas/AgentOutputSettings' + - type: 'null' + description: Settings that control the response and rendering. + type: object + required: + - query + title: AnswerAgentRunRequest + description: 'Request body for POST /v1/agent/answer/runs. + + + Frozen contract: no output_schema, no structured outputs, and no inline + + data — ever. Cards are the only data-export path (via /v1/contents).' + AnswerAgentRunSummary: + properties: + run_id: + type: string + title: Run Id + status: + $ref: '#/components/schemas/AgentRunStatus' + created_at: + type: string + title: Created At + completed_at: + anyOf: + - type: string + - type: 'null' + title: Completed At + thread_id: + anyOf: + - type: string + - type: 'null' + title: Thread Id + usage: + anyOf: + - $ref: '#/components/schemas/Usage' + - type: 'null' + object: + type: string + const: agent.run + title: Object + default: agent.run + type: object + required: + - run_id + - status + - created_at + title: AnswerAgentRunSummary + AnswerAgentStreamEnvelope: + properties: + seq: + type: integer + minimum: 0.0 + title: Seq + run_id: + type: string + title: Run Id + thread_id: + anyOf: + - type: string + - type: 'null' + title: Thread Id + category: + $ref: '#/components/schemas/StreamCategory' + block: + oneOf: + - $ref: '#/components/schemas/ToolCallEvent' + - $ref: '#/components/schemas/ToolResultEvent' + - $ref: '#/components/schemas/ToolErrorEvent' + - $ref: '#/components/schemas/ToolRetryEvent' + - $ref: '#/components/schemas/StatusEvent' + - $ref: '#/components/schemas/SubagentEvent' + - $ref: '#/components/schemas/ReasoningEvent' + - $ref: '#/components/schemas/TextEvent' + - $ref: '#/components/schemas/DataPipelineAnswerEvent' + - $ref: '#/components/schemas/AnswerAgentResultEvent' + - $ref: '#/components/schemas/RunSummaryEvent' + - $ref: '#/components/schemas/HeartbeatEvent' + - $ref: '#/components/schemas/StreamResetEvent' + - $ref: '#/components/schemas/StreamDoneEvent' + title: Block + discriminator: + propertyName: kind + mapping: + agent_result: '#/components/schemas/AnswerAgentResultEvent' + data_pipeline_answer: '#/components/schemas/DataPipelineAnswerEvent' + heartbeat: '#/components/schemas/HeartbeatEvent' + reasoning: '#/components/schemas/ReasoningEvent' + run_summary: '#/components/schemas/RunSummaryEvent' + status: '#/components/schemas/StatusEvent' + stream_done: '#/components/schemas/StreamDoneEvent' + stream_reset: '#/components/schemas/StreamResetEvent' + subagent: '#/components/schemas/SubagentEvent' + text: '#/components/schemas/TextEvent' + tool_call: '#/components/schemas/ToolCallEvent' + tool_error: '#/components/schemas/ToolErrorEvent' + tool_result: '#/components/schemas/ToolResultEvent' + tool_retry: '#/components/schemas/ToolRetryEvent' + type: object + required: + - seq + - run_id + - category + - block + title: AnswerAgentStreamEnvelope + description: 'Public SSE envelope for the Answer Agent run stream. Identical + wire shape + + to AgentStreamEnvelope; the only difference is the terminal agent_result + + block carries AnswerAgentResult (top-level citations, no web_results).' + AnswerResponse: + properties: + answer: + type: string + title: Answer + description: Synthesized text answer. + cards: + items: + $ref: '#/components/schemas/TakoCard' + type: array + title: Cards + description: Tako cards backing the answer; cards[0] is the lead card — + the best one to show alongside the answer. + web_results: + items: + $ref: '#/components/schemas/WebResult' + type: array + title: Web Results + request_id: + type: string + title: Request Id + usage: + anyOf: + - $ref: '#/components/schemas/Usage' + - type: 'null' + type: object + required: + - answer + - request_id + title: AnswerResponse + description: 'Response for POST /api/v1/answer: the synthesized answer plus + the + + retrieval behind it.' + BaseAPIError: + properties: + error_message: + type: string + title: Error Message + error_type: + $ref: '#/components/schemas/APIErrorType' + type: object + required: + - error_message + - error_type + title: BaseAPIError + ColumnDescriptor: + properties: + name: + anyOf: + - type: string + - type: 'null' + title: Name + metric: + anyOf: + - type: string + - type: 'null' + title: Metric + entity: + anyOf: + - type: string + - type: 'null' + title: Entity + unit: + anyOf: + - type: string + - type: 'null' + title: Unit + dtype: + anyOf: + - $ref: '#/components/schemas/TakoDatasetColumnType' + - type: 'null' + type: object + title: ColumnDescriptor + description: 'Structured description of one exported value column. + + + Producers set the semantic parts they know (``metric``, ``entity``, + + ``unit``). ``name`` and ``dtype`` are filled centrally: ``name`` is the + + rendered canonical header (set by ``apply_descriptor_names``), and ``dtype`` + + is inferred from the real column data in ``finalize_export_frame``. A + + producer-set ``dtype`` would drift from the cells, and a producer cannot know + + the disambiguated ``name`` before the whole frame is rendered. + + + In the exported manifest ``name`` equals the column''s actual header, and + the + + manifest is positionally aligned with the exported columns (manifest entry + + ``i`` describes column ``i``).' + ComponentConfig: + properties: + component_type: + $ref: '#/components/schemas/ComponentTypeEnum' + description: 'Component type: + + - bubble: Bubble chart (scatter plot with size dimension for 3-variable + data) + + - categorical_bar: Bar chart with categorical x-axis (for example, regions + or products) + + - choropleth: Choropleth map showing geographic data with color intensity + by region (US states or world) + + - data_table_chart: Bar chart with auto-generated data table below showing + values + + - financial_boxes: Financial metric boxes with values and growth indicators + (for example, Revenue or EPS) + + - generic_timeseries: timeseries chart + + - header: Card header with title and description, automatically styled + with theme + + - heatmap: 2D heatmap with color intensity representing values (for example, + a correlation matrix) + + - histogram: Histogram chart showing frequency distribution of values + + - person_card: Person profile card from an Exa person search result (includes + career, education, and about tabs) + + - pie: Pie chart showing proportional data as slices of a circle + + - scatter: Scatter plot showing relationships between two continuous variables + + - table: Data table with configurable columns and rows + + - boxplot: Box plot showing statistical distributions (min, Q1, median, + Q3, max) + + - treemap: Treemap chart showing hierarchical data as proportional rectangles + + - waterfall: Waterfall chart showing incremental positive and negative + changes (for example, an income statement breakdown)' + component_variant: + anyOf: + - type: string + - type: 'null' + title: Component Variant + description: Component variant (for example, 'simple' or 'financial'). + config: + additionalProperties: true + type: object + title: Config + description: Component configuration data + type: object + required: + - component_type + - config + title: ComponentConfig + description: Configuration for a single component in a card. + ComponentTypeEnum: + type: string + enum: + - categorical_bar + - choropleth + - data_table_chart + - financial_boxes + - generic_timeseries + - header + - heatmap + - histogram + - marimekko + - pie + - scatter + - table + - boxplot + - treemap + - waterfall + - sankey + - bubble + - person_card + - timeline + - top_level_metric + title: ComponentTypeEnum + description: 'Component types supported for thin viz schemas with builder support. + + + These component types have dedicated builders that process configurations, + + add defaults, and apply theme styling automatically.' + ContentItem: + properties: + content_format: + anyOf: + - $ref: '#/components/schemas/ContentsFormat' + - type: 'null' + description: 'Serialization of the returned card data: ''csv'', ''json_records'', + or ''json_compact''. Null for web text (always returned as raw text) and + for a quote-only response (nothing was serialized).' + cost: + type: number + title: Cost + description: Price of this item in USD. On a /contents response this is + the amount actually billed; on a quote_only response or a search/answer + downloadable card it is a prospective /contents export price (nothing + was fetched or billed). Pair with export_pricing to compute a larger export's + cost before fetching. + default: 0.0 + data: + anyOf: + - type: string + - type: 'null' + title: Data + description: 'Inline payload as text: card data serialized to CSV, or a + web page''s extracted text. Set only for the ''csv'' card format and for + web text; null otherwise.' + records: + anyOf: + - items: + additionalProperties: + anyOf: + - type: string + - type: number + - type: integer + - type: boolean + - type: 'null' + type: object + type: array + - type: 'null' + title: Records + description: 'Inline card data as verbose JSON: a list of row objects keyed + by column name. Set only when content_format is ''json_records''.' + dataset: + anyOf: + - $ref: '#/components/schemas/TakoDataset' + - type: 'null' + description: Inline card data as a compact TakoDataset (typed column headers + plus positional row arrays). Set only when content_format is 'json_compact'. + url: + anyOf: + - type: string + - type: 'null' + title: Url + description: Presigned download URL for the content, returned in 'url' delivery + mode. Null for inline delivery and for quotes; pair with expires_at. + expires_at: + anyOf: + - type: string + - type: 'null' + title: Expires At + description: ISO-8601 timestamp after which the presigned url stops working. + Null whenever url is null. + total_rows: + anyOf: + - type: integer + - type: 'null' + title: Total Rows + description: True total number of rows in the card's data, independent of + how many rows were returned. Compare with truncated to tell whether more + rows are available via a larger max_rows. Null for web text. + truncated: + type: boolean + title: Truncated + description: True when the returned rows were capped (by the request's max_rows + or the 2,000-row system ceiling) and total_rows exceeds the number returned. + default: false + export_pricing: + anyOf: + - $ref: '#/components/schemas/ExportPricing' + - type: 'null' + description: Rate card for a downloadable card CSV, so a caller can compute + a full export's cost before fetching. Null for web text and other non-downloadable + content. + manifest: + anyOf: + - items: + $ref: '#/components/schemas/ColumnDescriptor' + type: array + - type: 'null' + title: Manifest + description: 'Per-column metadata, one entry per exported column, in column + order: entry i describes column i (CSV header i, json_records key i, or + dataset.columns[i]). Each entry carries name (the column header), dtype, + unit, metric, and entity. Null for web or quote responses that carry no + tabular columns.' + source_url: + type: string + title: Source Url + description: The originating result URL from the request. + type: object + required: + - source_url + title: ContentItem + description: 'A single downloadable artifact. + + + It inherits `content_format`, `cost`, the payload fields (`data`, + + `records`, `dataset`, `total_rows`, `truncated`), and the presigned `url` + + and `expires_at` from ResultContent. URL mode populates `url` and + + `expires_at`. INLINE mode populates one of the payload fields and leaves + + `url` and `expires_at` null. A `quote_only` request yields a third shape: + + `cost` (plus `export_pricing` for a card) with every payload and url + + field null and `content_format` null — the price of the export, with + + nothing fetched or charged.' + ContentsDeliveryMode: + type: string + enum: + - url + - inline + title: ContentsDeliveryMode + description: 'How contents reach the caller. URL returns a short-lived presigned + file + + download; INLINE returns the content in the response body.' + ContentsFormat: + type: string + enum: + - csv + - json_records + - json_compact + title: ContentsFormat + description: 'Serialization of tabular (Tako card) data. Web content is always + raw text + + and carries no content format (content_format is null).' + ContentsRequest: + properties: + url: + type: string + title: Url + description: The result URL to fetch downloadable content for (a TakoCard.webpage_url + or a WebResult.url). A Tako card URL yields a CSV of the card's data; + any other URL yields the page's extracted text. + examples: + - https://tako.com/card/abc123 + mode: + $ref: '#/components/schemas/ContentsDeliveryMode' + description: 'Delivery mode. ''url'' (the default) returns a presigned download + link. ''inline'' returns the content in the response body: CSV data up + to the 2,000-row system ceiling (with total_rows and truncated reported), + or web text.' + default: url + content_format: + $ref: '#/components/schemas/ContentsFormat' + description: 'Serialization for Tako card data: ''csv'' (default), ''json_records'', + or ''json_compact''. Ignored for web URLs (always text).' + default: csv + max_rows: + anyOf: + - type: integer + minimum: 1.0 + - type: 'null' + title: Max Rows + description: Optional cap on the rows returned for a Tako card CSV export. + When omitted, it defaults to the free-row allowance (20 rows), billed + at the baseline only. Raise it to export more rows, up to the 2,000-row + system ceiling (Tako clamps larger values). Rows beyond the free allowance + bill at the per-1,000-row rate. Billing counts the rows actually returned. + Web URLs ignore this field. + examples: + - 100 + max_chars: + type: integer + maximum: 1000000.0 + minimum: 1.0 + title: Max Chars + description: Character cap on extracted web page text. Ignored for Tako + card URLs (they use max_rows). The default is the full page text, up to + the 1000000-character ceiling. To truncate, pass a smaller value. + default: 1000000 + examples: + - 50000 + quote_only: + type: boolean + title: Quote Only + description: When true, return only the price of the export (cost + export_pricing) + without fetching content or charging. The response item's payload and + url fields are null, and the request is free. `max_rows` shapes the quote + and defaults to the 20-row free allowance, as a real export does. The + request ignores `mode` and `content_format`. The same export-safe gate + applies, so an unexportable card still returns 403. + default: false + type: object + required: + - url + title: ContentsRequest + description: 'Request body for POST /api/v1/contents. + + + The caller passes the result URL it wants downloadable content for, and + + the endpoint detects the right content from the URL itself. A Tako card + + URL resolves to the card''s underlying data. Any other URL resolves to + + the page''s extracted full text. `mode` controls delivery: `url` (the + + default) returns a presigned download link, and `inline` returns the + + content in the response. `content_format` selects the card serialization + + (csv, json_records, or json_compact); web URLs ignore it (always + + text).' + ContentsResponse: + properties: + contents: + items: + $ref: '#/components/schemas/ContentItem' + type: array + title: Contents + description: Downloadable artifacts for the requested URL. + request_id: + type: string + title: Request Id + description: Unique identifier for this request. + usage: + anyOf: + - $ref: '#/components/schemas/Usage' + - type: 'null' + type: object + required: + - request_id + title: ContentsResponse + description: 'Response for POST /api/v1/contents. + + + `contents` is a list so that the contract stays stable if a single result + + ever yields multiple artifacts. Today it always carries exactly one + + item.' + CreateCardRequest: + properties: + components: + items: + $ref: '#/components/schemas/ComponentConfig' + type: array + title: Components + description: Full component configurations + title: + anyOf: + - type: string + - type: 'null' + title: Title + description: Card title (falls back to header component title) + description: + anyOf: + - type: string + - type: 'null' + title: Description + description: Card description + source: + anyOf: + - type: string + - type: 'null' + title: Source + description: Data source attribution (displayed in footer) + height: + anyOf: + - type: integer + maximum: 2000.0 + minimum: 100.0 + - type: 'null' + title: Height + description: Chart height in pixels. When set, overrides the default aspect-ratio-based + height for all chart components in this card. Must be between 100 and + 2000. + postmessage_embed: + type: boolean + title: Postmessage Embed + description: When True, the embed iframe operates in postMessage mode. The + parent page injects visualization_data via window.postMessage after the + iframe loads; the embed URL carries no inline data. The response includes + embed_mode='postmessage' when this is True, and embed_mode='post' otherwise. + default: false + normalize_currencies: + anyOf: + - type: string + - type: 'null' + title: Normalize Currencies + description: Target ISO 4217 currency code (for example, 'USD' or 'EUR'). + When set, Tako converts datasets with recognized currency units to this + currency with historical exchange rates. Tako also adds a methodology + section that explains the conversion. + image_ttl_minutes: + anyOf: + - type: integer + maximum: 1440.0 + minimum: 1.0 + - type: 'null' + title: Image Ttl Minutes + description: Minutes to keep the preview image available for download (zero-data-retention + (ZDR) cards only). Min 1, max 1440 (24 hours). When set on a ZDR card, + Tako generates a temporary preview image and keeps it available for download + until the TTL expires. Non-ZDR cards ignore this field. + type: object + required: + - components + title: CreateCardRequest + description: Request model for creating a card directly with components. + DataFreshness: + properties: + data_as_of: + anyOf: + - type: string + - type: 'null' + title: Data As Of + description: Date of the most recent observation in the card's data, as + an ISO date (YYYY-MM-DD). Null for card types without a coverage date. + examples: + - '2026-06-30' + last_updated: + anyOf: + - type: string + - type: 'null' + title: Last Updated + description: Date Tako last refreshed the underlying data, as an ISO date + (YYYY-MM-DD). + examples: + - '2026-07-14' + type: object + title: DataFreshness + description: 'When the card''s data was last observed and last refreshed. Either + field + + may be null when that date isn''t available for the card type.' + DataPipelineAnswerEvent: + properties: + kind: + type: string + const: data_pipeline_answer + title: Kind + default: data_pipeline_answer + id: + type: string + title: Id + chart_refs: + items: + type: string + type: array + title: Chart Refs + type: object + required: + - id + title: DataPipelineAnswerEvent + description: 'Signals that the data pipeline produced one or more charts for + the + + answer. It carries only chart references. The answer text streams in + + `text` events, and the structured result arrives in the terminal + + `agent_result` event, not here.' + DataSourceSettings: + properties: + count: + type: integer + maximum: 20.0 + minimum: 1.0 + title: Count + description: Maximum number of results to return for this source. 1-20. + default: 5 + include_contents: + type: boolean + title: Include Contents + description: Inline this source's underlying data directly in the response. + For the Tako data source, that is serialized card data (see content_format). + For web results, that is the extracted text. + default: false + mode: + $ref: '#/components/schemas/ContentsDeliveryMode' + description: Delivery for inlined card data when include_contents is true. + For Tako cards, include_contents always returns a small free inline preview + of the most-recent rows in the response body. This field therefore has + no effect on Tako cards; it stays for schema stability. total_rows and + truncated on the returned content indicate when more data is available. + For the full, priced export, call POST /api/v1/contents, which supports + mode='url' for a presigned download link. + default: inline + content_format: + $ref: '#/components/schemas/ContentsFormat' + description: 'Serialization for card data: ''json_compact'' (default), ''json_records'', + or ''csv''.' + default: json_compact + node_ids: + items: + type: string + type: array + maxItems: 20 + title: Node Ids + description: 'Graph node ids to pin into the search; the /v1/graph endpoints + return these ids. Pinned nodes always become retrieval candidates and + get a strong boost; organic results do not change. Ids are not durable + across knowledge-graph rebuilds: the search skips an id that no longer + resolves (in strict mode it simply cannot match). Malformed ids fail the + request with a 400. Max 20.' + strict: + type: boolean + title: Strict + description: When true, return only data cards that match at least one node + in node_ids (which must then be non-empty). When false (the default), + pinned nodes rank first but organic results still return. Web results + do not change either way. + default: false + additionalProperties: false + type: object + title: DataSourceSettings + description: 'Tako data (card) source settings. It adds the two contents-export + + fields on top of the base count and include_contents. Web sources use the + + base SourceSettings: Tako always inlines web content as raw text, so web + + carries neither field.' + EntityClassName: + type: string + enum: + - Companies + - Cryptocurrencies + - Financial Instruments + - Internet Browsers + - Commodities + - People + - Currencies + - Stock Exchanges + - Securities + - IPOs + - Government Debt Instruments + - Treasury Securities + - Airports + - Airlines + - Vehicle Types + - Transportation Modes + - Drugs + - Drug Categories + - Diseases + - Chemical Elements + - Chemical Compounds + - Celestial Bodies + - Occupations + - Social Media Platforms + - Operating Systems + - Search Engines + - Device Types + - LLMs + - LLM Families + - LLM Benchmarks + - Industries + - NAICS Industries + - BLS Industries + - PSC Categories + - Federal Contractors + - Federal Agencies + - Federal Subagencies + - ICE Contracts + - ICE Contractors + - Elections + - Political Offices + - Pollsters + - Agricultural Products + - Tobacco Products + - Prediction Markets + - Prediction Events + - Real Estate Property Types + - Continents + - World Regions + - Countries + - States + - Counties + - Metro Areas + - Cities + - F1 Drivers + - F1 Teams + - F1 Circuits + - F1 Events + - NASCAR Drivers + - NASCAR Teams + - NASCAR Tracks + - NASCAR Events + - NASCAR Races + - NASCAR Owners + - NASCAR Manufacturers + - Soccer Teams + - Soccer Players + - Soccer Competitions + - Soccer Conferences + - Basketball Teams + - Basketball Players + - Basketball Conferences + - Basketball Divisions + - Baseball Teams + - Baseball Players + - Baseball Conferences + - Baseball Divisions + - Baseball Competitions + - Football Teams + - Football Players + - Football Conferences + - Football Divisions + - Sports + - Sports Leagues + title: EntityClassName + ErrorObject: + properties: + code: + type: string + title: Code + message: + type: string + title: Message + type: object + required: + - code + - message + title: ErrorObject + ExportPricing: + properties: + baseline_usd: + type: number + title: Baseline Usd + description: Flat USD charged once per card export, independent of row count. + row_cpm_usd: + type: number + title: Row Cpm Usd + description: USD charged per 1,000 rows on rows beyond the free allowance + (free_rows). Card-level total across the card's priced sources; no per-source + breakdown. + free_rows: + type: integer + title: Free Rows + description: Rows included at the baseline price before the per-1,000-row + rate (row_cpm_usd) begins to apply. + max_rows_ceiling: + type: integer + title: Max Rows Ceiling + description: Hard cap on rows a single export can return and bill; a larger + requested max_rows is clamped to this. + type: object + required: + - baseline_usd + - row_cpm_usd + - free_rows + - max_rows_ceiling + title: ExportPricing + description: 'Card-CSV export pricing RATE, published so a caller can compute + an export''s + + cost before fetching. Full charge = + + baseline_usd + row_cpm_usd * max(0, rows - free_rows) / 1000, rows <= max_rows_ceiling. + + row_cpm_usd is the card-level total (sum of the card''s distinct priced sources'' + + per-1,000-row rate); no per-source breakdown.' + GeoLocation: + properties: + latitude: + type: number + maximum: 90.0 + minimum: -90.0 + title: Latitude + description: Latitude of the end user (degrees, -90 to 90). + longitude: + type: number + maximum: 180.0 + minimum: -180.0 + title: Longitude + description: Longitude of the end user (degrees, -180 to 180). + additionalProperties: false + type: object + required: + - latitude + - longitude + title: GeoLocation + GraphNode: + properties: + id: + type: string + title: Id + description: Opaque, human-friendly public id (::::, + where prefix is ent for an entity or mt for a metric). Ids are not durable + across knowledge-graph rebuilds — resolve them at request time rather + than storing them. + type: + $ref: '#/components/schemas/GraphNodeType' + name: + type: string + title: Name + aliases: + items: + type: string + type: array + title: Aliases + description: + anyOf: + - type: string + - type: 'null' + title: Description + subtype: + anyOf: + - $ref: '#/components/schemas/EntityClassName' + - type: 'null' + label: + anyOf: + - $ref: '#/components/schemas/NerLabel' + - type: 'null' + description: Public NER label of the node (PERSON, ORG, GPE, ...). Derived + from the node's stored annotation; null when the internal annotation has + no public equivalent. + type: object + required: + - id + - type + - name + title: GraphNode + GraphNodeType: + type: string + enum: + - metric + - entity + title: GraphNodeType + GraphRelatedResponse: + properties: + node: + $ref: '#/components/schemas/GraphNode' + relations: + anyOf: + - items: + $ref: '#/components/schemas/GraphRelation' + type: array + - type: 'null' + title: Relations + relation: + anyOf: + - $ref: '#/components/schemas/GraphRelationPage' + - type: 'null' + inferred_labels: + anyOf: + - items: + $ref: '#/components/schemas/NerLabel' + type: array + - type: 'null' + title: Inferred Labels + description: Labels that Tako NER inferred from `q` when infer_label ran + (boost applied). An empty list means inference ran and found nothing. + The field is absent when you supplied an explicit `label`, when infer_label=false, + or when there was no q. + type: object + required: + - node + title: GraphRelatedResponse + GraphRelation: + properties: + key: + type: string + title: Key + description: 'Stable pagination handle. Fixed keys: metrics, entities, siblings, + part_of, members. Named relations: rel:.' + kind: + $ref: '#/components/schemas/RelationKind' + label: + type: string + title: Label + description: Human-readable group label. + items: + items: + $ref: '#/components/schemas/GraphNode' + type: array + title: Items + total: + type: integer + title: Total + total_capped: + type: boolean + title: Total Capped + description: True when `total` hit the server-side fetch cap; the true count + is at least `total`. Render as 'total+'. + default: false + type: object + required: + - key + - kind + - label + - items + - total + title: GraphRelation + GraphRelationPage: + properties: + key: + type: string + title: Key + description: The relation key of this page. + kind: + $ref: '#/components/schemas/RelationKind' + label: + type: string + title: Label + description: Human-readable group label. + items: + items: + $ref: '#/components/schemas/GraphNode' + type: array + title: Items + total: + type: integer + title: Total + total_capped: + type: boolean + title: Total Capped + description: True when `total` hit the server-side fetch cap; the true count + is at least `total`. Pagination ends at the cap — narrow with `q` to reach + the tail. + default: false + next_cursor: + anyOf: + - type: string + - type: 'null' + title: Next Cursor + type: object + required: + - key + - kind + - label + - items + - total + title: GraphRelationPage + GraphSearchResponse: + properties: + results: + items: + $ref: '#/components/schemas/GraphNode' + type: array + title: Results + inferred_labels: + anyOf: + - items: + $ref: '#/components/schemas/NerLabel' + type: array + - type: 'null' + title: Inferred Labels + description: Labels that Tako NER inferred from `q` when infer_label ran + (boost applied). An empty list means inference ran and found nothing. + The field is absent when you supplied an explicit `label`, when infer_label=false, + or when there was no q. + type: object + required: + - results + title: GraphSearchResponse + HeartbeatEvent: + properties: + kind: + type: string + const: heartbeat + title: Kind + default: heartbeat + type: object + title: HeartbeatEvent + KnowledgeCardMethodology: + properties: + methodology_name: + anyOf: + - type: string + - type: 'null' + title: Methodology Name + description: The name of the methodology + examples: + - Where the Data Comes From - S&P Global + methodology_description: + anyOf: + - type: string + - type: 'null' + title: Methodology Description + description: 'A concise, one-sentence summary of the methodology: the source + and what it measures. When no concise summary is available, this carries + the full methodology text.' + examples: + - Financial metrics standardized by S&P Global from company regulatory filings, + press releases, and restatements. + type: object + required: + - methodology_name + - methodology_description + title: KnowledgeCardMethodology + KnowledgeCardRelevance: + type: string + enum: + - High + - Medium + - Low + title: KnowledgeCardRelevance + MetricDefinition: + properties: + name: + type: string + title: Name + description: The metric's display name + examples: + - Gross Domestic Product (current US$) + definition: + type: string + title: Definition + description: Human-readable definition of the metric + type: object + required: + - name + - definition + title: MetricDefinition + description: 'Definition of a metric shown on a card. Sourced from the metric''s + + ValueType definition — the same text as the in-app methodology ''Metrics'' + + tab.' + NerLabel: + type: string + enum: + - PERSON + - ORG + - GPE + - LOC + - PRODUCT + - EVENT + - LANGUAGE + - MONEY + - METRIC + - STOCK_TICKER + - WEBSITE + title: NerLabel + OutputSettings: + properties: + image_dark_mode: + anyOf: + - type: boolean + - type: 'null' + title: Image Dark Mode + description: Whether to render card preview images in dark mode. + force_refresh: + type: boolean + title: Force Refresh + description: 'Instant mode only, and currently informational: on these endpoints, + instant (effort=''instant'') always operates in build-and-refresh mode + regardless of this flag. Instant retrieves data for embeds that are missing + or stale, then creates or refreshes their static embeds. Embeds that are + already fresh (within the refresh cadence) return as-is; Tako does not + re-retrieve them. Identical queries therefore reuse the same content-addressed + embed and return a stable embed URL.' + default: false + additionalProperties: false + type: object + title: OutputSettings + ReasoningEvent: + properties: + kind: + type: string + const: reasoning + title: Kind + default: reasoning + id: + type: string + title: Id + delta: + type: string + title: Delta + done: + type: boolean + title: Done + default: false + type: object + required: + - id + - delta + title: ReasoningEvent + RelationKind: + type: string + enum: + - related + - data + - sibling + - membership + title: RelationKind + description: How Tako derived a relation group. + ResultContent: + properties: + content_format: + anyOf: + - $ref: '#/components/schemas/ContentsFormat' + - type: 'null' + description: 'Serialization of the returned card data: ''csv'', ''json_records'', + or ''json_compact''. Null for web text (always returned as raw text) and + for a quote-only response (nothing was serialized).' + cost: + type: number + title: Cost + description: Price of this item in USD. On a /contents response this is + the amount actually billed; on a quote_only response or a search/answer + downloadable card it is a prospective /contents export price (nothing + was fetched or billed). Pair with export_pricing to compute a larger export's + cost before fetching. + default: 0.0 + data: + anyOf: + - type: string + - type: 'null' + title: Data + description: 'Inline payload as text: card data serialized to CSV, or a + web page''s extracted text. Set only for the ''csv'' card format and for + web text; null otherwise.' + records: + anyOf: + - items: + additionalProperties: + anyOf: + - type: string + - type: number + - type: integer + - type: boolean + - type: 'null' + type: object + type: array + - type: 'null' + title: Records + description: 'Inline card data as verbose JSON: a list of row objects keyed + by column name. Set only when content_format is ''json_records''.' + dataset: + anyOf: + - $ref: '#/components/schemas/TakoDataset' + - type: 'null' + description: Inline card data as a compact TakoDataset (typed column headers + plus positional row arrays). Set only when content_format is 'json_compact'. + url: + anyOf: + - type: string + - type: 'null' + title: Url + description: Presigned download URL for the content, returned in 'url' delivery + mode. Null for inline delivery and for quotes; pair with expires_at. + expires_at: + anyOf: + - type: string + - type: 'null' + title: Expires At + description: ISO-8601 timestamp after which the presigned url stops working. + Null whenever url is null. + total_rows: + anyOf: + - type: integer + - type: 'null' + title: Total Rows + description: True total number of rows in the card's data, independent of + how many rows were returned. Compare with truncated to tell whether more + rows are available via a larger max_rows. Null for web text. + truncated: + type: boolean + title: Truncated + description: True when the returned rows were capped (by the request's max_rows + or the 2,000-row system ceiling) and total_rows exceeds the number returned. + default: false + export_pricing: + anyOf: + - $ref: '#/components/schemas/ExportPricing' + - type: 'null' + description: Rate card for a downloadable card CSV, so a caller can compute + a full export's cost before fetching. Null for web text and other non-downloadable + content. + manifest: + anyOf: + - items: + $ref: '#/components/schemas/ColumnDescriptor' + type: array + - type: 'null' + title: Manifest + description: 'Per-column metadata, one entry per exported column, in column + order: entry i describes column i (CSV header i, json_records key i, or + dataset.columns[i]). Each entry carries name (the column header), dtype, + unit, metric, and entity. Null for web or quote responses that carry no + tabular columns.' + type: object + title: ResultContent + description: 'Describes the downloadable content behind a result. + + + Pricing: for a Tako card CSV export, the charge is a flat per-export + + baseline plus a per-source, per-row CPM on the rows returned beyond the + + free-row allowance. Web text bills at the standard Contents rate. + + + The meaning of `cost` depends on the surface. On `/contents` responses, + + `cost` is the actual charge, and it reconciles with what that response + + billed. The exception is a `quote_only` response, where `cost` is a + + prospective price: Tako neither fetched nor billed the export, and the + + payload and url fields are null. On search and answer downloadable + + cards, `cost` is a prospective `/contents` quote (the per-export + + baseline floor), not what this response billed. On a metered + + `include_contents` request, the per-card `cost` therefore diverges from + + `usage.total_cost_usd` (the inline preview bills about $0), and the sum + + of per-card `cost` will not reconcile with `usage`. Treat `cost` plus + + `export_pricing` as the price of a `/contents` export of this card. + + + `export_pricing` carries the rate so that a caller can compute the full + + charge before fetching: baseline_usd + row_cpm_usd * max(0, rows - + + free_rows) / 1000, with rows <= max_rows_ceiling. `export_pricing` is + + null for web text and non-downloadable content. + + + Tako populates exactly one payload group once it delivers contents: + + `data` (CSV or web text), `records` (verbose JSON), `dataset` (compact + + TakoDataset), or `url` plus `expires_at` (presigned download). + + `content_format` names the serialization; it is null for web text and + + for an undelivered quote. When every payload field is unset, this is + + just the quote (`cost`); fetch the content later via the Contents + + endpoint.' + RetrievalAgentEffort: + type: string + enum: + - medium + title: RetrievalAgentEffort + description: 'Effort taxonomy for the Retrieval Agent (POST /v1/agent/retrieval/runs). + + + Only ''medium'' is available today. `effort` stays a request field for + + forward compatibility and for parity with AnswerAgentEffort. The values + + ''low'' and ''high'' return 400 at validation.' + RetrievalAgentResult: + properties: + answer: + anyOf: + - type: string + - type: 'null' + title: Answer + cards: + items: + $ref: '#/components/schemas/TakoCard' + type: array + title: Cards + citations: + items: + $ref: '#/components/schemas/AgentAnswerCitation' + type: array + title: Citations + metadata: + anyOf: + - $ref: '#/components/schemas/AgentAnswerMetadata' + - type: 'null' + structured_output: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Structured Output + description: Caller-shaped structured output (present only if the request + carried output_schema and status is not 'failed'). Dataset slots contain + a TakoDataset envelope of exact retrieved rows, or null when the agent + left them unfilled. + structured_output_status: + anyOf: + - $ref: '#/components/schemas/StructuredOutputStatus' + - type: 'null' + description: complete | partial | failed. Present when the request supplied + output_schema and the run reached the finalize step. It can be absent + when the whole run failed or timed out before finalize; the run-level + status and error convey that, and the echoed request.output_schema still + identifies the run as structured. + structured_output_citations: + anyOf: + - additionalProperties: + items: + type: integer + type: array + type: object + - type: 'null' + title: Structured Output Citations + description: Best-effort field-path -> citation-index map joining the top-level + citations registry (same [n] index space as the answer). Absent when status + is 'failed'. + unfilled_fields: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Unfilled Fields + description: Dot-separated paths of unfilled dataset slots; present only + if status is 'partial'. + structured_output_error: + anyOf: + - $ref: '#/components/schemas/ErrorObject' + - type: 'null' + description: Why structured output failed; present only if status is 'failed'. + request_id: + anyOf: + - type: string + - type: 'null' + title: Request Id + type: object + title: RetrievalAgentResult + description: 'Final retrieval-agent output. answer is markdown prose with [n] + + citation markers. cards reuse the sibling TakoCard. citations is the + + unified top-level registry for data and web sources — there is no + + web_results field. metadata carries definitions, assumptions, and + + methodology. The structured_output_* fields carry the caller-shaped + + output_schema result; they are present only if the request supplied + + output_schema. See each field''s description for the exact presence + + rules.' + RetrievalAgentResultEvent: + properties: + kind: + type: string + const: agent_result + title: Kind + default: agent_result + id: + type: string + title: Id + data: + $ref: '#/components/schemas/RetrievalAgentResult' + type: object + required: + - id + - data + title: RetrievalAgentResultEvent + RetrievalAgentRun: + properties: + run_id: + type: string + title: Run Id + object: + type: string + const: agent.retrieval.run + title: Object + default: agent.retrieval.run + thread_id: + anyOf: + - type: string + - type: 'null' + title: Thread Id + status: + $ref: '#/components/schemas/AgentRunStatus' + created_at: + type: string + title: Created At + completed_at: + anyOf: + - type: string + - type: 'null' + title: Completed At + result: + anyOf: + - $ref: '#/components/schemas/RetrievalAgentResult' + - type: 'null' + error: + anyOf: + - $ref: '#/components/schemas/ErrorObject' + - type: 'null' + usage: + anyOf: + - $ref: '#/components/schemas/Usage' + - type: 'null' + request: + anyOf: + - $ref: '#/components/schemas/RetrievalAgentRunRequest' + - type: 'null' + type: object + required: + - run_id + - status + - created_at + title: RetrievalAgentRun + description: The retrieval-agent run resource returned by dispatch (202) and + poll (GET). + RetrievalAgentRunList: + properties: + object: + type: string + const: list + title: Object + default: list + data: + items: + $ref: '#/components/schemas/RetrievalAgentRunSummary' + type: array + title: Data + has_more: + type: boolean + title: Has More + default: false + next_cursor: + anyOf: + - type: string + - type: 'null' + title: Next Cursor + type: object + required: + - data + title: RetrievalAgentRunList + description: 'List envelope for GET /v1/agent/retrieval/runs. The shape follows + the + + OpenAI and Exa list convention.' + RetrievalAgentRunRequest: + properties: + query: + type: string + title: Query + description: Natural-language data-retrieval request for the retrieval agent. + examples: + - S&P 500 semiconductor companies' 2024 revenue and YoY growth + thread_id: + anyOf: + - type: string + format: uuid + - type: 'null' + title: Thread Id + description: Existing thread to continue (follow-up). Omit to start a new + thread. + effort: + $ref: '#/components/schemas/RetrievalAgentEffort' + description: Retrieval-agent effort. Only 'medium' is currently supported. + default: medium + source_indexes: + items: + type: string + enum: + - data + - web + type: array + title: Source Indexes + description: 'Which sources the agent may use: ''data'' (curated knowledge), + ''web'' (open-web search), or both. Defaults to [''data'', ''web'']. Tako + accepts the legacy value ''tako'' as a synonym for ''data''.' + examples: + - - data + - - web + - - data + - web + cards: + type: boolean + title: Cards + description: 'Whether the agent may build visualization cards. The default + is true and permissive: the agent may build cards, but it does not have + to (a completed run with no cards is legitimate). Set false to suppress + card building entirely.' + default: true + output_schema: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Output Schema + description: 'JSON Schema for structured output. The agent writes the synthesized + fields. Dataset slots (marked with x-tako-dataset: true) receive exact + retrieved rows as a TakoDataset. Supported subset: the object, array, + string, number, integer, and boolean types, plus enum, required, and description. + Caps: 16KB, depth 5, 64 properties, 4 dataset slots. Violations return + 400 output_schema_invalid.' + locale: + type: string + title: Locale + description: BCP-47 locale. Drives the language of the agent's answer and + the locale used when rendering card preview images. Defaults to en-US. + default: en-US + timezone: + anyOf: + - type: string + - type: 'null' + title: Timezone + description: IANA timezone (for example, 'America/New_York'). The agent + uses it to render dates and times in card preview images. It does not + affect the returned data. + output_settings: + anyOf: + - $ref: '#/components/schemas/AgentOutputSettings' + - type: 'null' + description: Settings that control the response and rendering. + type: object + required: + - query + title: RetrievalAgentRunRequest + description: Request body for POST /v1/agent/retrieval/runs. + RetrievalAgentRunSummary: + properties: + run_id: + type: string + title: Run Id + status: + $ref: '#/components/schemas/AgentRunStatus' + created_at: + type: string + title: Created At + completed_at: + anyOf: + - type: string + - type: 'null' + title: Completed At + thread_id: + anyOf: + - type: string + - type: 'null' + title: Thread Id + usage: + anyOf: + - $ref: '#/components/schemas/Usage' + - type: 'null' + object: + type: string + const: agent.retrieval.run + title: Object + default: agent.retrieval.run + type: object + required: + - run_id + - status + - created_at + title: RetrievalAgentRunSummary + RetrievalAgentStreamEnvelope: + properties: + seq: + type: integer + minimum: 0.0 + title: Seq + run_id: + type: string + title: Run Id + thread_id: + anyOf: + - type: string + - type: 'null' + title: Thread Id + category: + $ref: '#/components/schemas/StreamCategory' + block: + oneOf: + - $ref: '#/components/schemas/ToolCallEvent' + - $ref: '#/components/schemas/ToolResultEvent' + - $ref: '#/components/schemas/ToolErrorEvent' + - $ref: '#/components/schemas/ToolRetryEvent' + - $ref: '#/components/schemas/StatusEvent' + - $ref: '#/components/schemas/SubagentEvent' + - $ref: '#/components/schemas/ReasoningEvent' + - $ref: '#/components/schemas/TextEvent' + - $ref: '#/components/schemas/DataPipelineAnswerEvent' + - $ref: '#/components/schemas/RetrievalAgentResultEvent' + - $ref: '#/components/schemas/RunSummaryEvent' + - $ref: '#/components/schemas/HeartbeatEvent' + - $ref: '#/components/schemas/StreamResetEvent' + - $ref: '#/components/schemas/StreamDoneEvent' + title: Block + discriminator: + propertyName: kind + mapping: + agent_result: '#/components/schemas/RetrievalAgentResultEvent' + data_pipeline_answer: '#/components/schemas/DataPipelineAnswerEvent' + heartbeat: '#/components/schemas/HeartbeatEvent' + reasoning: '#/components/schemas/ReasoningEvent' + run_summary: '#/components/schemas/RunSummaryEvent' + status: '#/components/schemas/StatusEvent' + stream_done: '#/components/schemas/StreamDoneEvent' + stream_reset: '#/components/schemas/StreamResetEvent' + subagent: '#/components/schemas/SubagentEvent' + text: '#/components/schemas/TextEvent' + tool_call: '#/components/schemas/ToolCallEvent' + tool_error: '#/components/schemas/ToolErrorEvent' + tool_result: '#/components/schemas/ToolResultEvent' + tool_retry: '#/components/schemas/ToolRetryEvent' + type: object + required: + - seq + - run_id + - category + - block + title: RetrievalAgentStreamEnvelope + description: 'Public SSE envelope for the Retrieval Agent run stream. It has + the same + + wire shape as AgentStreamEnvelope. The only difference is that the + + terminal agent_result block carries RetrievalAgentResult (unified + + top-level citations, no web_results), so the SSE terminal matches the + + GET poll result.' + RunSummaryEvent: + properties: + kind: + type: string + const: run_summary + title: Kind + default: run_summary + status: + $ref: '#/components/schemas/AgentRunStatus' + created_at: + type: string + title: Created At + completed_at: + anyOf: + - type: string + - type: 'null' + title: Completed At + error: + anyOf: + - $ref: '#/components/schemas/ErrorObject' + - type: 'null' + usage: + anyOf: + - $ref: '#/components/schemas/Usage' + - type: 'null' + type: object + required: + - status + - created_at + title: RunSummaryEvent + description: 'Terminal run metadata. The stream emits it once, immediately before + + stream_done. It mirrors the GET-poll run fields that the stream otherwise + + lacks; the result stays in the agent_result event. status, created_at, + + and completed_at are always present. error appears only on failure. usage + + appears only on metered (pay-as-you-go) runs and is null otherwise. + + agent_result plus run_summary equals the GET poll object. + + + It carries the same envelope `seq` as the stream_done that follows it (it + + has no seq of its own), and the stream suppresses its SSE `id:`, so it + + advances no resume cursor. Identify it by `kind`; never assume `seq` is + + unique per frame.' + SearchEffortLevel: + type: string + enum: + - fast + - instant + - deep + title: SearchEffortLevel + description: 'Public effort taxonomy for search and answer. FAST is the default. + + INSTANT serves cached embeds without a new data retrieval and works in + + all environments. DEEP widens Tako retrieval and adds an LLM rerank for + + higher-quality results; it is slower and bills at a premium tier.' + SearchRequest: + properties: + query: + type: string + title: Query + description: Natural language search query. + examples: + - Intel vs Nvidia headcount since 2013 + effort: + $ref: '#/components/schemas/SearchEffortLevel' + description: 'Search effort level: ''fast'' (default), ''instant'', or ''deep''.' + default: fast + sources: + $ref: '#/components/schemas/Sources' + description: Per-source settings. The search includes an index only if its + key is present. Defaults to {data:{}, web:{}} (data and web, count 5 each). + Tako accepts the legacy key 'tako' as a synonym for 'data'. + location: + anyOf: + - $ref: '#/components/schemas/GeoLocation' + - type: 'null' + description: Optional coordinates of the end user. Resolves the location + for implicit-location queries (for example, weather). An explicit location + in the query overrides these coordinates. + country_code: + type: string + title: Country Code + description: ISO 3166-1 alpha-2 country code for localization. + default: US + locale: + type: string + title: Locale + description: BCP-47 locale tag for language and formatting. + default: en-US + timezone: + anyOf: + - type: string + - type: 'null' + title: Timezone + description: IANA timezone (for example, 'America/New_York'). + output_settings: + anyOf: + - $ref: '#/components/schemas/OutputSettings' + - type: 'null' + description: Settings that control the response shape. + additionalProperties: false + type: object + required: + - query + title: SearchRequest + SearchResponse: + properties: + cards: + items: + $ref: '#/components/schemas/TakoCard' + type: array + title: Cards + web_results: + items: + $ref: '#/components/schemas/WebResult' + type: array + title: Web Results + request_id: + type: string + title: Request Id + usage: + anyOf: + - $ref: '#/components/schemas/Usage' + - type: 'null' + type: object + required: + - request_id + title: SearchResponse + Sources: + properties: + data: + anyOf: + - $ref: '#/components/schemas/DataSourceSettings' + - type: 'null' + description: Tako data source (curated knowledge). The search includes it + only if present. Tako accepts the legacy key 'tako' as a synonym. + web: + anyOf: + - $ref: '#/components/schemas/WebSourceSettings' + - type: 'null' + description: Web source. The search includes it only if present. + additionalProperties: false + type: object + title: Sources + description: 'Per-source settings. The search includes an index only if its + field is + + present. + + + The Tako data source is named `data`. Tako still accepts the legacy key + + `tako` as a synonym and maps it to `data` before validation.' + StatusEvent: + properties: + kind: + type: string + const: status + title: Kind + default: status + message: + type: string + title: Message + parent_id: + anyOf: + - type: string + - type: 'null' + title: Parent Id + type: object + required: + - message + title: StatusEvent + StreamCategory: + type: string + enum: + - content + - activity + - control + title: StreamCategory + StreamDoneEvent: + properties: + kind: + type: string + const: stream_done + title: Kind + default: stream_done + type: object + title: StreamDoneEvent + StreamResetEvent: + properties: + kind: + type: string + const: stream_reset + title: Kind + default: stream_reset + type: object + title: StreamResetEvent + StructuredOutputStatus: + type: string + enum: + - complete + - partial + - failed + title: StructuredOutputStatus + description: Terminal status of the structured-output channel. + SubagentEvent: + properties: + kind: + type: string + const: subagent + title: Kind + default: subagent + agent_id: + type: string + title: Agent Id + subagent_type: + type: string + title: Subagent Type + parent_id: + anyOf: + - type: string + - type: 'null' + title: Parent Id + event: + type: string + enum: + - dispatch + - complete + title: Event + type: object + required: + - agent_id + - subagent_type + - event + title: SubagentEvent + TakoCard: + properties: + card_id: + anyOf: + - type: string + - type: 'null' + title: Card Id + title: + anyOf: + - type: string + - type: 'null' + title: Title + description: + anyOf: + - type: string + - type: 'null' + title: Description + semantic_description: + anyOf: + - type: string + - type: 'null' + title: Semantic Description + webpage_url: + anyOf: + - type: string + - type: 'null' + title: Webpage Url + image_url: + anyOf: + - type: string + - type: 'null' + title: Image Url + embed_url: + anyOf: + - type: string + - type: 'null' + title: Embed Url + sources: + anyOf: + - items: + $ref: '#/components/schemas/TakoCardSource' + type: array + - type: 'null' + title: Sources + methodologies: + anyOf: + - items: + $ref: '#/components/schemas/KnowledgeCardMethodology' + type: array + - type: 'null' + title: Methodologies + source_indexes: + anyOf: + - items: + $ref: '#/components/schemas/TakoSourceIndex' + type: array + - type: 'null' + title: Source Indexes + card_type: + anyOf: + - type: string + - type: 'null' + title: Card Type + relevance: + anyOf: + - $ref: '#/components/schemas/KnowledgeCardRelevance' + - type: 'null' + content: + anyOf: + - $ref: '#/components/schemas/ResultContent' + - type: 'null' + exportable: + type: boolean + title: Exportable + description: Whether the /contents endpoint can download this card's data. + false means the export is not available; use the card's chart and inline + preview instead. true means the export is eligible but not guaranteed; + the /contents endpoint can still return 403, so fall back to the preview + on error. + default: false + relevance_score: + anyOf: + - type: number + - type: 'null' + title: Relevance Score + description: Numeric relevance of this card to the query on a 1.0-5.0 scale + (5.0 = exact match; higher is more relevant). Only populated for entitled + accounts; null otherwise. + nodes: + anyOf: + - items: + $ref: '#/components/schemas/TakoCardNode' + type: array + - type: 'null' + title: Nodes + description: Graph nodes (entities and metrics) behind this card. The response + includes them by default. Absent for web-only cards or when node resolution + was not available. Use each id with /v1/graph/node/{id} for full detail + (aliases, subtype), or pass ids in sources.data.node_ids to pin these + nodes in future searches. + metric_definitions: + anyOf: + - items: + $ref: '#/components/schemas/MetricDefinition' + type: array + - type: 'null' + title: Metric Definitions + description: Definitions of the metrics this card displays (name + definition). + Null when no displayed metric has a definition available. + data_freshness: + anyOf: + - $ref: '#/components/schemas/DataFreshness' + - type: 'null' + description: 'Freshness dates for the card''s data: the coverage date (data_as_of) + and the last refresh date (last_updated). Null when neither date is available.' + type: object + title: TakoCard + description: 'A Tako knowledge card on the search and answer surfaces. It carries + a + + `content` download descriptor.' + TakoCardNode: + properties: + id: + type: string + title: Id + description: Opaque, human-friendly public id (::::, + where prefix is ent for an entity or mt for a metric). Ids are not durable + across knowledge-graph rebuilds — resolve them at request time rather + than storing them. + type: + $ref: '#/components/schemas/GraphNodeType' + name: + type: string + title: Name + description: + anyOf: + - type: string + - type: 'null' + title: Description + type: object + required: + - id + - type + - name + title: TakoCardNode + description: 'Slim graph node for the search and answer card surface + + (TakoCard.nodes). It is deliberately narrower than GraphNode: it carries + + no `aliases` and no `subtype`. Callers that need the full node resolve + + the id via /v1/graph/node/{id}.' + TakoCardSource: + properties: + source_name: + anyOf: + - type: string + - type: 'null' + title: Source Name + description: The name of the source + examples: + - S&P Global + - The World Bank + source_description: + anyOf: + - type: string + - type: 'null' + title: Source Description + description: The description of the source + source_index: + $ref: '#/components/schemas/TakoSourceIndex' + description: The index of the source + examples: + - data + - web + url: + anyOf: + - type: string + - type: 'null' + title: Url + description: The URL of the source + examples: + - https://xignite.com + source_text: + anyOf: + - type: string + - type: 'null' + title: Source Text + description: Raw excerpts retrieved from the source page — the unmodified + web content that grounded the answer. Present for WEB sources; null for + DATA sources. + type: object + required: + - source_index + title: TakoCardSource + description: 'A source that backs a TakoCard on the SDK surfaces. It uses the + + {data, web} TakoSourceIndex taxonomy.' + TakoDatasetColumn: + properties: + name: + type: string + title: Name + description: Column name. + type: + $ref: '#/components/schemas/TakoDatasetColumnType' + description: 'Logical column type: ''string'', ''number'', ''boolean'', + ''date'', or ''datetime''. Temporal cells are ISO-8601 strings.' + unit: + anyOf: + - type: string + - type: 'null' + title: Unit + description: Structured unit for the column values, e.g. 'USD billions', + '%'. Null when unitless. + type: object + required: + - name + - type + title: TakoDatasetColumn + description: Typed header entry; `type` is the JSON-facing column type. + TakoDatasetColumnType: + type: string + enum: + - string + - number + - boolean + - date + - datetime + title: TakoDatasetColumnType + description: 'Logical column type declared in a TakoDataset header. Temporal + cells + + are ISO-8601 strings. Each column declares ''date'' or ''datetime'' on its + + own: a temporal column whose non-null values are all timezone-naive + + midnights declares ''date''.' + TakoDatasetSource: + properties: + name: + type: string + title: Name + description: Human-readable source name (e.g. 'FRED', 'S&P Global'). + index: + type: string + enum: + - data + - web + title: Index + description: 'Source index the rows came from: ''data'' (Tako) or ''web''.' + default: data + type: object + required: + - name + title: TakoDatasetSource + description: 'Per-dataset provenance entry. `index` names the source index, + not a + + citation display number. It is "data" for every dataset today — web + + content never fills a dataset slot.' + TakoSourceIndex: + type: string + enum: + - data + - web + title: TakoSourceIndex + description: 'Public source taxonomy for the SDK card surfaces (v3 search, v1 + answer, + + agent). It is symmetric with the request taxonomy {data, web}.' + TextEvent: + properties: + kind: + type: string + const: text + title: Kind + default: text + id: + type: string + title: Id + delta: + type: string + title: Delta + done: + type: boolean + title: Done + default: false + type: object + required: + - id + - delta + title: TextEvent + ThinVizCard: + properties: + card_id: + anyOf: + - type: string + - type: 'null' + title: Card Id + description: Public ID of the created card. + title: + anyOf: + - type: string + - type: 'null' + title: Title + description: Card title. + description: + anyOf: + - type: string + - type: 'null' + title: Description + description: Card description. + webpage_url: + anyOf: + - type: string + - type: 'null' + title: Webpage Url + description: Hosted page URL for the card. + image_url: + anyOf: + - type: string + - type: 'null' + title: Image Url + description: Static preview image URL for the card. + embed_url: + anyOf: + - type: string + - type: 'null' + title: Embed Url + description: Embeddable URL for the card. + card_type: + anyOf: + - type: string + - type: 'null' + title: Card Type + description: The card's chart type (for example, 'bar'). + visualization_data: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Visualization Data + description: Inline chart config + data for rendering the card. + examples: + - data: + - x: '2013' + y: 10000 + viz_config: + title: Revenue + embed_mode: + anyOf: + - type: string + enum: + - post + - postmessage + - type: 'null' + title: Embed Mode + description: 'How Tako delivers the embed: ''postmessage'' for postMessage + embeds, and ''post'' otherwise.' + type: object + title: ThinVizCard + description: 'Response for POST /api/v1/thin_viz/create — a created visualization + card. + + + Distinct from KnowledgeCard: thin_viz builds a self-contained chart from + + a caller-supplied component schema, so it never carries retrieval + + provenance (sources, source_indexes) or downloadable raw data. Render the + + card via `embed_url` and preview it via `image_url`. + + `visualization_data` carries the inline chart config and data. + + + This schema documents the populated subset of the response. The wire + + payload also includes the remaining KnowledgeCard fields (sources, + + source_indexes, methodologies, data_url, and so on) as null.' + ToolCallEvent: + properties: + kind: + type: string + const: tool_call + title: Kind + default: tool_call + id: + type: string + title: Id + tool: + type: string + title: Tool + status_message: + anyOf: + - type: string + - type: 'null' + title: Status Message + parent_id: + anyOf: + - type: string + - type: 'null' + title: Parent Id + done: + type: boolean + title: Done + default: false + type: object + required: + - id + - tool + title: ToolCallEvent + ToolErrorEvent: + properties: + kind: + type: string + const: tool_error + title: Kind + default: tool_error + id: + type: string + title: Id + tool: + type: string + title: Tool + error: + type: string + title: Error + parent_id: + anyOf: + - type: string + - type: 'null' + title: Parent Id + type: object + required: + - id + - tool + - error + title: ToolErrorEvent + ToolResultEvent: + properties: + kind: + type: string + const: tool_result + title: Kind + default: tool_result + id: + type: string + title: Id + tool: + type: string + title: Tool + elapsed_ms: + type: integer + title: Elapsed Ms + default: 0 + link: + anyOf: + - type: string + - type: 'null' + title: Link + parent_id: + anyOf: + - type: string + - type: 'null' + title: Parent Id + type: object + required: + - id + - tool + title: ToolResultEvent + ToolRetryEvent: + properties: + kind: + type: string + const: tool_retry + title: Kind + default: tool_retry + id: + type: string + title: Id + tool: + type: string + title: Tool + error: + type: string + title: Error + elapsed_ms: + type: integer + title: Elapsed Ms + default: 0 + parent_id: + anyOf: + - type: string + - type: 'null' + title: Parent Id + type: object + required: + - id + - tool + - error + title: ToolRetryEvent + Usage: + properties: + total_cost_usd: + type: number + title: Total Cost Usd + description: Total quoted USD cost of this request. Sum of compute cost + and data cost. + compute: + anyOf: + - $ref: '#/components/schemas/UsageCompute' + - type: 'null' + description: Compute cost breakdown. Present only on surfaces with a compute + step (absent for contents). + data: + anyOf: + - $ref: '#/components/schemas/UsageData' + - type: 'null' + description: Inline-data cost breakdown. Present only when the surface emitted + billable inline data. + type: object + required: + - total_cost_usd + title: Usage + description: 'Usage for one metered request. `total_cost_usd` is always present + (the + + total quoted charge). `compute` and `data` are the additive breakdown; + + each appears only where it applies. total_cost_usd always equals the sum + + of the components that appear.' + UsageCompute: + properties: + cost_usd: + type: number + title: Cost Usd + description: USD cost of running the operation. + type: object + required: + - cost_usd + title: UsageCompute + description: 'The cost of running the operation. Absent on surfaces with no + compute + + step (contents).' + UsageData: + properties: + cost_usd: + type: number + title: Cost Usd + description: USD cost of the inline data delivered in the response. + datasets: + type: integer + title: Datasets + description: Number of billed data units (datasets) included in the response. + type: object + required: + - cost_usd + - datasets + title: UsageData + description: 'The cost and quantity of inline data delivered in the response: + the + + agent per-dataset surcharge, the search and answer include_contents + + charge, or the contents per-item cost. `datasets` is the count of billed + + data units. Absent when the surface did not or cannot emit inline data + + (for example, the answer agent).' + WebCategory: + type: string + enum: + - news + - sports + - finance + title: WebCategory + description: 'Web search category filter. Only ''news'' maps to a provider category + + today; ''sports'' and ''finance'' are accepted but have no effect yet.' + WebResult: + properties: + title: + type: string + title: Title + description: Title of the web page. + url: + type: string + title: Url + description: URL of the web page. + snippet: + anyOf: + - type: string + - type: 'null' + title: Snippet + description: Excerpt(s) from the page that matched the query. + source_name: + anyOf: + - type: string + - type: 'null' + title: Source Name + description: Publisher or domain name, when Tako can extract it from the + URL. + publish_date: + anyOf: + - type: string + - type: 'null' + title: Publish Date + description: Publication date of the page, when available. + content: + anyOf: + - $ref: '#/components/schemas/ResultContent' + - type: 'null' + description: Downloadable content descriptor for this result, fetched via + the Contents endpoint. Web results are always downloadable as text. None + for callers that do not populate it. + citation_number: + anyOf: + - type: integer + - type: 'null' + title: Citation Number + description: 1-based citation number that the answer's inline [N] markers + reference. Set only when the answer inline-cites this result (the Agent + API); None on raw-retrieval surfaces. + type: object + required: + - title + - url + title: WebResult + description: 'A single raw web search result from the WEB source index. + + + Distinct from `KnowledgeCardSource` (a citation inside a synthesized + + answer) and `KnowledgeCard` (a Tako visualization). Web results are + + raw retrieval output — title, URL, optional snippet. They do not depend + + on any LLM synthesis that may also happen over them.' + WebSourceSettings: + properties: + count: + type: integer + maximum: 20.0 + minimum: 1.0 + title: Count + description: Maximum number of results to return for this source. 1-20. + default: 5 + include_contents: + type: boolean + title: Include Contents + description: Inline this source's underlying data directly in the response. + For the Tako data source, that is serialized card data (see content_format). + For web results, that is the extracted text. + default: false + category: + anyOf: + - $ref: '#/components/schemas/WebCategory' + - type: 'null' + description: Restrict web results to a category. 'news' filters to news + sources. 'sports' and 'finance' are accepted but have no effect yet. Omit + for a general web search. + include_domains: + items: + type: string + type: array + maxItems: 20 + title: Include Domains + description: Return only results from these domains (bare hosts, for example + 'cnn.com'). Max 20. + exclude_domains: + items: + type: string + type: array + maxItems: 20 + title: Exclude Domains + description: Drop results from these domains (bare hosts, for example 'cnn.com'). + Max 20. + snippet_max_chars: + type: integer + maximum: 20000.0 + minimum: 1.0 + title: Snippet Max Chars + description: Character cap on the text excerpt returned per web result. + Default 1000, maximum 20000. + default: 1000 + article_content_max_chars: + type: integer + maximum: 1000000.0 + minimum: 1.0 + title: Article Content Max Chars + description: Character cap on the full article text when include_contents + is true. Default 30000, maximum 1000000. + default: 30000 + published_after: + anyOf: + - type: string + format: date + - type: 'null' + title: Published After + description: Keep only web results published on or after this date (ISO + 'YYYY-MM-DD'). Tako applies the date on the provider where possible, and + always filters the returned results. Results with no known publication + date are kept. Omit for no lower bound. + published_before: + anyOf: + - type: string + format: date + - type: 'null' + title: Published Before + description: Keep only web results published on or before this date (ISO + 'YYYY-MM-DD'). Results with no known publication date are kept. Omit for + no upper bound. + additionalProperties: false + type: object + title: WebSourceSettings + description: 'Web source settings. Adds category, domain filters, and character + caps + + on top of the base count and include_contents.' + securitySchemes: + apiKey: + type: apiKey + name: X-API-Key + in: header diff --git a/tests/contract/request.contract.test.ts b/tests/contract/request.contract.test.ts new file mode 100644 index 0000000..98aeb04 --- /dev/null +++ b/tests/contract/request.contract.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { buildSearchRequestBody } from "../../src/request"; +import { check, propertiesOf } from "./spec"; + +/** + * Request-side contract: every body this SDK sends must validate against Tako's + * published request schemas. + * + * Both `SearchRequest` and its nested `DataSourceSettings` / `WebSourceSettings` + * declare `additionalProperties: false`. That is the OpenAPI projection of the + * server's Pydantic `extra="forbid"`, so a property the schema rejects is a + * property the API rejects — a 400, not a silently-ignored field. + */ +describe("POST /v3/search — request body contract", () => { + it("sends a spec-valid body for the default config", () => { + const { valid, errors } = check("SearchRequest", buildSearchRequestBody({}, "q")); + expect(errors).toEqual([]); + expect(valid).toBe(true); + }); + + it("sends a spec-valid body for every documented config field", () => { + const body = buildSearchRequestBody( + { + effort: "deep", + countryCode: "GB", + locale: "en-GB", + timezone: "Europe/London", + sources: { data: { count: 10, includeContents: true }, web: { count: 3 } }, + outputSettings: { imageDarkMode: true, forceRefresh: false }, + }, + "q", + ); + const { valid, errors } = check("SearchRequest", body); + expect(errors).toEqual([]); + expect(valid).toBe(true); + }); + + // ---- P0-1: deferDataRetrieval is a field the API removed ---- + + it("DataSourceSettings does not accept defer_data_retrieval", () => { + // Guards the spec fact the bug depends on, so this test explains itself + // if Tako ever reintroduces the field. + expect(propertiesOf("DataSourceSettings")).toEqual([ + "count", + "include_contents", + "mode", + "content_format", + "node_ids", + "strict", + ]); + }); + + it("never emits defer_data_retrieval, whichever source key is used", () => { + // The config option is gone, so no caller can reach the field. This asserts + // the request builder itself cannot emit it either — including via the + // deprecated `tako` alias, which maps onto the same `data` wire key. + for (const sources of [ + { data: { count: 10, includeContents: true } }, + { tako: { count: 10, includeContents: true } }, + ]) { + const body = buildSearchRequestBody({ sources }, "q"); + expect(JSON.stringify(body)).not.toContain("defer"); + expect(check("SearchRequest", body).errors).toEqual([]); + } + }); + + it("rejects defer_data_retrieval if it is ever reintroduced by hand", () => { + // Pins the reason the option was removed: the API forbids unknown + // properties, so smuggling one back in is a 400, not a no-op. + const body = buildSearchRequestBody({ sources: { data: { count: 1 } } }, "q"); + const smuggled = { + ...body, + sources: { data: { ...body.sources!.data, defer_data_retrieval: true } }, + }; + expect(check("SearchRequest", smuggled).errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + instancePath: "/sources/data", + keyword: "additionalProperties", + params: { additionalProperty: "defer_data_retrieval" }, + }), + ]), + ); + }); +}); + +/** + * Request-side contract for POST /v1/contents. The tool builds this body inline + * (src/tools/contents.ts) rather than through a helper, so it is reproduced here. + */ +describe("POST /v1/contents — request body contract", () => { + it("sends a spec-valid body in url mode", () => { + const { errors } = check("ContentsRequest", { + url: "https://tako.com/card/abc123", + mode: "url", + }); + expect(errors).toEqual([]); + }); + + it("sends a spec-valid body in inline mode", () => { + const { errors } = check("ContentsRequest", { + url: "https://tako.com/card/abc123", + mode: "inline", + }); + expect(errors).toEqual([]); + }); +}); diff --git a/tests/contract/response.contract.test.ts b/tests/contract/response.contract.test.ts new file mode 100644 index 0000000..943b094 --- /dev/null +++ b/tests/contract/response.contract.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from "vitest"; +import { + ContentItemFromJSON, + ContentsFormat, + SearchResponseFromJSON, + TakoSourceIndex, +} from "tako-sdk"; +import { enumOf, propertiesOf, requiredOf, spec } from "./spec"; + +const schemas: Record = spec.components.schemas; + +/** + * Response-side contract, checked against two independent oracles: + * + * 1. Tako's vendored OpenAPI document (./openapi.yaml). + * 2. `tako-sdk`, Tako's official TypeScript client, generated from that + * same document and released from the Tako monorepo. + * + * When both agree that a field is absent, this SDK's type declaring it is drift. + * The `tako-sdk` decoders are the sharper oracle: they materialise exactly the + * fields the model knows, so a field they drop is a field the API stopped + * sending. + */ + +describe("SearchResponse / AnswerResponse — P0-2: usage replaced contents_total_cost", () => { + it("the spec declares usage and not contents_total_cost", () => { + expect(propertiesOf("SearchResponse")).toContain("usage"); + expect(propertiesOf("SearchResponse")).not.toContain("contents_total_cost"); + expect(propertiesOf("AnswerResponse")).toContain("usage"); + expect(propertiesOf("AnswerResponse")).not.toContain("contents_total_cost"); + }); + + it("the official client drops contents_total_cost and keeps usage", () => { + const decoded = SearchResponseFromJSON({ + cards: [], + web_results: [], + contents_total_cost: 0, + request_id: "r", + usage: { total_cost_usd: 0.01, compute: { cost_usd: 0.01 } }, + }); + expect(Object.keys(decoded)).not.toContain("contents_total_cost"); + expect(decoded.usage).toEqual({ + total_cost_usd: 0.01, + compute: { cost_usd: 0.01 }, + data: undefined, + }); + }); + + it("Usage carries the total plus an additive compute/data breakdown", () => { + expect(propertiesOf("Usage")).toEqual(["total_cost_usd", "compute", "data"]); + expect(requiredOf("Usage")).toEqual(["total_cost_usd"]); + }); +}); + +describe("SearchResponse — P0-5: only request_id is guaranteed", () => { + it("cards and web_results are optional in the spec", () => { + expect(requiredOf("SearchResponse")).toEqual(["request_id"]); + }); + + it("the official client leaves cards/web_results undefined when omitted", () => { + const decoded = SearchResponseFromJSON({ request_id: "r" }); + expect(decoded.request_id).toBe("r"); + expect(decoded.cards).toBeUndefined(); + expect(decoded.web_results).toBeUndefined(); + }); + + it("AnswerResponse guarantees answer alongside request_id", () => { + expect(requiredOf("AnswerResponse")).toEqual(["answer", "request_id"]); + }); +}); + +describe("ResultContent / ContentItem — P0-3: format became content_format", () => { + it("the spec names the field content_format, not format", () => { + for (const schema of ["ResultContent", "ContentItem"]) { + expect(propertiesOf(schema)).toContain("content_format"); + expect(propertiesOf(schema)).not.toContain("format"); + } + }); + + it("the serialization enum is csv/json_records/json_compact — 'text' is gone", () => { + expect(enumOf("ContentsFormat")).toEqual(["csv", "json_records", "json_compact"]); + expect(enumOf("ContentsFormat")).not.toContain("text"); + expect(Object.values(ContentsFormat)).toEqual(["csv", "json_records", "json_compact"]); + }); + + it("the official client drops `format` and materialises content_format", () => { + const decoded = ContentItemFromJSON({ + source_url: "https://tako.com/card/x", + format: "csv", // what this SDK's type claims + content_format: "csv", // what the API actually sends + cost: 0, + truncated: false, + }); + expect(Object.keys(decoded)).not.toContain("format"); + expect(decoded.content_format).toBe("csv"); + }); + + it("carries payload and pricing fields this SDK omits entirely", () => { + const props = propertiesOf("ResultContent"); + expect(props).toContain("records"); // json_records payload + expect(props).toContain("dataset"); // json_compact payload (TakoDataset) + expect(props).toContain("export_pricing"); // rate card for a priced export + expect(props).toContain("manifest"); // per-column metadata + }); +}); + +describe("TakoCardSource — P0-4: the source index taxonomy collapsed to {data, web}", () => { + it("the spec enum is exactly data | web", () => { + expect(enumOf("TakoSourceIndex")).toEqual(["data", "web"]); + expect(Object.values(TakoSourceIndex)).toEqual(["data", "web"]); + }); + + it("dropped the legacy tako / connected_data / tako_deep_v2 values", () => { + const values = enumOf("TakoSourceIndex"); + for (const legacy of ["tako", "connected_data", "tako_deep_v2"]) { + expect(values).not.toContain(legacy); + } + }); + + it("source_index is a bare enum — there are no segment/private-index object shapes", () => { + // 2.x exported TakoCardSourceIndexSegment and TakoCardSourcePrivateIndex as + // public types. Neither ever existed in the API; both were removed in 3.0. + const names = Object.keys(schemas); + expect(names).not.toContain("CardSourceIndexSegment"); + expect(names).not.toContain("CardSourcePrivateIndex"); + expect(names).not.toContain("TakoCardSourceIndexSegment"); + expect(names).not.toContain("TakoCardSourcePrivateIndex"); + + const sourceIndex = schemas.TakoCardSource.properties.source_index; + expect(sourceIndex).toEqual({ + $ref: "#/components/schemas/TakoSourceIndex", + description: "The index of the source", + examples: ["data", "web"], + }); + }); +}); + +describe("dataset cells — justifies the one patch in types.conformance.ts", () => { + // tako-sdk's generator renders these unions as an empty interface + // (`RowsInnerInner {}`). The spec is unambiguous, so TakoDatasetCell follows + // the spec and the conformance fixture patches the official type instead. + const CELL_UNION = [ + { type: "string" }, + { type: "number" }, + { type: "integer" }, + { type: "boolean" }, + { type: "null" }, + ]; + + it("dataset rows hold string | number | boolean | null", () => { + expect(schemas.TakoDataset.properties.rows.items.items.anyOf).toEqual(CELL_UNION); + }); + + it("json_records values hold the same union", () => { + expect(schemas.ResultContent.properties.records.anyOf[0].items.additionalProperties.anyOf).toEqual( + CELL_UNION, + ); + }); +}); + +describe("methodologies — required keys with nullable values", () => { + it("both fields are required, so the keys are always present", () => { + expect(requiredOf("KnowledgeCardMethodology")).toEqual([ + "methodology_name", + "methodology_description", + ]); + }); +}); + +describe("TakoCard — P0-6: agent-relevant fields missing from this SDK", () => { + it("the spec carries exportable, relevance_score, nodes, metric_definitions, data_freshness", () => { + const props = propertiesOf("TakoCard"); + expect(props).toContain("exportable"); + expect(props).toContain("relevance_score"); + expect(props).toContain("nodes"); + expect(props).toContain("metric_definitions"); + expect(props).toContain("data_freshness"); + }); + + it("data_freshness reports coverage and refresh dates", () => { + expect(propertiesOf("DataFreshness")).toEqual(["data_as_of", "last_updated"]); + }); +}); diff --git a/tests/contract/spec.ts b/tests/contract/spec.ts new file mode 100644 index 0000000..b758349 --- /dev/null +++ b/tests/contract/spec.ts @@ -0,0 +1,60 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import Ajv2020 from "ajv/dist/2020"; +import addFormats from "ajv-formats"; +import YAML from "yaml"; + +/** + * Tako's published OpenAPI document, vendored verbatim from + * https://docs.tako.com/api-reference/openapi.yaml + * + * It is generated from the Tako monorepo and is the authoritative description of + * the live API. Tests validate against it so that API drift fails CI instead of + * silently shipping. Refresh with `pnpm spec:refresh`. + */ +const SPEC_PATH = fileURLToPath(new URL("./openapi.yaml", import.meta.url)); + +export const spec = YAML.parse(readFileSync(SPEC_PATH, "utf8")); + +const ajv = new Ajv2020({ + strict: false, // the document carries OpenAPI keywords ajv doesn't know + allErrors: true, +}); +addFormats(ajv); +ajv.addSchema(spec, "openapi"); + +/** + * Compile a validator for one `#/components/schemas/` entry. + * + * Request schemas declare `additionalProperties: false`, which mirrors the + * server's Pydantic `extra="forbid"`: a field the model rejects fails here too. + */ +export function validator(schemaName: string) { + const validate = ajv.getSchema(`openapi#/components/schemas/${schemaName}`); + if (!validate) throw new Error(`No such schema in the Tako spec: ${schemaName}`); + return validate; +} + +/** Validate `value`, returning the ajv error list (empty when valid). */ +export function check(schemaName: string, value: unknown) { + const validate = validator(schemaName); + const valid = validate(value); + return { valid, errors: validate.errors ?? [] }; +} + +/** The property names the spec allows on a schema. */ +export function propertiesOf(schemaName: string): string[] { + return Object.keys(spec.components.schemas[schemaName].properties ?? {}); +} + +/** The `required` list the spec declares for a schema. */ +export function requiredOf(schemaName: string): string[] { + return spec.components.schemas[schemaName].required ?? []; +} + +/** Resolve a schema's enum values, following a single `$ref` hop if present. */ +export function enumOf(schemaName: string): unknown[] { + const schema = spec.components.schemas[schemaName]; + if (!schema) throw new Error(`No such schema in the Tako spec: ${schemaName}`); + return schema.enum ?? []; +} diff --git a/tests/contract/types.conformance.test.ts b/tests/contract/types.conformance.test.ts new file mode 100644 index 0000000..e519c19 --- /dev/null +++ b/tests/contract/types.conformance.test.ts @@ -0,0 +1,44 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const execFileAsync = promisify(execFile); +const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url)); + +/** + * Compiles tests/contract/types.conformance.ts and returns tsc's diagnostics + * (empty string on a clean compile). + * + * That file assigns `tako-sdk`'s official generated types — produced from the + * same OpenAPI document the live API is built from — into this SDK's declared + * public types, and back again. Any diagnostic is real drift: a field this SDK + * promises TypeScript will be present which the API does not send, or an enum + * value one side knows and the other doesn't. + * + * Keeping this green is what stops `src/types.ts` silently rotting against the + * API the way it did between 2.0.1 and 3.0.0. When Tako ships an API change and + * `tako-sdk` is bumped, this test fails until the types catch up. + */ +async function compileConformance(): Promise { + try { + await execFileAsync("pnpm", ["exec", "tsc", "-p", "tsconfig.conformance.json"], { + cwd: REPO_ROOT, + }); + return ""; + } catch (err) { + // tsc exits non-zero when it reports diagnostics; they land on stdout. + return (err as { stdout?: string }).stdout ?? ""; + } +} + +describe("type conformance against tako-sdk (the official generated client)", () => { + it( + "this SDK's types match the API", + async () => { + expect(await compileConformance()).toBe(""); + }, + // A cold tsc run over src + the conformance fixture. + 60_000, + ); +}); diff --git a/tests/contract/types.conformance.ts b/tests/contract/types.conformance.ts new file mode 100644 index 0000000..83b5f3d --- /dev/null +++ b/tests/contract/types.conformance.ts @@ -0,0 +1,108 @@ +/** + * Type-level conformance between this SDK's public types and `tako-sdk`, Tako's + * official client generated from the published OpenAPI document. + * + * This file is deliberately NOT part of `pnpm typecheck`. It is compiled by + * `types.conformance.test.ts`, which asserts it compiles clean. Every + * assignment below is a question: + * + * "Can this SDK's declared type actually hold what the API sends?" + * + * A clean compile means yes. Any error is a real runtime hazard: a field the + * SDK promises TypeScript will be there, which the API does not send. + * + * The `Tako*Response` types are the wire shapes and must match the official + * client field for field. The `Tako*Result` types are what the tools return + * after normalization, so they are checked in the other direction: whatever the + * SDK guarantees must be a shape the API could actually have produced. + */ +import type { + ContentItem as OfficialContentItem, + ContentsFormat as OfficialContentsFormat, + ResultContent as OfficialResultContent, + SearchResponse as OfficialSearchResponse, + AnswerResponse as OfficialAnswerResponse, + ContentsResponse as OfficialContentsResponse, + TakoCard as OfficialTakoCard, + TakoDataset as OfficialTakoDataset, + WebResult as OfficialWebResult, + TakoSourceIndex as OfficialTakoSourceIndex, +} from "tako-sdk"; +import type { + TakoAnswerResponse, + TakoAnswerResult, + TakoCard, + TakoContentFormat, + TakoContentItem, + TakoContentsResponse, + TakoContentsResult, + TakoSearchResponse, + TakoSearchResult, + TakoSourceIndex, +} from "../../src/types"; + +/** + * One documented deviation from the official client. + * + * The spec types dataset cells and `records` values as + * `anyOf [string, number, integer, boolean, null]`, but the OpenAPI generator + * renders that union as an empty interface (`RowsInnerInner {}`), which admits + * objects and rejects null — strictly less precise than the spec. + * `TakoDatasetCell` follows the spec instead, so the official type is patched at + * that one leaf rather than degrading ours. `response.contract.test.ts` pins the + * spec's actual union so this stays evidence-based. + * + * Everything outside these two fields is compared strictly. + */ +type Cell = string | number | boolean | null; +type FixedDataset = Omit & { rows: Cell[][] }; +type FixedContent = Omit & { + records?: Record[] | null; + dataset?: FixedDataset | null; +}; +type WithFixedContent = Omit & { content?: FixedContent | null }; +type FixedCard = WithFixedContent; +type FixedWebResult = WithFixedContent; +type WithFixedCollections = Omit & { + cards?: FixedCard[]; + web_results?: FixedWebResult[]; +}; + +declare const officialSearch: WithFixedCollections; +declare const officialAnswer: WithFixedCollections; +declare const officialContents: Omit & { + contents?: (Omit & FixedContent)[]; +}; +declare const officialContentItem: Omit & FixedContent; +declare const officialCard: FixedCard; +declare const officialSourceIndex: OfficialTakoSourceIndex; +declare const officialFormat: OfficialContentsFormat; + +// --- Wire types: must accept exactly what the API sends --- + +export const search: TakoSearchResponse = officialSearch; +export const answer: TakoAnswerResponse = officialAnswer; +export const contents: TakoContentsResponse = officialContents; +export const contentItem: TakoContentItem = officialContentItem; +export const card: TakoCard = officialCard; + +// --- Enums: must agree in both directions --- + +export const format: TakoContentFormat = officialFormat; +export const formatBack: OfficialContentsFormat = null as unknown as TakoContentFormat; +export const sourceIndex: TakoSourceIndex = officialSourceIndex; +export const sourceIndexBack: OfficialTakoSourceIndex = null as unknown as TakoSourceIndex; + +// --- Result types: the normalized shapes the tools return --- +// +// Checked the other way round. Normalization only fills in absent collections, +// so every result must still be a valid wire response — that proves the +// normalizer adds guarantees without inventing fields. + +declare const searchResult: TakoSearchResult; +declare const answerResult: TakoAnswerResult; +declare const contentsResult: TakoContentsResult; + +export const searchResultIsWireValid: WithFixedCollections = searchResult; +export const answerResultIsWireValid: WithFixedCollections = answerResult; +export const contentsResultIsWireValid: typeof officialContents = contentsResult; diff --git a/tests/index.test.ts b/tests/index.test.ts index cdf4063..fc27dc3 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -12,7 +12,7 @@ describe("index barrel", () => { }); it("the exported takoSearch produces a working tool", async () => { - stubFetch(200, JSON.stringify({ cards: [], web_results: [], contents_total_cost: 0, request_id: "r" })); + stubFetch(200, JSON.stringify({ cards: [], web_results: [], request_id: "r" })); const res = await runTool(takoSearch({ apiKey: "key" }), { query: "x" }); expect((res as any).request_id).toBe("r"); }); diff --git a/tests/search.test.ts b/tests/search.test.ts index 7fc3329..59c1c0f 100644 --- a/tests/search.test.ts +++ b/tests/search.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, afterEach, vi } from "vitest"; import { takoSearch } from "../src/tools/search"; import { stubFetch, runTool } from "./_helpers"; -const OK = JSON.stringify({ cards: [], web_results: [], contents_total_cost: 0, request_id: "r" }); +const OK = JSON.stringify({ cards: [], web_results: [], request_id: "r" }); afterEach(() => vi.unstubAllGlobals()); @@ -28,7 +28,7 @@ describe("takoSearch", () => { const t = takoSearch({ apiKey: "key", effort: "deep", - sources: { data: { count: 10, deferDataRetrieval: true } }, + sources: { data: { count: 10, includeContents: true } }, timezone: "America/New_York", outputSettings: { imageDarkMode: true }, }); @@ -39,7 +39,7 @@ describe("takoSearch", () => { effort: "deep", country_code: "US", locale: "en-US", - sources: { data: { count: 10, defer_data_retrieval: true } }, + sources: { data: { count: 10, include_contents: true } }, timezone: "America/New_York", output_settings: { image_dark_mode: true }, }); @@ -49,12 +49,38 @@ describe("takoSearch", () => { const fetchMock = stubFetch(200, OK); const t = takoSearch({ apiKey: "key", - sources: { tako: { count: 10, deferDataRetrieval: true } }, + sources: { tako: { count: 10, includeContents: true } }, }); await runTool(t, { query: "x" }); const init = fetchMock.mock.calls[0][1] as RequestInit; const body = JSON.parse(init.body as string); - expect(body.sources).toEqual({ data: { count: 10, defer_data_retrieval: true } }); + expect(body.sources).toEqual({ data: { count: 10, include_contents: true } }); + }); + + it("normalizes absent collections to empty arrays", async () => { + // The API guarantees only request_id, so a valid response can omit both + // collections. Callers still get arrays they can read without a guard. + stubFetch(200, JSON.stringify({ request_id: "r" })); + const res = (await runTool(takoSearch({ apiKey: "key" }), { query: "x" })) as any; + expect(res.cards).toEqual([]); + expect(res.web_results).toEqual([]); + expect(res.request_id).toBe("r"); + }); + + it("passes usage through untouched", async () => { + stubFetch( + 200, + JSON.stringify({ + request_id: "r", + usage: { total_cost_usd: 0.03, compute: { cost_usd: 0.01 }, data: { cost_usd: 0.02, datasets: 2 } }, + }), + ); + const res = (await runTool(takoSearch({ apiKey: "key" }), { query: "x" })) as any; + expect(res.usage).toEqual({ + total_cost_usd: 0.03, + compute: { cost_usd: 0.01 }, + data: { cost_usd: 0.02, datasets: 2 }, + }); }); it("honors baseUrl override (trailing slash stripped)", async () => { diff --git a/tests/types.test.ts b/tests/types.test.ts index eeb0f80..a5dd850 100644 --- a/tests/types.test.ts +++ b/tests/types.test.ts @@ -4,11 +4,12 @@ import type { TakoAnswerResult, TakoContentsResult, TakoCard, + TakoCardSource, TakoWebResult, TakoContentItem, TakoRetrievalConfig, TakoContentsConfig, - TakoKnowledgeCardSource, + TakoUsage, } from "../src/types"; describe("types", () => { @@ -18,60 +19,101 @@ describe("types", () => { title: "Nvidia vs AMD", semantic_description: "headcount", relevance: "High", - source_indexes: ["tako", { index_type: "connected_data", segment_id: "123" }], - sources: [{ source_name: "S&P", source_description: null, source_index: "tako", url: null }], + relevance_score: 4.5, + exportable: true, + source_indexes: ["data", "web"], + sources: [{ source_name: "S&P", source_description: null, source_index: "data", url: null }], methodologies: [{ methodology_name: "m", methodology_description: null }], - content: { format: "csv", cost: 0 }, + nodes: [{ id: "ent::nvidia::ab12", type: "entity", name: "Nvidia" }], + metric_definitions: [{ name: "Full Time Employees", definition: "Headcount at fiscal year end." }], + data_freshness: { data_as_of: "2026-01-31", last_updated: "2026-02-14" }, + content: { content_format: "csv", cost: 0 }, }; const web: TakoWebResult = { title: "W", url: "https://e.com", citation_number: 1 }; - const res: TakoSearchResult = { cards: [card], web_results: [web], contents_total_cost: 0, request_id: "r" }; + const usage: TakoUsage = { total_cost_usd: 0.02, compute: { cost_usd: 0.02 } }; + const res: TakoSearchResult = { cards: [card], web_results: [web], request_id: "r", usage }; expect(res.cards[0].card_id).toBe("c1"); + expect(res.usage?.total_cost_usd).toBe(0.02); }); - it("models all three source_index shapes", () => { - const sources: TakoKnowledgeCardSource[] = [ - { source_name: "S&P", source_description: null, source_index: "tako", url: null }, - { - source_name: "Segment", - source_description: null, - source_index: { index_type: "connected_data", segment_id: "123" }, - url: null, - }, - { - source_name: "Private", - source_description: null, - source_index: { index_type: "connected_data", private_index_id: "pi_1" }, - url: null, - }, + it("models a card source over the {data, web} taxonomy", () => { + const sources: TakoCardSource[] = [ + { source_name: "S&P", source_index: "data" }, + { source_name: "Reuters", source_index: "web", source_text: "raw excerpt" }, ]; - expect(sources).toHaveLength(3); + expect(sources.map((s) => s.source_index)).toEqual(["data", "web"]); }); it("models an answer result", () => { - const res: TakoAnswerResult = { answer: "A", cards: [], web_results: [], contents_total_cost: 0, request_id: "r" }; + const res: TakoAnswerResult = { answer: "A", cards: [], web_results: [], request_id: "r" }; expect(res.answer).toBe("A"); }); - it("models a contents result", () => { + it("models a contents result in url mode", () => { const item: TakoContentItem = { source_url: "https://tako.com/card/x", url: "https://signed", expires_at: "2026-01-01T00:00:00Z", - format: "csv", + content_format: "csv", cost: 0, total_rows: 5, truncated: false, + export_pricing: { baseline_usd: 0.01, row_cpm_usd: 0.5, free_rows: 20, max_rows_ceiling: 2000 }, }; const res: TakoContentsResult = { contents: [item], request_id: "r" }; - expect(res.contents[0].format).toBe("csv"); + expect(res.contents[0].content_format).toBe("csv"); + }); + + it("models the json_compact dataset payload", () => { + const item: TakoContentItem = { + source_url: "https://tako.com/card/x", + content_format: "json_compact", + dataset: { + columns: [ + { name: "year", type: "date" }, + { name: "employees", type: "number", unit: "count" }, + ], + rows: [ + ["2024-01-31", 29600], + ["2025-01-31", 36000], + ], + total_rows: 2, + truncated: false, + ref: "https://tako.com/card/x", + sources: [{ name: "S&P Global", index: "data" }], + provenance: "query", + }, + }; + expect(item.dataset?.rows[1][1]).toBe(36000); + }); + + it("models web page text, which carries no content_format", () => { + // The field is optional as well as nullable: web text arrives as either an + // explicit null or an absent key, so both must typecheck and both must be + // caught by a loose `== null` test. + const explicitNull: TakoContentItem = { + source_url: "https://e.com/article", + content_format: null, + data: "extracted prose", + }; + const absent: TakoContentItem = { + source_url: "https://e.com/article", + data: "extracted prose", + }; + expect(explicitNull.content_format).toBeNull(); + expect(absent.content_format).toBeUndefined(); + for (const item of [explicitNull, absent]) { + expect(item.content_format == null).toBe(true); + expect(item.data).toBe("extracted prose"); + } }); it("accepts retrieval + contents config", () => { const r: TakoRetrievalConfig = { apiKey: "k", - baseUrl: "https://staging.trytako.com", + baseUrl: "https://staging.tako.com", effort: "deep", - sources: { data: { count: 10, deferDataRetrieval: true }, web: { count: 3, includeContents: true } }, + sources: { data: { count: 10, includeContents: true }, web: { count: 3, includeContents: true } }, countryCode: "US", locale: "en-US", timezone: "America/New_York", diff --git a/tsconfig.check.json b/tsconfig.check.json index 7905020..e6b2696 100644 --- a/tsconfig.check.json +++ b/tsconfig.check.json @@ -1,5 +1,9 @@ { "extends": "./tsconfig.json", "compilerOptions": { "noEmit": true, "rootDir": "." }, - "include": ["src", "tests", "examples"] + "include": ["src", "tests", "examples"], + // Compiled on purpose by tests/contract/types.conformance.test.ts, which + // asserts it compiles clean against tako-sdk. Excluded here so drift surfaces + // as that one named test failing, not as noise across `pnpm typecheck`. + "exclude": ["node_modules", "dist", "tests/contract/types.conformance.ts"] } diff --git a/tsconfig.conformance.json b/tsconfig.conformance.json new file mode 100644 index 0000000..29f3e32 --- /dev/null +++ b/tsconfig.conformance.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "noEmit": true, "rootDir": "." }, + "include": ["tests/contract/types.conformance.ts", "src"] +} From f6697cbb7240f270c7eefa41f17c3740947da5da Mon Sep 17 00:00:00 2001 From: Eric Date: Mon, 3 Aug 2026 13:29:13 -0700 Subject: [PATCH 2/6] docs: correct cost guidance after live API verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified every P0 claim against the real API with a key. All six type fixes hold and every live response validates against the vendored spec via ajv. Two claims in the docs did not survive contact and are corrected here. `usage` is not populated. `contents_total_cost` is confirmed gone, but the spec's replacement is absent from every response tested: plain search, deep search, search with includeContents, answer, contents quote_only, and contents inline. Recommending `usage?.total_cost_usd` as the migration path was wrong — it always reads undefined. Per-item `content.cost` and `content.export_pricing` are populated and are where pricing actually lives today. The type stays `usage?: TakoUsage | null` so it lights up if Tako starts emitting it. Empty collections are sent, not omitted. The API returns `cards: []` on a web-only search rather than dropping the key, so 2.x's always-present typing was a latent hazard rather than an active crash. The contract still permits omission and tako-sdk decodes an absent collection to undefined, so the optional wire type and the normalizer both stay. Live results worth recording: - defer_data_retrieval → HTTP 400 "Extra inputs are not permitted", confirming the removal was load-bearing and not cosmetic. - exportable predicts export outcomes exactly: false → 403 "Data export is not available for this card (protected source)", true → 200. - export_pricing returns free_rows=20, max_rows_ceiling=2000, confirming the "capped at 1000 rows" doc fix. - includeContents returns content_format "json_compact" with a real TakoDataset payload, a shape 2.x's `format: "csv" | "text"` could not represent. --- MIGRATING.md | 18 ++++++++++++------ README.md | 4 +++- src/types.ts | 6 ++++++ 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/MIGRATING.md b/MIGRATING.md index 4c67439..38325b2 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -22,18 +22,24 @@ The API removed `defer_data_retrieval` from its data-source settings, and those | `content.format` | `content.content_format` | | `TakoContentFormat = 'csv' \| 'text'` | `'csv' \| 'json_records' \| 'json_compact'` | -**Cost.** `contents_total_cost` no longer exists. `usage` replaces it and adds a breakdown: +**Cost.** `contents_total_cost` no longer exists — confirmed absent from every live response. The spec defines `usage` as its successor, but **the API does not currently populate `usage` either**, so there is no drop-in replacement for an aggregate request cost: ```ts // 2.x const cost = result.contents_total_cost; // undefined at runtime -// 3.0 -const cost = result.usage?.total_cost_usd ?? 0; -const compute = result.usage?.compute?.cost_usd; // running the operation -const data = result.usage?.data?.cost_usd; // inline data delivered +// 3.0 — typed, but currently always undefined in practice +const cost = result.usage?.total_cost_usd; + +// 3.0 — where pricing actually lives today: per item +for (const card of result.cards) { + card.content?.cost; // USD, e.g. 0.001 + card.content?.export_pricing; // rate card for a full /contents export +} ``` +Verified 2026-08 across plain, `deep` and `includeContents` search, answer, and both contents modes: `usage` was absent from every response. It is typed `usage?: TakoUsage | null` so it will light up if Tako starts emitting it, but do not build cost tracking on it yet. Sum the per-item `cost` fields instead. + **Content format.** The field was renamed *and* its values changed. `'text'` is gone: web page text is now signalled by `content_format === null`. ```ts @@ -68,7 +74,7 @@ if (src.source_index === 'data') // correct ### Guaranteed collections -The API guarantees only `request_id` (plus `answer` on the answer surface) and omits empty collections rather than sending `[]`. 2.x typed `cards`, `web_results` and `contents` as always-present, so `result.cards.length` could throw on a perfectly valid response. +The API guarantees only `request_id` (plus `answer` on the answer surface); the collections are not in its `required` list, so a valid response may omit them. In practice the API currently does send `cards: []` on a web-only search, so 2.x's always-present typing was a latent hazard rather than an active crash — but the contract permits omission, and `tako-sdk` decodes an absent collection to `undefined`. 3.0 keeps them non-optional **and makes it true**: the tools normalize absent collections to `[]` before returning. No caller changes needed, and no `?.` required. diff --git a/README.md b/README.md index c96bcf3..8de74d1 100644 --- a/README.md +++ b/README.md @@ -97,10 +97,12 @@ The LLM supplies only the dynamic input: `{ query }` for `takoSearch`/`takoAnswe cards: TakoCard[]; // Tako knowledge cards (title, description, image_url, webpage_url, sources, ...) web_results: TakoWebResult[]; request_id: string; - usage?: TakoUsage | null; // { total_cost_usd, compute?, data? } + usage?: TakoUsage | null; // { total_cost_usd, compute?, data? } — see note } ``` +> **Cost reporting.** `usage` is what the API spec defines for per-request cost, but as of 2026-08 it is not populated on any endpoint. For pricing today, read the per-item `content.cost` and `content.export_pricing` on each card, which are populated. + `takoAnswer` additionally includes `answer: string` (with `cards[0]` as the lead card). `takoContents` resolves to `{ contents: TakoContentItem[]; request_id: string; usage? }`. The API guarantees only `request_id` and omits empty collections, so the tools normalize: `cards`, `web_results` and `contents` are **always arrays**. No `?.` needed. diff --git a/src/types.ts b/src/types.ts index 88b9988..2ca061f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -90,6 +90,12 @@ export interface TakoUsageData { /** * Usage for one metered request. `total_cost_usd` always equals the sum of * whichever breakdown components are present. + * + * The spec defines this as the successor to the removed `contents_total_cost`, + * but as of 2026-08 the API does not populate it on search, answer or contents + * (verified live across plain, deep and include_contents calls). Treat it as + * genuinely optional. For per-item pricing today, read `TakoResultContent.cost` + * and `TakoResultContent.export_pricing`, which are populated. */ export interface TakoUsage { /** Total quoted USD cost of this request. */ From e21eac7ab9d937348dc9e993394c603ee9ac592f Mon Sep 17 00:00:00 2001 From: Eric Date: Mon, 3 Aug 2026 13:47:18 -0700 Subject: [PATCH 3/6] chore: ignore local codebase-analysis output .understand-anything/ is 460K of local analysis output that sits untracked in the repo. Ignoring it so it cannot be swept into a commit on a public repo. --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index af3f68a..ac664cb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ dist .env *.log .DS_Store + +# Local codebase-analysis output. Not part of the package; keep it out of the +# public repo so a stray `git add -A` can't publish it. +.understand-anything/ From e85ba81696811490ff731506a1dc91dfb991141b Mon Sep 17 00:00:00 2001 From: Eric Date: Mon, 3 Aug 2026 14:07:19 -0700 Subject: [PATCH 4/6] fix: close two holes in the drift gate, plus review corrections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review on #9. Two findings were test-integrity bugs that made the drift claim weaker than advertised; the rest are accuracy fixes. The conformance test passed when tsc never ran. A launch failure (spawn error, or pnpm rejecting the local node version) leaves stdout empty, so `err.stdout ?? ""` returned "" and `expect("").toBe("")` passed in ~250ms with nothing checked. It now requires a numeric exit code with non-empty stdout to treat output as diagnostics, and throws with exit code and stderr otherwise, so "could not check" is loud instead of indistinguishable from "no drift". One-directional assignability missed drift by a whole key. Deleting `data_freshness` and `relevance_score` from TakoCard, or leaving a stale optional `format?: "csv" | "text"` on TakoResultContent, both compiled clean with all 26 contract tests green — an absent optional key is assignable, and no excess-property check applies to a `declare const`. That is five of the six card fields this PR adds, ungated. Adds a key-set symmetry check over all eight mirrored types; both regressions now fail the compile and the error names the key. All eight were already symmetric, so nothing else changed. Contents request body now goes through `buildContentsRequestBody`, shared by the tool and the contract test, so a change to the body shape breaks the test instead of sliding past a hand-written copy. `ContentsRequest` is also the one request schema without `additionalProperties: false`, so extras cannot be caught by ajv there; asserted directly instead, and the comments no longer generalize past where that holds. Documentation corrections: - MIGRATING.md taught `content_format === null`, the exact bug the PR reports catching in review. Now `== null`, matching types.ts, README and the example. - The responses table still pointed at `usage?.total_cost_usd` after 868dac2 corrected the prose. Now says no replacement, read per-item cost. - README claimed the API "omits empty collections"; live testing found it sends `cards: []`. Omission is contract-permitted, not observed. - Softened "API drift fails CI" in README and MIGRATING: both oracles are pinned snapshots, so the suite catches regressions here, not changes Tako ships. CONTRIBUTING documents how to refresh them. - The deprecated TakoCardSourceIndex alias now notes the value set collapsed. - Removed backend-internals references from public comments. - examples/contents.ts printed `usage?.total_cost_usd ?? 0`, always 0; now prints the per-item cost the API populates. - CONTRIBUTING documents test:contract and spec:refresh, and notes the conformance test shells out to tsc. Tests: answer surface had no absent-collections coverage; added. Normalized the staging host fixture in search.test.ts to tako.com. --- CONTRIBUTING.md | 20 ++++++++ MIGRATING.md | 12 ++--- README.md | 4 +- examples/contents.ts | 3 +- src/request.ts | 11 ++++ src/tools/contents.ts | 9 +++- src/types.ts | 6 ++- tests/answer.test.ts | 10 ++++ tests/contract/request.contract.test.ts | 64 ++++++++++++++++-------- tests/contract/response.contract.test.ts | 4 +- tests/contract/spec.ts | 14 +++--- tests/contract/types.conformance.test.ts | 17 +++++-- tests/contract/types.conformance.ts | 30 +++++++++++ tests/search.test.ts | 4 +- 14 files changed, 163 insertions(+), 45 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f7f24b5..2fa3336 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,8 +5,28 @@ ```bash pnpm install pnpm test # vitest (mocked fetch — no live API calls) +pnpm test:contract # just tests/contract — the API contract suite pnpm typecheck # tsc over src + tests + examples pnpm build # tsup → dist/ +pnpm spec:refresh # re-vendor tests/contract/openapi.yaml from docs.tako.com +``` + +`pnpm test` includes `tests/contract/types.conformance.test.ts`, which shells out +to a cold `tsc` run, so expect it to take a second or two — much longer than the +rest of the suite. It is the slowest test and the one that fails if `src/types.ts` +drifts from the API. + +## Keeping the API contract honest + +`tests/contract/` checks this SDK's types against two pinned references: the +vendored `openapi.yaml` and the `tako-sdk` version in the lockfile. Both are +snapshots, so **the suite catches a regression in this repo, not a change Tako +ships.** To check for upstream drift, refresh them and re-run: + +```bash +pnpm spec:refresh +pnpm update tako-sdk --latest +pnpm test ``` Examples make live calls; run them manually with keys set in `.env` (see `.env.example`): diff --git a/MIGRATING.md b/MIGRATING.md index 38325b2..d3fc30b 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -2,9 +2,9 @@ ## 2.x → 3.0 -Tako's API moved on between June and August 2026 and this SDK's types did not follow. 3.0 realigns them. Every change below is a case where 2.x described something the API no longer does — so if code depended on it, it was already broken at runtime, whatever TypeScript said. +3.0 realigns this SDK's types with the current Tako API. Every change below is a case where 2.x described something the API no longer does — so if code depended on it, it was already broken at runtime, whatever TypeScript said. -`tests/contract/` now validates these types against Tako's published OpenAPI document and against [`tako-sdk`](https://www.npmjs.com/package/tako-sdk), Tako's official generated client, so this class of drift fails CI from now on. +`tests/contract/` validates these types against Tako's published OpenAPI document and against [`tako-sdk`](https://www.npmjs.com/package/tako-sdk), Tako's official generated client, so a type that stops matching either one fails CI. Both are pinned snapshots, refreshed deliberately rather than continuously. ### Config @@ -18,7 +18,7 @@ The API removed `defer_data_retrieval` from its data-source settings, and those | 2.x | 3.0 | | --- | --- | -| `result.contents_total_cost: number` | `result.usage?.total_cost_usd` | +| `result.contents_total_cost: number` | **No replacement.** Read per-item `content.cost` / `content.export_pricing` (see below) | | `content.format` | `content.content_format` | | `TakoContentFormat = 'csv' \| 'text'` | `'csv' \| 'json_records' \| 'json_compact'` | @@ -40,7 +40,7 @@ for (const card of result.cards) { Verified 2026-08 across plain, `deep` and `includeContents` search, answer, and both contents modes: `usage` was absent from every response. It is typed `usage?: TakoUsage | null` so it will light up if Tako starts emitting it, but do not build cost tracking on it yet. Sum the per-item `cost` fields instead. -**Content format.** The field was renamed *and* its values changed. `'text'` is gone: web page text is now signalled by `content_format === null`. +**Content format.** The field was renamed *and* its values changed. `'text'` is gone: web page text is signalled by the absence of a format. The field is optional as well as nullable, so it may arrive as `null` **or** be missing entirely — test it loosely with `== null`, never `=== null`. ```ts // 2.x @@ -48,7 +48,7 @@ if (item.format === 'csv') parseCsv(item.data); else if (item.format === 'text') readProse(item.data); // both branches dead // 3.0 -if (item.content_format === null) readProse(item.data); // web page text +if (item.content_format == null) readProse(item.data); // web page text else parseCsv(item.data); // card data ``` @@ -70,7 +70,7 @@ if (src.source_index === 'tako') // never matches — compiles fine, never run if (src.source_index === 'data') // correct ``` -`source_index` is also a plain string now. 2.x modelled it as a union that could be an object (`{ index_type, segment_id }`), which the API has never sent on this surface — any code narrowing on that shape can be deleted. +`source_index` is now the required two-member union `'data' | 'web'`. 2.x modelled it as a union that could also be an object (`{ index_type, segment_id }`), which the API has never sent on this surface — any code narrowing on that shape can be deleted. ### Guaranteed collections diff --git a/README.md b/README.md index 8de74d1..108b507 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ The LLM supplies only the dynamic input: `{ query }` for `takoSearch`/`takoAnswe `takoAnswer` additionally includes `answer: string` (with `cards[0]` as the lead card). `takoContents` resolves to `{ contents: TakoContentItem[]; request_id: string; usage? }`. -The API guarantees only `request_id` and omits empty collections, so the tools normalize: `cards`, `web_results` and `contents` are **always arrays**. No `?.` needed. +The API guarantees only `request_id` — the contract permits omitting the collections — so the tools normalize: `cards`, `web_results` and `contents` are **always arrays**. No `?.` needed. ### Reading a card @@ -151,7 +151,7 @@ import type { } from '@takoviz/ai-sdk'; ``` -The types mirror Tako's published OpenAPI document. `tests/contract/` validates them against a vendored copy of that spec and against [`tako-sdk`](https://www.npmjs.com/package/tako-sdk), Tako's official generated client, so API drift fails CI rather than shipping. +The types mirror Tako's published OpenAPI document. `tests/contract/` validates them against a vendored copy of that spec and against [`tako-sdk`](https://www.npmjs.com/package/tako-sdk), Tako's official generated client, so a type that stops matching the API fails CI. Both references are pinned snapshots, refreshed by `pnpm spec:refresh` and a `tako-sdk` bump. If you need the raw wire shapes (where collections are optional, before the tools normalize them), import `TakoSearchResponse`, `TakoAnswerResponse` or `TakoContentsResponse`. diff --git a/examples/contents.ts b/examples/contents.ts index 87678d5..a252b99 100644 --- a/examples/contents.ts +++ b/examples/contents.ts @@ -50,7 +50,8 @@ async function main() { console.log(`Rows: ${item.total_rows}${item.truncated ? " (truncated)" : ""}`); } console.log("Data (first 500 chars):\n", item.data?.slice(0, 500)); - console.log("Request cost (USD):", downloaded.usage?.total_cost_usd ?? 0); + // Per-item cost is what the API populates; the aggregate `usage` is not. + console.log("Item cost (USD):", item.cost ?? 0); return; } catch (err) { // `exportable: true` is eligibility, not a guarantee — still fall back. diff --git a/src/request.ts b/src/request.ts index 93c8794..777b625 100644 --- a/src/request.ts +++ b/src/request.ts @@ -1,6 +1,7 @@ import type { TakoAnswerResponse, TakoAnswerResult, + TakoContentsMode, TakoContentsResponse, TakoContentsResult, TakoRetrievalConfig, @@ -71,6 +72,16 @@ export function buildSearchRequestBody(config: TakoRetrievalConfig, query: strin return body; } +export interface ContentsRequestBody { + url: string; + mode: TakoContentsMode; +} + +/** Map a url + delivery mode to the POST body the contents endpoint expects. */ +export function buildContentsRequestBody(url: string, mode: TakoContentsMode): ContentsRequestBody { + return { url, mode }; +} + // ----- Response normalizers ----- // // The API guarantees only `request_id` (plus `answer` on the answer surface) and diff --git a/src/tools/contents.ts b/src/tools/contents.ts index 7c1219c..612a146 100644 --- a/src/tools/contents.ts +++ b/src/tools/contents.ts @@ -1,7 +1,12 @@ import { tool, type Tool } from "ai"; import { z } from "zod"; import { callTako } from "../client"; -import { normalizeContentsResult, resolveApiKey, resolveBaseUrl } from "../request"; +import { + buildContentsRequestBody, + normalizeContentsResult, + resolveApiKey, + resolveBaseUrl, +} from "../request"; import type { TakoContentsConfig, TakoContentsResponse, TakoContentsResult } from "../types"; /** @@ -49,7 +54,7 @@ export function takoContents( baseUrl: resolveBaseUrl(config), path: "/api/v1/contents", apiKey: resolveApiKey(config), - body: { url, mode }, + body: buildContentsRequestBody(url, mode), operation: "fetch contents", }), ), diff --git a/src/types.ts b/src/types.ts index 2ca061f..19e89dc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,7 +13,11 @@ export type TakoContentFormat = "csv" | "json_records" | "json_compact"; /** Public source taxonomy for the card surfaces. */ export type TakoSourceIndex = "data" | "web"; -/** @deprecated Renamed to {@link TakoSourceIndex}. */ +/** + * @deprecated Renamed to {@link TakoSourceIndex}, and the value set collapsed: + * 2.x had `"tako" | "web" | "connected_data" | "tako_deep_v2"`, this resolves to + * `"data" | "web"`. Comparisons against the removed values no longer compile. + */ export type TakoCardSourceIndex = TakoSourceIndex; export type TakoKnowledgeCardRelevance = "High" | "Medium" | "Low"; diff --git a/tests/answer.test.ts b/tests/answer.test.ts index c282988..345d4ec 100644 --- a/tests/answer.test.ts +++ b/tests/answer.test.ts @@ -21,4 +21,14 @@ describe("takoAnswer", () => { }); expect((res as any).answer).toBe("AMD grew faster."); }); + + it("normalizes absent collections to empty arrays", async () => { + // The contract guarantees only `answer` and `request_id` here, so a bare + // response is valid. Callers still get arrays they can read without a guard. + stubFetch(200, JSON.stringify({ answer: "x", request_id: "r" })); + const res = (await runTool(takoAnswer({ apiKey: "key" }), { query: "q" })) as any; + expect(res.cards).toEqual([]); + expect(res.web_results).toEqual([]); + expect(res.answer).toBe("x"); + }); }); diff --git a/tests/contract/request.contract.test.ts b/tests/contract/request.contract.test.ts index 98aeb04..1e1cdc5 100644 --- a/tests/contract/request.contract.test.ts +++ b/tests/contract/request.contract.test.ts @@ -1,15 +1,19 @@ import { describe, expect, it } from "vitest"; -import { buildSearchRequestBody } from "../../src/request"; -import { check, propertiesOf } from "./spec"; +import { buildContentsRequestBody, buildSearchRequestBody } from "../../src/request"; +import { check, propertiesOf, spec } from "./spec"; /** * Request-side contract: every body this SDK sends must validate against Tako's * published request schemas. * - * Both `SearchRequest` and its nested `DataSourceSettings` / `WebSourceSettings` - * declare `additionalProperties: false`. That is the OpenAPI projection of the - * server's Pydantic `extra="forbid"`, so a property the schema rejects is a - * property the API rejects — a 400, not a silently-ignored field. + * `SearchRequest` and its nested `Sources` / `DataSourceSettings` / + * `WebSourceSettings` / `OutputSettings` declare `additionalProperties: false`, + * so the API rejects an unknown property with a 400 rather than ignoring it — + * verified live, see the P0-1 case below. + * + * `ContentsRequest` is the one request schema that does NOT declare it, so + * unknown properties pass validation there. The contents cases below therefore + * gate field *shape*, not extras. */ describe("POST /v3/search — request body contract", () => { it("sends a spec-valid body for the default config", () => { @@ -85,23 +89,43 @@ describe("POST /v3/search — request body contract", () => { }); /** - * Request-side contract for POST /v1/contents. The tool builds this body inline - * (src/tools/contents.ts) rather than through a helper, so it is reproduced here. + * Request-side contract for POST /v1/contents, built through the same helper the + * tool uses (`buildContentsRequestBody`), so a change to the body shape breaks + * these cases rather than sliding past a hand-written copy. */ describe("POST /v1/contents — request body contract", () => { - it("sends a spec-valid body in url mode", () => { - const { errors } = check("ContentsRequest", { - url: "https://tako.com/card/abc123", - mode: "url", - }); - expect(errors).toEqual([]); + it("sends a spec-valid body in both delivery modes", () => { + for (const mode of ["url", "inline"] as const) { + const body = buildContentsRequestBody("https://tako.com/card/abc123", mode); + expect(check("ContentsRequest", body).errors).toEqual([]); + expect(body.mode).toBe(mode); + } }); - it("sends a spec-valid body in inline mode", () => { - const { errors } = check("ContentsRequest", { - url: "https://tako.com/card/abc123", - mode: "inline", - }); - expect(errors).toEqual([]); + it("emits only properties the schema defines", () => { + // ContentsRequest does not set additionalProperties: false, so ajv cannot + // catch an extra field here. Assert it directly instead, so this surface is + // still gated the way the search surface is by the schema itself. + const allowed = propertiesOf("ContentsRequest"); + const body = buildContentsRequestBody("https://tako.com/card/abc123", "inline"); + expect(Object.keys(body).filter((k) => !allowed.includes(k))).toEqual([]); + }); + + it("pins that ContentsRequest is the lone schema without additionalProperties:false", () => { + // Documents the asymmetry the comment above relies on. If Tako tightens this + // schema, this test fails and the comment (plus spec.ts) should be updated. + const forbidsExtras = (name: string) => + spec.components.schemas[name].additionalProperties === false; + for (const name of [ + "SearchRequest", + "Sources", + "DataSourceSettings", + "WebSourceSettings", + "OutputSettings", + "GeoLocation", + ]) { + expect(forbidsExtras(name), `${name} should forbid extras`).toBe(true); + } + expect(forbidsExtras("ContentsRequest")).toBe(false); }); }); diff --git a/tests/contract/response.contract.test.ts b/tests/contract/response.contract.test.ts index 943b094..490a06e 100644 --- a/tests/contract/response.contract.test.ts +++ b/tests/contract/response.contract.test.ts @@ -13,8 +13,8 @@ const schemas: Record = spec.components.schemas; * Response-side contract, checked against two independent oracles: * * 1. Tako's vendored OpenAPI document (./openapi.yaml). - * 2. `tako-sdk`, Tako's official TypeScript client, generated from that - * same document and released from the Tako monorepo. + * 2. `tako-sdk`, Tako's official TypeScript client, generated from that same + * document. * * When both agree that a field is absent, this SDK's type declaring it is drift. * The `tako-sdk` decoders are the sharper oracle: they materialise exactly the diff --git a/tests/contract/spec.ts b/tests/contract/spec.ts index b758349..35e5359 100644 --- a/tests/contract/spec.ts +++ b/tests/contract/spec.ts @@ -6,11 +6,11 @@ import YAML from "yaml"; /** * Tako's published OpenAPI document, vendored verbatim from - * https://docs.tako.com/api-reference/openapi.yaml + * https://docs.tako.com/api-reference/openapi.yaml — the authoritative + * description of the live API. * - * It is generated from the Tako monorepo and is the authoritative description of - * the live API. Tests validate against it so that API drift fails CI instead of - * silently shipping. Refresh with `pnpm spec:refresh`. + * Tests validate against it so a mismatch fails CI instead of shipping. The copy + * is a pinned snapshot; refresh it with `pnpm spec:refresh`. */ const SPEC_PATH = fileURLToPath(new URL("./openapi.yaml", import.meta.url)); @@ -26,8 +26,10 @@ ajv.addSchema(spec, "openapi"); /** * Compile a validator for one `#/components/schemas/` entry. * - * Request schemas declare `additionalProperties: false`, which mirrors the - * server's Pydantic `extra="forbid"`: a field the model rejects fails here too. + * Where a request schema declares `additionalProperties: false`, the API rejects + * unknown properties with a 400 rather than ignoring them — so a body that fails + * here fails against the live API too. Note that `ContentsRequest` does *not* + * declare it, so unknown properties pass validation on that schema. */ export function validator(schemaName: string) { const validate = ajv.getSchema(`openapi#/components/schemas/${schemaName}`); diff --git a/tests/contract/types.conformance.test.ts b/tests/contract/types.conformance.test.ts index e519c19..f4cf6d2 100644 --- a/tests/contract/types.conformance.test.ts +++ b/tests/contract/types.conformance.test.ts @@ -25,10 +25,21 @@ async function compileConformance(): Promise { await execFileAsync("pnpm", ["exec", "tsc", "-p", "tsconfig.conformance.json"], { cwd: REPO_ROOT, }); - return ""; + return ""; // exit 0 — tsc ran and found nothing } catch (err) { - // tsc exits non-zero when it reports diagnostics; they land on stdout. - return (err as { stdout?: string }).stdout ?? ""; + const e = err as { code?: unknown; stdout?: string; stderr?: string }; + + // tsc reports diagnostics on stdout and exits non-zero. Anything else means + // the check never ran — a spawn failure, or pnpm/node refusing to start. + // Returning "" there would make "could not check" indistinguishable from + // "no drift", so this test would pass while proving nothing. + if (typeof e.code === "number" && e.stdout && e.stdout.trim() !== "") { + return e.stdout; + } + throw new Error( + `The conformance compile did not run, so drift is unverified. ` + + `exit=${String(e.code)}\nstdout: ${e.stdout ?? "(empty)"}\nstderr: ${e.stderr ?? "(empty)"}`, + ); } } diff --git a/tests/contract/types.conformance.ts b/tests/contract/types.conformance.ts index 83b5f3d..724f1f9 100644 --- a/tests/contract/types.conformance.ts +++ b/tests/contract/types.conformance.ts @@ -36,9 +36,12 @@ import type { TakoContentItem, TakoContentsResponse, TakoContentsResult, + TakoDataset, + TakoResultContent, TakoSearchResponse, TakoSearchResult, TakoSourceIndex, + TakoWebResult, } from "../../src/types"; /** @@ -93,6 +96,33 @@ export const formatBack: OfficialContentsFormat = null as unknown as TakoContent export const sourceIndex: TakoSourceIndex = officialSourceIndex; export const sourceIndexBack: OfficialTakoSourceIndex = null as unknown as TakoSourceIndex; +// --- Key symmetry --- +// +// The assignments above prove our types are not over-strict, but they cannot +// catch drift by a whole key in either direction: an optional key we declare and +// the API does not send is still assignable, and a key the API sends that we +// omit is simply ignored (no excess-property check applies to a `declare const`). +// Deleting `data_freshness` from TakoCard passed every assignment above. +// +// This compares key sets directly, so both classes fail the compile and the +// error names the offending key. Compared against the unpatched official types: +// only value types were ever patched, never key sets. +type KeyDiff = + | Exclude + | Exclude; +type SameKeys = [KeyDiff] extends [never] + ? true + : { KEY_DRIFT: KeyDiff }; + +export const cardKeys: true = true as SameKeys; +export const webResultKeys: true = true as SameKeys; +export const resultContentKeys: true = true as SameKeys; +export const contentItemKeys: true = true as SameKeys; +export const datasetKeys: true = true as SameKeys; +export const searchKeys: true = true as SameKeys; +export const answerKeys: true = true as SameKeys; +export const contentsKeys: true = true as SameKeys; + // --- Result types: the normalized shapes the tools return --- // // Checked the other way round. Normalization only fills in absent collections, diff --git a/tests/search.test.ts b/tests/search.test.ts index 59c1c0f..19c9d3f 100644 --- a/tests/search.test.ts +++ b/tests/search.test.ts @@ -85,9 +85,9 @@ describe("takoSearch", () => { it("honors baseUrl override (trailing slash stripped)", async () => { const fetchMock = stubFetch(200, OK); - const t = takoSearch({ apiKey: "key", baseUrl: "https://staging.trytako.com/" }); + const t = takoSearch({ apiKey: "key", baseUrl: "https://staging.tako.com/" }); await runTool(t, { query: "x" }); - expect(fetchMock.mock.calls[0][0]).toBe("https://staging.trytako.com/api/v3/search"); + expect(fetchMock.mock.calls[0][0]).toBe("https://staging.tako.com/api/v3/search"); }); it("falls back to TAKO_API_KEY env and throws clearly when unset", async () => { From 3d1e03c0444c275e6c032c3bb563d5e78f681a33 Mon Sep 17 00:00:00 2001 From: Eric Date: Mon, 3 Aug 2026 14:30:51 -0700 Subject: [PATCH 5/6] ci: gate the publish job on typecheck and tests The publish job ran install -> build -> publish. ci.yml gates pull requests, but the release commit reaches main through release-please rather than through a PR, so publish was the one step that shipped without verification. Adds the same build/typecheck/test sequence ci.yml runs, ahead of publish, so a broken type or a failing contract test stops the release instead of reaching npm. --- .github/workflows/release-please.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 0000fb6..03857ef 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -35,7 +35,12 @@ jobs: registry-url: "https://registry.npmjs.org" cache: pnpm - run: pnpm install --frozen-lockfile + # Same gate ci.yml runs on a PR. The release commit reaches main through + # release-please rather than through CI, so without these the publish is + # the one step that ships unverified. - run: pnpm build + - run: pnpm typecheck + - run: pnpm test - run: pnpm publish --no-git-checks --provenance --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} From d1948303c9b231bb1d5c53ad34cf8b55ea1f1944 Mon Sep 17 00:00:00 2001 From: Eric Date: Mon, 3 Aug 2026 14:47:13 -0700 Subject: [PATCH 6/6] fix: gate nested types for key drift, correct remaining docs and fixtures Addresses the second review round on #9. Key symmetry now covers the nested tier. The previous fix gated the eight top-level mirrored types, which left both drift classes live one level down: deleting `last_updated` from TakoDataFreshness compiled clean with all 27 contract tests green, because `keyof TakoCard` never changes and the narrowed nested type stays assignable. That was the P0-6 regression sitting directly under the field the check was written to protect. Extends the check to the twelve nested pairs (DataFreshness, TakoCardSource, TakoCardNode, MetricDefinition, KnowledgeCardMethodology, Usage, UsageCompute, UsageData, ExportPricing, ColumnDescriptor, TakoDatasetColumn, TakoDatasetSource). All twelve were already symmetric, so nothing else changed. Verified four regressions now fail and name the key: last_updated, max_rows_ceiling, and a stale optional `format` on either TakoCardSource or TakoUsage. Removed the live pre-production hostname from test fixtures. `staging.tako.com` was committed as a fixture in a public repo, and an earlier consistency fix in this PR made it worse by taking it from one occurrence to three. These tests only need some base URL to prove override and trailing-slash handling, so they now use the `https://e.com` placeholder already used for web-result fixtures. Also swapped the pre-existing `trytako.com` in client.test.ts while in the area. No real Tako host remains in any fixture. Corrected the last two copies of the empty-collections claim that live verification disproved. The JSDoc on TakoSearchResponse ships in dist/index.d.ts and surfaces in consumer IntelliSense, so it had the widest reach of any copy; the normalizer comment in request.ts had the same wording. Both now say the contract permits omission while the API currently sends empty arrays. --- src/request.ts | 7 ++-- src/types.ts | 6 +-- tests/client.test.ts | 8 ++-- tests/contract/types.conformance.ts | 58 +++++++++++++++++++++++++++++ tests/search.test.ts | 4 +- tests/types.test.ts | 2 +- 6 files changed, 72 insertions(+), 13 deletions(-) diff --git a/src/request.ts b/src/request.ts index 777b625..8dd1d12 100644 --- a/src/request.ts +++ b/src/request.ts @@ -84,9 +84,10 @@ export function buildContentsRequestBody(url: string, mode: TakoContentsMode): C // ----- Response normalizers ----- // -// The API guarantees only `request_id` (plus `answer` on the answer surface) and -// omits collections rather than sending them empty. Tools normalize before -// returning so callers can read `result.cards.length` without a guard. +// The API guarantees only `request_id` (plus `answer` on the answer surface); the +// contract permits omitting the collections, though it currently sends them +// empty. Normalizing either shape lets callers read `result.cards.length` +// without a guard. export function normalizeSearchResult(response: TakoSearchResponse): TakoSearchResult { return { diff --git a/src/types.ts b/src/types.ts index 19e89dc..aad7f0b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -282,9 +282,9 @@ export interface TakoWebResult { // ----- Wire responses (exactly what the API sends) ----- /** - * The raw `POST /api/v3/search` body. Only `request_id` is guaranteed; the - * collections are absent rather than empty when there is nothing to report. - * Tools return the normalized {@link TakoSearchResult} instead. + * The raw `POST /api/v3/search` body. Only `request_id` is guaranteed — the + * contract permits omitting the collections, though the API currently sends them + * empty. Tools normalize either shape and return {@link TakoSearchResult}. */ export interface TakoSearchResponse { cards?: TakoCard[]; diff --git a/tests/client.test.ts b/tests/client.test.ts index f044d6c..f8d679a 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -8,7 +8,7 @@ describe("callTako", () => { it("posts JSON with X-API-Key and returns the parsed body", async () => { const fetchMock = stubFetch(200, JSON.stringify({ ok: true })); const res = await callTako<{ ok: boolean }>({ - baseUrl: "https://trytako.com", + baseUrl: "https://e.com", path: "/api/v3/search", apiKey: "key", body: { query: "x" }, @@ -16,7 +16,7 @@ describe("callTako", () => { }); expect(res).toEqual({ ok: true }); const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; - expect(url).toBe("https://trytako.com/api/v3/search"); + expect(url).toBe("https://e.com/api/v3/search"); expect(init.method).toBe("POST"); const headers = init.headers as Record; expect(headers["X-API-Key"]).toBe("key"); @@ -27,7 +27,7 @@ describe("callTako", () => { it("throws a clear error when apiKey is missing (before fetching)", async () => { const fetchMock = stubFetch(200, "{}"); await expect( - callTako({ baseUrl: "https://trytako.com", path: "/x", apiKey: undefined, body: {}, operation: "search" }), + callTako({ baseUrl: "https://e.com", path: "/x", apiKey: undefined, body: {}, operation: "search" }), ).rejects.toThrow(/TAKO_API_KEY is required/); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -35,7 +35,7 @@ describe("callTako", () => { it("wraps a non-2xx response with status and body text", async () => { stubFetch(401, "unauthorized", "text/plain"); await expect( - callTako({ baseUrl: "https://trytako.com", path: "/x", apiKey: "k", body: {}, operation: "search" }), + callTako({ baseUrl: "https://e.com", path: "/x", apiKey: "k", body: {}, operation: "search" }), ).rejects.toThrow(/Failed to search with Tako: Tako API error: 401 - unauthorized/); }); }); diff --git a/tests/contract/types.conformance.ts b/tests/contract/types.conformance.ts index 724f1f9..f723912 100644 --- a/tests/contract/types.conformance.ts +++ b/tests/contract/types.conformance.ts @@ -27,20 +27,45 @@ import type { TakoDataset as OfficialTakoDataset, WebResult as OfficialWebResult, TakoSourceIndex as OfficialTakoSourceIndex, + // Nested mirrored types — gated for key symmetry below. + ColumnDescriptor as OfficialColumnDescriptor, + DataFreshness as OfficialDataFreshness, + ExportPricing as OfficialExportPricing, + KnowledgeCardMethodology as OfficialKnowledgeCardMethodology, + MetricDefinition as OfficialMetricDefinition, + TakoCardNode as OfficialTakoCardNode, + TakoCardSource as OfficialTakoCardSource, + TakoDatasetColumn as OfficialTakoDatasetColumn, + TakoDatasetSource as OfficialTakoDatasetSource, + Usage as OfficialUsage, + UsageCompute as OfficialUsageCompute, + UsageData as OfficialUsageData, } from "tako-sdk"; import type { TakoAnswerResponse, TakoAnswerResult, TakoCard, + TakoCardNode, + TakoCardSource, + TakoColumnDescriptor, TakoContentFormat, TakoContentItem, TakoContentsResponse, TakoContentsResult, + TakoDataFreshness, TakoDataset, + TakoDatasetColumn, + TakoDatasetSource, + TakoExportPricing, + TakoKnowledgeCardMethodology, + TakoMetricDefinition, TakoResultContent, TakoSearchResponse, TakoSearchResult, TakoSourceIndex, + TakoUsage, + TakoUsageCompute, + TakoUsageData, TakoWebResult, } from "../../src/types"; @@ -123,6 +148,39 @@ export const searchKeys: true = true as SameKeys; export const contentsKeys: true = true as SameKeys; +// Nested types need the same gate. Checking only the eight above leaves both +// drift classes live one level down: deleting `last_updated` from +// TakoDataFreshness compiled clean, because `keyof TakoCard` never changes and +// the narrowed nested type stays assignable. That is the P0-6 regression sitting +// directly under the field the check was written to protect. +export const dataFreshnessKeys: true = true as SameKeys; +export const cardSourceKeys: true = true as SameKeys; +export const cardNodeKeys: true = true as SameKeys; +export const metricDefinitionKeys: true = true as SameKeys< + TakoMetricDefinition, + OfficialMetricDefinition +>; +export const methodologyKeys: true = true as SameKeys< + TakoKnowledgeCardMethodology, + OfficialKnowledgeCardMethodology +>; +export const usageKeys: true = true as SameKeys; +export const usageComputeKeys: true = true as SameKeys; +export const usageDataKeys: true = true as SameKeys; +export const exportPricingKeys: true = true as SameKeys; +export const columnDescriptorKeys: true = true as SameKeys< + TakoColumnDescriptor, + OfficialColumnDescriptor +>; +export const datasetColumnKeys: true = true as SameKeys< + TakoDatasetColumn, + OfficialTakoDatasetColumn +>; +export const datasetSourceKeys: true = true as SameKeys< + TakoDatasetSource, + OfficialTakoDatasetSource +>; + // --- Result types: the normalized shapes the tools return --- // // Checked the other way round. Normalization only fills in absent collections, diff --git a/tests/search.test.ts b/tests/search.test.ts index 19c9d3f..fc20480 100644 --- a/tests/search.test.ts +++ b/tests/search.test.ts @@ -85,9 +85,9 @@ describe("takoSearch", () => { it("honors baseUrl override (trailing slash stripped)", async () => { const fetchMock = stubFetch(200, OK); - const t = takoSearch({ apiKey: "key", baseUrl: "https://staging.tako.com/" }); + const t = takoSearch({ apiKey: "key", baseUrl: "https://e.com/" }); await runTool(t, { query: "x" }); - expect(fetchMock.mock.calls[0][0]).toBe("https://staging.tako.com/api/v3/search"); + expect(fetchMock.mock.calls[0][0]).toBe("https://e.com/api/v3/search"); }); it("falls back to TAKO_API_KEY env and throws clearly when unset", async () => { diff --git a/tests/types.test.ts b/tests/types.test.ts index a5dd850..eeffc3e 100644 --- a/tests/types.test.ts +++ b/tests/types.test.ts @@ -111,7 +111,7 @@ describe("types", () => { it("accepts retrieval + contents config", () => { const r: TakoRetrievalConfig = { apiKey: "k", - baseUrl: "https://staging.tako.com", + baseUrl: "https://e.com", effort: "deep", sources: { data: { count: 10, includeContents: true }, web: { count: 3, includeContents: true } }, countryCode: "US",