fix(OpenAI Node): Filter non-chat models out of the chat model dropdown (#35785)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Ricardo Espinoza <ricardo@n8n.io>
This commit is contained in:
Jaakko Husso 2026-08-18 10:47:43 +00:00 committed by GitHub
parent 6155d8e882
commit 207c29f8e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 44 additions and 41 deletions

View File

@ -73,6 +73,8 @@ describe('model-discovery', () => {
{ id: 'gpt-5' },
{ id: 'whisper-1' },
{ id: 'dall-e-3' },
{ id: 'gpt-image-2' },
{ id: 'gpt-4o-transcribe' },
{ id: 'text-embedding-3-small' },
{ id: 'gpt-4o' },
],

View File

@ -1,6 +1,6 @@
import { shouldIncludeModel } from '../modelFiltering';
import { shouldIncludeOpenAiModel } from '../providers/openai';
describe('shouldIncludeModel', () => {
describe('shouldIncludeOpenAiModel', () => {
const testCases: Array<{ modelId: string; officialAPI: boolean }> = [
// Excluded model types
{ modelId: 'babbage-002', officialAPI: false },
@ -15,9 +15,21 @@ describe('shouldIncludeModel', () => {
{ modelId: 'gpt-4o-realtime-preview', officialAPI: false }, // infix check for -realtime
{ modelId: 'gpt-3.5-turbo-instruct', officialAPI: false }, // gpt-* with instruct
// Non-chat families named gpt-*, so caught by infix rather than prefix
{ modelId: 'gpt-image-2', officialAPI: false },
{ modelId: 'chatgpt-image-latest', officialAPI: false },
{ modelId: 'gpt-transcribe', officialAPI: false },
{ modelId: 'gpt-4o-transcribe-diarize', officialAPI: false },
// Every diarization model OpenAI ships today is also *-transcribe-diarize,
// so this pins the -diarize clause on its own
{ modelId: 'gpt-4o-diarize', officialAPI: false },
// Included models (standard chat models)
{ modelId: 'gpt-4', officialAPI: true },
{ modelId: 'gpt-4o', officialAPI: true },
// Unsupported on responses, but supported on chat/completions, which the
// OpenAI node's "Message a Model" uses, so it must stay selectable
{ modelId: 'gpt-audio', officialAPI: true },
{ modelId: 'o1-preview', officialAPI: true },
{ modelId: 'ft:gpt-3.5-turbo', officialAPI: true }, // fine-tuned models
@ -28,7 +40,7 @@ describe('shouldIncludeModel', () => {
describe('Custom API behavior', () => {
it.each(testCases)('should include "$modelId"', ({ modelId }) => {
expect(shouldIncludeModel(modelId, true)).toBe(true);
expect(shouldIncludeOpenAiModel(modelId, true)).toBe(true);
});
});
@ -39,7 +51,7 @@ describe('shouldIncludeModel', () => {
}));
it.each(testCasesWithAction)('should $action "$modelId"', ({ modelId, officialAPI }) => {
expect(shouldIncludeModel(modelId, false)).toBe(officialAPI);
expect(shouldIncludeOpenAiModel(modelId, false)).toBe(officialAPI);
});
});
});

View File

@ -59,9 +59,10 @@ export { listGroqModels } from './providers/groq';
export { listMistralModels } from './providers/mistral';
export { listNvidiaModels } from './providers/nvidia';
export {
isOpenAiCustomEndpoint,
listOpenAiModels,
shouldIncludeOpenAiModel,
OFFICIAL_OPENAI_HOSTNAMES,
isOpenAiCustomEndpoint,
} from './providers/openai';
export { listOpenRouterModels } from './providers/openrouter';
export { listVercelModels } from './providers/vercel';

View File

@ -1,7 +1,7 @@
import { baseUrl, bearerHeaders, byName, getJson, idsToModels, type IdItem } from '../request';
import type { ListModelsFn } from '../types';
const OFFICIAL_OPENAI_HOSTNAMES = ['api.openai.com', 'ai-assistant.n8n.io'];
export const OFFICIAL_OPENAI_HOSTNAMES = ['api.openai.com', 'ai-assistant.n8n.io'];
export function isOpenAiCustomEndpoint(baseURL?: string): boolean {
if (baseURL === undefined) return false;
@ -14,8 +14,11 @@ export function isOpenAiCustomEndpoint(baseURL?: string): boolean {
}
/**
* Source: LMChatOpenAi `methods/loadModels.ts` — on the official API, exclude
* non-chat model families; on custom (proxy/self-hosted) hosts include all.
* On the official API, exclude non-chat model families; on custom
* (proxy/self-hosted) hosts include all, since their naming is not OpenAI's.
*
* Single source of truth for every OpenAI model dropdown: the LMChatOpenAi
* sub-node, the OpenAI node's `modelSearch`, and the agents model catalog.
*/
export function shouldIncludeOpenAiModel(modelId: string, isCustomApi: boolean): boolean {
if (isCustomApi) return true;
@ -24,6 +27,13 @@ export function shouldIncludeOpenAiModel(modelId: string, isCustomApi: boolean):
modelId.startsWith('davinci') ||
modelId.startsWith('computer-use') ||
modelId.startsWith('dall-e') ||
// Newer non-chat families are named gpt-*, so they clear the gpt- prefix and
// have to be matched by infix. Only list families unsupported on *both*
// chat/completions and responses: gpt-audio-* is absent on purpose, it works
// on chat/completions.
modelId.includes('-image') ||
modelId.includes('-transcribe') ||
modelId.includes('-diarize') ||
modelId.startsWith('text-embedding') ||
modelId.startsWith('tts') ||
modelId.includes('-tts') ||

View File

@ -1,30 +0,0 @@
/**
* Determines whether a model should be included in the model list based on
* whether it's a custom API and the model's ID.
*
* @param modelId - The ID of the model to check
* @param isCustomAPI - Whether this is a custom API (not official OpenAI)
* @returns true if the model should be included, false otherwise
*/
export function shouldIncludeModel(modelId: string, isCustomAPI: boolean): boolean {
// For custom APIs, include all models
if (isCustomAPI) {
return true;
}
// For official OpenAI API, exclude certain model types
return !(
modelId.startsWith('babbage') ||
modelId.startsWith('davinci') ||
modelId.startsWith('computer-use') ||
modelId.startsWith('dall-e') ||
modelId.startsWith('text-embedding') ||
modelId.startsWith('tts') ||
modelId.includes('-tts') ||
modelId.startsWith('whisper') ||
modelId.startsWith('omni-moderation') ||
modelId.startsWith('sora') ||
modelId.includes('-realtime') ||
(modelId.startsWith('gpt-') && modelId.includes('instruct'))
);
}

View File

@ -30,6 +30,8 @@ describe('modelSearch', () => {
{ id: 'babbage-002' },
{ id: 'whisper-1' },
{ id: 'dall-e-3' },
{ id: 'gpt-image-2' },
{ id: 'gpt-4o-transcribe' },
{ id: 'gpt-4o-realtime-preview' },
],
});

View File

@ -1,3 +1,7 @@
import {
OFFICIAL_OPENAI_HOSTNAMES,
shouldIncludeOpenAiModel,
} from '@n8n/ai-utilities/model-discovery';
import type {
IDataObject,
ILoadOptionsFunctions,
@ -7,7 +11,6 @@ import type {
import type { Assistant } from 'openai/resources/beta/assistants';
import type { Model } from 'openai/resources/models';
import { shouldIncludeModel } from '../helpers/modelFiltering';
import { apiRequest } from '../transport';
export async function fileSearch(
@ -78,8 +81,11 @@ export async function modelSearch(
): Promise<INodeListSearchResult> {
const credentials = await this.getCredentials<{ url: string }>('openAiApi');
const url = credentials.url && new URL(credentials.url);
const isCustomAPI = !!(url && !['api.openai.com', 'ai-assistant.n8n.io'].includes(url.hostname));
return await getModelSearch((model) => shouldIncludeModel(model.id, isCustomAPI))(this, filter);
const isCustomAPI = !!(url && !OFFICIAL_OPENAI_HOSTNAMES.includes(url.hostname));
return await getModelSearch((model) => shouldIncludeOpenAiModel(model.id, isCustomAPI))(
this,
filter,
);
}
export async function videoModelSearch(