diff --git a/.changeset/merge-chat-provider-routing.md b/.changeset/merge-chat-provider-routing.md new file mode 100644 index 00000000..3e5d41c8 --- /dev/null +++ b/.changeset/merge-chat-provider-routing.md @@ -0,0 +1,5 @@ +--- +'@openrouter/ai-sdk-provider': patch +--- + +Chat and completion models now shallow merge `provider` routing preferences instead of replacing them, matching the image model. Precedence, lowest to highest, is model `provider` settings (chat only), provider `extraBody.provider`, model `extraBody.provider`, then `providerOptions.openrouter.provider`. Non-object values such as `null` are ignored. Behavior change: a runtime `providerOptions.openrouter.provider` no longer drops configured keys it does not set. To remove a configured key, set it explicitly in the runtime object. diff --git a/e2e/issues/issue-587-provider-routing-merge.test.ts b/e2e/issues/issue-587-provider-routing-merge.test.ts new file mode 100644 index 00000000..3d43a84f --- /dev/null +++ b/e2e/issues/issue-587-provider-routing-merge.test.ts @@ -0,0 +1,54 @@ +/** + * Regression test for GitHub issue #587 + * https://github.com/OpenRouterTeam/ai-sdk-provider/issues/587 + * + * Issue: "Chat model replaces configured provider routing instead of merging + * (consistency with image model, #551)" + * + * This test verifies that a call-level `providerOptions.openrouter.provider` + * is shallow merged into the model's configured provider routing, so keys it + * does not set (`allow_fallbacks`, `sort`) are still sent. + */ +import { generateText } from 'ai'; +import { describe, expect, it } from 'vitest'; +import { createOpenRouter } from '@/src'; + +describe('Issue #587: chat provider routing is merged, not replaced', () => { + it('keeps configured routing keys when the call overrides order', async () => { + const bodies: Array> = []; + const openrouter = createOpenRouter({ + apiKey: 'test-key', + fetch: async (_input, init) => { + bodies.push(JSON.parse(String(init?.body))); + return new Response( + JSON.stringify({ + id: 'chatcmpl-587', + model: 'google/gemini-2.5-flash', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Hello' }, + finish_reason: 'stop', + }, + ], + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + }, + }); + + await generateText({ + model: openrouter.chat('google/gemini-2.5-flash', { + provider: { order: ['google'], allow_fallbacks: false, sort: 'price' }, + }), + prompt: 'Hello', + providerOptions: { openrouter: { provider: { order: ['anthropic'] } } }, + }); + + expect(bodies[0]?.provider).toStrictEqual({ + order: ['anthropic'], + allow_fallbacks: false, + sort: 'price', + }); + }); +}); diff --git a/src/chat/index.test.ts b/src/chat/index.test.ts index d0bc35a3..bc515c91 100644 --- a/src/chat/index.test.ts +++ b/src/chat/index.test.ts @@ -6531,3 +6531,103 @@ describe('reasoning call option', () => { }); }); }); + +// https://github.com/OpenRouterTeam/ai-sdk-provider/issues/587 +describe('provider routing merge', () => { + const server = createTestServer({ + 'https://openrouter.ai/api/v1/chat/completions': { + response: { + type: 'json-value', + body: { + id: 'chatcmpl-test', + object: 'chat.completion', + created: 1711115037, + model: 'google/gemini-2.5-flash', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Hello!' }, + finish_reason: 'stop', + }, + ], + }, + }, + }, + }); + + beforeAll(() => server.server.start()); + afterEach(() => server.server.reset()); + afterAll(() => server.server.stop()); + + const settings = { + provider: { + order: ['google'], + allow_fallbacks: false, + sort: 'price' as const, + }, + }; + const providerOptions = { + openrouter: { provider: { order: ['anthropic'] } }, + }; + + it('merges runtime provider routing into the model settings in doGenerate', async () => { + await provider + .chat('google/gemini-2.5-flash', settings) + .doGenerate({ prompt: TEST_PROMPT, providerOptions }); + + expect((await server.calls[0]!.requestBodyJson).provider).toStrictEqual({ + order: ['anthropic'], + allow_fallbacks: false, + sort: 'price', + }); + }); + + it('merges runtime provider routing into the model settings in doStream', async () => { + await provider + .chat('google/gemini-2.5-flash', settings) + .doStream({ prompt: TEST_PROMPT, providerOptions }) + .catch(() => undefined); + + expect((await server.calls[0]!.requestBodyJson).provider).toStrictEqual({ + order: ['anthropic'], + allow_fallbacks: false, + sort: 'price', + }); + }); + + it('merges settings, provider extraBody, model extraBody, then runtime', async () => { + await createOpenRouter({ + apiKey: 'test-api-key', + extraBody: { provider: { sort: 'latency', only: ['google'] } }, + }) + .chat('google/gemini-2.5-flash', { + ...settings, + extraBody: { provider: { only: ['anthropic'], ignore: ['openai'] } }, + }) + .doGenerate({ + prompt: TEST_PROMPT, + providerOptions: { + openrouter: { provider: { ignore: ['azure'] } }, + }, + }); + + expect((await server.calls[0]!.requestBodyJson).provider).toStrictEqual({ + order: ['google'], + allow_fallbacks: false, + sort: 'latency', + only: ['anthropic'], + ignore: ['azure'], + }); + }); + + it('ignores a null runtime provider', async () => { + await provider.chat('google/gemini-2.5-flash', settings).doGenerate({ + prompt: TEST_PROMPT, + providerOptions: { openrouter: { provider: null } }, + }); + + expect((await server.calls[0]!.requestBodyJson).provider).toStrictEqual( + settings.provider, + ); + }); +}); diff --git a/src/chat/index.ts b/src/chat/index.ts index 2515710e..1689893f 100644 --- a/src/chat/index.ts +++ b/src/chat/index.ts @@ -50,6 +50,7 @@ import { createFinishReason, mapOpenRouterFinishReason, } from '../utils/map-finish-reason'; +import { mergeProviderRouting } from '../utils/merge-provider-routing'; import { resolveReasoning } from '../utils/resolve-reasoning'; import { withStreamErrorHandling } from '../utils/with-stream-error-handling'; import { @@ -283,12 +284,24 @@ export class OpenRouterChatLanguageModel implements LanguageModelV4 { const openrouterOptions = providerOptions.openrouter || {}; // Extract cacheControl (camelCase) and normalize to cache_control (snake_case) - const { cacheControl, ...restOpenrouterOptions } = - openrouterOptions as Record; + const { + cacheControl, + provider: runtimeProvider, + ...restOpenrouterOptions + } = openrouterOptions as Record; + + // Shallow merge provider routing preferences instead of replacing them. + const providerRouting = mergeProviderRouting( + this.settings.provider, + this.config.extraBody?.provider, + this.settings.extraBody?.provider, + runtimeProvider, + ); const args = { ...this.getArgs(options), ...restOpenrouterOptions, + ...(providerRouting !== undefined && { provider: providerRouting }), // Support both cacheControl (camelCase) and cache_control (snake_case) // from providerOptions, in addition to settings.cache_control ...(cacheControl != null && !('cache_control' in restOpenrouterOptions) @@ -596,12 +609,24 @@ export class OpenRouterChatLanguageModel implements LanguageModelV4 { const openrouterOptions = providerOptions.openrouter || {}; // Extract cacheControl (camelCase) and normalize to cache_control (snake_case) - const { cacheControl, ...restOpenrouterOptions } = - openrouterOptions as Record; + const { + cacheControl, + provider: runtimeProvider, + ...restOpenrouterOptions + } = openrouterOptions as Record; + + // Shallow merge provider routing preferences instead of replacing them. + const providerRouting = mergeProviderRouting( + this.settings.provider, + this.config.extraBody?.provider, + this.settings.extraBody?.provider, + runtimeProvider, + ); const args = { ...this.getArgs(options), ...restOpenrouterOptions, + ...(providerRouting !== undefined && { provider: providerRouting }), // Support both cacheControl (camelCase) and cache_control (snake_case) // from providerOptions, in addition to settings.cache_control ...(cacheControl != null && !('cache_control' in restOpenrouterOptions) diff --git a/src/completion/index.test.ts b/src/completion/index.test.ts index 29efe716..856afbb6 100644 --- a/src/completion/index.test.ts +++ b/src/completion/index.test.ts @@ -1108,3 +1108,55 @@ describe('reasoning call option', () => { }); }); }); + +// https://github.com/OpenRouterTeam/ai-sdk-provider/issues/587 +describe('provider routing merge', () => { + const server = createTestServer({ + 'https://openrouter.ai/api/v1/completions': { + response: { + type: 'json-value', + body: { + id: 'cmpl-test', + object: 'text_completion', + created: 1711363706, + model: 'openai/gpt-3.5-turbo-instruct', + choices: [{ index: 0, text: 'Hello!', finish_reason: 'stop' }], + }, + }, + }, + }); + + beforeAll(() => server.server.start()); + afterEach(() => server.server.reset()); + afterAll(() => server.server.stop()); + + const model = () => + createOpenRouter({ + apiKey: 'test-api-key', + extraBody: { provider: { order: ['openai'], allow_fallbacks: false } }, + }).completion('openai/gpt-3.5-turbo-instruct', { + extraBody: { provider: { sort: 'price' } }, + }); + const providerOptions = { + openrouter: { provider: { order: ['azure'] } }, + }; + const expected = { order: ['azure'], allow_fallbacks: false, sort: 'price' }; + + it('merges runtime provider routing into extraBody routing in doGenerate', async () => { + await model().doGenerate({ prompt: TEST_PROMPT, providerOptions }); + + expect((await server.calls[0]!.requestBodyJson).provider).toStrictEqual( + expected, + ); + }); + + it('merges runtime provider routing into extraBody routing in doStream', async () => { + await model() + .doStream({ prompt: TEST_PROMPT, providerOptions }) + .catch(() => undefined); + + expect((await server.calls[0]!.requestBodyJson).provider).toStrictEqual( + expected, + ); + }); +}); diff --git a/src/completion/index.ts b/src/completion/index.ts index bd27fa79..acb55103 100644 --- a/src/completion/index.ts +++ b/src/completion/index.ts @@ -37,6 +37,7 @@ import { createFinishReason, mapOpenRouterFinishReason, } from '../utils/map-finish-reason'; +import { mergeProviderRouting } from '../utils/merge-provider-routing'; import { resolveReasoning } from '../utils/resolve-reasoning'; import { withStreamErrorHandling } from '../utils/with-stream-error-handling'; import { @@ -198,11 +199,20 @@ export class OpenRouterCompletionLanguageModel implements LanguageModelV4 { options: LanguageModelV4CallOptions, ): Promise>> { const providerOptions = options.providerOptions || {}; - const openrouterOptions = providerOptions.openrouter || {}; + const { provider: runtimeProvider, ...openrouterOptions } = + providerOptions.openrouter || {}; + + // Shallow merge provider routing preferences instead of replacing them. + const providerRouting = mergeProviderRouting( + this.config.extraBody?.provider, + this.settings.extraBody?.provider, + runtimeProvider, + ); const args = { ...this.getArgs(options), ...openrouterOptions, + ...(providerRouting !== undefined && { provider: providerRouting }), }; const { value: response, responseHeaders } = await postJsonToApi({ @@ -306,11 +316,20 @@ export class OpenRouterCompletionLanguageModel implements LanguageModelV4 { options: LanguageModelV4CallOptions, ): Promise>> { const providerOptions = options.providerOptions || {}; - const openrouterOptions = providerOptions.openrouter || {}; + const { provider: runtimeProvider, ...openrouterOptions } = + providerOptions.openrouter || {}; + + // Shallow merge provider routing preferences instead of replacing them. + const providerRouting = mergeProviderRouting( + this.config.extraBody?.provider, + this.settings.extraBody?.provider, + runtimeProvider, + ); const args = { ...this.getArgs(options), ...openrouterOptions, + ...(providerRouting !== undefined && { provider: providerRouting }), }; const { value: response, responseHeaders } = await postJsonToApi({ diff --git a/src/utils/merge-provider-routing.test.ts b/src/utils/merge-provider-routing.test.ts new file mode 100644 index 00000000..c299fc94 --- /dev/null +++ b/src/utils/merge-provider-routing.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { mergeProviderRouting } from './merge-provider-routing'; + +describe('mergeProviderRouting', () => { + it('shallow merges sources, later sources winning', () => { + expect( + mergeProviderRouting( + { order: ['google'], sort: 'price' }, + { sort: 'latency' }, + { order: ['anthropic'] }, + ), + ).toStrictEqual({ order: ['anthropic'], sort: 'latency' }); + }); + + it('ignores null, arrays and primitives', () => { + expect( + mergeProviderRouting({ sort: 'price' }, null, ['x'], 'y', undefined), + ).toStrictEqual({ sort: 'price' }); + }); + + it('returns undefined when no source is an object', () => { + expect(mergeProviderRouting(undefined, null, [])).toBeUndefined(); + }); +}); diff --git a/src/utils/merge-provider-routing.ts b/src/utils/merge-provider-routing.ts new file mode 100644 index 00000000..ac625c9a --- /dev/null +++ b/src/utils/merge-provider-routing.ts @@ -0,0 +1,15 @@ +/** + * Shallow merges `provider` routing preferences instead of replacing them. + * Pass the sources from lowest to highest precedence (later wins). Values + * that are not plain objects (null, arrays, primitives) are ignored. Returns + * undefined when no source is an object. + */ +export function mergeProviderRouting( + ...sources: unknown[] +): Record | undefined { + const objects = sources.filter( + (source): source is Record => + typeof source === 'object' && source !== null && !Array.isArray(source), + ); + return objects.length > 0 ? Object.assign({}, ...objects) : undefined; +}