feat(lmChatOpenRouter Node): Add provider routing options (#24822)

Co-authored-by: Dawid Myslak <dawid.myslak@gmail.com>
This commit is contained in:
Alejandro Sanz 2026-08-11 14:05:55 +02:00 committed by GitHub
parent a91e012de6
commit d7dcba5c80
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 254 additions and 5 deletions

View File

@ -275,6 +275,91 @@ export class LmChatOpenRouter implements INodeType {
'Controls diversity via nucleus sampling: 0.5 means half of all likelihood-weighted options are considered. We generally recommend altering this or temperature but not both.',
type: 'number',
},
{
displayName: 'Provider Routing',
name: 'providerRouting',
type: 'collection',
default: {},
description:
'Configure which sub-providers handle your requests. <a href="https://openrouter.ai/docs/provider-routing">Learn more</a>.',
placeholder: 'Add Provider Routing Option',
options: [
{
displayName: 'Order',
name: 'order',
type: 'string',
default: '',
placeholder: 'anthropic,openai,google',
description:
'Comma-separated list of provider slugs to try in order. <a href="https://openrouter.ai/docs/provider-routing#provider-sorting">Learn more</a>.',
},
{
displayName: 'Allow Fallbacks',
name: 'allowFallbacks',
type: 'boolean',
default: true,
description: 'Whether to allow backup providers when the primary is unavailable',
},
{
displayName: 'Require Parameters',
name: 'requireParameters',
type: 'boolean',
default: false,
description:
'Whether to only use providers that support all parameters in your request',
},
{
displayName: 'Data Collection',
name: 'dataCollection',
type: 'options',
options: [
{ name: 'Allow', value: 'allow' },
{ name: 'Deny', value: 'deny' },
],
default: 'allow',
description:
"Select 'Deny' to route requests only through providers that don't collect your data",
},
{
displayName: 'Zero Data Retention (ZDR)',
name: 'zdr',
type: 'boolean',
default: false,
description:
'Whether to restrict routing to only providers with Zero Data Retention endpoints',
},
{
displayName: 'Only',
name: 'only',
type: 'string',
default: '',
placeholder: 'azure,anthropic',
description:
'Comma-separated list of provider slugs to allow for this request. Only these providers will be used. <a href="https://openrouter.ai/docs/guides/routing/provider-selection#allowing-only-specific-providers">Learn more</a>.',
},
{
displayName: 'Ignore',
name: 'ignore',
type: 'string',
default: '',
placeholder: 'anthropic,openai',
description: 'Comma-separated list of provider slugs to skip for this request',
},
{
displayName: 'Sort',
name: 'sort',
type: 'options',
options: [
{ name: 'Price', value: 'price' },
{ name: 'Throughput', value: 'throughput' },
{ name: 'Latency', value: 'latency' },
],
default: '',
description:
'Sort providers by a specific attribute. Disables load balancing and tries providers in order. <a href="https://openrouter.ai/docs/provider-routing#provider-sorting">Learn more</a>.',
},
],
},
],
},
],
@ -294,6 +379,16 @@ export class LmChatOpenRouter implements INodeType {
temperature?: number;
topP?: number;
responseFormat?: 'text' | 'json_object';
providerRouting?: {
order?: string;
allowFallbacks?: boolean;
requireParameters?: boolean;
dataCollection?: 'allow' | 'deny';
zdr?: boolean;
only?: string;
ignore?: string;
sort?: 'price' | 'throughput' | 'latency';
};
};
const timeout = options.timeout;
@ -308,6 +403,55 @@ export class LmChatOpenRouter implements INodeType {
},
};
// Build provider routing object
const provider: Record<string, unknown> = {};
if (options.providerRouting) {
const routing = options.providerRouting;
if (routing.order) {
provider.order = routing.order
.split(',')
.map((p) => p.trim())
.filter((p) => p);
}
if (routing.allowFallbacks !== undefined) {
provider.allow_fallbacks = routing.allowFallbacks;
}
if (routing.requireParameters !== undefined) {
provider.require_parameters = routing.requireParameters;
}
if (routing.dataCollection) {
provider.data_collection = routing.dataCollection;
}
if (routing.zdr !== undefined) {
provider.zdr = routing.zdr;
}
if (routing.only) {
provider.only = routing.only
.split(',')
.map((p) => p.trim())
.filter((p) => p);
}
if (routing.ignore) {
provider.ignore = routing.ignore
.split(',')
.map((p) => p.trim())
.filter((p) => p);
}
if (routing.sort) {
provider.sort = routing.sort;
}
}
// Build modelKwargs
const modelKwargs: Record<string, unknown> = {};
if (options.responseFormat) {
modelKwargs.response_format = { type: options.responseFormat };
}
if (Object.keys(provider).length > 0) {
modelKwargs.provider = provider;
}
const model = new ChatOpenAI({
apiKey: credentials.apiKey,
model: modelName,
@ -316,11 +460,7 @@ export class LmChatOpenRouter implements INodeType {
maxRetries: options.maxRetries ?? 2,
configuration,
callbacks: [new N8nLlmTracing(this)],
modelKwargs: options.responseFormat
? {
response_format: { type: options.responseFormat },
}
: undefined,
modelKwargs: Object.keys(modelKwargs).length > 0 ? modelKwargs : undefined,
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this, openAiFailedAttemptHandler),
});

View File

@ -166,6 +166,115 @@ describe('LmChatOpenRouter', () => {
const callArgs = MockedChatOpenAI.mock.calls[0][0];
expect(callArgs?.configuration?.fetch).toEqual(expect.any(Function));
});
describe('provider routing options', () => {
it('should handle order option with comma-separated providers and spaces', async () => {
const ctx = setupMockContext();
ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
if (paramName === 'model') return 'anthropic/claude-sonnet-4-20250514';
if (paramName === 'options') {
return { providerRouting: { order: ' anthropic , openai ,, google ' } };
}
return undefined;
});
await node.supplyData.call(ctx, 0);
expect(MockedChatOpenAI).toHaveBeenCalledWith(
expect.objectContaining({
modelKwargs: expect.objectContaining({
provider: expect.objectContaining({
order: ['anthropic', 'openai', 'google'],
}),
}),
}),
);
});
it('should map provider routing fields to OpenRouter payload', async () => {
const ctx = setupMockContext();
ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
if (paramName === 'model') return 'anthropic/claude-sonnet-4-20250514';
if (paramName === 'options') {
return {
providerRouting: {
allowFallbacks: false,
requireParameters: true,
dataCollection: 'deny',
zdr: true,
only: 'azure,anthropic',
ignore: 'google',
sort: 'price',
},
};
}
return undefined;
});
await node.supplyData.call(ctx, 0);
expect(MockedChatOpenAI).toHaveBeenCalledWith(
expect.objectContaining({
modelKwargs: expect.objectContaining({
provider: expect.objectContaining({
allow_fallbacks: false,
require_parameters: true,
data_collection: 'deny',
zdr: true,
only: ['azure', 'anthropic'],
ignore: ['google'],
sort: 'price',
}),
}),
}),
);
});
it('should include response format and provider routing together', async () => {
const ctx = setupMockContext();
ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
if (paramName === 'model') return 'anthropic/claude-sonnet-4-20250514';
if (paramName === 'options') {
return {
responseFormat: 'json_object',
providerRouting: {
order: 'anthropic,openai',
sort: 'price',
},
};
}
return undefined;
});
await node.supplyData.call(ctx, 0);
expect(MockedChatOpenAI).toHaveBeenCalledWith(
expect.objectContaining({
modelKwargs: expect.objectContaining({
response_format: { type: 'json_object' },
provider: expect.objectContaining({
order: ['anthropic', 'openai'],
sort: 'price',
}),
}),
}),
);
});
it('should not add provider to modelKwargs when providerRouting is empty', async () => {
const ctx = setupMockContext();
ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
if (paramName === 'model') return 'anthropic/claude-sonnet-4-20250514';
if (paramName === 'options') return { providerRouting: {} };
return undefined;
});
await node.supplyData.call(ctx, 0);
const callArgs = MockedChatOpenAI.mock.calls[0][0];
expect(callArgs?.modelKwargs).toBeUndefined();
});
});
});
describe('fetch wrapper (empty tool call arguments fix)', () => {