mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
fix(core): Pass reasoning effort on open router model node (#41179)
This commit is contained in:
parent
1b02863c00
commit
683fd11259
|
|
@ -199,6 +199,36 @@ export class LmChatOpenRouter implements INodeType {
|
|||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Reasoning Effort',
|
||||
name: 'reasoningEffort',
|
||||
type: 'options',
|
||||
default: 'medium',
|
||||
description:
|
||||
'Controls the amount of reasoning effort the model will use to generate a response. Higher values may result in more accurate and detailed responses, but may also take longer to generate.',
|
||||
options: [
|
||||
{
|
||||
name: 'Extra High',
|
||||
value: 'xhigh',
|
||||
},
|
||||
{
|
||||
name: 'High',
|
||||
value: 'high',
|
||||
},
|
||||
{
|
||||
name: 'Low',
|
||||
value: 'low',
|
||||
},
|
||||
{
|
||||
name: 'Medium',
|
||||
value: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'Minimal',
|
||||
value: 'minimal',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Frequency Penalty',
|
||||
name: 'frequencyPenalty',
|
||||
|
|
@ -375,6 +405,7 @@ export class LmChatOpenRouter implements INodeType {
|
|||
const modelName = this.getNodeParameter('model', itemIndex) as string;
|
||||
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as {
|
||||
reasoningEffort?: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||
frequencyPenalty?: number;
|
||||
maxTokens?: number;
|
||||
maxRetries: number;
|
||||
|
|
@ -411,50 +442,52 @@ export class LmChatOpenRouter implements INodeType {
|
|||
},
|
||||
};
|
||||
|
||||
const { reasoningEffort, providerRouting, responseFormat, ...modelOptions } = options;
|
||||
// Build provider routing object
|
||||
const provider: Record<string, unknown> = {};
|
||||
if (options.providerRouting) {
|
||||
const routing = options.providerRouting;
|
||||
|
||||
if (routing.order) {
|
||||
provider.order = routing.order
|
||||
if (providerRouting) {
|
||||
if (providerRouting.order) {
|
||||
provider.order = providerRouting.order
|
||||
.split(',')
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p);
|
||||
}
|
||||
if (routing.allowFallbacks !== undefined) {
|
||||
provider.allow_fallbacks = routing.allowFallbacks;
|
||||
if (providerRouting.allowFallbacks !== undefined) {
|
||||
provider.allow_fallbacks = providerRouting.allowFallbacks;
|
||||
}
|
||||
if (routing.requireParameters !== undefined) {
|
||||
provider.require_parameters = routing.requireParameters;
|
||||
if (providerRouting.requireParameters !== undefined) {
|
||||
provider.require_parameters = providerRouting.requireParameters;
|
||||
}
|
||||
if (routing.dataCollection) {
|
||||
provider.data_collection = routing.dataCollection;
|
||||
if (providerRouting.dataCollection) {
|
||||
provider.data_collection = providerRouting.dataCollection;
|
||||
}
|
||||
if (routing.zdr !== undefined) {
|
||||
provider.zdr = routing.zdr;
|
||||
if (providerRouting.zdr !== undefined) {
|
||||
provider.zdr = providerRouting.zdr;
|
||||
}
|
||||
if (routing.only) {
|
||||
provider.only = routing.only
|
||||
if (providerRouting.only) {
|
||||
provider.only = providerRouting.only
|
||||
.split(',')
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p);
|
||||
}
|
||||
if (routing.ignore) {
|
||||
provider.ignore = routing.ignore
|
||||
if (providerRouting.ignore) {
|
||||
provider.ignore = providerRouting.ignore
|
||||
.split(',')
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p);
|
||||
}
|
||||
if (routing.sort) {
|
||||
provider.sort = routing.sort;
|
||||
if (providerRouting.sort) {
|
||||
provider.sort = providerRouting.sort;
|
||||
}
|
||||
}
|
||||
|
||||
// Build modelKwargs
|
||||
const modelKwargs: Record<string, unknown> = {};
|
||||
if (options.responseFormat) {
|
||||
modelKwargs.response_format = { type: options.responseFormat };
|
||||
if (reasoningEffort) {
|
||||
modelKwargs.reasoning = { effort: reasoningEffort };
|
||||
}
|
||||
if (responseFormat) {
|
||||
modelKwargs.response_format = { type: responseFormat };
|
||||
}
|
||||
if (Object.keys(provider).length > 0) {
|
||||
modelKwargs.provider = provider;
|
||||
|
|
@ -463,7 +496,7 @@ export class LmChatOpenRouter implements INodeType {
|
|||
const model = new ChatOpenAI({
|
||||
apiKey: credentials.apiKey,
|
||||
model: modelName,
|
||||
...options,
|
||||
...modelOptions,
|
||||
timeout,
|
||||
maxRetries: options.maxRetries ?? 2,
|
||||
configuration,
|
||||
|
|
|
|||
|
|
@ -281,6 +281,80 @@ describe('LmChatOpenRouter', () => {
|
|||
expect(callArgs?.modelKwargs).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reasoning effort', () => {
|
||||
const setupWithOptions = (model: string, options: Record<string, unknown>) => {
|
||||
const ctx = setupMockContext();
|
||||
ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'model') return model;
|
||||
if (paramName === 'options') return options;
|
||||
return undefined;
|
||||
});
|
||||
return ctx;
|
||||
};
|
||||
|
||||
it.each(['minimal', 'low', 'medium', 'high', 'xhigh'])(
|
||||
'should set reasoning.effort to %s in modelKwargs',
|
||||
async (effort) => {
|
||||
const ctx = setupWithOptions('openai/gpt-6-sol', { reasoningEffort: effort });
|
||||
|
||||
await node.supplyData.call(ctx, 0);
|
||||
|
||||
expect(MockedChatOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modelKwargs: { reasoning: { effort } },
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('should not pass reasoningEffort as a ChatOpenAI field', async () => {
|
||||
const ctx = setupWithOptions('openai/gpt-6-sol', { reasoningEffort: 'high' });
|
||||
|
||||
await node.supplyData.call(ctx, 0);
|
||||
|
||||
const callArgs = MockedChatOpenAI.mock.calls[0][0];
|
||||
expect(callArgs).not.toHaveProperty('reasoningEffort');
|
||||
expect(callArgs).not.toHaveProperty('reasoning');
|
||||
});
|
||||
|
||||
it('should include reasoning, response format and provider routing together', async () => {
|
||||
const ctx = setupWithOptions('openai/gpt-6-sol', {
|
||||
reasoningEffort: 'low',
|
||||
responseFormat: 'json_object',
|
||||
providerRouting: { zdr: true },
|
||||
});
|
||||
|
||||
await node.supplyData.call(ctx, 0);
|
||||
|
||||
expect(MockedChatOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modelKwargs: {
|
||||
reasoning: { effort: 'low' },
|
||||
response_format: { type: 'json_object' },
|
||||
provider: { zdr: true },
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['openai/gpt-6-sol', 'openai/gpt-5', 'anthropic/claude-sonnet-4-20250514'])(
|
||||
'should serialize reasoning in the OpenRouter request for %s',
|
||||
async (model) => {
|
||||
const ctx = setupWithOptions(model, { reasoningEffort: 'high' });
|
||||
await node.supplyData.call(ctx, 0);
|
||||
|
||||
// Feed the node's constructor args into the real ChatOpenAI to inspect the request body
|
||||
const actual =
|
||||
await vi.importActual<typeof import('@langchain/openai')>('@langchain/openai');
|
||||
const realModel = new actual.ChatOpenAI(MockedChatOpenAI.mock.calls[0][0]);
|
||||
const params = realModel.invocationParams({});
|
||||
|
||||
expect(params).toMatchObject({ model, reasoning: { effort: 'high' } });
|
||||
expect(params).not.toHaveProperty('reasoning_effort');
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetch wrapper (empty tool call arguments fix)', () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user