first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,835 @@
import { ChatOpenAI, type ChatOpenAIFields, type ClientOptions } from '@langchain/openai';
import pick from 'lodash/pick';
import {
NodeConnectionTypes,
type INodeProperties,
type IDataObject,
type INodeType,
type INodeTypeDescription,
type ISupplyDataFunctions,
type SupplyData,
} from 'n8n-workflow';
import { checkDomainRestrictions } from '@utils/checkDomainRestrictions';
import { mergeCustomHeaders } from '@utils/helpers';
import { openAiFailedAttemptHandler } from '../../vendors/OpenAi/helpers/error-handling';
import {
makeN8nLlmFailedAttemptHandler,
N8nLlmTracing,
getProxyAgent,
getConnectionHintNoticeField,
} from '@n8n/ai-utilities';
import { formatBuiltInTools, prepareAdditionalResponsesParams } from './common';
import { searchModels } from './methods/loadModels';
import type { ModelOptions } from './types';
import { Container } from '@n8n/di';
import { AiConfig } from '@n8n/config';
const INCLUDE_JSON_WARNING: INodeProperties = {
displayName:
'If using JSON response format, you must include word "json" in the prompt in your chain or agent. Also, make sure to select latest models released post November 2023.',
name: 'notice',
type: 'notice',
default: '',
};
const completionsResponseFormat: INodeProperties = {
displayName: 'Response Format',
name: 'responseFormat',
default: 'text',
type: 'options',
options: [
{
name: 'Text',
value: 'text',
description: 'Regular text response',
},
{
name: 'JSON',
value: 'json_object',
description:
'Enables JSON mode, which should guarantee the message the model generates is valid JSON',
},
],
};
const jsonSchemaExample = `{
"type": "object",
"properties": {
"message": {
"type": "string"
}
},
"additionalProperties": false,
"required": ["message"]
}`;
export class LmChatOpenAi implements INodeType {
methods = {
listSearch: {
searchModels,
},
};
description: INodeTypeDescription = {
displayName: 'OpenAI Chat Model',
name: 'lmChatOpenAi',
icon: { light: 'file:openAiLight.svg', dark: 'file:openAiLight.dark.svg' },
group: ['transform'],
version: [1, 1.1, 1.2, 1.3],
description: 'For advanced usage with an AI chain',
defaults: {
name: 'OpenAI Chat Model',
},
codex: {
categories: ['AI'],
subcategories: {
AI: ['Language Models', 'Root Nodes'],
'Language Models': ['Chat Models (Recommended)'],
},
resources: {
primaryDocumentation: [
{
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.lmchatopenai/',
},
],
},
},
inputs: [],
outputs: [NodeConnectionTypes.AiLanguageModel],
outputNames: ['Model'],
credentials: [
{
name: 'openAiApi',
required: true,
},
],
requestDefaults: {
ignoreHttpStatusErrors: true,
baseURL:
'={{ $parameter.options?.baseURL?.split("/").slice(0,-1).join("/") || $credentials?.url?.split("/").slice(0,-1).join("/") || "https://api.openai.com" }}',
},
properties: [
getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]),
{
...INCLUDE_JSON_WARNING,
displayOptions: {
show: {
'/options.responseFormat': ['json_object'],
},
},
},
{
...INCLUDE_JSON_WARNING,
displayOptions: {
show: {
'/options.textFormat.textOptions.type': ['json_object'],
},
},
},
{
displayName: 'Model',
name: 'model',
type: 'options',
description:
'The model which will generate the completion. <a href="https://beta.openai.com/docs/models/overview">Learn more</a>.',
typeOptions: {
loadOptions: {
routing: {
request: {
method: 'GET',
url: '={{ $parameter.options?.baseURL?.split("/").slice(-1).pop() || $credentials?.url?.split("/").slice(-1).pop() || "v1" }}/models',
},
output: {
postReceive: [
{
type: 'rootProperty',
properties: {
property: 'data',
},
},
{
type: 'filter',
properties: {
// If the baseURL is not set or is set to api.openai.com, include only chat models
pass: `={{
($parameter.options?.baseURL && !$parameter.options?.baseURL?.startsWith('https://api.openai.com/')) ||
($credentials?.url && !$credentials.url.startsWith('https://api.openai.com/')) ||
$responseItem.id.startsWith('ft:') ||
$responseItem.id.startsWith('o1') ||
$responseItem.id.startsWith('o3') ||
($responseItem.id.startsWith('gpt-') && !$responseItem.id.includes('instruct'))
}}`,
},
},
{
type: 'setKeyValue',
properties: {
name: '={{$responseItem.id}}',
value: '={{$responseItem.id}}',
},
},
{
type: 'sort',
properties: {
key: 'name',
},
},
],
},
},
},
},
routing: {
send: {
type: 'body',
property: 'model',
},
},
default: 'gpt-5-mini',
builderHint: { message: 'Always default to latest mini model gpt-5-mini' },
displayOptions: {
hide: {
'@version': [{ _cnd: { gte: 1.2 } }],
},
},
},
{
displayName: 'Model',
name: 'model',
type: 'resourceLocator',
default: { mode: 'list', value: 'gpt-5-mini' },
builderHint: { message: 'Always default to latest mini model gpt-5-mini' },
required: true,
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
placeholder: 'Select a model...',
typeOptions: {
searchListMethod: 'searchModels',
searchable: true,
},
},
{
displayName: 'ID',
name: 'id',
type: 'string',
placeholder: 'gpt-5-mini',
},
],
description: 'The model. Choose from the list, or specify an ID.',
displayOptions: {
hide: {
'@version': [{ _cnd: { lte: 1.1 } }],
},
},
},
{
displayName:
'When using non-OpenAI models via "Base URL" override, not all models might be chat-compatible or support other features, like tools calling or JSON response format',
name: 'notice',
type: 'notice',
default: '',
displayOptions: {
show: {
'/options.baseURL': [{ _cnd: { exists: true } }],
},
},
},
{
displayName: 'Use Responses API',
name: 'responsesApiEnabled',
type: 'boolean',
default: true,
description:
'Whether to use the Responses API to generate the response. <a href="https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.lmchatopenai/#use-responses-api">Learn more</a>.',
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
},
{
displayName: 'Built-in Tools',
name: 'builtInTools',
placeholder: 'Add Built-in Tool',
type: 'collection',
default: {},
options: [
{
displayName: 'Web Search',
name: 'webSearch',
type: 'collection',
default: { searchContextSize: 'medium' },
options: [
{
displayName: 'Search Context Size',
name: 'searchContextSize',
type: 'options',
default: 'medium',
description:
'High level guidance for the amount of context window space to use for the search',
options: [
{ name: 'Low', value: 'low' },
{ name: 'Medium', value: 'medium' },
{ name: 'High', value: 'high' },
],
},
{
displayName: 'Web Search Allowed Domains',
name: 'allowedDomains',
type: 'string',
default: '',
description:
'Comma-separated list of domains to search. Only domains in this list will be searched.',
placeholder: 'e.g. google.com, wikipedia.org',
},
{
displayName: 'Country',
name: 'country',
type: 'string',
default: '',
placeholder: 'e.g. US, GB',
},
{
displayName: 'City',
name: 'city',
type: 'string',
default: '',
placeholder: 'e.g. New York, London',
},
{
displayName: 'Region',
name: 'region',
type: 'string',
default: '',
placeholder: 'e.g. New York, London',
},
],
},
{
displayName: 'File Search',
name: 'fileSearch',
type: 'collection',
default: { vectorStoreIds: '[]' },
options: [
{
displayName: 'Vector Store IDs',
name: 'vectorStoreIds',
description:
'The vector store IDs to use for the file search. Vector stores are managed via OpenAI Dashboard. <a href="https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.lmchatopenai/#built-in-tools">Learn more</a>.',
type: 'json',
default: '[]',
required: true,
},
{
displayName: 'Filters',
name: 'filters',
type: 'json',
default: '{}',
},
{
displayName: 'Max Results',
name: 'maxResults',
type: 'number',
default: 1,
typeOptions: { minValue: 1, maxValue: 50 },
},
],
},
{
displayName: 'Code Interpreter',
name: 'codeInterpreter',
type: 'boolean',
default: true,
description: 'Whether to allow the model to execute code in a sandboxed environment',
},
],
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.3 } }],
'/responsesApiEnabled': [true],
},
},
},
{
displayName: 'Options',
name: 'options',
placeholder: 'Add Option',
description: 'Additional options to add',
type: 'collection',
default: {},
options: [
{
displayName: 'Base URL',
name: 'baseURL',
default: 'https://api.openai.com/v1',
description: 'Override the default base URL for the API',
type: 'string',
displayOptions: {
hide: {
'@version': [{ _cnd: { gte: 1.1 } }],
},
},
},
{
displayName: 'Frequency Penalty',
name: 'frequencyPenalty',
default: 0,
typeOptions: { maxValue: 2, minValue: -2, numberPrecision: 1 },
description:
"Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim",
type: 'number',
},
{
displayName: 'Maximum Number of Tokens',
name: 'maxTokens',
default: -1,
description:
'The maximum number of tokens to generate in the completion. Most models have a context length of 2048 tokens (except for the newest models, which support 32,768).',
type: 'number',
typeOptions: {
maxValue: 32768,
},
},
{
...completionsResponseFormat,
displayOptions: {
show: {
'@version': [{ _cnd: { lt: 1.3 } }],
},
},
},
{
...completionsResponseFormat,
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.3 } }],
'/responsesApiEnabled': [false],
},
},
},
{
displayName: 'Response Format',
name: 'textFormat',
type: 'fixedCollection',
default: { textOptions: [{ type: 'text' }] },
options: [
{
displayName: 'Text',
name: 'textOptions',
values: [
{
displayName: 'Type',
name: 'type',
type: 'options',
default: '',
options: [
{ name: 'Text', value: 'text' },
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
{ name: 'JSON Schema (recommended)', value: 'json_schema' },
{ name: 'JSON Object', value: 'json_object' },
],
},
{
displayName: 'Verbosity',
name: 'verbosity',
type: 'options',
default: 'medium',
options: [
{ name: 'Low', value: 'low' },
{ name: 'Medium', value: 'medium' },
{ name: 'High', value: 'high' },
],
},
{
displayName: 'Name',
name: 'name',
type: 'string',
default: 'my_schema',
description:
'The name of the response format. Must be a-z, A-Z, 0-9, or contain underscores and dashes, with a maximum length of 64.',
displayOptions: {
show: {
type: ['json_schema'],
},
},
},
{
displayName:
'All properties in the schema must be set to "required", when using "strict" mode.',
name: 'requiredNotice',
type: 'notice',
default: '',
displayOptions: {
show: {
strict: [true],
},
},
},
{
displayName: 'Schema',
name: 'schema',
type: 'json',
default: jsonSchemaExample,
description: 'The schema of the response format',
displayOptions: {
show: {
type: ['json_schema'],
},
},
},
{
displayName: 'Description',
name: 'description',
type: 'string',
default: '',
description: 'The description of the response format',
displayOptions: {
show: {
type: ['json_schema'],
},
},
},
{
displayName: 'Strict',
name: 'strict',
type: 'boolean',
default: false,
description:
'Whether to require that the AI will always generate responses that match the provided JSON Schema',
displayOptions: {
show: {
type: ['json_schema'],
},
},
},
],
},
],
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.3 } }],
'/responsesApiEnabled': [true],
},
},
},
{
displayName: 'Presence Penalty',
name: 'presencePenalty',
default: 0,
typeOptions: { maxValue: 2, minValue: -2, numberPrecision: 1 },
description:
"Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics",
type: 'number',
},
{
displayName: 'Sampling Temperature',
name: 'temperature',
default: 0.7,
typeOptions: { maxValue: 2, minValue: 0, numberPrecision: 1 },
description:
'Controls randomness: Lowering results in less random completions. As the temperature approaches zero, the model will become deterministic and repetitive.',
type: 'number',
},
{
displayName: 'Reasoning Effort',
name: 'reasoningEffort',
default: 'medium',
description:
'Controls the amount of reasoning tokens to use. A value of "low" will favor speed and economical token usage, "high" will favor more complete reasoning at the cost of more tokens generated and slower responses.',
type: 'options',
options: [
{
name: 'Low',
value: 'low',
description: 'Favors speed and economical token usage',
},
{
name: 'Medium',
value: 'medium',
description: 'Balance between speed and reasoning accuracy',
},
{
name: 'High',
value: 'high',
description:
'Favors more complete reasoning at the cost of more tokens generated and slower responses',
},
],
displayOptions: {
show: {
// reasoning_effort is only available on o1, o1-versioned, or on o3-mini and beyond, and gpt-5 models. Not on o1-mini or other GPT-models.
'/model': [{ _cnd: { regex: '(^o1([-\\d]+)?$)|(^o[3-9].*)|(^gpt-5.*)' } }],
},
},
},
{
displayName: 'Timeout',
name: 'timeout',
default: 60000,
description: 'Maximum amount of time a request is allowed to take in milliseconds',
type: 'number',
},
{
displayName: 'Max Retries',
name: 'maxRetries',
default: 2,
description: 'Maximum number of retries to attempt',
type: 'number',
},
{
displayName: 'Top P',
name: 'topP',
default: 1,
typeOptions: { maxValue: 1, minValue: 0, numberPrecision: 1 },
description:
'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: 'Conversation ID',
name: 'conversationId',
default: '',
description:
'The conversation that this response belongs to. Input items and output items from this response are automatically added to this conversation after this response completes.',
type: 'string',
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.3 } }],
'/responsesApiEnabled': [true],
},
},
},
{
displayName: 'Prompt Cache Key',
name: 'promptCacheKey',
type: 'string',
default: '',
description:
'Used by OpenAI to cache responses for similar requests to optimize your cache hit rates',
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.3 } }],
'/responsesApiEnabled': [true],
},
},
},
{
displayName: 'Safety Identifier',
name: 'safetyIdentifier',
type: 'string',
default: '',
description:
"A stable identifier used to help detect users of your application that may be violating OpenAI's usage policies. The IDs should be a string that uniquely identifies each user.",
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.3 } }],
'/responsesApiEnabled': [true],
},
},
},
{
displayName: 'Service Tier',
name: 'serviceTier',
type: 'options',
default: 'auto',
description: 'The service tier to use for the request',
options: [
{ name: 'Auto', value: 'auto' },
{ name: 'Flex', value: 'flex' },
{ name: 'Default', value: 'default' },
{ name: 'Priority', value: 'priority' },
],
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.3 } }],
'/responsesApiEnabled': [true],
},
},
},
{
displayName: 'Metadata',
name: 'metadata',
type: 'json',
description:
'Set of 16 key-value pairs that can be attached to an object. This can be useful for storing additional information about the object in a structured format, and querying for objects via API or the dashboard. Keys are strings with a maximum length of 64 characters. Values are strings with a maximum length of 512 characters.',
default: '{}',
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.3 } }],
'/responsesApiEnabled': [true],
},
},
},
{
displayName: 'Top Logprobs',
name: 'topLogprobs',
type: 'number',
default: 0,
description:
'An integer between 0 and 20 specifying the number of most likely tokens to return at each token position, each with an associated log probability',
typeOptions: {
minValue: 0,
maxValue: 20,
},
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.3 } }],
'/responsesApiEnabled': [true],
},
},
},
{
displayName: 'Prompt',
name: 'promptConfig',
type: 'fixedCollection',
description:
'Configure the reusable prompt template configured via OpenAI Dashboard. <a href="https://platform.openai.com/docs/guides/prompt-engineering#reusable-prompts">Learn more</a>.',
default: { promptOptions: [{ promptId: '' }] },
options: [
{
displayName: 'Prompt',
name: 'promptOptions',
values: [
{
displayName: 'Prompt ID',
name: 'promptId',
type: 'string',
default: '',
description: 'The unique identifier of the prompt template to use',
},
{
displayName: 'Version',
name: 'version',
type: 'string',
default: '',
description: 'Optional version of the prompt template',
},
{
displayName: 'Variables',
name: 'variables',
type: 'json',
default: '{}',
description: 'Variables to be substituted into the prompt template',
},
],
},
],
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.3 } }],
'/responsesApiEnabled': [true],
},
},
},
],
},
],
};
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const credentials = await this.getCredentials('openAiApi');
const version = this.getNode().typeVersion;
const modelName =
version >= 1.2
? (this.getNodeParameter('model.value', itemIndex) as string)
: (this.getNodeParameter('model', itemIndex) as string);
const responsesApiEnabled = this.getNodeParameter('responsesApiEnabled', itemIndex, false);
const options = this.getNodeParameter('options', itemIndex, {}) as ModelOptions;
const { openAiDefaultHeaders: defaultHeaders } = Container.get(AiConfig);
const configuration: ClientOptions = {
defaultHeaders,
};
if (options.baseURL) {
checkDomainRestrictions(this, credentials, options.baseURL);
configuration.baseURL = options.baseURL;
} else if (credentials.url) {
configuration.baseURL = credentials.url as string;
}
const timeout = options.timeout;
configuration.fetchOptions = {
dispatcher: getProxyAgent(configuration.baseURL ?? 'https://api.openai.com/v1', {
headersTimeout: timeout,
bodyTimeout: timeout,
}),
};
configuration.defaultHeaders = mergeCustomHeaders(
credentials,
(configuration.defaultHeaders ?? {}) as Record<string, string>,
);
// Extra options to send to OpenAI, that are not directly supported by LangChain
const modelKwargs: Record<string, unknown> = {};
if (responsesApiEnabled) {
const kwargs = prepareAdditionalResponsesParams(options);
Object.assign(modelKwargs, kwargs);
} else {
if (options.responseFormat) modelKwargs.response_format = { type: options.responseFormat };
if (options.reasoningEffort && ['low', 'medium', 'high'].includes(options.reasoningEffort)) {
modelKwargs.reasoning_effort = options.reasoningEffort;
}
}
const includedOptions = pick(options, [
'frequencyPenalty',
'maxTokens',
'presencePenalty',
'temperature',
'topP',
'baseURL',
]);
const fields: ChatOpenAIFields = {
apiKey: credentials.apiKey as string,
model: modelName,
...includedOptions,
timeout,
maxRetries: options.maxRetries ?? 2,
configuration,
callbacks: [new N8nLlmTracing(this)],
modelKwargs,
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this, openAiFailedAttemptHandler),
// Set to false to ensure compatibility with OpenAI-compatible backends (LM Studio, vLLM, etc.)
// that reject strict: null in tool definitions
supportsStrictToolCalling: false,
};
// by default ChatOpenAI can switch to responses API automatically, so force it only on 1.3 and above to keep backwards compatibility
if (responsesApiEnabled) {
fields.useResponsesApi = true;
}
const model = new ChatOpenAI(fields);
if (responsesApiEnabled) {
const tools = formatBuiltInTools(
this.getNodeParameter('builtInTools', itemIndex, {}) as IDataObject,
);
// pass tools to the model metadata, ToolAgent will use it to create agent configuration
if (tools.length) {
model.metadata = {
...model.metadata,
tools,
};
}
}
return {
response: model,
};
}
}
@@ -0,0 +1,150 @@
import type { OpenAIClient } from '@langchain/openai';
import type { ChatOpenAIToolType } from '@langchain/openai/dist/utils/tools';
import get from 'lodash/get';
import isObject from 'lodash/isObject';
import { isObjectEmpty, jsonParse } from 'n8n-workflow';
import type {
BuiltInTools,
ChatResponseRequest,
ModelOptions,
PromptOptions,
TextOptions,
} from './types';
const removeEmptyProperties = <T>(rest: { [key: string]: any }): T => {
return Object.keys(rest)
.filter(
(k) =>
rest[k] !== '' && rest[k] !== undefined && !(isObject(rest[k]) && isObjectEmpty(rest[k])),
)
.reduce((a, k) => ({ ...a, [k]: rest[k] }), {}) as unknown as T;
};
const toArray = (str: string) =>
str
.split(',')
.map((e) => e.trim())
.filter(Boolean);
export const formatBuiltInTools = (builtInTools: BuiltInTools) => {
const tools: ChatOpenAIToolType[] = [];
if (builtInTools) {
const webSearchOptions = get(builtInTools, 'webSearch');
if (webSearchOptions) {
let allowedDomains: string[] | undefined;
const allowedDomainsRaw = get(webSearchOptions, 'allowedDomains', '');
if (allowedDomainsRaw) {
allowedDomains = toArray(allowedDomainsRaw);
}
let userLocation: OpenAIClient.Responses.WebSearchTool.UserLocation | undefined;
if (webSearchOptions.country || webSearchOptions.city || webSearchOptions.region) {
userLocation = {
type: 'approximate',
country: webSearchOptions.country as string,
city: webSearchOptions.city as string,
region: webSearchOptions.region as string,
};
}
tools.push({
type: 'web_search',
search_context_size: get(webSearchOptions, 'searchContextSize', 'medium'),
user_location: userLocation,
...(allowedDomains && { filters: { allowed_domains: allowedDomains } }),
});
}
if (builtInTools.codeInterpreter) {
tools.push({
type: 'code_interpreter',
container: {
type: 'auto',
},
});
}
if (builtInTools.fileSearch) {
const vectorStoreIds = get(builtInTools.fileSearch, 'vectorStoreIds', '[]');
const filters = get(builtInTools.fileSearch, 'filters', '{}');
tools.push({
type: 'file_search',
vector_store_ids: jsonParse(vectorStoreIds, {
errorMessage: 'Failed to parse vector store IDs',
}),
filters: filters
? jsonParse(filters, { errorMessage: 'Failed to parse filters' })
: undefined,
max_num_results: get(builtInTools.fileSearch, 'maxResults') as number,
});
}
}
return tools;
};
export const prepareAdditionalResponsesParams = (options: ModelOptions) => {
const body: Partial<ChatResponseRequest> = {
prompt_cache_key: options.promptCacheKey,
safety_identifier: options.safetyIdentifier,
service_tier: options.serviceTier,
top_logprobs: options.topLogprobs,
};
if (options.conversationId) {
body.conversation = options.conversationId;
}
if (options.metadata) {
body.metadata = jsonParse(options.metadata, {
errorMessage: 'Failed to parse metadata',
});
}
if (options.promptConfig) {
const prompt = get(options, 'promptConfig.promptOptions', {} as PromptOptions);
body.prompt = removeEmptyProperties({
id: prompt.promptId,
version: prompt.version,
...(prompt.variables && {
variables: jsonParse(prompt.variables, {
errorMessage: 'Failed to parse prompt variables',
}),
}),
});
}
if (options.textFormat) {
const textOptions = get(options, 'textFormat.textOptions', {} as TextOptions);
const textConfig: OpenAIClient.Responses.ResponseTextConfig = {
verbosity: textOptions.verbosity as OpenAIClient.Responses.ResponseTextConfig['verbosity'],
};
if (textOptions.type === 'json_schema') {
textConfig.format = {
type: textOptions.type,
name: textOptions.name as string,
schema: jsonParse(textOptions.schema as string, {
errorMessage: 'Failed to parse schema',
}),
};
} else {
textConfig.format = {
type: textOptions.type as 'json_object' | 'text',
};
}
if (textConfig.format) {
textConfig.format = removeEmptyProperties(textConfig.format);
}
body.text = textConfig;
}
if (options.reasoningEffort) {
body.reasoning = {
effort: options.reasoningEffort,
};
}
return body;
};
@@ -0,0 +1,192 @@
import type { ILoadOptionsFunctions } from 'n8n-workflow';
import OpenAI from 'openai';
import { searchModels } from '../loadModels';
jest.mock('openai');
describe('searchModels', () => {
let mockContext: jest.Mocked<ILoadOptionsFunctions>;
let mockOpenAI: jest.Mocked<typeof OpenAI>;
beforeEach(() => {
mockContext = {
getCredentials: jest.fn().mockResolvedValue({
apiKey: 'test-api-key',
}),
getNodeParameter: jest.fn().mockReturnValue(''),
} as unknown as jest.Mocked<ILoadOptionsFunctions>;
// Setup OpenAI mock with required properties
const mockOpenAIInstance = {
apiKey: 'test-api-key',
organization: null,
project: null,
_options: {},
models: {
list: jest.fn().mockResolvedValue({
data: [
{ id: 'gpt-4' },
{ id: 'gpt-3.5-turbo' },
{ id: 'gpt-3.5-turbo-instruct' },
{ id: 'ft:gpt-3.5-turbo' },
{ id: 'o1-model' },
{ id: 'whisper-1' },
{ id: 'davinci-instruct-beta' },
{ id: 'computer-use-preview' },
{ id: 'whisper-1-preview' },
{ id: 'tts-model' },
{ id: 'other-model' },
],
}),
},
} as unknown as OpenAI;
(OpenAI as jest.MockedClass<typeof OpenAI>).mockImplementation(() => mockOpenAIInstance);
mockOpenAI = OpenAI as jest.Mocked<typeof OpenAI>;
});
afterEach(() => {
jest.clearAllMocks();
});
it('should return filtered models if custom API endpoint is not provided', async () => {
const result = await searchModels.call(mockContext);
expect(mockOpenAI).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: 'https://api.openai.com/v1',
apiKey: 'test-api-key',
}),
);
expect(result.results).toEqual([
{ name: 'ft:gpt-3.5-turbo', value: 'ft:gpt-3.5-turbo' },
{ name: 'gpt-3.5-turbo', value: 'gpt-3.5-turbo' },
{ name: 'gpt-4', value: 'gpt-4' },
{ name: 'o1-model', value: 'o1-model' },
{ name: 'other-model', value: 'other-model' },
]);
});
it('should initialize OpenAI with correct credentials', async () => {
mockContext.getCredentials.mockResolvedValueOnce({
apiKey: 'test-api-key',
url: 'https://test-url.com',
});
await searchModels.call(mockContext);
expect(mockOpenAI).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: 'https://test-url.com',
apiKey: 'test-api-key',
}),
);
});
it('should use default OpenAI URL if no custom URL provided', async () => {
mockContext.getCredentials = jest.fn().mockResolvedValue({
apiKey: 'test-api-key',
});
await searchModels.call(mockContext);
expect(mockOpenAI).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: 'https://api.openai.com/v1',
apiKey: 'test-api-key',
}),
);
});
it('should include all models for custom API endpoints', async () => {
mockContext.getNodeParameter = jest.fn().mockReturnValue('https://custom-api.com');
const result = await searchModels.call(mockContext);
expect(result.results).toEqual([
{ name: 'computer-use-preview', value: 'computer-use-preview' },
{ name: 'davinci-instruct-beta', value: 'davinci-instruct-beta' },
{ name: 'ft:gpt-3.5-turbo', value: 'ft:gpt-3.5-turbo' },
{ name: 'gpt-3.5-turbo', value: 'gpt-3.5-turbo' },
{ name: 'gpt-3.5-turbo-instruct', value: 'gpt-3.5-turbo-instruct' },
{ name: 'gpt-4', value: 'gpt-4' },
{ name: 'o1-model', value: 'o1-model' },
{ name: 'other-model', value: 'other-model' },
{ name: 'tts-model', value: 'tts-model' },
{ name: 'whisper-1', value: 'whisper-1' },
{ name: 'whisper-1-preview', value: 'whisper-1-preview' },
]);
expect(result.results).toHaveLength(11);
});
it('should treat ai-assistant.n8n.io as official API', async () => {
mockContext.getCredentials.mockResolvedValueOnce({
apiKey: 'test-api-key',
url: 'https://ai-assistant.n8n.io/v1',
});
const result = await searchModels.call(mockContext);
expect(result.results).toEqual([
{ name: 'ft:gpt-3.5-turbo', value: 'ft:gpt-3.5-turbo' },
{ name: 'gpt-3.5-turbo', value: 'gpt-3.5-turbo' },
{ name: 'gpt-4', value: 'gpt-4' },
{ name: 'o1-model', value: 'o1-model' },
{ name: 'other-model', value: 'other-model' },
]);
});
it('should filter models based on search term', async () => {
const result = await searchModels.call(mockContext, 'gpt');
expect(result.results).toEqual([
{ name: 'ft:gpt-3.5-turbo', value: 'ft:gpt-3.5-turbo' },
{ name: 'gpt-3.5-turbo', value: 'gpt-3.5-turbo' },
{ name: 'gpt-4', value: 'gpt-4' },
]);
});
it('should handle case-insensitive search', async () => {
const result = await searchModels.call(mockContext, 'GPT');
expect(result.results).toEqual([
{ name: 'ft:gpt-3.5-turbo', value: 'ft:gpt-3.5-turbo' },
{ name: 'gpt-3.5-turbo', value: 'gpt-3.5-turbo' },
{ name: 'gpt-4', value: 'gpt-4' },
]);
});
it('should return models sorted alphabetically by id', async () => {
// Setup a mock with scrambled order
const mockUnsortedInstance = {
apiKey: 'test-api-key',
models: {
list: jest.fn().mockResolvedValue({
data: [
{ id: 'gpt-4' },
{ id: 'a-model' },
{ id: 'o1-model' },
{ id: 'gpt-3.5-turbo' },
{ id: 'z-model' },
],
}),
},
} as unknown as OpenAI;
(OpenAI as jest.MockedClass<typeof OpenAI>).mockImplementation(() => mockUnsortedInstance);
// Custom API endpoint to include all models
mockContext.getNodeParameter = jest.fn().mockReturnValue('https://custom-api.com');
const result = await searchModels.call(mockContext);
// Verify the results are sorted alphabetically
expect(result.results).toEqual([
{ name: 'a-model', value: 'a-model' },
{ name: 'gpt-3.5-turbo', value: 'gpt-3.5-turbo' },
{ name: 'gpt-4', value: 'gpt-4' },
{ name: 'o1-model', value: 'o1-model' },
{ name: 'z-model', value: 'z-model' },
]);
});
});
@@ -0,0 +1,49 @@
import type { ILoadOptionsFunctions, INodeListSearchResult } from 'n8n-workflow';
import OpenAI from 'openai';
import { shouldIncludeModel } from '../../../vendors/OpenAi/helpers/modelFiltering';
import { getProxyAgent } from '@n8n/ai-utilities';
import { Container } from '@n8n/di';
import { AiConfig } from '@n8n/config';
export async function searchModels(
this: ILoadOptionsFunctions,
filter?: string,
): Promise<INodeListSearchResult> {
const credentials = await this.getCredentials('openAiApi');
const baseURL =
(this.getNodeParameter('options.baseURL', '') as string) ||
(credentials.url as string) ||
'https://api.openai.com/v1';
const { openAiDefaultHeaders: defaultHeaders } = Container.get(AiConfig);
const openai = new OpenAI({
baseURL,
apiKey: credentials.apiKey as string,
fetchOptions: {
dispatcher: getProxyAgent(baseURL),
},
defaultHeaders,
});
const { data: models = [] } = await openai.models.list();
const url = baseURL && new URL(baseURL);
const isCustomAPI = !!(url && !['api.openai.com', 'ai-assistant.n8n.io'].includes(url.hostname));
const filteredModels = models.filter((model: { id: string }) => {
const includeModel = shouldIncludeModel(model.id, isCustomAPI);
if (!filter) return includeModel;
return includeModel && model.id.toLowerCase().includes(filter.toLowerCase());
});
filteredModels.sort((a, b) => a.id.localeCompare(b.id));
return {
results: filteredModels.map((model: { id: string }) => ({
name: model.id,
value: model.id,
})),
};
}
@@ -0,0 +1,3 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M36.8671 16.3718C37.7746 13.648 37.4621 10.6642 36.0108 8.18661C33.8282 4.38653 29.4407 2.43149 25.1556 3.35151C23.2493 1.20396 20.5105 -0.0173148 17.6392 0.000185533C13.2591 -0.00981468 9.37273 2.81025 8.0252 6.97783C5.21139 7.5541 2.78258 9.31538 1.3613 11.8117C-0.837493 15.6018 -0.336232 20.3794 2.60133 23.6294C1.69381 26.3532 2.00632 29.3371 3.4576 31.8146C5.64015 35.6147 10.0277 37.5697 14.3128 36.6497C16.2179 38.7973 18.9579 40.0185 21.8292 39.9998C26.2118 40.011 30.0994 37.1885 31.4469 33.0171C34.2608 32.4409 36.6896 30.6796 38.1108 28.1833C40.3071 24.3932 39.8046 19.6194 36.8683 16.3693L36.8671 16.3718ZM21.8317 37.386C20.078 37.3885 18.3792 36.7747 17.0329 35.6509C17.0941 35.6184 17.2004 35.5597 17.2691 35.5172L25.2343 30.9171C25.6418 30.6858 25.8918 30.2521 25.8893 29.7833V18.5543L29.2557 20.4981C29.2919 20.5156 29.3157 20.5506 29.3207 20.5906V29.8896C29.3157 34.0247 25.9668 37.3772 21.8317 37.386ZM5.7264 30.5071C4.84763 28.9896 4.53137 27.2108 4.83263 25.4845C4.89138 25.5195 4.99513 25.5832 5.06888 25.6257L13.0341 30.2258C13.4378 30.4621 13.9378 30.4621 14.3428 30.2258L24.0668 24.6107V28.4983C24.0693 28.5383 24.0505 28.577 24.0193 28.602L15.9679 33.2509C12.3815 35.3159 7.80144 34.0884 5.72765 30.5071H5.7264ZM3.6301 13.1205C4.50512 11.6004 5.8864 10.4379 7.53144 9.83415C7.53144 9.9029 7.52769 10.0242 7.52769 10.1092V19.3106C7.52519 19.7781 7.77519 20.2119 8.18145 20.4431L17.9054 26.057L14.5391 28.0008C14.5053 28.0233 14.4628 28.027 14.4253 28.0108L6.37266 23.3582C2.79383 21.2856 1.56631 16.7068 3.62885 13.1217L3.6301 13.1205ZM31.2882 19.5569L21.5642 13.9417L24.9306 11.9992C24.9643 11.9767 25.0068 11.9729 25.0443 11.9892L33.097 16.638C36.6821 18.7093 37.9108 23.2957 35.8395 26.8808C34.9633 28.3983 33.5832 29.5608 31.9395 30.1658V20.6894C31.9432 20.2219 31.6945 19.7894 31.2894 19.5569H31.2882ZM34.6383 14.5142C34.5795 14.478 34.4758 14.4155 34.402 14.373L26.4368 9.77289C26.0331 9.53664 25.5331 9.53664 25.1281 9.77289L15.4041 15.388V11.5004C15.4016 11.4604 15.4204 11.4217 15.4516 11.3967L23.503 6.75158C27.0894 4.68279 31.6745 5.91406 33.742 9.50164C34.6158 11.0167 34.932 12.7905 34.6358 14.5142H34.6383ZM13.5741 21.4431L10.2065 19.4994C10.1702 19.4819 10.1465 19.4468 10.1415 19.4068V10.1079C10.144 5.96781 13.5028 2.61274 17.6429 2.61524C19.3942 2.61524 21.0892 3.23025 22.4355 4.35028C22.3743 4.38278 22.2693 4.44153 22.1992 4.48403L14.2341 9.08413C13.8266 9.31538 13.5766 9.74789 13.5791 10.2167L13.5741 21.4406V21.4431ZM15.4029 17.5006L19.7342 14.9993L24.0655 17.4993V22.5007L19.7342 25.0007L15.4029 22.5007V17.5006Z" fill="#C3C9D5"/>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

@@ -0,0 +1,3 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M36.8671 16.3718C37.7746 13.648 37.4621 10.6642 36.0108 8.18661C33.8282 4.38653 29.4407 2.43149 25.1556 3.35151C23.2493 1.20396 20.5105 -0.0173148 17.6392 0.000185533C13.2591 -0.00981468 9.37273 2.81025 8.0252 6.97783C5.21139 7.5541 2.78258 9.31538 1.3613 11.8117C-0.837493 15.6018 -0.336232 20.3794 2.60133 23.6294C1.69381 26.3532 2.00632 29.3371 3.4576 31.8146C5.64015 35.6147 10.0277 37.5697 14.3128 36.6497C16.2179 38.7973 18.9579 40.0185 21.8292 39.9998C26.2118 40.011 30.0994 37.1885 31.4469 33.0171C34.2608 32.4409 36.6896 30.6796 38.1108 28.1833C40.3071 24.3932 39.8046 19.6194 36.8683 16.3693L36.8671 16.3718ZM21.8317 37.386C20.078 37.3885 18.3792 36.7747 17.0329 35.6509C17.0941 35.6184 17.2004 35.5597 17.2691 35.5172L25.2343 30.9171C25.6418 30.6858 25.8918 30.2521 25.8893 29.7833V18.5543L29.2557 20.4981C29.2919 20.5156 29.3157 20.5506 29.3207 20.5906V29.8896C29.3157 34.0247 25.9668 37.3772 21.8317 37.386ZM5.7264 30.5071C4.84763 28.9896 4.53137 27.2108 4.83263 25.4845C4.89138 25.5195 4.99513 25.5832 5.06888 25.6257L13.0341 30.2258C13.4378 30.4621 13.9378 30.4621 14.3428 30.2258L24.0668 24.6107V28.4983C24.0693 28.5383 24.0505 28.577 24.0193 28.602L15.9679 33.2509C12.3815 35.3159 7.80144 34.0884 5.72765 30.5071H5.7264ZM3.6301 13.1205C4.50512 11.6004 5.8864 10.4379 7.53144 9.83415C7.53144 9.9029 7.52769 10.0242 7.52769 10.1092V19.3106C7.52519 19.7781 7.77519 20.2119 8.18145 20.4431L17.9054 26.057L14.5391 28.0008C14.5053 28.0233 14.4628 28.027 14.4253 28.0108L6.37266 23.3582C2.79383 21.2856 1.56631 16.7068 3.62885 13.1217L3.6301 13.1205ZM31.2882 19.5569L21.5642 13.9417L24.9306 11.9992C24.9643 11.9767 25.0068 11.9729 25.0443 11.9892L33.097 16.638C36.6821 18.7093 37.9108 23.2957 35.8395 26.8808C34.9633 28.3983 33.5832 29.5608 31.9395 30.1658V20.6894C31.9432 20.2219 31.6945 19.7894 31.2894 19.5569H31.2882ZM34.6383 14.5142C34.5795 14.478 34.4758 14.4155 34.402 14.373L26.4368 9.77289C26.0331 9.53664 25.5331 9.53664 25.1281 9.77289L15.4041 15.388V11.5004C15.4016 11.4604 15.4204 11.4217 15.4516 11.3967L23.503 6.75158C27.0894 4.68279 31.6745 5.91406 33.742 9.50164C34.6158 11.0167 34.932 12.7905 34.6358 14.5142H34.6383ZM13.5741 21.4431L10.2065 19.4994C10.1702 19.4819 10.1465 19.4468 10.1415 19.4068V10.1079C10.144 5.96781 13.5028 2.61274 17.6429 2.61524C19.3942 2.61524 21.0892 3.23025 22.4355 4.35028C22.3743 4.38278 22.2693 4.44153 22.1992 4.48403L14.2341 9.08413C13.8266 9.31538 13.5766 9.74789 13.5791 10.2167L13.5741 21.4406V21.4431ZM15.4029 17.5006L19.7342 14.9993L24.0655 17.4993V22.5007L19.7342 25.0007L15.4029 22.5007V17.5006Z" fill="#7D7D87"/>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

@@ -0,0 +1,217 @@
import type { IDataObject } from 'n8n-workflow';
import { formatBuiltInTools, prepareAdditionalResponsesParams } from '../common';
describe('formatBuiltInTools', () => {
it('returns empty array when no built-in tools provided', () => {
expect(formatBuiltInTools(undefined as unknown as IDataObject)).toEqual([]);
expect(formatBuiltInTools({} as unknown as IDataObject)).toEqual([]);
});
it('formats web_search with allowed domains and user location', () => {
const tools = formatBuiltInTools({
webSearch: {
searchContextSize: 'high',
allowedDomains: 'example.com, sub.domain.org , , another.net',
country: 'US',
city: 'NYC',
region: 'NY',
},
} as unknown as IDataObject);
expect(tools).toEqual([
{
type: 'web_search',
search_context_size: 'high',
user_location: {
type: 'approximate',
country: 'US',
city: 'NYC',
region: 'NY',
},
filters: { allowed_domains: ['example.com', 'sub.domain.org', 'another.net'] },
},
]);
});
it.each([
[{}, false],
[
{
country: 'US',
},
true,
],
[
{
city: 'NYC',
},
true,
],
[
{
region: 'NY',
},
true,
],
])(
'formats web_search with allowed domains and %s user location',
(userLocation, hasUserLocation) => {
const tools = formatBuiltInTools({
webSearch: {
searchContextSize: 'high',
allowedDomains: 'example.com, sub.domain.org , , another.net',
...userLocation,
},
} as unknown as IDataObject);
const commonData = {
type: 'web_search',
search_context_size: 'high',
filters: { allowed_domains: ['example.com', 'sub.domain.org', 'another.net'] },
};
if (hasUserLocation) {
expect(tools).toEqual([
expect.objectContaining({ ...commonData, user_location: expect.anything() }),
]);
} else {
expect(tools).toEqual([
expect.objectContaining({ ...commonData, user_location: undefined }),
]);
}
},
);
it('adds code_interpreter tool when enabled', () => {
const tools = formatBuiltInTools({ codeInterpreter: true } as unknown as IDataObject);
expect(tools).toEqual([
{
type: 'code_interpreter',
container: { type: 'auto' },
},
]);
});
it('formats file_search tool with parsed vector_store_ids and filters', () => {
const tools = formatBuiltInTools({
fileSearch: {
vectorStoreIds: '["vs1","vs2"]',
filters: '{"file_types":["pdf"],"tags":["t1"]}',
maxResults: 50,
},
} as unknown as IDataObject);
expect(tools).toEqual([
{
type: 'file_search',
vector_store_ids: ['vs1', 'vs2'],
filters: { file_types: ['pdf'], tags: ['t1'] },
max_num_results: 50,
},
]);
});
it('omits filters when empty string provided in file_search', () => {
const tools = formatBuiltInTools({
fileSearch: { vectorStoreIds: '["only"]', filters: '', maxResults: 3 },
} as unknown as IDataObject);
expect(tools).toEqual([
{
type: 'file_search',
vector_store_ids: ['only'],
filters: undefined,
max_num_results: 3,
},
]);
});
});
describe('prepareAdditionalResponsesParams', () => {
it('maps simple scalar fields and conversation id', () => {
const body = prepareAdditionalResponsesParams({
promptCacheKey: 'cache-1',
safetyIdentifier: 'safe-1',
serviceTier: 'default',
topLogprobs: 5,
conversationId: 'conv-123',
} as unknown as IDataObject);
expect(body).toEqual({
prompt_cache_key: 'cache-1',
safety_identifier: 'safe-1',
service_tier: 'default',
top_logprobs: 5,
conversation: 'conv-123',
});
});
it('parses metadata JSON', () => {
const body = prepareAdditionalResponsesParams({
metadata: '{"a":1}',
} as unknown as IDataObject);
expect(body).toEqual({ metadata: { a: 1 } });
});
it('builds prompt config with variables JSON', () => {
const body = prepareAdditionalResponsesParams({
promptConfig: {
promptOptions: {
promptId: 'p1',
version: 'v2',
variables: '{"x":true,"y":2}',
},
},
} as unknown as IDataObject);
expect(body).toEqual({
prompt: {
id: 'p1',
version: 'v2',
variables: { x: true, y: 2 },
},
});
});
it('sets text format for json_schema with parsed schema and verbosity', () => {
const body = prepareAdditionalResponsesParams({
textFormat: {
textOptions: {
type: 'json_schema',
name: 'MySchema',
schema: '{"type":"object","properties":{"a":{"type":"number"}}}',
verbosity: 'low',
},
},
} as unknown as IDataObject);
expect(body).toEqual({
text: {
verbosity: 'low',
format: {
type: 'json_schema',
name: 'MySchema',
schema: { type: 'object', properties: { a: { type: 'number' } } },
},
},
});
});
it('sets text format for json_object and text', () => {
const jsonObj = prepareAdditionalResponsesParams({
textFormat: { textOptions: { type: 'json_object', verbosity: 'medium' } },
} as unknown as IDataObject);
expect(jsonObj).toEqual({ text: { verbosity: 'medium', format: { type: 'json_object' } } });
const text = prepareAdditionalResponsesParams({
textFormat: { textOptions: { type: 'text', verbosity: 'high' } },
} as unknown as IDataObject);
expect(text).toEqual({ text: { verbosity: 'high', format: { type: 'text' } } });
});
it('sets reasoning effort', () => {
const body = prepareAdditionalResponsesParams({
reasoningEffort: 'low',
} as unknown as IDataObject);
expect(body).toEqual({ reasoning: { effort: 'low' } });
});
});
@@ -0,0 +1,61 @@
import type { OpenAIClient } from '@langchain/openai';
export type BuiltInTools = {
webSearch?: {
searchContextSize?: 'low' | 'medium' | 'high';
allowedDomains?: string;
country?: string;
city?: string;
region?: string;
};
fileSearch?: {
vectorStoreIds?: string;
filters?: string;
maxResults?: number;
};
codeInterpreter?: boolean;
};
export type ModelOptions = {
baseURL?: string;
frequencyPenalty?: number;
maxTokens?: number;
responseFormat?: 'text' | 'json_object';
presencePenalty?: number;
temperature?: number;
reasoningEffort?: 'low' | 'medium' | 'high';
timeout?: number;
maxRetries?: number;
topP?: number;
conversationId?: string;
metadata?: string;
promptCacheKey?: string;
safetyIdentifier?: string;
serviceTier?: 'auto' | 'flex' | 'default' | 'priority';
topLogprobs?: number;
textFormat?: {
textOptions?: TextOptions;
};
promptConfig?: {
promptOptions?: PromptOptions;
};
};
export type PromptOptions = {
promptId?: string;
version?: string;
variables?: string;
};
export type TextOptions = {
type?: 'text' | 'json_schema' | 'json_object';
verbosity?: 'low' | 'medium' | 'high';
name?: string;
schema?: string;
description?: string;
strict?: boolean;
};
export type ChatResponseRequest = OpenAIClient.Responses.ResponseCreateParamsNonStreaming & {
conversation?: { id: string } | string;
top_logprobs?: number;
};