Skip to content

Commit d2bbe7d

Browse files
committed
fix(ai): warn on unknown chat options
1 parent 79d8812 commit d2bbe7d

6 files changed

Lines changed: 196 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/ai': patch
3+
---
4+
5+
Warn when `chat()` receives unknown top-level options instead of silently dropping them.

packages/ai/src/activities/chat/index.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,51 @@ export interface TextActivityOptions<
502502
debug?: DebugOption
503503
}
504504

505+
const CHAT_OPTION_KEYS = new Set([
506+
'adapter',
507+
'messages',
508+
'systemPrompts',
509+
'tools',
510+
'mcp',
511+
'metadata',
512+
'modelOptions',
513+
'abortController',
514+
'agentLoopStrategy',
515+
'lazyToolsConfig',
516+
'conversationId',
517+
'threadId',
518+
'runId',
519+
'parentRunId',
520+
'state',
521+
'resume',
522+
'outputSchema',
523+
'stream',
524+
'middleware',
525+
'context',
526+
'debug',
527+
])
528+
529+
function warnOnUnknownChatOptions(
530+
options: Pick<
531+
TextActivityOptions<AnyTextAdapter, SchemaInput, boolean>,
532+
'debug'
533+
> &
534+
object,
535+
): void {
536+
const unknownKeys = Object.keys(options).filter(
537+
(key) => !CHAT_OPTION_KEYS.has(key),
538+
)
539+
if (unknownKeys.length === 0) return
540+
541+
const hint = unknownKeys.includes('providerOptions')
542+
? ' Did you mean `modelOptions`?'
543+
: ''
544+
resolveDebugOption(options.debug).warn(
545+
`chat() received unknown top-level option(s): ${unknownKeys.join(', ')}.${hint}`,
546+
{ unknownKeys },
547+
)
548+
}
549+
505550
// ===========================
506551
// Chat Options Helper
507552
// ===========================
@@ -3642,6 +3687,7 @@ export function chat<
36423687
TMiddleware
36433688
>,
36443689
): TextActivityResult<TSchema, TStream, TTools> {
3690+
warnOnUnknownChatOptions(options)
36453691
validateCapabilities(options.middleware ?? [], options.adapter)
36463692

36473693
const { outputSchema, stream } = options

packages/ai/tests/debug-logging-chat.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,46 @@ describe('debug logging — chat integration', () => {
172172
expect(logger.warn).not.toHaveBeenCalled()
173173
})
174174

175+
it('warns when an unknown top-level option is spread into chat()', async () => {
176+
const logger = makeSpyLogger()
177+
const { adapter } = createMockAdapter({
178+
iterations: [[ev.runStarted(), ev.runFinished('stop')]],
179+
})
180+
181+
const misplacedOptions = {
182+
providerOptions: { thinking: { type: 'enabled' } },
183+
}
184+
const stream = chat({
185+
adapter,
186+
messages: [{ role: 'user', content: 'hello' }],
187+
...misplacedOptions,
188+
debug: { logger: logger as unknown as Logger },
189+
})
190+
await collectChunks(stream as AsyncIterable<StreamChunk>)
191+
192+
expect(logger.warn).toHaveBeenCalledTimes(1)
193+
const warning = logPrefixes(logger.warn.mock.calls)[0]!
194+
expect(warning).toContain('providerOptions')
195+
expect(warning).toContain('modelOptions')
196+
})
197+
198+
it('silences unknown-option warnings when the errors category is disabled', async () => {
199+
const logger = makeSpyLogger()
200+
const { adapter } = createMockAdapter({
201+
iterations: [[ev.runStarted(), ev.runFinished('stop')]],
202+
})
203+
204+
const stream = chat({
205+
adapter,
206+
messages: [{ role: 'user', content: 'hello' }],
207+
...{ providerOptions: { thinking: { type: 'enabled' } } },
208+
debug: { logger: logger as unknown as Logger, errors: false },
209+
})
210+
await collectChunks(stream as AsyncIterable<StreamChunk>)
211+
212+
expect(logger.warn).not.toHaveBeenCalled()
213+
})
214+
175215
it('omitted debug — errors still log via default ConsoleLogger', async () => {
176216
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
177217
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {})

testing/e2e/src/routeTree.gen.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { Route as ChatClientDefaultBridgeRouteImport } from './routes/chat-clien
2828
import { Route as IndexRouteImport } from './routes/index'
2929
import { Route as ProviderIndexRouteImport } from './routes/$provider/index'
3030
import { Route as ApiVideoRouteImport } from './routes/api.video'
31+
import { Route as ApiUnknownChatOptionsRouteImport } from './routes/api.unknown-chat-options'
3132
import { Route as ApiTtsRouteImport } from './routes/api.tts'
3233
import { Route as ApiTranscriptionRouteImport } from './routes/api.transcription'
3334
import { Route as ApiToolsTestRouteImport } from './routes/api.tools-test'
@@ -174,6 +175,11 @@ const ApiVideoRoute = ApiVideoRouteImport.update({
174175
path: '/api/video',
175176
getParentRoute: () => rootRouteImport,
176177
} as any)
178+
const ApiUnknownChatOptionsRoute = ApiUnknownChatOptionsRouteImport.update({
179+
id: '/api/unknown-chat-options',
180+
path: '/api/unknown-chat-options',
181+
getParentRoute: () => rootRouteImport,
182+
} as any)
177183
const ApiTtsRoute = ApiTtsRouteImport.update({
178184
id: '/api/tts',
179185
path: '/api/tts',
@@ -484,6 +490,7 @@ export interface FileRoutesByFullPath {
484490
'/api/tools-test': typeof ApiToolsTestRoute
485491
'/api/transcription': typeof ApiTranscriptionRouteWithChildren
486492
'/api/tts': typeof ApiTtsRouteWithChildren
493+
'/api/unknown-chat-options': typeof ApiUnknownChatOptionsRoute
487494
'/api/video': typeof ApiVideoRouteWithChildren
488495
'/$provider/': typeof ProviderIndexRoute
489496
'/api/audio/stream': typeof ApiAudioStreamRoute
@@ -553,6 +560,7 @@ export interface FileRoutesByTo {
553560
'/api/tools-test': typeof ApiToolsTestRoute
554561
'/api/transcription': typeof ApiTranscriptionRouteWithChildren
555562
'/api/tts': typeof ApiTtsRouteWithChildren
563+
'/api/unknown-chat-options': typeof ApiUnknownChatOptionsRoute
556564
'/api/video': typeof ApiVideoRouteWithChildren
557565
'/$provider': typeof ProviderIndexRoute
558566
'/api/audio/stream': typeof ApiAudioStreamRoute
@@ -623,6 +631,7 @@ export interface FileRoutesById {
623631
'/api/tools-test': typeof ApiToolsTestRoute
624632
'/api/transcription': typeof ApiTranscriptionRouteWithChildren
625633
'/api/tts': typeof ApiTtsRouteWithChildren
634+
'/api/unknown-chat-options': typeof ApiUnknownChatOptionsRoute
626635
'/api/video': typeof ApiVideoRouteWithChildren
627636
'/$provider/': typeof ProviderIndexRoute
628637
'/api/audio/stream': typeof ApiAudioStreamRoute
@@ -694,6 +703,7 @@ export interface FileRouteTypes {
694703
| '/api/tools-test'
695704
| '/api/transcription'
696705
| '/api/tts'
706+
| '/api/unknown-chat-options'
697707
| '/api/video'
698708
| '/$provider/'
699709
| '/api/audio/stream'
@@ -763,6 +773,7 @@ export interface FileRouteTypes {
763773
| '/api/tools-test'
764774
| '/api/transcription'
765775
| '/api/tts'
776+
| '/api/unknown-chat-options'
766777
| '/api/video'
767778
| '/$provider'
768779
| '/api/audio/stream'
@@ -832,6 +843,7 @@ export interface FileRouteTypes {
832843
| '/api/tools-test'
833844
| '/api/transcription'
834845
| '/api/tts'
846+
| '/api/unknown-chat-options'
835847
| '/api/video'
836848
| '/$provider/'
837849
| '/api/audio/stream'
@@ -902,6 +914,7 @@ export interface RootRouteChildren {
902914
ApiToolsTestRoute: typeof ApiToolsTestRoute
903915
ApiTranscriptionRoute: typeof ApiTranscriptionRouteWithChildren
904916
ApiTtsRoute: typeof ApiTtsRouteWithChildren
917+
ApiUnknownChatOptionsRoute: typeof ApiUnknownChatOptionsRoute
905918
ApiVideoRoute: typeof ApiVideoRouteWithChildren
906919
ProviderIndexRoute: typeof ProviderIndexRoute
907920
}
@@ -1041,6 +1054,13 @@ declare module '@tanstack/react-router' {
10411054
preLoaderRoute: typeof ApiVideoRouteImport
10421055
parentRoute: typeof rootRouteImport
10431056
}
1057+
'/api/unknown-chat-options': {
1058+
id: '/api/unknown-chat-options'
1059+
path: '/api/unknown-chat-options'
1060+
fullPath: '/api/unknown-chat-options'
1061+
preLoaderRoute: typeof ApiUnknownChatOptionsRouteImport
1062+
parentRoute: typeof rootRouteImport
1063+
}
10441064
'/api/tts': {
10451065
id: '/api/tts'
10461066
path: '/api/tts'
@@ -1499,6 +1519,7 @@ const rootRouteChildren: RootRouteChildren = {
14991519
ApiToolsTestRoute: ApiToolsTestRoute,
15001520
ApiTranscriptionRoute: ApiTranscriptionRouteWithChildren,
15011521
ApiTtsRoute: ApiTtsRouteWithChildren,
1522+
ApiUnknownChatOptionsRoute: ApiUnknownChatOptionsRoute,
15021523
ApiVideoRoute: ApiVideoRouteWithChildren,
15031524
ProviderIndexRoute: ProviderIndexRoute,
15041525
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { createFileRoute } from '@tanstack/react-router'
2+
import { chat } from '@tanstack/ai'
3+
import type { AnyTextAdapter, Logger } from '@tanstack/ai'
4+
5+
export const Route = createFileRoute('/api/unknown-chat-options')({
6+
server: {
7+
handlers: {
8+
GET: async () => {
9+
const warnings: Array<string> = []
10+
const logger: Logger = {
11+
debug: () => {},
12+
info: () => {},
13+
warn: (message) => warnings.push(message),
14+
error: () => {},
15+
}
16+
const adapter = {
17+
kind: 'text',
18+
name: 'unknown-chat-options-test',
19+
model: 'test-model',
20+
'~types': {
21+
providerOptions: {} as Record<string, unknown>,
22+
inputModalities: ['text'],
23+
messageMetadataByModality: {
24+
text: undefined,
25+
image: undefined,
26+
audio: undefined,
27+
video: undefined,
28+
document: undefined,
29+
},
30+
toolCapabilities: [] as ReadonlyArray<string>,
31+
toolCallMetadata: undefined,
32+
systemPromptMetadata: undefined as never,
33+
},
34+
chatStream: () =>
35+
(async function* () {
36+
yield {
37+
type: 'RUN_STARTED',
38+
runId: 'run-1',
39+
threadId: 'thread-1',
40+
timestamp: Date.now(),
41+
}
42+
yield {
43+
type: 'RUN_FINISHED',
44+
runId: 'run-1',
45+
threadId: 'thread-1',
46+
finishReason: 'stop',
47+
timestamp: Date.now(),
48+
}
49+
})(),
50+
structuredOutput: async () => ({ data: {}, rawText: '{}' }),
51+
} as unknown as AnyTextAdapter
52+
53+
const misplacedOptions = {
54+
providerOptions: { thinking: { type: 'enabled' } },
55+
}
56+
for await (const _chunk of chat({
57+
adapter,
58+
messages: [{ role: 'user', content: 'hello' }],
59+
...misplacedOptions,
60+
debug: { logger },
61+
})) {
62+
// Drain the stream so the route exercises the complete chat path.
63+
}
64+
65+
return Response.json({ warnings })
66+
},
67+
},
68+
},
69+
})
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { test, expect } from './fixtures'
2+
3+
test.describe('chat() unknown top-level options (#1073)', () => {
4+
test('warns when providerOptions is silently dropped', async ({
5+
request,
6+
}) => {
7+
const response = await request.get('/api/unknown-chat-options')
8+
9+
expect(response.ok()).toBe(true)
10+
const body = (await response.json()) as { warnings: Array<string> }
11+
expect(body.warnings).toHaveLength(1)
12+
expect(body.warnings[0]).toContain('providerOptions')
13+
expect(body.warnings[0]).toContain('modelOptions')
14+
})
15+
})

0 commit comments

Comments
 (0)