diff --git a/.changeset/openrouter-stream-usage-opt-out.md b/.changeset/openrouter-stream-usage-opt-out.md new file mode 100644 index 0000000000..24342c0562 --- /dev/null +++ b/.changeset/openrouter-stream-usage-opt-out.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-openrouter': patch +--- + +Respect `modelOptions.streamOptions: { includeUsage: false }`. The adapter no longer sends `stream_options` in that case, for chat streams and structured output streams. This lets you stream with OpenRouter `provider.requireParameters: true`. diff --git a/docs/adapters/openrouter.md b/docs/adapters/openrouter.md index 25f8fefd3b..78c35fdbd4 100644 --- a/docs/adapters/openrouter.md +++ b/docs/adapters/openrouter.md @@ -340,7 +340,7 @@ const stream = chat({ Provider slugs are the lowercase ids shown on OpenRouter's model pages (`"anthropic"`, `"openai"`, `"groq"`, …). Other fields on `provider`: - `only` / `ignore`: allow-list or deny-list of provider slugs, merged with your account-wide provider settings for this request. -- `requireParameters`: only route to providers that support every parameter in the request. Without it, OpenRouter sends each provider only the parameters it supports and silently drops the rest, so a `responseFormat` or a sampling option can be ignored without an error. +- `requireParameters`: only route to providers that support every parameter in the request. Without it, OpenRouter sends each provider only the parameters it supports and silently drops the rest, so a `responseFormat` or a sampling option can be ignored without an error. When you stream with `requireParameters: true`, also set `modelOptions.streamOptions: { includeUsage: false }`. Without it, the adapter sends `stream_options`, and OpenRouter returns a 404 for providers that do not support that parameter. - `sort`: `"price"`, `"throughput"`, or `"latency"`, or an object with `by` and `partition`, applied when `order` is not set. Setting it disables load balancing. - `maxPrice`: the highest pricing you accept. It takes `prompt` and `completion` in USD per million tokens, plus `request` (per-request pricing) and `image` (per image) where a provider offers them. Providers above the limit are skipped. - `quantizations`: restrict to providers serving the model at given quantization levels (e.g. `"fp8"`, `"int4"`). diff --git a/packages/ai-openrouter/src/adapters/text.ts b/packages/ai-openrouter/src/adapters/text.ts index 67adae4387..625027bdc2 100644 --- a/packages/ai-openrouter/src/adapters/text.ts +++ b/packages/ai-openrouter/src/adapters/text.ts @@ -177,10 +177,12 @@ export class OpenRouterTextAdapter< chatRequest: { ...chatRequest, stream: true, - streamOptions: { - ...(chatRequest.streamOptions ?? {}), - includeUsage: true, - }, + // `includeUsage: false` drops `stream_options` from the wire body. + // OpenRouter `provider.requireParameters: true` needs this. + streamOptions: + chatRequest.streamOptions?.includeUsage === false + ? undefined + : { ...chatRequest.streamOptions, includeUsage: true }, }, }, { @@ -424,8 +426,10 @@ export class OpenRouterTextAdapter< // Strip streamOptions/tools/responseFormat from the base request before // adding the resolved structured-output format. Structured output // doesn't carry tools — keeping them can confuse strict-mode validation - // upstream. (`stream` is already absent — `mapOptionsToRequest` returns - // `Omit`; we set it explicitly below.) + // upstream. `streamOptions` is set again below (omitted when the caller + // sets `includeUsage: false`). (`stream` is already absent — + // `mapOptionsToRequest` returns `Omit`; we set it + // explicitly below.) const { streamOptions: _so, tools: _t, @@ -447,7 +451,10 @@ export class OpenRouterTextAdapter< chatRequest: { ...cleanParams, stream: true, - streamOptions: { includeUsage: true }, + streamOptions: + chatRequest.streamOptions?.includeUsage === false + ? undefined + : { includeUsage: true }, responseFormat, }, }, diff --git a/packages/ai-openrouter/tests/openrouter-adapter.test.ts b/packages/ai-openrouter/tests/openrouter-adapter.test.ts index 9cdfa84a8b..9ad0edf71b 100644 --- a/packages/ai-openrouter/tests/openrouter-adapter.test.ts +++ b/packages/ai-openrouter/tests/openrouter-adapter.test.ts @@ -1683,6 +1683,40 @@ describe('OpenRouter structured output', () => { expect(rawParams.chatRequest.stream).toBe(true) }) + it('respects includeUsage false for structured output streams', async () => { + setupMockSdkClient([ + { + id: 'c1', + model: 'anthropic/claude-sonnet-4.5', + choices: [{ delta: { content: '{"ok":true}' }, finishReason: 'stop' }], + }, + ]) + const adapter = createAdapter() + + await chat({ + adapter, + messages: [{ role: 'user', content: 'Respond with ok' }], + modelOptions: { streamOptions: { includeUsage: false } }, + outputSchema: { + type: 'object', + properties: { ok: { type: 'boolean' } }, + required: ['ok'], + }, + }) + + const structuredCall = mockSend.mock.calls.find( + ([args]: Array) => args.chatRequest.responseFormat, + ) + expect(structuredCall).toBeDefined() + // The SDK sends JSON.stringify of the outbound schema result. + const body = JSON.parse( + JSON.stringify( + ChatRequest$outboundSchema.parse(structuredCall[0].chatRequest), + ), + ) + expect(body).not.toHaveProperty('stream_options') + }) + it('parses JSON response content correctly', async () => { const nonStreamResponse = { choices: [ @@ -2560,6 +2594,33 @@ describe('OpenRouter stream_options conversion', () => { expect((serialized as any).stream_options).toEqual({ include_usage: true }) }) + it('respects an explicit includeUsage false override', async () => { + setupMockSdkClient([ + { + id: 'x', + model: 'anthropic/claude-sonnet-4.5', + choices: [{ delta: { content: 'hi' }, finishReason: 'stop' }], + }, + ]) + const adapter = createAdapter() + + for await (const _ of adapter.chatStream({ + model: 'anthropic/claude-sonnet-4.5', + messages: [{ role: 'user', content: 'hi' }], + modelOptions: { streamOptions: { includeUsage: false } }, + logger: testLogger, + })) { + // consume + } + + const [rawParams] = mockSend.mock.calls[0]! + // The SDK sends JSON.stringify of the outbound schema result. + const body = JSON.parse( + JSON.stringify(ChatRequest$outboundSchema.parse(rawParams.chatRequest)), + ) + expect(body).not.toHaveProperty('stream_options') + }) + it('propagates the abort signal to the SDK call', async () => { setupMockSdkClient([ { diff --git a/testing/e2e/fixtures/chat/openrouter-stream-options-wire.json b/testing/e2e/fixtures/chat/openrouter-stream-options-wire.json new file mode 100644 index 0000000000..1dffcc7f19 --- /dev/null +++ b/testing/e2e/fixtures/chat/openrouter-stream-options-wire.json @@ -0,0 +1,10 @@ +{ + "fixtures": [ + { + "match": { "userMessage": "[stream-options-wire] usage check" }, + "response": { + "content": "Usage checked." + } + } + ] +} diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 03f6e7e77a..bd6a863f70 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -64,6 +64,7 @@ import { Route as ApiOtelUsageRouteImport } from './routes/api.otel-usage' import { Route as ApiOtelTranscriptionRouteImport } from './routes/api.otel-transcription' import { Route as ApiOtelMediaRouteImport } from './routes/api.otel-media' import { Route as ApiOpenrouterWebToolsWireRouteImport } from './routes/api.openrouter-web-tools-wire' +import { Route as ApiOpenrouterStreamOptionsWireRouteImport } from './routes/api.openrouter-stream-options-wire' import { Route as ApiOpenrouterRetryCodesRouteImport } from './routes/api.openrouter-retry-codes' import { Route as ApiOpenrouterReasoningWireRouteImport } from './routes/api.openrouter-reasoning-wire' import { Route as ApiOpenrouterJsonObjectWireRouteImport } from './routes/api.openrouter-json-object-wire' @@ -407,6 +408,12 @@ const ApiOpenrouterWebToolsWireRoute = path: '/api/openrouter-web-tools-wire', getParentRoute: () => rootRouteImport, } as any) +const ApiOpenrouterStreamOptionsWireRoute = + ApiOpenrouterStreamOptionsWireRouteImport.update({ + id: '/api/openrouter-stream-options-wire', + path: '/api/openrouter-stream-options-wire', + getParentRoute: () => rootRouteImport, + } as any) const ApiOpenrouterRetryCodesRoute = ApiOpenrouterRetryCodesRouteImport.update({ id: '/api/openrouter-retry-codes', path: '/api/openrouter-retry-codes', @@ -803,6 +810,7 @@ export interface FileRoutesByFullPath { '/api/openrouter-json-object-wire': typeof ApiOpenrouterJsonObjectWireRoute '/api/openrouter-reasoning-wire': typeof ApiOpenrouterReasoningWireRoute '/api/openrouter-retry-codes': typeof ApiOpenrouterRetryCodesRoute + '/api/openrouter-stream-options-wire': typeof ApiOpenrouterStreamOptionsWireRoute '/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute '/api/otel-media': typeof ApiOtelMediaRoute '/api/otel-transcription': typeof ApiOtelTranscriptionRoute @@ -919,6 +927,7 @@ export interface FileRoutesByTo { '/api/openrouter-json-object-wire': typeof ApiOpenrouterJsonObjectWireRoute '/api/openrouter-reasoning-wire': typeof ApiOpenrouterReasoningWireRoute '/api/openrouter-retry-codes': typeof ApiOpenrouterRetryCodesRoute + '/api/openrouter-stream-options-wire': typeof ApiOpenrouterStreamOptionsWireRoute '/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute '/api/otel-media': typeof ApiOtelMediaRoute '/api/otel-transcription': typeof ApiOtelTranscriptionRoute @@ -1036,6 +1045,7 @@ export interface FileRoutesById { '/api/openrouter-json-object-wire': typeof ApiOpenrouterJsonObjectWireRoute '/api/openrouter-reasoning-wire': typeof ApiOpenrouterReasoningWireRoute '/api/openrouter-retry-codes': typeof ApiOpenrouterRetryCodesRoute + '/api/openrouter-stream-options-wire': typeof ApiOpenrouterStreamOptionsWireRoute '/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute '/api/otel-media': typeof ApiOtelMediaRoute '/api/otel-transcription': typeof ApiOtelTranscriptionRoute @@ -1154,6 +1164,7 @@ export interface FileRouteTypes { | '/api/openrouter-json-object-wire' | '/api/openrouter-reasoning-wire' | '/api/openrouter-retry-codes' + | '/api/openrouter-stream-options-wire' | '/api/openrouter-web-tools-wire' | '/api/otel-media' | '/api/otel-transcription' @@ -1270,6 +1281,7 @@ export interface FileRouteTypes { | '/api/openrouter-json-object-wire' | '/api/openrouter-reasoning-wire' | '/api/openrouter-retry-codes' + | '/api/openrouter-stream-options-wire' | '/api/openrouter-web-tools-wire' | '/api/otel-media' | '/api/otel-transcription' @@ -1386,6 +1398,7 @@ export interface FileRouteTypes { | '/api/openrouter-json-object-wire' | '/api/openrouter-reasoning-wire' | '/api/openrouter-retry-codes' + | '/api/openrouter-stream-options-wire' | '/api/openrouter-web-tools-wire' | '/api/otel-media' | '/api/otel-transcription' @@ -1503,6 +1516,7 @@ export interface RootRouteChildren { ApiOpenrouterJsonObjectWireRoute: typeof ApiOpenrouterJsonObjectWireRoute ApiOpenrouterReasoningWireRoute: typeof ApiOpenrouterReasoningWireRoute ApiOpenrouterRetryCodesRoute: typeof ApiOpenrouterRetryCodesRoute + ApiOpenrouterStreamOptionsWireRoute: typeof ApiOpenrouterStreamOptionsWireRoute ApiOpenrouterWebToolsWireRoute: typeof ApiOpenrouterWebToolsWireRoute ApiOtelMediaRoute: typeof ApiOtelMediaRoute ApiOtelTranscriptionRoute: typeof ApiOtelTranscriptionRoute @@ -1917,6 +1931,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiOpenrouterWebToolsWireRouteImport parentRoute: typeof rootRouteImport } + '/api/openrouter-stream-options-wire': { + id: '/api/openrouter-stream-options-wire' + path: '/api/openrouter-stream-options-wire' + fullPath: '/api/openrouter-stream-options-wire' + preLoaderRoute: typeof ApiOpenrouterStreamOptionsWireRouteImport + parentRoute: typeof rootRouteImport + } '/api/openrouter-retry-codes': { id: '/api/openrouter-retry-codes' path: '/api/openrouter-retry-codes' @@ -2477,6 +2498,7 @@ const rootRouteChildren: RootRouteChildren = { ApiOpenrouterJsonObjectWireRoute: ApiOpenrouterJsonObjectWireRoute, ApiOpenrouterReasoningWireRoute: ApiOpenrouterReasoningWireRoute, ApiOpenrouterRetryCodesRoute: ApiOpenrouterRetryCodesRoute, + ApiOpenrouterStreamOptionsWireRoute: ApiOpenrouterStreamOptionsWireRoute, ApiOpenrouterWebToolsWireRoute: ApiOpenrouterWebToolsWireRoute, ApiOtelMediaRoute: ApiOtelMediaRoute, ApiOtelTranscriptionRoute: ApiOtelTranscriptionRoute, diff --git a/testing/e2e/src/routes/api.openrouter-stream-options-wire.ts b/testing/e2e/src/routes/api.openrouter-stream-options-wire.ts new file mode 100644 index 0000000000..aed54aeeef --- /dev/null +++ b/testing/e2e/src/routes/api.openrouter-stream-options-wire.ts @@ -0,0 +1,60 @@ +import { createFileRoute } from '@tanstack/react-router' +import { chat, createChatOptions } from '@tanstack/ai' +import { createOpenRouterText } from '@tanstack/ai-openrouter' +import { HTTPClient } from '@openrouter/sdk' + +const LLMOCK_DEFAULT_BASE = process.env.LLMOCK_URL || 'http://127.0.0.1:4010' +const DUMMY_KEY = 'sk-e2e-test-dummy-key' + +/** + * Drives the real OpenRouter SDK request path with or without the + * `streamOptions: { includeUsage: false }` opt-out. The companion spec reads + * aimock's request journal to check that the opt-out removes `stream_options` + * from the wire body (#1037), and that the default still sends it. + */ +export const Route = createFileRoute('/api/openrouter-stream-options-wire')({ + server: { + handlers: { + POST: async ({ request }) => { + const url = new URL(request.url) + const testId = url.searchParams.get('testId') ?? undefined + const usageOff = url.searchParams.get('scenario') === 'usage-off' + + const httpClient = new HTTPClient() + if (testId) { + httpClient.addHook('beforeRequest', (req) => { + const next = new Request(req) + next.headers.set('X-Test-Id', testId) + return next + }) + } + + const adapter = createOpenRouterText('openai/gpt-5', DUMMY_KEY, { + serverURL: `${LLMOCK_DEFAULT_BASE}/v1`, + httpClient, + }) + + try { + for await (const _ of chat({ + ...createChatOptions({ adapter }), + messages: [ + { role: 'user', content: '[stream-options-wire] usage check' }, + ], + modelOptions: usageOff + ? { streamOptions: { includeUsage: false } } + : undefined, + })) { + // Drain the stream. + } + } catch (error) { + return Response.json({ + ok: false, + error: error instanceof Error ? error.message : String(error), + }) + } + + return Response.json({ ok: true }) + }, + }, + }, +}) diff --git a/testing/e2e/tests/openrouter-stream-options-wire.spec.ts b/testing/e2e/tests/openrouter-stream-options-wire.spec.ts new file mode 100644 index 0000000000..3152713332 --- /dev/null +++ b/testing/e2e/tests/openrouter-stream-options-wire.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from './fixtures' + +type JournalEntry = { + headers?: Record + body: { + stream_options?: Record + } | null +} + +test.describe('openrouter — stream_options wire format', () => { + for (const scenario of ['usage-off', 'default'] as const) { + test(`${scenario}: stream_options on the wire (#1037)`, async ({ + request, + aimockPort, + testId, + }) => { + const response = await request.post( + `/api/openrouter-stream-options-wire?testId=${encodeURIComponent(testId)}&scenario=${scenario}`, + ) + expect(response.ok()).toBe(true) + expect(((await response.json()) as { ok: boolean }).ok).toBe(true) + + const journalResponse = await request.get( + `http://127.0.0.1:${aimockPort}/v1/_requests`, + ) + const entries = (await journalResponse.json()) as Array + const captured = entries.find( + (entry) => entry.headers?.['x-test-id'] === testId, + ) + expect(captured).toBeDefined() + expect(captured?.body).toEqual(expect.any(Object)) + + if (scenario === 'usage-off') { + expect(captured?.body).not.toHaveProperty('stream_options') + } else { + expect(captured?.body?.stream_options).toEqual({ include_usage: true }) + } + }) + } +})