Skip to content

Commit 243b8fa

Browse files
tombeckenhamclaude
andauthored
fix(ai-openrouter): stop forwarding root observability metadata to the wire request (#737)
* fix(ai-openrouter): stop forwarding root observability metadata to the wire request 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 now states this contract explicitly. Fixes #735 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(ai-openrouter): drop unnecessary modelOptions cast flagged by review modelOptions accepts { metadata } directly in the responses test, so the OpenRouterResponsesTextProviderOptions assertion (and its out-of-order type import, flagged by CodeRabbit) are unnecessary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 21720dd commit 243b8fa

8 files changed

Lines changed: 197 additions & 19 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@tanstack/ai-openrouter': patch
3+
'@tanstack/ai': patch
4+
---
5+
6+
fix(ai-openrouter): stop forwarding root observability `metadata` to the provider wire request (#735)
7+
8+
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`.
9+
10+
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.

packages/ai-openrouter/src/adapters/responses-text.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1580,7 +1580,11 @@ export class OpenRouterResponsesTextAdapter<
15801580
> = {
15811581
...modelOptions,
15821582
model: options.model + variantSuffix,
1583-
...(options.metadata !== undefined && { metadata: options.metadata }),
1583+
// Root `metadata` is observability-only and intentionally not forwarded:
1584+
// the SDK validates wire `metadata` as `Record<string, string>`, while
1585+
// root metadata may carry arbitrarily structured values (#735). Callers
1586+
// set wire metadata via `modelOptions.metadata`, which flows through
1587+
// the spread.
15841588
...(() => {
15851589
const prompts = normalizeSystemPrompts(options.systemPrompts)
15861590
if (prompts.length === 0) return {}

packages/ai-openrouter/src/adapters/text.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1156,16 +1156,15 @@ export class OpenRouterTextAdapter<
11561156
? convertToolsToProviderFormat(options.tools)
11571157
: undefined
11581158

1159-
// `modelOptions` is the sole sampling surface: callers set provider-native
1160-
// wire names (`temperature`, `topP`, `maxCompletionTokens`, etc.) there and
1161-
// they flow through the spread below. The root `temperature`/`topP`/
1162-
// `maxTokens` fields are intentionally NOT read here. Root `metadata` is
1163-
// still part of the contract, so forward it the same way the responses
1164-
// adapter does.
1159+
// `modelOptions` is the sole wire surface: callers set provider-native
1160+
// names (`temperature`, `topP`, `maxCompletionTokens`, `metadata`, etc.)
1161+
// there and they flow through the spread below. Root `metadata` is
1162+
// observability-only (middleware, devtools, event client) and must NOT be
1163+
// forwarded here — it may carry arbitrarily structured values while the
1164+
// SDK validates `chatRequest.metadata` as `Record<string, string>` (#735).
11651165
const request: Omit<ChatRequest, 'stream'> = {
11661166
...restModelOptions,
11671167
model: options.model + variantSuffix,
1168-
...(options.metadata !== undefined && { metadata: options.metadata }),
11691168
messages,
11701169
...(tools && tools.length > 0 && { tools }),
11711170
}

packages/ai-openrouter/tests/openrouter-adapter.test.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1533,16 +1533,39 @@ describe('OpenRouter modelOptions pass-through', () => {
15331533
expect(params.maxCompletionTokens).toBe(9999)
15341534
})
15351535

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

15401540
for await (const _ of chat({
15411541
adapter,
15421542
messages: [{ role: 'user', content: 'test' }],
1543-
// Root `metadata` is still part of the contract; it must not be dropped
1544-
// by the chat-completions request builder.
1545-
metadata: { env: 'test' },
1543+
// Root `metadata` is observability-only (middleware, devtools, event
1544+
// client) and may carry structured values; the SDK validates wire
1545+
// `chatRequest.metadata` as `Record<string, string>`, so forwarding it
1546+
// fails client-side Zod validation on every call.
1547+
metadata: { tags: ['a', 'b'], prompt: { name: 'p', version: 1 } },
1548+
})) {
1549+
// consume
1550+
}
1551+
1552+
const [rawParams] = mockSend.mock.calls[0]!
1553+
const params = rawParams.chatRequest
1554+
expect(params).not.toHaveProperty('metadata')
1555+
})
1556+
1557+
it('root metadata does not clobber modelOptions.metadata (#735)', async () => {
1558+
setupMockSdkClient(minimalStreamChunks)
1559+
const adapter = createAdapter()
1560+
1561+
for await (const _ of chat({
1562+
adapter,
1563+
messages: [{ role: 'user', content: 'test' }],
1564+
metadata: { observationName: 'my-call' },
1565+
// `modelOptions.metadata` is the typed home for OpenRouter wire
1566+
// metadata; it must reach the request untouched even when root
1567+
// observability metadata is also present.
1568+
modelOptions: { metadata: { env: 'test' } } as OpenRouterTextModelOptions,
15461569
})) {
15471570
// consume
15481571
}

packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,60 @@ describe('OpenRouter responses adapter — request shape', () => {
197197
expect(params.model).toBe('openai/gpt-4o-mini:thinking')
198198
})
199199

200+
it('does not forward root observability metadata; modelOptions.metadata reaches the wire (#735)', async () => {
201+
setupMockSdkClient([
202+
{
203+
type: 'response.completed',
204+
sequenceNumber: 1,
205+
response: {
206+
model: 'openai/gpt-4o-mini',
207+
output: [],
208+
usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
209+
},
210+
},
211+
])
212+
const adapter = createAdapter()
213+
for await (const _ of chat({
214+
adapter,
215+
messages: [{ role: 'user', content: 'hi' }],
216+
// Root `metadata` is observability-only and may carry structured
217+
// values; the SDK validates wire `metadata` as `Record<string,
218+
// string>`, so forwarding it fails client-side Zod validation.
219+
metadata: { tags: ['a', 'b'], prompt: { name: 'p', version: 1 } },
220+
// `modelOptions.metadata` is the typed home for wire metadata and
221+
// must not be clobbered by root metadata.
222+
modelOptions: { metadata: { env: 'test' } },
223+
})) {
224+
// consume
225+
}
226+
const params = mockSend.mock.calls[0]![0].responsesRequest
227+
expect(params.metadata).toEqual({ env: 'test' })
228+
})
229+
230+
it('omits wire metadata entirely when only root observability metadata is set (#735)', async () => {
231+
setupMockSdkClient([
232+
{
233+
type: 'response.completed',
234+
sequenceNumber: 1,
235+
response: {
236+
model: 'openai/gpt-4o-mini',
237+
output: [],
238+
usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
239+
},
240+
},
241+
])
242+
const adapter = createAdapter()
243+
for await (const _ of chat({
244+
adapter,
245+
messages: [{ role: 'user', content: 'hi' }],
246+
metadata: { observationName: 'my-call' },
247+
})) {
248+
// consume
249+
}
250+
const params = mockSend.mock.calls[0]![0].responsesRequest
251+
expect(params).not.toHaveProperty('metadata')
252+
})
253+
200254
it('rejects webSearchTool() as RUN_ERROR pointing at the chat adapter', async () => {
201255
const adapter = createAdapter()
202256
const ws = webSearchTool() as Tool

packages/ai/src/types.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -820,14 +820,14 @@ export interface TextOptions<
820820
systemPrompts?: Array<SystemPrompt>
821821
agentLoopStrategy?: AgentLoopStrategy
822822
/**
823-
* Additional metadata to attach to the request.
824-
* Can be used for tracking, debugging, or passing custom information.
825-
* Structure and constraints vary by provider.
823+
* Observability metadata attached to this call. Surfaced to middleware,
824+
* devtools, and the event client; values may be arbitrarily structured
825+
* (objects, arrays). Adapters never forward this field onto the provider
826+
* wire request.
826827
*
827-
* Provider usage:
828-
* - OpenAI: `metadata` (Record<string, string>) - max 16 key-value pairs, keys max 64 chars, values max 512 chars
829-
* - Anthropic: `metadata` (Record<string, any>) - includes optional user_id (max 256 chars)
830-
* - Gemini: Not directly available in TextProviderOptions
828+
* To send provider-side request metadata, use the provider's
829+
* `modelOptions` field instead, where the provider supports one (e.g.
830+
* OpenAI's and OpenRouter's `metadata` are both Record<string, string>).
831831
*/
832832
metadata?: Record<string, any> | undefined
833833
modelOptions?: TProviderOptionsForModel

testing/e2e/src/routes/api.chat.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,23 @@ export const Route = createFileRoute('/api/chat')({
9090
]
9191
: [systemPrompt]
9292

93+
// Test-only flag — when set to `true`, the route passes
94+
// structured root-level observability `metadata` (objects,
95+
// arrays) to `chat()`. Enables root-metadata-wire.spec.ts to
96+
// verify the call still completes: the adapter keeps root
97+
// metadata off the provider request, so the OpenRouter SDK's
98+
// Record<string, string> outbound validation never sees it
99+
// (regression coverage for #735). Wire-absence itself is
100+
// asserted by the adapter unit tests.
101+
const rootObservabilityMetadata =
102+
fp.structuredRootMetadata === true
103+
? {
104+
observationName: 'e2e-root-metadata',
105+
tags: ['a', 'b'],
106+
prompt: { name: 'p', version: 1 },
107+
}
108+
: undefined
109+
93110
// Two structured-output-streaming features differ only in which
94111
// schema they bind to. Branched per-feature so TS can pick the
95112
// right `chat<TSchema>()` overload without a `never` cast.
@@ -141,6 +158,9 @@ export const Route = createFileRoute('/api/chat')({
141158
messages: params.messages,
142159
threadId: params.threadId,
143160
runId: params.runId,
161+
...(rootObservabilityMetadata && {
162+
metadata: rootObservabilityMetadata,
163+
}),
144164
abortController,
145165
})
146166

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { test, expect } from './fixtures'
2+
3+
/**
4+
* End-to-end regression coverage for #735: root-level observability
5+
* `metadata` on `chat()` must never be forwarded onto the provider wire
6+
* request.
7+
*
8+
* In `@tanstack/ai-openrouter` 0.13.x the chat-completions mapper copied
9+
* root `metadata` into OpenRouter's `chatRequest.metadata`. The
10+
* `@openrouter/sdk` validates that field as `Record<string, string>`
11+
* client-side, so structured observability metadata (objects, arrays —
12+
* the documented usage for middleware/devtools consumers) failed Zod
13+
* validation before the request ever left the process, killing every
14+
* call with `RUN_ERROR`.
15+
*
16+
* Wire-shape coverage lives in the unit tests
17+
* `packages/ai-openrouter/tests/openrouter-adapter.test.ts` and
18+
* `openrouter-responses-adapter.test.ts`, which inspect the request
19+
* handed to the SDK directly. What this spec covers (which those
20+
* cannot): the full HTTP path — test → route → `chat()` → adapter →
21+
* real `@openrouter/sdk` outbound validation — tolerates structured
22+
* root metadata. Pre-fix, the SDK's own Zod schema rejects the request
23+
* and the stream emits RUN_ERROR instead of completing.
24+
*/
25+
test.describe('root observability metadata — wire path', () => {
26+
test('chat completes end-to-end on OpenRouter without the root metadata reaching the wire request', async ({
27+
request,
28+
testId,
29+
aimockPort,
30+
}) => {
31+
const body = {
32+
threadId: 'thread-root-meta-1',
33+
runId: 'run-root-meta-1',
34+
state: {},
35+
messages: [
36+
{ id: 'u1', role: 'user', content: '[chat] recommend a guitar' },
37+
],
38+
tools: [],
39+
context: [],
40+
forwardedProps: {
41+
provider: 'openrouter',
42+
feature: 'chat',
43+
testId,
44+
aimockPort,
45+
// Opt-in flag handled by `api.chat.ts` — passes structured
46+
// root-level observability metadata (arrays, nested objects) to
47+
// `chat()`. The adapter must keep it off the provider request;
48+
// pre-fix, the SDK's own outbound Zod validation rejects the
49+
// request before it reaches aimock and the stream ends in
50+
// RUN_ERROR.
51+
structuredRootMetadata: true,
52+
},
53+
}
54+
const response = await request.post('/api/chat', {
55+
data: body,
56+
headers: { 'Content-Type': 'application/json' },
57+
})
58+
expect(
59+
response.ok(),
60+
`expected 200, got ${response.status()}: ${await response.text()}`,
61+
).toBe(true)
62+
const text = await response.text()
63+
expect(text).toContain('RUN_FINISHED')
64+
// No RUN_ERROR — the @openrouter/sdk's outbound Record<string, string>
65+
// validation never saw the structured metadata.
66+
expect(text).not.toContain('RUN_ERROR')
67+
})
68+
})

0 commit comments

Comments
 (0)