From 57cedc01d75b74a666f288d25befa9eab03e3c3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CSebastian?= <64795732+slegarraga@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:34:54 -0400 Subject: [PATCH] fix(ai-openrouter): keep root metadata out of requests --- .changeset/openrouter-root-metadata.md | 5 +++ packages/ai-openrouter/src/adapters/text.ts | 6 +-- .../tests/openrouter-adapter.test.ts | 37 +++++++++++++++++-- testing/e2e/src/routes/$provider/$feature.tsx | 5 ++- testing/e2e/src/routes/api.chat.ts | 14 +++++++ testing/e2e/tests/chat.spec.ts | 18 +++++++++ 6 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 .changeset/openrouter-root-metadata.md diff --git a/.changeset/openrouter-root-metadata.md b/.changeset/openrouter-root-metadata.md new file mode 100644 index 000000000..a24945134 --- /dev/null +++ b/.changeset/openrouter-root-metadata.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-openrouter': patch +--- + +Keep root chat metadata as observability context instead of forwarding it to OpenRouter chat-completions request metadata. diff --git a/packages/ai-openrouter/src/adapters/text.ts b/packages/ai-openrouter/src/adapters/text.ts index 2648da0a3..5076a901d 100644 --- a/packages/ai-openrouter/src/adapters/text.ts +++ b/packages/ai-openrouter/src/adapters/text.ts @@ -1160,12 +1160,12 @@ export class OpenRouterTextAdapter< // 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. + // observability context for middleware/devtools, not OpenRouter request + // metadata. Provider-native request metadata still flows through + // `modelOptions.metadata` via `...restModelOptions`. 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..5b8dae3f9 100644 --- a/packages/ai-openrouter/tests/openrouter-adapter.test.ts +++ b/packages/ai-openrouter/tests/openrouter-adapter.test.ts @@ -1508,6 +1508,34 @@ describe('OpenRouter modelOptions pass-through', () => { expect(params.sessionId).toBe('session-abc') }) + it('keeps root observability metadata out of the SDK request', async () => { + setupMockSdkClient(minimalStreamChunks) + const adapter = createAdapter() + + for await (const _ of chat({ + adapter, + messages: [{ role: 'user', content: 'test' }], + metadata: { + observationName: 'my-call', + tags: ['a', 'b'], + prompt: { name: 'prompt', version: 1 }, + sessionId: undefined, + }, + modelOptions: { + metadata: { env: 'test' }, + } as OpenRouterTextModelOptions, + })) { + // consume + } + + const [rawParams] = mockSend.mock.calls[0]! + const params = rawParams.chatRequest + expect(params.metadata).toEqual({ env: 'test' }) + + const serialized = ChatRequest$outboundSchema.parse(params) + expect(serialized).toHaveProperty('metadata', { env: 'test' }) + }) + it('reads sampling from modelOptions; modelOptions is the sole sampling source', async () => { setupMockSdkClient(minimalStreamChunks) const adapter = createAdapter() @@ -1533,15 +1561,16 @@ 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 metadata to the request', 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. + // Root `metadata` is observability context. OpenRouter request metadata + // belongs in modelOptions.metadata, where the SDK's Record + // schema can validate provider-native values. metadata: { env: 'test' }, })) { // consume @@ -1549,7 +1578,7 @@ describe('OpenRouter modelOptions pass-through', () => { const [rawParams] = mockSend.mock.calls[0]! const params = rawParams.chatRequest - expect(params.metadata).toEqual({ env: 'test' }) + expect(params).not.toHaveProperty('metadata') }) it('appends variant to model name instead of passing it as a separate property', async () => { diff --git a/testing/e2e/src/routes/$provider/$feature.tsx b/testing/e2e/src/routes/$provider/$feature.tsx index ea080c4fc..340295286 100644 --- a/testing/e2e/src/routes/$provider/$feature.tsx +++ b/testing/e2e/src/routes/$provider/$feature.tsx @@ -36,6 +36,8 @@ export const Route = createFileRoute('/$provider/$feature')({ : undefined, persistence: search.persistence === 'localStorage' ? 'localStorage' : undefined, + rootMetadata: + search.rootMetadata === 'structured' ? 'structured' : undefined, } }, }) @@ -190,7 +192,7 @@ function ChatFeature({ const tools = needsApproval ? clientTools(addToCartClient) : undefined - const { testId, aimockPort, persistence } = Route.useSearch() + const { testId, aimockPort, persistence, rootMetadata } = Route.useSearch() const persistenceEnabled = persistence === 'localStorage' const baseChatId = `e2e-chat-${testId ?? `${provider}-${feature}`}` // When persistence is on, expose a tiny thread switcher so e2e can verify that @@ -267,6 +269,7 @@ function ChatFeature({ testId, aimockPort, previousInteractionId: interactionId, + rootMetadata, }, persistence: persistence === 'localStorage' ? localStoragePersistence : undefined, diff --git a/testing/e2e/src/routes/api.chat.ts b/testing/e2e/src/routes/api.chat.ts index c99b24fd0..f8ac58a0b 100644 --- a/testing/e2e/src/routes/api.chat.ts +++ b/testing/e2e/src/routes/api.chat.ts @@ -90,6 +90,16 @@ export const Route = createFileRoute('/api/chat')({ ] : [systemPrompt] + const rootMetadata = + fp.rootMetadata === 'structured' + ? { + observationName: 'openrouter-root-metadata', + tags: ['e2e', 'openrouter'], + prompt: { name: 'chat', version: 1 }, + sessionId: undefined, + } + : 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. @@ -105,6 +115,7 @@ export const Route = createFileRoute('/api/chat')({ outputSchema: guitarRecommendationSchema, stream: true, abortController, + ...(rootMetadata && { metadata: rootMetadata }), }) : feature === 'multi-turn-structured' ? chat({ @@ -117,6 +128,7 @@ export const Route = createFileRoute('/api/chat')({ outputSchema: recipeSchema, stream: true, abortController, + ...(rootMetadata && { metadata: rootMetadata }), }) : feature === 'agentic-structured-stream' ? chat({ @@ -131,6 +143,7 @@ export const Route = createFileRoute('/api/chat')({ outputSchema: guitarRecommendationSchema, stream: true, abortController, + ...(rootMetadata && { metadata: rootMetadata }), }) : chat({ ...adapterOptions, @@ -142,6 +155,7 @@ export const Route = createFileRoute('/api/chat')({ threadId: params.threadId, runId: params.runId, abortController, + ...(rootMetadata && { metadata: rootMetadata }), }) // Cast: `chat()` returns `AsyncIterable | diff --git a/testing/e2e/tests/chat.spec.ts b/testing/e2e/tests/chat.spec.ts index 939e82dd0..8dc5e8c7b 100644 --- a/testing/e2e/tests/chat.spec.ts +++ b/testing/e2e/tests/chat.spec.ts @@ -53,6 +53,24 @@ for (const provider of providersFor('chat')) { }) } +test.describe('openrouter root metadata', () => { + test('streams when root observability metadata is structured', async ({ + page, + testId, + aimockPort, + }) => { + await page.goto( + `${featureUrl('openrouter', 'chat', testId, aimockPort)}&rootMetadata=structured`, + ) + + await sendMessage(page, '[chat] recommend a guitar') + await waitForResponse(page) + + const response = await getLastAssistantMessage(page) + expect(response).toContain('Fender Stratocaster') + }) +}) + test.describe('openai chat persistence', () => { test('persists chat messages across browser reload with localStorage', async ({ page,