import type { INodeType, INodeTypeDescription, ISupplyDataFunctions, SupplyData, } from 'n8n-workflow'; import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; import { loadChatOpenAi, loadN8nLlmTracing } from '../shared/langchainModules'; import { modelLoader } from '../shared/loadModels'; import { errorMessage, normalizeBaseUrl, parseExtraBody } from '../shared/teamorouter'; interface ChatModelOptions { temperature?: number; maxTokens?: number; topP?: number; forceJsonResponse?: boolean; serviceTier?: 'auto' | 'fast'; extraBody?: unknown; timeout?: number; maxRetries?: number; } export class LmChatTeamRouter implements INodeType { description: INodeTypeDescription = { displayName: 'TeamoRouter Chat Model', name: 'lmChatTeamRouter', icon: 'file:teamorouter.svg', group: ['transform'], version: 1, description: 'Chat model served by the TeamoRouter gateway (GPT, Claude, Gemini, DeepSeek, GLM, Grok, Kimi)', defaults: { name: 'TeamoRouter Chat Model', }, codex: { categories: ['AI'], subcategories: { AI: ['Language Models', 'Root Nodes'], 'Language Models': ['Chat Models (Recommended)'], }, resources: { primaryDocumentation: [ { url: 'https://git.itoservice.ru/dosnav/n8n-nodes-teamorouter', }, ], }, }, inputs: [], outputs: [NodeConnectionTypes.AiLanguageModel], outputNames: ['Model'], credentials: [ { name: 'teamorouterApi', required: true, }, ], properties: [ { displayName: 'Model', name: 'model', type: 'options', typeOptions: { loadOptionsMethod: 'getChatModels', }, default: '', required: true, description: 'Model ID to use. The list is read live from GET /v1/models. For an ID that is missing from the list, switch this field to expression mode and enter the ID.', }, { displayName: 'Options', name: 'options', placeholder: 'Add Option', type: 'collection', default: {}, options: [ { displayName: 'Extra Body', name: 'extraBody', type: 'json', default: '', description: 'Extra JSON properties to merge into the request body, e.g. {"top_k": 40}. Overrides the fields set by the options above.', }, { displayName: 'Force JSON Response', name: 'forceJsonResponse', type: 'boolean', default: false, description: 'Whether to request a JSON object from the model. The prompt must mention JSON.', }, { displayName: 'Maximum Number of Tokens', name: 'maxTokens', type: 'number', default: -1, description: 'Maximum number of tokens to generate. -1 leaves it to the model.', }, { displayName: 'Max Retries', name: 'maxRetries', type: 'number', default: 2, description: 'Maximum number of retries for failed requests', }, { displayName: 'Sampling Temperature', name: 'temperature', type: 'number', default: 0.7, typeOptions: { maxValue: 2, minValue: 0, numberPrecision: 1 }, description: 'Controls randomness: lower values make the output more deterministic and repetitive', }, { displayName: 'Service Tier', name: 'serviceTier', type: 'options', default: 'auto', options: [ { name: 'Auto', value: 'auto', description: 'Standard processing' }, { name: 'Fast', value: 'fast', description: 'TeamoRouter Fast mode: up to 2.5x faster, billed at 2x the standard rate', }, ], description: 'Processing mode requested from TeamoRouter', }, { displayName: 'Timeout', name: 'timeout', type: 'number', default: 600000, description: 'Time to wait for an answer in milliseconds. TeamoRouter allows responses up to 600 seconds.', }, { displayName: 'Top P', name: 'topP', type: 'number', default: 1, typeOptions: { maxValue: 1, minValue: 0, numberPrecision: 2 }, description: 'Controls diversity via nucleus sampling. Change this or the temperature, not both.', }, ], }, ], }; methods = { loadOptions: { getChatModels: modelLoader('chat'), }, }; async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { const credentials = await this.getCredentials('teamorouterApi'); const modelName = this.getNodeParameter('model', itemIndex) as string; if (typeof modelName !== 'string' || modelName === '') { throw new NodeOperationError( this.getNode(), 'No model selected. Pick one from the list or switch the field to expression mode and enter the model ID.', ); } const options = this.getNodeParameter('options', itemIndex, {}) as ChatModelOptions; const modelKwargs: Record = {}; if (options.forceJsonResponse === true) modelKwargs.response_format = { type: 'json_object' }; if (options.serviceTier === 'fast') modelKwargs.service_tier = 'fast'; Object.assign(modelKwargs, parseExtraBody(options.extraBody, this.getNode())); const config: Record = { apiKey: credentials.apiKey, model: modelName, configuration: { baseURL: normalizeBaseUrl(credentials.baseUrl as string) }, timeout: typeof options.timeout === 'number' && options.timeout > 0 ? options.timeout : 600000, maxRetries: typeof options.maxRetries === 'number' ? options.maxRetries : 2, // Keep the plain Chat Completions endpoint unless the installed LangChain decides otherwise // (it always uses the Responses API for "gpt-5.6*" and "codex" IDs, which TeamoRouter supports too). useResponsesApi: false, }; if (typeof options.temperature === 'number') config.temperature = options.temperature; if (typeof options.topP === 'number') config.topP = options.topP; if (typeof options.maxTokens === 'number' && options.maxTokens > 0) { config.maxTokens = options.maxTokens; } if (Object.keys(modelKwargs).length > 0) config.modelKwargs = modelKwargs; let model: unknown; try { const ChatOpenAi = loadChatOpenAi(); model = new ChatOpenAi(config); } catch (error) { throw new NodeOperationError(this.getNode(), errorMessage(error)); } const tracing = loadN8nLlmTracing(this); if (tracing !== undefined) { (model as { callbacks?: unknown[] }).callbacks = [tracing]; } return { response: model }; } }