mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(lmChatOpenRouter Node): Add provider routing options (#24822)
Co-authored-by: Dawid Myslak <dawid.myslak@gmail.com>
This commit is contained in:
parent
a91e012de6
commit
d7dcba5c80
|
|
@ -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),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user