diff --git a/packages/components/credentials/OrcaRouterApi.credential.ts b/packages/components/credentials/OrcaRouterApi.credential.ts new file mode 100644 index 00000000000..a33489e47d1 --- /dev/null +++ b/packages/components/credentials/OrcaRouterApi.credential.ts @@ -0,0 +1,27 @@ +import { INodeParams, INodeCredential } from '../src/Interface' + +class OrcaRouterAPIAuth implements INodeCredential { + label: string + name: string + version: number + description: string + inputs: INodeParams[] + + constructor() { + this.label = 'OrcaRouter API Key' + this.name = 'orcaRouterApi' + this.version = 1.0 + this.description = + 'Sign up at https://www.orcarouter.ai and create an API key at the console. Keys begin with sk-orca-.' + this.inputs = [ + { + label: 'OrcaRouter API Key', + name: 'orcaRouterApiKey', + type: 'password', + description: 'API key issued by OrcaRouter (starts with sk-orca-).' + } + ] + } +} + +module.exports = { credClass: OrcaRouterAPIAuth } diff --git a/packages/components/nodes/chatmodels/ChatOrcaRouter/ChatOrcaRouter.test.ts b/packages/components/nodes/chatmodels/ChatOrcaRouter/ChatOrcaRouter.test.ts new file mode 100644 index 00000000000..acdf1cbbea8 --- /dev/null +++ b/packages/components/nodes/chatmodels/ChatOrcaRouter/ChatOrcaRouter.test.ts @@ -0,0 +1,253 @@ +jest.mock('@langchain/openai', () => { + class FakeChatOpenAI { + fields: any + constructor(fields: any) { + this.fields = fields + } + } + return { ChatOpenAI: FakeChatOpenAI } +}) + +jest.mock('../../../src/utils', () => ({ + getBaseClasses: jest.fn().mockReturnValue(['BaseChatModel']), + getCredentialData: jest.fn(), + getCredentialParam: jest.fn() +})) + +jest.mock('axios', () => ({ get: jest.fn() })) + +import axios from 'axios' +import { getCredentialData, getCredentialParam } from '../../../src/utils' + +const { nodeClass: ChatOrcaRouter } = require('./ChatOrcaRouter') + +describe('ChatOrcaRouter', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('live-fetches the catalog, filters to chat models, and puts the auto router first', async () => { + ;(axios.get as jest.Mock).mockResolvedValue({ + data: { + data: [ + { model_name: 'openai/gpt-5.5', supported_endpoint_types: ['openai'] }, + { model_name: 'anthropic/claude-opus-4.8', supported_endpoint_types: ['anthropic'] }, + // non-chat entries that must be filtered out: + { model_name: 'openai/dall-e-3', supported_endpoint_types: ['image-generation'] }, + { model_name: 'openai/text-embedding-3-small', supported_endpoint_types: ['openai'] }, + { model_name: 'kling/kling-video', supported_endpoint_types: ['openai-video'] }, + { model_name: 'openai/gpt-5-codex', supported_endpoint_types: ['openai'] }, + { model_name: 'openai/gpt-5-pro', supported_endpoint_types: ['openai-response'] } + ] + } + }) + + const node = new ChatOrcaRouter() + const models = await node.loadMethods.listModels() + + expect(axios.get).toHaveBeenCalledWith('https://www.orcarouter.ai/api/pricing', expect.any(Object)) + expect(models[0]).toEqual({ label: 'Auto router (orcarouter/auto)', name: 'orcarouter/auto' }) + const names = models.map((m: any) => m.name) + expect(names).toEqual(expect.arrayContaining(['orcarouter/auto', 'openai/gpt-5.5', 'anthropic/claude-opus-4.8'])) + // filtered out: image / embedding / video / codex / responses-only + expect(names).not.toContain('openai/dall-e-3') + expect(names).not.toContain('openai/text-embedding-3-small') + expect(names).not.toContain('kling/kling-video') + expect(names).not.toContain('openai/gpt-5-codex') + expect(names).not.toContain('openai/gpt-5-pro') + }) + + it('falls back to flagship presets when the catalog fetch fails', async () => { + ;(axios.get as jest.Mock).mockRejectedValue(new Error('network down')) + + const node = new ChatOrcaRouter() + const models = await node.loadMethods.listModels() + + expect(models[0]).toEqual({ label: 'Auto router (orcarouter/auto)', name: 'orcarouter/auto' }) + expect(models.map((m: any) => m.name)).toEqual( + expect.arrayContaining(['orcarouter/auto', 'openai/gpt-5.5', 'anthropic/claude-opus-4.8', 'qwen/qwen3.7-max']) + ) + }) + + it('declares orcaRouterApi as its credential', () => { + const node = new ChatOrcaRouter() + expect(node.credential.credentialNames).toEqual(['orcaRouterApi']) + }) + + it('does not default Temperature, so reasoning models and the auto router are not sent a rejected field', () => { + const node = new ChatOrcaRouter() + const temp = node.inputs.find((i: any) => i.name === 'temperature') + expect(temp).toBeDefined() + expect(temp.default).toBeUndefined() + expect(temp.optional).toBe(true) + }) + + it('wires the OrcaRouter base URL, API key, and attribution headers onto the ChatOpenAI client', async () => { + ;(getCredentialData as jest.Mock).mockResolvedValue({ orcaRouterApiKey: 'sk-orca-test-key' }) + ;(getCredentialParam as jest.Mock).mockImplementation((key, credentialData) => credentialData[key]) + + const node = new ChatOrcaRouter() + const model = await node.init( + { + id: 'node-1', + credential: 'cred-1', + inputs: { + modelName: 'orcarouter/auto', + temperature: '0.5', + streaming: true, + allowImageUploads: false + } + }, + '', + {} + ) + + expect(model.fields).toMatchObject({ + modelName: 'orcarouter/auto', + openAIApiKey: 'sk-orca-test-key', + apiKey: 'sk-orca-test-key', + temperature: 0.5, + streaming: true + }) + expect(model.fields.configuration.baseURL).toBe('https://api.orcarouter.ai/v1') + expect(model.fields.configuration.defaultHeaders).toMatchObject({ + 'HTTP-Referer': 'https://www.orcarouter.ai/', + 'X-Title': 'Flowise' + }) + }) + + it('forwards advanced numeric params and honours a custom basepath', async () => { + ;(getCredentialData as jest.Mock).mockResolvedValue({ orcaRouterApiKey: 'sk-orca-x' }) + ;(getCredentialParam as jest.Mock).mockImplementation((key, credentialData) => credentialData[key]) + + const node = new ChatOrcaRouter() + const model = await node.init( + { + id: 'node-1', + credential: 'cred-1', + inputs: { + modelName: 'openai/gpt-5.5', + temperature: '0.2', + maxTokens: '1024', + topP: '0.8', + frequencyPenalty: '0.1', + presencePenalty: '0.2', + timeout: '30', + basepath: 'https://router.internal/v1', + streaming: false + } + }, + '', + {} + ) + + expect(model.fields).toMatchObject({ + modelName: 'openai/gpt-5.5', + temperature: 0.2, + maxTokens: 1024, + topP: 0.8, + frequencyPenalty: 0.1, + presencePenalty: 0.2, + timeout: 30, + streaming: false + }) + expect(model.fields.configuration.baseURL).toBe('https://router.internal/v1') + }) + + it('omits temperature when the user leaves it blank so reasoning models do not 400', async () => { + ;(getCredentialData as jest.Mock).mockResolvedValue({ orcaRouterApiKey: 'sk-orca-x' }) + ;(getCredentialParam as jest.Mock).mockImplementation((key, credentialData) => credentialData[key]) + + const node = new ChatOrcaRouter() + const model = await node.init( + { + id: 'node-1', + credential: 'cred-1', + inputs: { + modelName: 'anthropic/claude-opus-4.8', + temperature: '', + streaming: true + } + }, + '', + {} + ) + + expect(model.fields.temperature).toBeUndefined() + }) + + it('honours an explicit 0 for numeric params instead of dropping the falsy-but-valid value', async () => { + ;(getCredentialData as jest.Mock).mockResolvedValue({ orcaRouterApiKey: 'sk-orca-x' }) + ;(getCredentialParam as jest.Mock).mockImplementation((key, credentialData) => credentialData[key]) + + const node = new ChatOrcaRouter() + const model = await node.init( + { + id: 'node-1', + credential: 'cred-1', + inputs: { + modelName: 'openai/gpt-5.5', + temperature: 0, + topP: 0, + frequencyPenalty: 0, + presencePenalty: 0, + streaming: true + } + }, + '', + {} + ) + + expect(model.fields.temperature).toBe(0) + expect(model.fields.topP).toBe(0) + expect(model.fields.frequencyPenalty).toBe(0) + expect(model.fields.presencePenalty).toBe(0) + }) + + it('merges user-provided Base Options on top of the default attribution headers', async () => { + ;(getCredentialData as jest.Mock).mockResolvedValue({ orcaRouterApiKey: 'sk-orca-x' }) + ;(getCredentialParam as jest.Mock).mockImplementation((key, credentialData) => credentialData[key]) + + const node = new ChatOrcaRouter() + const model = await node.init( + { + id: 'node-1', + credential: 'cred-1', + inputs: { + modelName: 'orcarouter/auto', + streaming: true, + baseOptions: { 'X-Title': 'My-App', 'X-Custom': 'yes' } + } + }, + '', + {} + ) + + expect(model.fields.configuration.defaultHeaders).toMatchObject({ + 'HTTP-Referer': 'https://www.orcarouter.ai/', + 'X-Title': 'My-App', + 'X-Custom': 'yes' + }) + }) + + it('throws a clear error when Base Options JSON is malformed', async () => { + ;(getCredentialData as jest.Mock).mockResolvedValue({ orcaRouterApiKey: 'sk-orca-x' }) + ;(getCredentialParam as jest.Mock).mockImplementation((key, credentialData) => credentialData[key]) + + const node = new ChatOrcaRouter() + await expect( + node.init( + { + id: 'node-1', + credential: 'cred-1', + inputs: { + modelName: 'orcarouter/auto', + baseOptions: '{not json' + } + }, + '', + {} + ) + ).rejects.toThrow(/Invalid JSON in the ChatOrcaRouter's BaseOptions/) + }) +}) diff --git a/packages/components/nodes/chatmodels/ChatOrcaRouter/ChatOrcaRouter.ts b/packages/components/nodes/chatmodels/ChatOrcaRouter/ChatOrcaRouter.ts new file mode 100644 index 00000000000..ebc1333482b --- /dev/null +++ b/packages/components/nodes/chatmodels/ChatOrcaRouter/ChatOrcaRouter.ts @@ -0,0 +1,267 @@ +import axios from 'axios' +import { ChatOpenAI as LangchainChatOpenAI, ChatOpenAIFields } from '@langchain/openai' +import { BaseCache } from '@langchain/core/caches' +import { ICommonObject, IMultiModalOption, INode, INodeData, INodeOptionsValue, INodeParams } from '../../../src/Interface' +import { getBaseClasses, getCredentialData, getCredentialParam } from '../../../src/utils' +import { ChatOrcaRouter } from './FlowiseChatOrcaRouter' + +const ORCAROUTER_DEFAULT_BASE_URL = 'https://api.orcarouter.ai/v1' +const ORCAROUTER_PRICING_URL = 'https://www.orcarouter.ai/api/pricing' +const ORCAROUTER_REFERER = 'https://www.orcarouter.ai/' +const ORCAROUTER_TITLE = 'Flowise' + +const ORCAROUTER_AUTO: INodeOptionsValue = { label: 'Auto router (orcarouter/auto)', name: 'orcarouter/auto' } + +// Offline fallback used when the live model list cannot be fetched. +const ORCAROUTER_FALLBACK_MODELS: INodeOptionsValue[] = [ + ORCAROUTER_AUTO, + { label: 'OpenAI GPT-5.5', name: 'openai/gpt-5.5' }, + { label: 'Anthropic Claude Opus 4.8', name: 'anthropic/claude-opus-4.8' }, + { label: 'Google Gemini 3.5 Flash', name: 'google/gemini-3.5-flash' }, + { label: 'xAI Grok 4.3', name: 'grok/grok-4.3' }, + { label: 'DeepSeek V4 Pro', name: 'deepseek/deepseek-v4-pro' }, + { label: 'MiniMax M2.7', name: 'minimax/minimax-m2.7' }, + { label: 'Qwen 3.7 Max', name: 'qwen/qwen3.7-max' } +] + +// Keep only chat-completions models; drop image/video/embedding/tts/stt/rerank and +// models served on non chat-completions endpoints (responses/completions only). +const isChatModel = (name: string, entry: any): boolean => { + const endpoints = new Set(entry?.supported_endpoint_types || []) + const outputs = new Set(entry?.output_modalities || []) + const n = (name || '').toLowerCase() + if (endpoints.has('image-generation') || endpoints.has('openai-video')) return false + if (outputs.has('image')) return false + if (['imagen', 'dall-e', 'gpt-image', 'grok-imagine'].some((k) => n.includes(k))) return false + if (n.includes('embedding') || n.includes('tts') || n.endsWith('-speech')) return false + if (n.includes('whisper') || n.includes('transcrib') || n.includes('rerank')) return false + if (endpoints.has('openai-response') && !endpoints.has('openai')) return false + if (n.includes('codex')) return false + if (/^openai\/gpt-5(\.\d+)?-pro/.test(n)) return false + return true +} + +class ChatOrcaRouter_ChatModels implements INode { + label: string + name: string + version: number + type: string + icon: string + category: string + description: string + baseClasses: string[] + credential: INodeParams + inputs: INodeParams[] + + constructor() { + this.label = 'ChatOrcaRouter' + this.name = 'chatOrcaRouter' + this.version = 1.0 + this.type = 'ChatOrcaRouter' + this.icon = 'orcaRouter.svg' + this.category = 'Chat Models' + this.description = 'Wrapper around OrcaRouter, an OpenAI-compatible LLM router that exposes 150+ models behind a single endpoint.' + this.baseClasses = [this.type, ...getBaseClasses(LangchainChatOpenAI)] + this.credential = { + label: 'Connect Credential', + name: 'credential', + type: 'credential', + credentialNames: ['orcaRouterApi'] + } + this.inputs = [ + { + label: 'Cache', + name: 'cache', + type: 'BaseCache', + optional: true + }, + { + label: 'Model Name', + name: 'modelName', + type: 'asyncOptions', + loadMethod: 'listModels', + freeSolo: true, + default: 'orcarouter/auto', + description: + 'The list is fetched live from the OrcaRouter catalog; you can also type any model id from https://www.orcarouter.ai/models. orcarouter/auto routes each request through the workspace-configured router.' + }, + { + label: 'Temperature', + name: 'temperature', + type: 'number', + step: 0.1, + optional: true, + description: + 'Leave blank to use the upstream default. Reasoning models (e.g. Claude Opus 4.8, GPT-5 family, DeepSeek Reasoner) and the auto router reject this field, so it is not set by default.' + }, + { + label: 'Streaming', + name: 'streaming', + type: 'boolean', + default: true, + optional: true, + additionalParams: true + }, + { + label: 'Allow Image Uploads', + name: 'allowImageUploads', + type: 'boolean', + description: + 'Allow image input. Refer to the docs for more details.', + default: false, + optional: true + }, + { + label: 'Max Tokens', + name: 'maxTokens', + type: 'number', + step: 1, + optional: true, + additionalParams: true + }, + { + label: 'Top Probability', + name: 'topP', + type: 'number', + step: 0.1, + optional: true, + additionalParams: true + }, + { + label: 'Frequency Penalty', + name: 'frequencyPenalty', + type: 'number', + step: 0.1, + optional: true, + additionalParams: true + }, + { + label: 'Presence Penalty', + name: 'presencePenalty', + type: 'number', + step: 0.1, + optional: true, + additionalParams: true + }, + { + label: 'Timeout', + name: 'timeout', + type: 'number', + step: 1, + optional: true, + additionalParams: true + }, + { + label: 'Base Path', + name: 'basepath', + type: 'string', + optional: true, + default: ORCAROUTER_DEFAULT_BASE_URL, + description: 'Override the OrcaRouter API base URL (e.g. for a self-hosted OrcaRouter-O2 deployment).', + additionalParams: true + }, + { + label: 'Base Options', + name: 'baseOptions', + type: 'json', + optional: true, + description: + 'Additional headers to send with every request. Useful for overriding the default HTTP-Referer / X-Title attribution headers.', + additionalParams: true + } + ] + } + + //@ts-ignore + loadMethods = { + async listModels(): Promise { + // Live-fetch the public OrcaRouter catalog (no auth) so new models appear automatically. + // The Model Name field is freeSolo, so users can still type any model id; if the fetch + // fails (offline / endpoint down) we fall back to the flagship presets. + try { + const resp = await axios.get(ORCAROUTER_PRICING_URL, { timeout: 10000 }) + const data = resp?.data?.data + if (!Array.isArray(data)) return ORCAROUTER_FALLBACK_MODELS + const seen = new Set() + const models: INodeOptionsValue[] = [] + for (const entry of data) { + const name = entry?.model_name + if (!name || name === ORCAROUTER_AUTO.name || seen.has(name) || !isChatModel(name, entry)) continue + seen.add(name) + models.push({ label: name, name }) + } + if (!models.length) return ORCAROUTER_FALLBACK_MODELS + models.sort((a, b) => a.name.localeCompare(b.name)) + // Surface the auto router at the top of the list. + return [ORCAROUTER_AUTO, ...models] + } catch (error) { + return ORCAROUTER_FALLBACK_MODELS + } + } + } + + async init(nodeData: INodeData, _: string, options: ICommonObject): Promise { + const temperature = nodeData.inputs?.temperature as string + const modelName = nodeData.inputs?.modelName as string + const maxTokens = nodeData.inputs?.maxTokens as string + const topP = nodeData.inputs?.topP as string + const frequencyPenalty = nodeData.inputs?.frequencyPenalty as string + const presencePenalty = nodeData.inputs?.presencePenalty as string + const timeout = nodeData.inputs?.timeout as string + const streaming = nodeData.inputs?.streaming as boolean + const basePath = (nodeData.inputs?.basepath as string) || ORCAROUTER_DEFAULT_BASE_URL + const baseOptions = nodeData.inputs?.baseOptions + const cache = nodeData.inputs?.cache as BaseCache + const allowImageUploads = nodeData.inputs?.allowImageUploads as boolean + + const credentialData = await getCredentialData(nodeData.credential ?? '', options) + const orcaRouterApiKey = getCredentialParam('orcaRouterApiKey', credentialData, nodeData) + + const obj: ChatOpenAIFields = { + modelName, + openAIApiKey: orcaRouterApiKey, + apiKey: orcaRouterApiKey, + streaming: streaming ?? true + } + + if (temperature != null && temperature !== '') obj.temperature = parseFloat(temperature) + if (maxTokens != null && maxTokens !== '') obj.maxTokens = parseInt(maxTokens, 10) + if (topP != null && topP !== '') obj.topP = parseFloat(topP) + if (frequencyPenalty != null && frequencyPenalty !== '') obj.frequencyPenalty = parseFloat(frequencyPenalty) + if (presencePenalty != null && presencePenalty !== '') obj.presencePenalty = parseFloat(presencePenalty) + if (timeout != null && timeout !== '') obj.timeout = parseInt(timeout, 10) + if (cache) obj.cache = cache + + let parsedBaseOptions: Record | undefined = undefined + if (baseOptions) { + try { + parsedBaseOptions = typeof baseOptions === 'object' ? baseOptions : JSON.parse(baseOptions) + } catch (exception) { + throw new Error("Invalid JSON in the ChatOrcaRouter's BaseOptions: " + exception) + } + } + + const defaultHeaders: Record = { + 'HTTP-Referer': ORCAROUTER_REFERER, + 'X-Title': ORCAROUTER_TITLE, + ...(parsedBaseOptions ?? {}) + } + + obj.configuration = { + baseURL: basePath, + defaultHeaders + } + + const multiModalOption: IMultiModalOption = { + image: { + allowImageUploads: allowImageUploads ?? false + } + } + + const model = new ChatOrcaRouter(nodeData.id, obj) + model.setMultiModalOption(multiModalOption) + return model + } +} + +module.exports = { nodeClass: ChatOrcaRouter_ChatModels } diff --git a/packages/components/nodes/chatmodels/ChatOrcaRouter/FlowiseChatOrcaRouter.ts b/packages/components/nodes/chatmodels/ChatOrcaRouter/FlowiseChatOrcaRouter.ts new file mode 100644 index 00000000000..3b33a98e020 --- /dev/null +++ b/packages/components/nodes/chatmodels/ChatOrcaRouter/FlowiseChatOrcaRouter.ts @@ -0,0 +1,20 @@ +import { ChatOpenAI as LangchainChatOpenAI, ChatOpenAIFields } from '@langchain/openai' +import { IMultiModalOption, IVisionChatModal } from '../../../src' + +export class ChatOrcaRouter extends LangchainChatOpenAI implements IVisionChatModal { + configuredModel: string + configuredMaxToken?: number + multiModalOption: IMultiModalOption + id: string + + constructor(id: string, fields?: ChatOpenAIFields) { + super(fields) + this.id = id + this.configuredModel = fields?.modelName ?? '' + this.configuredMaxToken = fields?.maxTokens + } + + setMultiModalOption(multiModalOption: IMultiModalOption): void { + this.multiModalOption = multiModalOption + } +} diff --git a/packages/components/nodes/chatmodels/ChatOrcaRouter/orcaRouter.svg b/packages/components/nodes/chatmodels/ChatOrcaRouter/orcaRouter.svg new file mode 100644 index 00000000000..90683a36b61 --- /dev/null +++ b/packages/components/nodes/chatmodels/ChatOrcaRouter/orcaRouter.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/packages/server/src/utils/index.ts b/packages/server/src/utils/index.ts index 2eeeb5b0eb6..d2081b64f31 100644 --- a/packages/server/src/utils/index.ts +++ b/packages/server/src/utils/index.ts @@ -1492,7 +1492,8 @@ export const isFlowValidForStream = (reactFlowNodes: IReactFlowNode[], endingNod 'chatFireworks', 'ChatSambanova', 'chatBaiduWenxin', - 'chatCometAPI' + 'chatCometAPI', + 'chatOrcaRouter' ], LLMs: ['azureOpenAI', 'openAI', 'ollama'] }