Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/root-metadata-not-forwarded.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@tanstack/ai-openrouter': patch
'@tanstack/ai': patch
---

fix(ai-openrouter): stop forwarding root observability `metadata` to the provider wire request (#735)

The OpenRouter chat-completions adapter (since 0.13.0) and responses adapter (since 0.9.0) copied `chat()`'s root-level observability `metadata` onto the wire as `chatRequest.metadata` / `responsesRequest.metadata`. The `@openrouter/sdk` validates those fields as `Record<string, string>`, so structured observability metadata (objects, arrays β€” the documented usage for middleware/devtools consumers) failed client-side Zod validation with `Input validation failed` on every call. The spread also clobbered an intentional, correctly-typed `modelOptions.metadata`.

Root `metadata` is observability-only again (middleware, devtools, event client) and `modelOptions.metadata` is the sole source for OpenRouter wire metadata, matching every other adapter. The `TextOptions.metadata` doc comment in `@tanstack/ai` now states this contract explicitly.
6 changes: 5 additions & 1 deletion packages/ai-openrouter/src/adapters/responses-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1580,7 +1580,11 @@ export class OpenRouterResponsesTextAdapter<
> = {
...modelOptions,
model: options.model + variantSuffix,
...(options.metadata !== undefined && { metadata: options.metadata }),
// Root `metadata` is observability-only and intentionally not forwarded:
// the SDK validates wire `metadata` as `Record<string, string>`, while
// root metadata may carry arbitrarily structured values (#735). Callers
// set wire metadata via `modelOptions.metadata`, which flows through
// the spread.
...(() => {
const prompts = normalizeSystemPrompts(options.systemPrompts)
if (prompts.length === 0) return {}
Expand Down
13 changes: 6 additions & 7 deletions packages/ai-openrouter/src/adapters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1156,16 +1156,15 @@ export class OpenRouterTextAdapter<
? convertToolsToProviderFormat(options.tools)
: undefined

// `modelOptions` is the sole sampling surface: callers set provider-native
// wire names (`temperature`, `topP`, `maxCompletionTokens`, etc.) there and
// they flow through the spread below. The root `temperature`/`topP`/
// `maxTokens` fields are intentionally NOT read here. Root `metadata` is
// still part of the contract, so forward it the same way the responses
// adapter does.
// `modelOptions` is the sole wire surface: callers set provider-native
// names (`temperature`, `topP`, `maxCompletionTokens`, `metadata`, etc.)
// there and they flow through the spread below. Root `metadata` is
// observability-only (middleware, devtools, event client) and must NOT be
// forwarded here β€” it may carry arbitrarily structured values while the
// SDK validates `chatRequest.metadata` as `Record<string, string>` (#735).
const request: Omit<ChatRequest, 'stream'> = {
...restModelOptions,
model: options.model + variantSuffix,
...(options.metadata !== undefined && { metadata: options.metadata }),
messages,
...(tools && tools.length > 0 && { tools }),
}
Expand Down
31 changes: 27 additions & 4 deletions packages/ai-openrouter/tests/openrouter-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1533,16 +1533,39 @@ describe('OpenRouter modelOptions pass-through', () => {
expect(params.maxCompletionTokens).toBe(9999)
})

it('forwards root metadata to the request (same as the responses adapter)', async () => {
it('does not forward root observability metadata to the request (#735)', async () => {
setupMockSdkClient(minimalStreamChunks)
const adapter = createAdapter()

for await (const _ of chat({
adapter,
messages: [{ role: 'user', content: 'test' }],
// Root `metadata` is still part of the contract; it must not be dropped
// by the chat-completions request builder.
metadata: { env: 'test' },
// Root `metadata` is observability-only (middleware, devtools, event
// client) and may carry structured values; the SDK validates wire
// `chatRequest.metadata` as `Record<string, string>`, so forwarding it
// fails client-side Zod validation on every call.
metadata: { tags: ['a', 'b'], prompt: { name: 'p', version: 1 } },
})) {
// consume
}

const [rawParams] = mockSend.mock.calls[0]!
const params = rawParams.chatRequest
expect(params).not.toHaveProperty('metadata')
})

it('root metadata does not clobber modelOptions.metadata (#735)', async () => {
setupMockSdkClient(minimalStreamChunks)
const adapter = createAdapter()

for await (const _ of chat({
adapter,
messages: [{ role: 'user', content: 'test' }],
metadata: { observationName: 'my-call' },
// `modelOptions.metadata` is the typed home for OpenRouter wire
// metadata; it must reach the request untouched even when root
// observability metadata is also present.
modelOptions: { metadata: { env: 'test' } } as OpenRouterTextModelOptions,
})) {
// consume
}
Expand Down
54 changes: 54 additions & 0 deletions packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,60 @@ describe('OpenRouter responses adapter β€” request shape', () => {
expect(params.model).toBe('openai/gpt-4o-mini:thinking')
})

it('does not forward root observability metadata; modelOptions.metadata reaches the wire (#735)', async () => {
setupMockSdkClient([
{
type: 'response.completed',
sequenceNumber: 1,
response: {
model: 'openai/gpt-4o-mini',
output: [],
usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
},
},
])
const adapter = createAdapter()
for await (const _ of chat({
adapter,
messages: [{ role: 'user', content: 'hi' }],
// Root `metadata` is observability-only and may carry structured
// values; the SDK validates wire `metadata` as `Record<string,
// string>`, so forwarding it fails client-side Zod validation.
metadata: { tags: ['a', 'b'], prompt: { name: 'p', version: 1 } },
// `modelOptions.metadata` is the typed home for wire metadata and
// must not be clobbered by root metadata.
modelOptions: { metadata: { env: 'test' } },
})) {
// consume
}
const params = mockSend.mock.calls[0]![0].responsesRequest
expect(params.metadata).toEqual({ env: 'test' })
})

it('omits wire metadata entirely when only root observability metadata is set (#735)', async () => {
setupMockSdkClient([
{
type: 'response.completed',
sequenceNumber: 1,
response: {
model: 'openai/gpt-4o-mini',
output: [],
usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
},
},
])
const adapter = createAdapter()
for await (const _ of chat({
adapter,
messages: [{ role: 'user', content: 'hi' }],
metadata: { observationName: 'my-call' },
})) {
// consume
}
const params = mockSend.mock.calls[0]![0].responsesRequest
expect(params).not.toHaveProperty('metadata')
})

it('rejects webSearchTool() as RUN_ERROR pointing at the chat adapter', async () => {
const adapter = createAdapter()
const ws = webSearchTool() as Tool
Expand Down
14 changes: 7 additions & 7 deletions packages/ai/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -819,14 +819,14 @@ export interface TextOptions<
systemPrompts?: Array<SystemPrompt>
agentLoopStrategy?: AgentLoopStrategy
/**
* Additional metadata to attach to the request.
* Can be used for tracking, debugging, or passing custom information.
* Structure and constraints vary by provider.
* Observability metadata attached to this call. Surfaced to middleware,
* devtools, and the event client; values may be arbitrarily structured
* (objects, arrays). Adapters never forward this field onto the provider
* wire request.
*
* Provider usage:
* - OpenAI: `metadata` (Record<string, string>) - max 16 key-value pairs, keys max 64 chars, values max 512 chars
* - Anthropic: `metadata` (Record<string, any>) - includes optional user_id (max 256 chars)
* - Gemini: Not directly available in TextProviderOptions
* To send provider-side request metadata, use the provider's
* `modelOptions` field instead, where the provider supports one (e.g.
* OpenAI's and OpenRouter's `metadata` are both Record<string, string>).
*/
metadata?: Record<string, any> | undefined
modelOptions?: TProviderOptionsForModel
Expand Down
20 changes: 20 additions & 0 deletions testing/e2e/src/routes/api.chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,23 @@ export const Route = createFileRoute('/api/chat')({
]
: [systemPrompt]

// Test-only flag β€” when set to `true`, the route passes
// structured root-level observability `metadata` (objects,
// arrays) to `chat()`. Enables root-metadata-wire.spec.ts to
// verify the call still completes: the adapter keeps root
// metadata off the provider request, so the OpenRouter SDK's
// Record<string, string> outbound validation never sees it
// (regression coverage for #735). Wire-absence itself is
// asserted by the adapter unit tests.
const rootObservabilityMetadata =
fp.structuredRootMetadata === true
? {
observationName: 'e2e-root-metadata',
tags: ['a', 'b'],
prompt: { name: 'p', version: 1 },
}
: undefined

// Two structured-output-streaming features differ only in which
// schema they bind to. Branched per-feature so TS can pick the
// right `chat<TSchema>()` overload without a `never` cast.
Expand Down Expand Up @@ -141,6 +158,9 @@ export const Route = createFileRoute('/api/chat')({
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
...(rootObservabilityMetadata && {
metadata: rootObservabilityMetadata,
}),
abortController,
})

Expand Down
68 changes: 68 additions & 0 deletions testing/e2e/tests/root-metadata-wire.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { test, expect } from './fixtures'

/**
* End-to-end regression coverage for #735: root-level observability
* `metadata` on `chat()` must never be forwarded onto the provider wire
* request.
*
* In `@tanstack/ai-openrouter` 0.13.x the chat-completions mapper copied
* root `metadata` into OpenRouter's `chatRequest.metadata`. The
* `@openrouter/sdk` validates that field as `Record<string, string>`
* client-side, so structured observability metadata (objects, arrays β€”
* the documented usage for middleware/devtools consumers) failed Zod
* validation before the request ever left the process, killing every
* call with `RUN_ERROR`.
*
* Wire-shape coverage lives in the unit tests
* `packages/ai-openrouter/tests/openrouter-adapter.test.ts` and
* `openrouter-responses-adapter.test.ts`, which inspect the request
* handed to the SDK directly. What this spec covers (which those
* cannot): the full HTTP path β€” test β†’ route β†’ `chat()` β†’ adapter β†’
* real `@openrouter/sdk` outbound validation β€” tolerates structured
* root metadata. Pre-fix, the SDK's own Zod schema rejects the request
* and the stream emits RUN_ERROR instead of completing.
*/
test.describe('root observability metadata β€” wire path', () => {
test('chat completes end-to-end on OpenRouter without the root metadata reaching the wire request', async ({
request,
testId,
aimockPort,
}) => {
const body = {
threadId: 'thread-root-meta-1',
runId: 'run-root-meta-1',
state: {},
messages: [
{ id: 'u1', role: 'user', content: '[chat] recommend a guitar' },
],
tools: [],
context: [],
forwardedProps: {
provider: 'openrouter',
feature: 'chat',
testId,
aimockPort,
// Opt-in flag handled by `api.chat.ts` β€” passes structured
// root-level observability metadata (arrays, nested objects) to
// `chat()`. The adapter must keep it off the provider request;
// pre-fix, the SDK's own outbound Zod validation rejects the
// request before it reaches aimock and the stream ends in
// RUN_ERROR.
structuredRootMetadata: true,
},
}
const response = await request.post('/api/chat', {
data: body,
headers: { 'Content-Type': 'application/json' },
})
expect(
response.ok(),
`expected 200, got ${response.status()}: ${await response.text()}`,
).toBe(true)
const text = await response.text()
expect(text).toContain('RUN_FINISHED')
// No RUN_ERROR β€” the @openrouter/sdk's outbound Record<string, string>
// validation never saw the structured metadata.
expect(text).not.toContain('RUN_ERROR')
})
})
Loading