TanStack / TanStack/ai

chat() silently ignores unknown top-level options — providerOptions typechecks through a spread and is discarded

Open
#1,073 1 comment 0 reactions 1 assignee View on GitHub

@AlemTuzlak is already working on this.

Since Aug 21, 2026.

has-pr waiting-on: maintainer
Dominant language
TypeScript
Stars
3.1k
Forks
331
Avg merge
1d 22h
Merged PRs (30d)
160

Description

What happens

chat() accepts an object containing a providerOptions key, runs without warning, and never sends it. No error, no log, no debug signal — the model just behaves as if no configuration was passed.

This is caller error, and I want to be upfront about that

modelOptions is the intended and only channel, and the types say so:

  • TextActivityOptions (activities/chat/index.d.ts:91) declares modelOptions?: TAdapter['~types']['providerOptions'] and no top-level providerOptions.
  • A literal providerOptions: on chat() is rejected — TS2353, "Object literal may only specify known properties".
  • At runtime, activities/chat/index.js reads only options.modelOptions. The string providerOptions does not appear in the compiled activity at all (grep -c → 0, versus 9 for modelOptions).
  • skills/ai-core/chat-experience/SKILL.md §g already lists "HIGH: Using providerOptions instead of modelOptions" as a known mistake.

So I am not asking you to honour providerOptions.

The ask

The one shape that escapes the type system fails completely silently.

TypeScript exempts spread-in properties from excess-property checking, so this compiles:

chat({
  adapter,
  messages,
  modelOptions: { max_tokens: 16384 },
  ...(thinking ? { providerOptions: thinking } : {}),
})

That is not an exotic pattern — it is the ordinary way to pass an option conditionally. In our codebase it survived two minor upgrades before anyone noticed that extended thinking had never once been enabled: no thinking field on the wire, no ThinkingPart / STEP_STARTED / STEP_FINISHED events, and reasoning that should have streamed as collapsible thinking content instead leaking into TEXT_MESSAGE_CONTENT. Nothing anywhere said the requested configuration had been dropped.

Reproduction

No API key needed; it inspects the outgoing request body against a local server.

// node repro.mjs   — deps: @tanstack/ai@0.43.0, @tanstack/ai-anthropic@0.16.4
import { createServer } from 'node:http'
import { chat } from '@tanstack/ai'
import { createAnthropicChat } from '@tanstack/ai-anthropic'

let captured = null
const server = createServer((req, res) => {
  const chunks = []
  req.on('data', (c) => chunks.push(c))
  req.on('end', () => {
    captured = JSON.parse(Buffer.concat(chunks).toString())
    res.writeHead(400, { 'content-type': 'application/json' })
    res.end('{"type":"error","error":{"type":"invalid_request_error","message":"probe"}}')
  })
})
await new Promise((r) => server.listen(0, '127.0.0.1', r))
const baseURL = `http://127.0.0.1:${server.address().port}`

const thinking = { thinking: { type: 'enabled', budget_tokens: 4096 } }

async function send(extra) {
  captured = null
  const adapter = createAnthropicChat('claude-sonnet-4-5', 'sk-probe', { baseURL, maxRetries: 0 })
  const stream = chat({
    adapter,
    messages: [{ role: 'user', content: 'hi' }],
    modelOptions: { max_tokens: 16384 },
    ...extra,
  })
  for await (const _ of stream) { /* drain */ }
  return captured
}

// The spread is load-bearing: a literal `providerOptions:` is a compile error.
console.log('providerOptions:', JSON.stringify(await send({ ...(thinking ? { providerOptions: thinking } : {}) })))
console.log('modelOptions   :', JSON.stringify(await send({ modelOptions: { max_tokens: 16384, ...thinking } })))
server.close()

Output:

providerOptions: {"model":"claude-sonnet-4-5","max_tokens":16384,"messages":[...],"tools":[],"stream":true}
modelOptions   : {"model":"claude-sonnet-4-5","max_tokens":16384,"messages":[...],"tools":[],"thinking":{"type":"enabled","budget_tokens":4096},"stream":true}

The first request reaches the provider with no thinking field.

Suggested fix

Warn on unrecognised top-level keys in chat() under the existing errors debug category. There is precedent one layer down — ai-anthropic already does this for its own bag:

anthropic.mapCommonOptionsToAnthropic dropped unknown modelOptions key(s): …

The activity layer having no equivalent is the gap. A single line naming providerOptions"did you mean modelOptions?" would have turned a multi-release silent no-op into a first-run log line.

Prior art checked

#593, #501 and #92 all concern provider options on the media activities (type extraction / model typing), not the chat path or the silent-drop behaviour. I could not find an existing issue for this.

Environment

@tanstack/ai@0.43.0, @tanstack/ai-anthropic@0.16.4, Node 22, TypeScript 5.x strict.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.