Skip to content

feat: expose the 16 unreachable Tako request options - #11

Merged
robertabbott merged 12 commits into
mainfrom
feat/request-options
Aug 4, 2026
Merged

feat: expose the 16 unreachable Tako request options#11
robertabbott merged 12 commits into
mainfrom
feat/request-options

Conversation

@robertabbott

@robertabbott robertabbott commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Why

3.0.0 realigned every response type against the API and built a CI drift gate for them. It never touched the request surface. Counted against the vendored spec, 16 request options were unreachable from this SDK — so "the types match the current API" was true in one direction only.

One case was sharper than a gap. 3.0.0 added TakoDataset / records types to parse json_records and json_compact payloads, but content_format was not settable on either surface, so no caller could ask for the shapes it had just learned to read.

Schema Was Missing
SearchRequest 7/8 location
DataSourceSettings 2/6 mode, content_format, node_ids, strict
WebSourceSettings 2/9 category, include_domains, exclude_domains, snippet_max_chars, article_content_max_chars, published_after, published_before
ContentsRequest 2/6 content_format, max_rows, max_chars, quote_only

/v1/answer and /v3/search share the identical SearchRequest schema, so every search-side addition reaches both tools through one builder.

The design decision that shaped the diff

No server-side numeric bound is mirrored client-side. The spec carries real limits — maxItems: 20 on the domain arrays, 1..20 on counts, a 2,000-row ceiling — and none of them appear in src/. That is deliberate, and it is the lesson of 3.0.0: the "capped at 1000 rows" claim this package shipped for two months was stale precisely because a number was duplicated where it could rot. The API stays the authority, so a limit Tako raises works immediately with no release here. A test over the source text of src/request.ts and src/types.ts enforces this, stripping comments first so the limits can still be documented in prose. It is verified load-bearing: a Math.min(count, 20) clamp fails it, and so does const CEILING = 2000.

The one exception is a logical invariant, not a bound: strict without a non-empty nodeIds can never match a card, so it throws locally rather than spending a billed request. This follows the z.url() precedent from #9.

Structure

Four builders in src/request.ts, one per nested schema — buildDataSourceSettings, buildWebSourceSettings, buildOutputSettings, buildGeoLocation — composed by buildSearchRequestBody. This is for testability, not tidiness: each of those schemas declares additionalProperties: false, so each builder validates against its own schema in isolation. A typo in one web field now fails a test named for that section instead of surfacing as a generic ajv error on a 40-key object.

TakoSourceOptions splits into TakoDataSourceOptions and TakoWebSourceOptions, because DataSourceSettings and WebSourceSettings genuinely diverge — the previous shared type only worked because just the two common fields were exposed.

Preventing recurrence

Every builder has a key-parity assertion: Object.keys(body).sort() must equal propertiesOf(<schema>).sort(), read from the vendored spec.

Scoped honestly, after review: propertiesOf reads a pinned snapshot, and ci.yml runs only build/typecheck/test — nothing refreshes it. So these assertions fail the moment a refreshed spec disagrees with the mapping; they do not detect an option Tako adds until somebody runs pnpm spec:refresh. That is a regression gate on this repo, not a live upstream detector. An earlier draft of this section claimed the gap "cannot silently reopen", which overstated it in exactly the way #9's oracle wording did. A scheduled refresh-and-diff job would close the remaining window and is deliberately not in this PR — it needs a decision on cadence and who triages the noise.

Review of this branch found two schemas (OutputSettings, Sources) where I had omitted that assertion, which would have left exactly the original bug reachable one level over. Both are now covered, and both new tests were verified to be load-bearing rather than decorative.

Separately, request-side enums were ungated by the type-drift gate. Verified before the fix: a stale member on TakoSearchEffort or TakoContentsMode compiled clean, while the same regression on any response-side enum was caught. Only the reverse (ours → official) assignment catches a stale member, and it was absent. Since this PR adds TakoWebCategory, a third such enum, leaving it ungated in the change that closes the request-surface gap would have been self-defeating. All three are now gated in both directions, and the gate was watched failing (TS2322, naming the offending type) before being reverted.

Behavior notes, stated because they surprise

  • contentFormat defaults differ by surface: json_compact on sources.data, csv on contents. Not normalized here — the SDK reports the API, it does not invent a third default.
  • sources.data.mode is inert. The spec documents it as having no effect on Tako cards and says it "stays for schema stability". Exposed for parity; the JSDoc and README both say it does nothing.
  • maxRows has a billing consequence. The first 20 rows are free; rows above bill at the per-1,000-row rate. Called out in the README because a user raising it blind gets a surprise charge.
  • quoteOnly is a free pre-flight price check — it returns cost and export_pricing without fetching content or charging.
  • sources.web.count defaults to 5 on search but 3 on answer. My first draft of the README table said 5 for both under a heading claiming the tools share a config. Caught in review; the spec states the asymmetry explicitly.

What this does not do

  • No model-controllable tool inputs. inputSchema stays { query } / { url }. feat!: realign types with Tako's current API and gate drift in CI #9 already rewrote all three tool descriptions with no routing eval; adding agent-settable filters would stack a second unmeasured routing change. That belongs behind an eval.
  • Does not wrap /v1/graph/*. nodeIds needs ids from those endpoints. They are public and reachable with a plain fetch; wrapping four more endpoints is its own change.
  • No numeric validation. Deliberate, per the section above.
  • callTako still performs no response-shape validation. Pre-existing and unchanged.

Compatibility

Non-breaking. Every added field is optional, TakoCardSourceOptions survives as a deprecated alias of TakoDataSourceOptions, and no commit carries a ! or BREAKING CHANGE footer — release-please will cut 3.1.0. buildContentsRequestBody's signature changed from (url, mode) to (url, config), which is safe because src/request.ts is not re-exported from src/index.ts.

Note this queues behind #8, the still-open 3.0.0 release PR.

Review round 1

Eight findings, all addressed. Three changed behavior:

  • strict/nodeIds now throws at takoSearch()/takoAnswer(), not inside execute. The old placement handed the message to the model, which can neither supply graph ids nor edit the config, once per invocation.
  • effort/country_code/locale are no longer sent unconditionally. They were the spec's own defaults restated in buildSearchRequestBody — the same rot the no-bounds policy exists to prevent, ten lines under the comment that says so. Behavior-neutral: the spec's defaults are exactly those values.
  • quoteOnly gets its own tool-description branch. The description branched on mode alone, so a quote-configured tool told the model to surface a link; the API nulls url and every payload field on a quote.

Two documentation corrections were live defects rather than polish: README still said requesting a specific format "is not configurable yet" (this PR makes it settable), and the numeric-range note promised a 400 for every out-of-range value — maxRows clamps instead, and bills the rows actually returned, so an over-ceiling value silently yields a short, charged export.

Two claims in this body were false and are corrected above.

Test plan

  • 84 tests pass, up from 52 on main
  • typecheck clean; conformance project compiles clean
  • build clean; dist/ contains zero references to tako-sdk
  • dependencies: {} — zero runtime dependencies preserved
  • All 16 mappings verified field-by-field against tests/contract/openapi.yaml, not against the tests
  • Drift gate watched failing, then reverted, for TakoSearchEffort and TakoWebCategory
  • No mirrored bound in src/request.ts or src/types.ts
  • Not exercised against the live API — no key was used; every claim here traces to the vendored spec

Lines changed

558 insertions, 43 deletions across 10 files: 210 logic (src/), 301 tests (tests/), 47 docs (README.md).

🤖 Generated with Claude Code

robertabbott and others added 8 commits August 3, 2026 17:15
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Final review fix wave on feat/request-options (5 findings): add key-parity
tests for OutputSettings and Sources (the two request schemas without a
drift trip-wire), fix the README takoAnswer web count default (3, not 5),
drop a redundant config spread in contents.ts, and complete the quoteOnly
and maxChars JSDoc/README defaults per the spec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@robertabbott robertabbott left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings are inline. The 16 mappings themselves check out field-by-field against the vendored spec, all four enums match, and every property of all seven request schemas is now reachable — the stated gap really is closed. What I flagged is around the edges: one README line that the PR falsifies and leaves in place, the tool description the model reads under quoteOnly, and the liveness of the parity gate the PR body leans on.

Comment thread README.md
| Option | Type | Notes |
| --- | --- | --- |
| `mode` | `"url" \| "inline"` | Default `"url"`. Changes the tool description the model reads. |
| `contentFormat` | `"csv" \| "json_records" \| "json_compact"` | Server default `"csv"` on this surface. |

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

README:177 still says Requesting a specific format is not configurable yet — is that line meant to survive this PR? It sits three lines under the table explaining which payload field each content_format populates, so a reader who reaches the records / dataset rows is told the option this PR adds does not exist. It contradicts the diff's own contentFormat rows here and at line 109. Was it missed because the new options landed in a new section rather than in the existing format table?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missed, and your diagnosis is right — the new options landed in a new section, so the existing format table was never re-read.

Fixed in abafe3d. That paragraph now says which surface returns what when contentFormat is unset, then names where to set it: on takoContents for an explicit fetch, on sources.data for a card inlined by a search.

Comment thread src/tools/contents.ts
path: "/api/v1/contents",
apiKey: resolveApiKey(config),
body: buildContentsRequestBody(url, mode),
body: buildContentsRequestBody(url, config),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With quoteOnly: true in the config, the description the model reads still promises content — is that intended? The description branches on mode only (lines 31-35): "url" tells the model to surface a presigned link, "inline" tells it to compute over the numbers. The spec says a quote-only response returns cost + export_pricing with url, data, records and dataset all null. So the model is told to surface a link, gets null, and its cheapest recovery is to call again. Should quoteOnly add a third description branch?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not intended — this one was a real hole. Fixed in 6665f40.

quoteOnly now takes its own branch ahead of mode, because the API ignores mode on a quote: the description says the call returns cost and rate card, that url and data are always null, and not to call again expecting rows. The wire body still sends both fields; only the description reorders.

Covered by a test asserting all three variants are distinct — quote-only does not mention computing over numbers, and neither delivery branch mentions quotes.

articleContentMaxChars: 1, publishedAfter: "2026-01-01",
publishedBefore: "2026-01-02",
});
expect(Object.keys(body).sort()).toEqual(propertiesOf("WebSourceSettings").sort());

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion fires only after somebody runs pnpm spec:refresh by hand — does that hold up the PR body's claim that the gap cannot silently reopen? propertiesOf reads the vendored tests/contract/openapi.yaml, a pinned snapshot; ci.yml runs build/typecheck/test, nothing refreshes it, and there is no scheduled workflow. When Tako adds a web option every parity test stays green until a human happens to refresh — the same silence the 3.0.0 drift ran in. Is a scheduled refresh-and-diff job in scope here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does not, and the PR body was wrong. Corrected there.

These assertions fail when a refreshed spec disagrees with the mapping. They cannot detect an option Tako adds until someone runs pnpm spec:refresh. That is a regression gate on this repo, not a live upstream detector — the same overclaim I flagged on #9's oracle wording, which I then reproduced here.

The scheduled refresh-and-diff job is deliberately not in this PR: cadence and who triages the noise are decisions, and it is the same call #9 made. The body now states what the gate actually guarantees and names the remaining window.

One thing I did tighten within the snapshot: the three request enums were pinned only against the tako-sdk devDependency, so the two references could disagree. See the reply on types.conformance.ts:134.

Comment thread src/request.ts
*/
export function buildDataSourceSettings(o: TakoDataSourceOptions): DataSourceSettingsBody {
if (o.strict && !o.nodeIds?.length) {
throw new Error(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

strict without nodeIds is a static config mistake, but this throw runs inside execute — should it fire at takoSearch() instead? buildDataSourceSettings is called per tool call (src/tools/search.ts:48, answer.ts:48), so the AI SDK turns it into a tool error the model reads, once per invocation. The message tells the reader to add ids from /v1/graph or set strict: false; the model can do neither, and re-calling reproduces it until the step limit. The developer never sees it at wiring time.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and this overrides a deferral I accepted earlier on weaker grounds. Fixed in 6665f40.

Extracted assertValidDataSourceOptions plus assertValidRetrievalConfig, called from takoSearch() and takoAnswer() at construction. buildDataSourceSettings still calls the former, so direct callers stay guarded and the invariant lives in one place.

Your point about who reads the message is what decided it: the text names two remedies, and the model has access to neither. Three tests cover it — construction throws before any fetch, the deprecated tako alias throws too, and strict with nodeIds still builds and reaches the wire.

Comment thread src/request.ts
* Map web source options to the API's `WebSourceSettings`.
*
* Numeric bounds are deliberately not checked here. The schema carries them and
* the API enforces them, so a limit raised by Tako needs no release of this SDK.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lines 138-140 hardcode fast / US / en-US and send them on every request — doesn't that mirror three server defaults, which this comment says the file deliberately does not do? A default Tako changes server-side then needs an SDK release, the rot the policy exists to prevent. Related: the PR body says A CI check asserts no bound leaks into the two files where the options live, but ci.yml runs only build/typecheck/test and no test reads either file's source text — is that check still to come?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both halves were right. Fixed in 6665f40 and 756239b.

The three defaults. They mirrored the spec's own values ten lines under the comment saying this file does not do that. buildSearchRequestBody now sends only query unconditionally; effort, country_code and locale appear only when set. Behavior-neutral today — the spec's defaults are exactly fast/US/en-US and only query is required — so Tako can now change one without a release here. I had told myself these were fine as pre-existing behavior, which was true and beside the point.

The CI claim was false. I ran that grep by hand during verification and wrote it up as though it were automated. There is now an actual test over the source text of request.ts and types.ts, stripping comments first so limits stay documentable in prose. Verified load-bearing rather than assumed: a Math.min(count, 20) clamp fails it, const CEILING = 2000 fails it, and 2000 inside a comment still passes.

Comment thread README.md Outdated
| `maxChars` | `number` | Web page text only. Server default 1000000, the full page text. |
| `quoteOnly` | `boolean` | Price the export without fetching it. The request is free and the payload is null. |

This SDK does not check the numeric ranges. The API enforces them and returns a 400, so a limit Tako raises works immediately without an SDK release.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API enforces them and returns a 400 — is that right for maxRows? The spec says Raise it to export more rows, up to the 2,000-row system ceiling (Tako clamps larger values), and that billing counts the rows actually returned. So an over-ceiling maxRows is silently truncated and billed, not rejected — the one numeric option where a caller who trusts the 400 gets a short export and no signal. Worth splitting maxRows out of this sentence?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, and this is the one that can cost money quietly. Fixed in abafe3d.

maxRows is split out of that sentence. The note now says most out-of-range values return a 400, then separately: a value above the 2,000-row ceiling is clamped rather than rejected, billing counts the rows actually returned, so you get a short export, a charge for it, and no error. It points at total_rows and truncated as the check.

// the API rejects the whole request. `request.contract.test.ts` does not reach
// them: it validates one effort value and iterates hand-written mode literals,
// so neither enumerates what the union declares.
export const effort: TakoSearchEffort = null as unknown as OfficialSearchEffortLevel;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These three request enums are gated against the tako-sdk devDependency but not against the vendored spec — is that asymmetry deliberate? response.contract.test.ts:81,109 pins ContentsFormat and TakoSourceIndex with enumOf(...) from the same snapshot these parity tests read. Without the equivalent here, a pnpm spec:refresh that adds a fourth effort level passes CI until someone separately bumps tako-sdk, so the two halves of the drift gate can disagree about which enum is current.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not deliberate — an asymmetry I did not notice. Fixed in 756239b.

Added enumOf() pins for SearchEffortLevel, ContentsDeliveryMode and WebCategory against the vendored spec, matching how response.contract.test.ts:81,109 pins the response-side enums. Both halves of the gate now read the same snapshot, so a refresh that adds a fourth effort level fails immediately instead of waiting on a tako-sdk bump.

The tako-sdk assignments stay — they catch the reverse case, where the generated client moves and the spec has not been refreshed.

Comment thread src/types.ts Outdated

export interface TakoSourceOptions {
/** Max results for this source, 120 (server default 5). */
/** Max results for this source, 1-20 (server default 5). */

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

server default 5 is the JSDoc a developer hovers on sources.web.count for both tools, but the spec's /v1/answer description says This endpoint returns 3 web results when you omit sources.web.count — should the shared comment carry the split the README states at line 118? Same shape two blocks down: the publishedAfter / publishedBefore JSDoc (lines 81-84) omits Results with no known publication date are kept, so a caller reading it as a recency guarantee gets undated pages.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fixed in abafe3d.

TakoSourceOptions.count now carries the split the README states: 1-20, takoSearch returns 5, takoAnswer returns 3, set it when you need the same count from both. It is the shared type, so the hover was telling half the readers the wrong number.

publishedAfter/publishedBefore now say outright that this is not a recency guarantee and that the API keeps a result whose publication date it does not know. That was the reading your comment describes, and the old text invited it.

robertabbott and others added 3 commits August 3, 2026 18:15
…de, and two JSDoc gaps

README said requesting a specific format is not configurable yet. This PR makes
that false: contentFormat is settable on takoContents and on sources.data.

The numeric-range note claimed the API returns a 400. maxRows does not. A value
above the 2000-row ceiling is clamped and billing counts the rows actually
returned, so a caller who trusts the 400 gets a short export, a charge, and no
signal. Split maxRows out and name total_rows/truncated as the check.

TakoSourceOptions.count is shared by both tools but said server default 5;
takoAnswer returns 3. publishedAfter/publishedBefore read as a recency guarantee
while the API keeps results whose publication date it does not know.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…escribe quote-only mode

Three review findings, all about a caller or a model acting on something this
package told them wrongly.

The strict/nodeIds guard fired inside `execute`, so the AI SDK handed it to the
model as a tool error, once per invocation. The message says to add ids from
/v1/graph or set strict to false; the model can do neither, and its cheapest
recovery is to call again until the step limit. A contradictory config is a
wiring mistake, so it now throws from takoSearch() and takoAnswer(), where the
developer sees it. Extracted assertValidDataSourceOptions and
assertValidRetrievalConfig; buildDataSourceSettings still calls the former, so
direct callers stay guarded and the invariant lives in one place.

buildSearchRequestBody sent effort=fast, country_code=US and locale=en-US on
every request. Those are the spec's own defaults, restated where they can rot —
the same failure that made 3.0.0 necessary, and the thing the comment ten lines
above claims this file does not do. Only `query` is required, so only `query` is
now unconditional. Verified behavior-neutral: the spec's defaults for all three
are exactly these values, so Tako applies the same ones and can change them
without a release here.

takoContents' description branched on `mode` alone. With quoteOnly the API
ignores mode and returns null for url and every payload field, so the model was
told to surface a link or compute over numbers, got null, and would call again.
quoteOnly now takes its own branch and says there is no content to expect. The
wire body still carries both fields.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ds policy

The three request-side enums were gated only against the tako-sdk
devDependency. The two references move independently, so a `pnpm spec:refresh`
adding a fourth effort level would pass CI until somebody separately bumped
tako-sdk, letting the two halves of the drift gate disagree about which enum is
current. Pinned with enumOf() against the vendored spec, matching how
response.contract.test.ts already pins ContentsFormat and TakoSourceIndex.

The no-mirrored-bounds rule was a comment and a claim in the pull request body,
not a check. It is now a test over the source text of request.ts and types.ts.
Comments are stripped first, because the bounds are documented in prose on
purpose and only executable code can rot. Verified load-bearing: a
Math.min(count, 20) clamp fails it, `const CEILING = 2000` fails it, and 2000 in
a comment still passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@robertabbott

Copy link
Copy Markdown
Collaborator Author

Addressed 8 comments from this review round — 3 behavior changes, 4 doc corrections, 1 test gap, plus two false claims retracted from the PR body. 84 tests (was 74). @claude-address

Every test in this repo imported from src/, so a fault that exists only in the
published tarball had nothing looking for it: a file missing from `files`, an
`exports` map a real resolver rejects, a devDependency imported at run time, or a
peer dependency the package needs and does not declare. The previous check for
this was a grep of dist/ for "tako-sdk", which covers one of the four.

`npm pack` produces the same tarball `npm publish` uploads, so none of this needs
a publish to find — which matters because an npm version cannot be republished.
Both new steps run in ci.yml and again in the publish job ahead of `pnpm publish`.

scripts/verify-package.mjs packs, installs the tarball into a scratch project
alongside the peer dependencies a consumer would install, imports the package,
builds all three tools, asserts the contents description still varies with config
and the strict/nodeIds guard survived the build, then type-checks a snippet
against the shipped .d.ts under nodenext resolution — the mode that reads the
`exports` map. Verified load-bearing against four injected faults, each caught
with its own message: dropping a type export, adding tako-sdk as a runtime
dependency, pointing `exports` at a missing file, and cutting dist from `files`.

lint:package adds publint and are-the-types-wrong. Both run via npx, so the
lockfile is untouched; publint is pinned to `--pack npm` so it does not depend on
the local pnpm being runnable. are-the-types-wrong ignores cjs-resolves-to-esm,
since ESM-only is deliberate here and dynamic import is the intended contract for
a CommonJS consumer.

publint found a real fault while being wired up: `exports["."]` listed `import`
before `types`, and those conditions are order-sensitive, so a resolver can miss
the types. are-the-types-wrong was green on it — TypeScript has a special case
that hid it — which is the argument for running both. Reordering also cleared
publint's second error about types resolving as ESM under `require`. Added
`sideEffects: false` while there, so bundlers can tree-shake.

The scratch type check sets skipLibCheck true deliberately: with it false, tsc
audits every .d.ts under node_modules and the `ai` package's own tree reports
missing @types/node and @types/json-schema, which is another package's noise and
loud enough to hide a real failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@robertabbott
robertabbott merged commit e2b59ac into main Aug 4, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants