From fc581b09033f2c3bfaca9408c37480a5b4f7d9c9 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:35:53 +1000 Subject: [PATCH 1/2] fix(ai-openrouter): stop forwarding root observability metadata to the wire request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, 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 --- .changeset/root-metadata-not-forwarded.md | 10 +++ .../src/adapters/responses-text.ts | 6 +- packages/ai-openrouter/src/adapters/text.ts | 13 ++-- .../tests/openrouter-adapter.test.ts | 31 +++++++-- .../openrouter-responses-adapter.test.ts | 57 ++++++++++++++++ packages/ai/src/types.ts | 14 ++-- testing/e2e/src/routes/api.chat.ts | 20 ++++++ testing/e2e/tests/root-metadata-wire.spec.ts | 68 +++++++++++++++++++ 8 files changed, 200 insertions(+), 19 deletions(-) create mode 100644 .changeset/root-metadata-not-forwarded.md create mode 100644 testing/e2e/tests/root-metadata-wire.spec.ts diff --git a/.changeset/root-metadata-not-forwarded.md b/.changeset/root-metadata-not-forwarded.md new file mode 100644 index 000000000..926e8867e --- /dev/null +++ b/.changeset/root-metadata-not-forwarded.md @@ -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`, 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. diff --git a/packages/ai-openrouter/src/adapters/responses-text.ts b/packages/ai-openrouter/src/adapters/responses-text.ts index a658d985e..0c3b7b9c0 100644 --- a/packages/ai-openrouter/src/adapters/responses-text.ts +++ b/packages/ai-openrouter/src/adapters/responses-text.ts @@ -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`, 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 {} diff --git a/packages/ai-openrouter/src/adapters/text.ts b/packages/ai-openrouter/src/adapters/text.ts index 2648da0a3..ef69eb7a5 100644 --- a/packages/ai-openrouter/src/adapters/text.ts +++ b/packages/ai-openrouter/src/adapters/text.ts @@ -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` (#735). const request: Omit = { ...restModelOptions, model: options.model + variantSuffix, - ...(options.metadata !== undefined && { metadata: options.metadata }), messages, ...(tools && tools.length > 0 && { tools }), } diff --git a/packages/ai-openrouter/tests/openrouter-adapter.test.ts b/packages/ai-openrouter/tests/openrouter-adapter.test.ts index 83d8c9251..779435b3c 100644 --- a/packages/ai-openrouter/tests/openrouter-adapter.test.ts +++ b/packages/ai-openrouter/tests/openrouter-adapter.test.ts @@ -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`, 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 } diff --git a/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts b/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts index a9bcfaa21..2daeddbbb 100644 --- a/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts +++ b/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts @@ -3,6 +3,7 @@ import { EventType, chat } from '@tanstack/ai' import { resolveDebugOption } from '@tanstack/ai/adapter-internals' import { ResponsesRequest$outboundSchema } from '@openrouter/sdk/models' import { createOpenRouterResponsesText } from '../src/adapters/responses-text' +import type { OpenRouterResponsesTextProviderOptions } from '../src/adapters/responses-text' import { webSearchTool } from '../src/tools/web-search-tool' import { webFetchTool } from '../src/tools/web-fetch-tool' import type { StreamChunk, Tool } from '@tanstack/ai' @@ -197,6 +198,62 @@ 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`, 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' }, + } as OpenRouterResponsesTextProviderOptions, + })) { + // 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 diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 6034e83ca..6039b2607 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -819,14 +819,14 @@ export interface TextOptions< systemPrompts?: Array 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) - max 16 key-value pairs, keys max 64 chars, values max 512 chars - * - Anthropic: `metadata` (Record) - 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). */ metadata?: Record | undefined modelOptions?: TProviderOptionsForModel diff --git a/testing/e2e/src/routes/api.chat.ts b/testing/e2e/src/routes/api.chat.ts index c99b24fd0..f3a5a4fe1 100644 --- a/testing/e2e/src/routes/api.chat.ts +++ b/testing/e2e/src/routes/api.chat.ts @@ -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 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()` overload without a `never` cast. @@ -141,6 +158,9 @@ export const Route = createFileRoute('/api/chat')({ messages: params.messages, threadId: params.threadId, runId: params.runId, + ...(rootObservabilityMetadata && { + metadata: rootObservabilityMetadata, + }), abortController, }) diff --git a/testing/e2e/tests/root-metadata-wire.spec.ts b/testing/e2e/tests/root-metadata-wire.spec.ts new file mode 100644 index 000000000..3d3765815 --- /dev/null +++ b/testing/e2e/tests/root-metadata-wire.spec.ts @@ -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` + * 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 + // validation never saw the structured metadata. + expect(text).not.toContain('RUN_ERROR') + }) +}) From 5a75984a2e6e8d0399562e71c804f4fba94e33d7 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:47:18 +1000 Subject: [PATCH 2/2] 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 --- .../ai-openrouter/tests/openrouter-responses-adapter.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts b/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts index 2daeddbbb..4a7be8fdc 100644 --- a/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts +++ b/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts @@ -3,7 +3,6 @@ import { EventType, chat } from '@tanstack/ai' import { resolveDebugOption } from '@tanstack/ai/adapter-internals' import { ResponsesRequest$outboundSchema } from '@openrouter/sdk/models' import { createOpenRouterResponsesText } from '../src/adapters/responses-text' -import type { OpenRouterResponsesTextProviderOptions } from '../src/adapters/responses-text' import { webSearchTool } from '../src/tools/web-search-tool' import { webFetchTool } from '../src/tools/web-fetch-tool' import type { StreamChunk, Tool } from '@tanstack/ai' @@ -220,9 +219,7 @@ describe('OpenRouter responses adapter — request shape', () => { 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' }, - } as OpenRouterResponsesTextProviderOptions, + modelOptions: { metadata: { env: 'test' } }, })) { // consume }