Skip to content

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

Description

@mathiasrscom

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    has-prAn open PR references this issuewaiting-on: maintainerThe ball is in the maintainers’ court

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions