Summary
Since @tanstack/ai-openrouter@0.13.0, the OpenRouter text adapter copies chat()'s root-level observability metadata onto the wire as OpenRouter's chatRequest.metadata. The @openrouter/sdk validates that field as Record<string, string>, so any caller passing structured observability metadata (objects, arrays — the documented usage for middleware/devtools/event-client consumers) now gets a hard client-side Input validation failed error on every call against the adapter.
Affected versions
@tanstack/ai-openrouter 0.13.0 and 0.13.1 (with @tanstack/ai 0.27.0 / 0.28.0)
- Not present in 0.12.x
What changed
Introduced in 6df32b5 (PR #660, the sampling-options → modelOptions move). mapOptionsToRequest in packages/ai-openrouter/src/adapters/text.ts gained:
const request: Omit<ChatRequest, 'stream'> = {
...restModelOptions,
model: options.model + variantSuffix,
...(options.metadata !== undefined && { metadata: options.metadata }),
messages,
...
}
In 0.12.x, mapOptionsToRequest never read options.metadata; root metadata stayed observability-only (middleware, devtools events, event client) and never touched the network request.
Reproduction
import { chat } from '@tanstack/ai'
import { openRouterText } from '@tanstack/ai-openrouter'
for await (const event of chat({
adapter: openRouterText('anthropic/claude-sonnet-4.6'),
messages: [{ role: 'user', content: 'hi' }],
stream: true,
// Observability metadata, per the middleware/devtools patterns:
metadata: {
observationName: 'my-call',
tags: ['a', 'b'],
prompt: { name: 'p', version: 1 },
sessionId: undefined,
},
})) {
// RUN_ERROR on the very first event
}
Result — the call dies before leaving the process:
RUN_ERROR: Input validation failed: [
{ "path": ["chatRequest","metadata","prompt"], "message": "Invalid input: expected string, received undefined" },
{ "path": ["chatRequest","metadata","tags"], "message": "Invalid input: expected string, received array" },
{ "path": ["chatRequest","metadata","metadata"], "message": "Invalid input: expected string, received object" },
{ "path": ["chatRequest","metadata","sessionId"],"message": "Invalid input: expected string, received undefined" }
]
(@openrouter/sdk's outbound Zod schema for ChatRequest.metadata is Record<string, string>.)
Why this looks like a bug rather than a feature
- The 0.27.0 release notes for the same PR say the opposite: "
metadata is unaffected and stays at the root." Root metadata is the documented slot for observability context consumed by middleware and the devtools/event stream — silently promoting it to wire data changes its meaning and breaks the documented usage with a runtime error.
- OpenRouter wire metadata already has a typed home:
modelOptions.metadata (via OpenRouterCommonOptions = Pick<ChatRequest, ... | 'metadata' | ...>). Anyone who wants OpenRouter-side analytics metadata can already pass Record<string, string> there.
- The new spread also breaks
modelOptions.metadata: it sits after ...restModelOptions, so a defined root metadata clobbers an intentional, correctly-typed modelOptions.metadata.
- No other adapter forwards root
metadata to its provider request — this is a one-line anomaly in the OpenRouter mapper.
Suggested fix
Remove the ...(options.metadata !== undefined && { metadata: options.metadata }) line from mapOptionsToRequest, leaving modelOptions.metadata as the only source for chatRequest.metadata. Happy to open the one-line PR with a regression test if that's the agreed direction.
Workaround (for anyone else hitting this)
Subclass the adapter and strip metadata before it reaches the mapper:
class WireSafeOpenRouterTextAdapter extends OpenRouterTextAdapter<Model> {
override chatStream(options: Parameters<Base['chatStream']>[0]) {
return super.chatStream({ ...options, metadata: undefined })
}
override structuredOutput(options: Parameters<Base['structuredOutput']>[0]) {
return super.structuredOutput({
...options,
chatOptions: { ...options.chatOptions, metadata: undefined },
})
}
override structuredOutputStream(options: Parameters<Base['structuredOutputStream']>[0]) {
return super.structuredOutputStream({
...options,
chatOptions: { ...options.chatOptions, metadata: undefined },
})
}
}
The event stream still carries the metadata (it's emitted by the chat() orchestrator, not the adapter), so observability consumers are unaffected.
Summary
Since
@tanstack/ai-openrouter@0.13.0, the OpenRouter text adapter copieschat()'s root-level observabilitymetadataonto the wire as OpenRouter'schatRequest.metadata. The@openrouter/sdkvalidates that field asRecord<string, string>, so any caller passing structured observability metadata (objects, arrays — the documented usage for middleware/devtools/event-client consumers) now gets a hard client-sideInput validation failederror on every call against the adapter.Affected versions
@tanstack/ai-openrouter0.13.0 and 0.13.1 (with@tanstack/ai0.27.0 / 0.28.0)What changed
Introduced in
6df32b5(PR #660, the sampling-options →modelOptionsmove).mapOptionsToRequestinpackages/ai-openrouter/src/adapters/text.tsgained:In 0.12.x,
mapOptionsToRequestnever readoptions.metadata; root metadata stayed observability-only (middleware, devtools events, event client) and never touched the network request.Reproduction
Result — the call dies before leaving the process:
(
@openrouter/sdk's outbound Zod schema forChatRequest.metadataisRecord<string, string>.)Why this looks like a bug rather than a feature
metadatais unaffected and stays at the root." Rootmetadatais the documented slot for observability context consumed by middleware and the devtools/event stream — silently promoting it to wire data changes its meaning and breaks the documented usage with a runtime error.modelOptions.metadata(viaOpenRouterCommonOptions = Pick<ChatRequest, ... | 'metadata' | ...>). Anyone who wants OpenRouter-side analytics metadata can already passRecord<string, string>there.modelOptions.metadata: it sits after...restModelOptions, so a defined rootmetadataclobbers an intentional, correctly-typedmodelOptions.metadata.metadatato its provider request — this is a one-line anomaly in the OpenRouter mapper.Suggested fix
Remove the
...(options.metadata !== undefined && { metadata: options.metadata })line frommapOptionsToRequest, leavingmodelOptions.metadataas the only source forchatRequest.metadata. Happy to open the one-line PR with a regression test if that's the agreed direction.Workaround (for anyone else hitting this)
Subclass the adapter and strip
metadatabefore it reaches the mapper:The event stream still carries the metadata (it's emitted by the
chat()orchestrator, not the adapter), so observability consumers are unaffected.