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
5 changes: 5 additions & 0 deletions .changeset/openrouter-root-metadata.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 3 additions & 3 deletions packages/ai-openrouter/src/adapters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChatRequest, 'stream'> = {
...restModelOptions,
model: options.model + variantSuffix,
...(options.metadata !== undefined && { metadata: options.metadata }),
messages,
...(tools && tools.length > 0 && { tools }),
}
Expand Down
37 changes: 33 additions & 4 deletions packages/ai-openrouter/tests/openrouter-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -1533,23 +1561,24 @@ 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<string,string>
// schema can validate provider-native values.
metadata: { env: 'test' },
})) {
// consume
}

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 () => {
Expand Down
5 changes: 4 additions & 1 deletion testing/e2e/src/routes/$provider/$feature.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export const Route = createFileRoute('/$provider/$feature')({
: undefined,
persistence:
search.persistence === 'localStorage' ? 'localStorage' : undefined,
rootMetadata:
search.rootMetadata === 'structured' ? 'structured' : undefined,
}
},
})
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -267,6 +269,7 @@ function ChatFeature({
testId,
aimockPort,
previousInteractionId: interactionId,
rootMetadata,
},
persistence:
persistence === 'localStorage' ? localStoragePersistence : undefined,
Expand Down
14 changes: 14 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,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<TSchema>()` overload without a `never` cast.
Expand All @@ -105,6 +115,7 @@ export const Route = createFileRoute('/api/chat')({
outputSchema: guitarRecommendationSchema,
stream: true,
abortController,
...(rootMetadata && { metadata: rootMetadata }),
})
: feature === 'multi-turn-structured'
? chat({
Expand All @@ -117,6 +128,7 @@ export const Route = createFileRoute('/api/chat')({
outputSchema: recipeSchema,
stream: true,
abortController,
...(rootMetadata && { metadata: rootMetadata }),
})
: feature === 'agentic-structured-stream'
? chat({
Expand All @@ -131,6 +143,7 @@ export const Route = createFileRoute('/api/chat')({
outputSchema: guitarRecommendationSchema,
stream: true,
abortController,
...(rootMetadata && { metadata: rootMetadata }),
})
: chat({
...adapterOptions,
Expand All @@ -142,6 +155,7 @@ export const Route = createFileRoute('/api/chat')({
threadId: params.threadId,
runId: params.runId,
abortController,
...(rootMetadata && { metadata: rootMetadata }),
})

// Cast: `chat()` returns `AsyncIterable<StreamChunk> |
Expand Down
18 changes: 18 additions & 0 deletions testing/e2e/tests/chat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down