Skip to content
Closed
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/merge-chat-provider-routing.md
Original file line number Diff line number Diff line change
@@ -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.
54 changes: 54 additions & 0 deletions e2e/issues/issue-587-provider-routing-merge.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>> = [];
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',
});
});
});
100 changes: 100 additions & 0 deletions src/chat/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6531,3 +6531,103 @@ describe('reasoning call option', () => {
});
});
});

// https://github.com/OpenRouterTeam/ai-sdk-provider/issues/587
describe('provider routing merge', () => {
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Resolved
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,
);
});
});
33 changes: 29 additions & 4 deletions src/chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, unknown>;
const {
cacheControl,
provider: runtimeProvider,
...restOpenrouterOptions
} = openrouterOptions as Record<string, unknown>;

// 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)
Expand Down Expand Up @@ -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<string, unknown>;
const {
cacheControl,
provider: runtimeProvider,
...restOpenrouterOptions
} = openrouterOptions as Record<string, unknown>;

// 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)
Expand Down
52 changes: 52 additions & 0 deletions src/completion/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
});
});
23 changes: 21 additions & 2 deletions src/completion/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -198,11 +199,20 @@ export class OpenRouterCompletionLanguageModel implements LanguageModelV4 {
options: LanguageModelV4CallOptions,
): Promise<Awaited<ReturnType<LanguageModelV4['doGenerate']>>> {
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({
Expand Down Expand Up @@ -306,11 +316,20 @@ export class OpenRouterCompletionLanguageModel implements LanguageModelV4 {
options: LanguageModelV4CallOptions,
): Promise<Awaited<ReturnType<LanguageModelV4['doStream']>>> {
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({
Expand Down
24 changes: 24 additions & 0 deletions src/utils/merge-provider-routing.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
15 changes: 15 additions & 0 deletions src/utils/merge-provider-routing.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | undefined {
const objects = sources.filter(
(source): source is Record<string, unknown> =>
typeof source === 'object' && source !== null && !Array.isArray(source),
);
return objects.length > 0 ? Object.assign({}, ...objects) : undefined;
}
Loading