From 6fa96eb2d53471de4bbb16f8243546e059350495 Mon Sep 17 00:00:00 2001 From: "jinhao.song" Date: Wed, 19 Aug 2026 12:06:55 +0000 Subject: [PATCH] feat(web): add OrcaRouter as a named LLM provider Mirror the existing OpenRouter wiring so OrcaRouter shows up as a first-class provider in the WebUI settings: new LLMProvider/OrcaRouterConfig entries, a createChatModel case backed by ChatOpenAI against https://api.orcarouter.ai/v1 (default model orcarouter/auto), settings persistence/validation, provider pick list + config card, and en/zh-CN i18n. Verified: tsc -b, 421 vitest tests (incl. new orcarouter cases), prettier --check, eslint (0 errors), and a live chat completion against the OrcaRouter gateway returned ORCA-LIVE-OK. Signed-off-by: jinhao.song --- gitnexus-web/src/components/SettingsPanel.tsx | 49 ++++++++++++++++++- gitnexus-web/src/config/ui-constants.ts | 2 + gitnexus-web/src/core/llm/agent.ts | 37 +++++++++++++- gitnexus-web/src/core/llm/settings-service.ts | 41 +++++++++++++++- gitnexus-web/src/core/llm/types.ts | 32 ++++++++++-- gitnexus-web/src/locales/en/settings.json | 4 ++ gitnexus-web/src/locales/zh-CN/settings.json | 4 ++ .../test/unit/settings-service.test.ts | 24 +++++++++ 8 files changed, 185 insertions(+), 8 deletions(-) diff --git a/gitnexus-web/src/components/SettingsPanel.tsx b/gitnexus-web/src/components/SettingsPanel.tsx index 9b32ffd92..0d4ab2e11 100644 --- a/gitnexus-web/src/components/SettingsPanel.tsx +++ b/gitnexus-web/src/components/SettingsPanel.tsx @@ -371,6 +371,7 @@ export const SettingsPanel = ({ 'minimax', 'glm', 'deepseek', + 'orcarouter', ]; return ( @@ -484,7 +485,9 @@ export const SettingsPanel = ({ ? '🔮' : provider === 'deepseek' ? '🐋' - : '☁️'} + : provider === 'orcarouter' + ? '🐳' + : '☁️'} {getProviderDisplayName(provider)} @@ -1007,6 +1010,50 @@ export const SettingsPanel = ({ )} + {/* OrcaRouter Settings */} + {settings.activeProvider === 'orcarouter' && ( + + setSettings((prev) => ({ + ...prev, + orcarouter: { ...prev.orcarouter!, apiKey: value }, + })), + onToggleVisibility: () => toggleApiKeyVisibility('orcarouter'), + }} + model={{ + value: settings.orcarouter?.model ?? 'orcarouter/auto', + placeholder: t('settings:providers.orcarouter.modelPlaceholder'), + onChange: (value) => + setSettings((prev) => ({ + ...prev, + orcarouter: { ...prev.orcarouter!, model: value }, + })), + helperText: 'orcarouter/auto (default) — or any model id exposed by the gateway', + }} + > +

+ OpenAI-compatible gateway. Get your API key from{' '} + + orcarouter.ai + + . +

+
+ )} + {/* GLM Settings */} {settings.activeProvider === 'glm' && (
diff --git a/gitnexus-web/src/config/ui-constants.ts b/gitnexus-web/src/config/ui-constants.ts index ef361881f..3a02b5a44 100644 --- a/gitnexus-web/src/config/ui-constants.ts +++ b/gitnexus-web/src/config/ui-constants.ts @@ -7,6 +7,8 @@ export const DEFAULT_BACKEND_URL = 'http://localhost:4747'; export const DEFAULT_OLLAMA_BASE_URL = 'http://localhost:11434'; export const DEFAULT_OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; +export const DEFAULT_ORCAROUTER_BASE_URL = 'https://api.orcarouter.ai/v1'; +export const DEFAULT_ORCAROUTER_MODEL = 'orcarouter/auto'; /** * sessionStorage key for the deploy access token sent as diff --git a/gitnexus-web/src/core/llm/agent.ts b/gitnexus-web/src/core/llm/agent.ts index 555cf0d10..8f92adcfd 100644 --- a/gitnexus-web/src/core/llm/agent.ts +++ b/gitnexus-web/src/core/llm/agent.ts @@ -31,6 +31,7 @@ import type { MiniMaxConfig, GLMConfig, DeepSeekConfig, + OrcaRouterConfig, AgentStreamChunk, AgentHistoryMessage, MiniMaxThinkingMode, @@ -41,7 +42,11 @@ import { buildDynamicSystemPrompt, CHAT_ONLY_PROMPT_NOTE, } from './context-builder'; -import { DEFAULT_OLLAMA_BASE_URL, DEFAULT_OPENROUTER_BASE_URL } from '../../config/ui-constants'; +import { + DEFAULT_OLLAMA_BASE_URL, + DEFAULT_OPENROUTER_BASE_URL, + DEFAULT_ORCAROUTER_BASE_URL, +} from '../../config/ui-constants'; import { DeepSeekChatOpenAI, normalizeMessageContent, @@ -271,6 +276,36 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => { }); } + case 'orcarouter': { + const orcaRouterConfig = config as OrcaRouterConfig; + + // Debug logging + if (import.meta.env.DEV) { + console.log('🐋 OrcaRouter config:', { + hasApiKey: !!orcaRouterConfig.apiKey, + model: orcaRouterConfig.model, + baseUrl: orcaRouterConfig.baseUrl, + }); + } + + if (!orcaRouterConfig.apiKey || orcaRouterConfig.apiKey.trim() === '') { + throw new Error('OrcaRouter API key is required but was not provided'); + } + + return new ChatOpenAI({ + openAIApiKey: orcaRouterConfig.apiKey, + apiKey: orcaRouterConfig.apiKey, // Fallback for some versions + modelName: orcaRouterConfig.model, + temperature: orcaRouterConfig.temperature ?? 0.1, + maxTokens: orcaRouterConfig.maxTokens, + configuration: { + apiKey: orcaRouterConfig.apiKey, // Ensure client receives it + baseURL: orcaRouterConfig.baseUrl ?? DEFAULT_ORCAROUTER_BASE_URL, + }, + streaming: true, + }); + } + case 'minimax': { const minimaxConfig = config as MiniMaxConfig; diff --git a/gitnexus-web/src/core/llm/settings-service.ts b/gitnexus-web/src/core/llm/settings-service.ts index fb2591172..4099485e5 100644 --- a/gitnexus-web/src/core/llm/settings-service.ts +++ b/gitnexus-web/src/core/llm/settings-service.ts @@ -18,10 +18,15 @@ import { MiniMaxConfig, GLMConfig, DeepSeekConfig, + OrcaRouterConfig, ProviderConfig, MINIMAX_MODEL_IDS, } from './types'; -import { DEFAULT_OPENROUTER_BASE_URL, DEFAULT_OLLAMA_BASE_URL } from '../../config/ui-constants'; +import { + DEFAULT_OPENROUTER_BASE_URL, + DEFAULT_OLLAMA_BASE_URL, + DEFAULT_ORCAROUTER_BASE_URL, +} from '../../config/ui-constants'; import { resilientFetch } from 'gitnexus-shared'; const STORAGE_KEY = 'gitnexus-llm-settings'; @@ -81,6 +86,10 @@ const mergeWithDefaults = (parsed?: Partial | null): LLMSettings => ...DEFAULT_LLM_SETTINGS.deepseek, ...parsed?.deepseek, }, + orcarouter: { + ...DEFAULT_LLM_SETTINGS.orcarouter, + ...parsed?.orcarouter, + }, }); const readSettings = (storage: Storage): Partial | null => { @@ -168,7 +177,9 @@ export const updateProviderSettings = ( ? Partial> : T extends 'deepseek' ? Partial> - : never + : T extends 'orcarouter' + ? Partial> + : never >, ): LLMSettings => { const current = loadSettings(); @@ -274,6 +285,17 @@ export const updateProviderSettings = ( saveSettings(updated); return updated; } + case 'orcarouter': { + const updated: LLMSettings = { + ...current, + orcarouter: { + ...(current.orcarouter ?? {}), + ...(updates as Partial>), + }, + }; + saveSettings(updated); + return updated; + } default: { // Should be unreachable due to T extends LLMProvider, but keep a safe fallback const updated: LLMSettings = { ...current }; @@ -355,6 +377,17 @@ const providerBuilders: Record = { if (!settings.deepseek?.apiKey) return null; return { provider: 'deepseek', ...settings.deepseek } as DeepSeekConfig; }, + orcarouter: (settings) => { + if (!settings.orcarouter?.apiKey || settings.orcarouter.apiKey.trim() === '') return null; + return { + provider: 'orcarouter', + apiKey: settings.orcarouter.apiKey, + model: settings.orcarouter.model || 'orcarouter/auto', + baseUrl: settings.orcarouter.baseUrl || DEFAULT_ORCAROUTER_BASE_URL, + temperature: settings.orcarouter.temperature, + maxTokens: settings.orcarouter.maxTokens, + } as OrcaRouterConfig; + }, }; export const getActiveProviderConfig = (): ProviderConfig | null => { @@ -427,6 +460,8 @@ export const getProviderDisplayName = (provider: LLMProvider): string => { return 'GLM (Z.AI)'; case 'deepseek': return 'DeepSeek'; + case 'orcarouter': + return 'OrcaRouter'; default: return provider; } @@ -459,6 +494,8 @@ export const getAvailableModels = (provider: LLMProvider): string[] => { return ['GLM-5', 'GLM-5-Turbo', 'GLM-4.7', 'GLM-4.5']; case 'deepseek': return ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner']; + case 'orcarouter': + return ['orcarouter/auto']; default: return []; } diff --git a/gitnexus-web/src/core/llm/types.ts b/gitnexus-web/src/core/llm/types.ts index c5198bd16..35f0d7024 100644 --- a/gitnexus-web/src/core/llm/types.ts +++ b/gitnexus-web/src/core/llm/types.ts @@ -2,13 +2,18 @@ * LLM Provider Types * * Type definitions for multi-provider LLM support. - * Supports OpenAI, Azure OpenAI, Gemini, Anthropic, Ollama, OpenRouter, MiniMax, GLM, and DeepSeek. + * Supports OpenAI, Azure OpenAI, Gemini, Anthropic, Ollama, OpenRouter, MiniMax, GLM, DeepSeek, and OrcaRouter. */ /** * Supported LLM providers */ -import { DEFAULT_OLLAMA_BASE_URL, DEFAULT_OPENROUTER_BASE_URL } from '../../config/ui-constants'; +import { + DEFAULT_OLLAMA_BASE_URL, + DEFAULT_OPENROUTER_BASE_URL, + DEFAULT_ORCAROUTER_BASE_URL, + DEFAULT_ORCAROUTER_MODEL, +} from '../../config/ui-constants'; export type LLMProvider = | 'openai' | 'azure-openai' @@ -18,7 +23,8 @@ export type LLMProvider = | 'openrouter' | 'minimax' | 'glm' - | 'deepseek'; + | 'deepseek' + | 'orcarouter'; export const MINIMAX_ANTHROPIC_BASE_URLS = { global_en: 'https://api.minimax.io/anthropic', @@ -183,6 +189,16 @@ export interface DeepSeekConfig extends BaseProviderConfig { model: string; // e.g., 'deepseek-v4-flash', 'deepseek-v4-pro' } +/** + * OrcaRouter configuration — OpenAI-compatible gateway + */ +export interface OrcaRouterConfig extends BaseProviderConfig { + provider: 'orcarouter'; + apiKey: string; + model: string; // e.g., 'orcarouter/auto' + baseUrl?: string; // defaults to https://api.orcarouter.ai/v1 +} + /** * Union type for all provider configurations */ @@ -195,7 +211,8 @@ export type ProviderConfig = | OpenRouterConfig | MiniMaxConfig | GLMConfig - | DeepSeekConfig; + | DeepSeekConfig + | OrcaRouterConfig; /** * Stored settings (what goes to localStorage) @@ -215,6 +232,7 @@ export interface LLMSettings { minimax?: Partial>; glm?: Partial>; deepseek?: Partial>; + orcarouter?: Partial>; // Intelligent Clustering Settings intelligentClustering: boolean; @@ -283,6 +301,12 @@ export const DEFAULT_LLM_SETTINGS: LLMSettings = { model: 'deepseek-v4-flash', temperature: 0.1, }, + orcarouter: { + apiKey: '', + model: DEFAULT_ORCAROUTER_MODEL, + baseUrl: DEFAULT_ORCAROUTER_BASE_URL, + temperature: 0.1, + }, }; /** diff --git a/gitnexus-web/src/locales/en/settings.json b/gitnexus-web/src/locales/en/settings.json index 91c6b2a68..04c964b30 100644 --- a/gitnexus-web/src/locales/en/settings.json +++ b/gitnexus-web/src/locales/en/settings.json @@ -93,6 +93,10 @@ }, "glm": { "apiKeyPlaceholder": "Enter your Z.AI API key" + }, + "orcarouter": { + "apiKeyPlaceholder": "Enter your OrcaRouter API key", + "helperLinkLabel": "OrcaRouter" } }, "loadingModels": "Loading models...", diff --git a/gitnexus-web/src/locales/zh-CN/settings.json b/gitnexus-web/src/locales/zh-CN/settings.json index 4bc4fb05b..00cca59e7 100644 --- a/gitnexus-web/src/locales/zh-CN/settings.json +++ b/gitnexus-web/src/locales/zh-CN/settings.json @@ -93,6 +93,10 @@ }, "glm": { "apiKeyPlaceholder": "输入 Z.AI API Key" + }, + "orcarouter": { + "apiKeyPlaceholder": "输入 OrcaRouter API Key", + "helperLinkLabel": "OrcaRouter" } }, "loadingModels": "正在加载模型...", diff --git a/gitnexus-web/test/unit/settings-service.test.ts b/gitnexus-web/test/unit/settings-service.test.ts index b0762604f..932607a90 100644 --- a/gitnexus-web/test/unit/settings-service.test.ts +++ b/gitnexus-web/test/unit/settings-service.test.ts @@ -151,6 +151,19 @@ describe('getActiveProviderConfig', () => { expect(config!.provider).toBe('deepseek'); }); + it('returns config for orcarouter when API key is set', () => { + const settings = loadSettings(); + settings.activeProvider = 'orcarouter'; + settings.orcarouter = { ...settings.orcarouter, apiKey: 'sk-orca-123' }; + saveSettings(settings); + + const config = getActiveProviderConfig(); + expect(config).not.toBeNull(); + expect(config!.provider).toBe('orcarouter'); + expect(config!.baseUrl).toBe('https://api.orcarouter.ai/v1'); + expect(config!.model).toBe('orcarouter/auto'); + }); + it('returns the regional endpoint and thinking mode for MiniMax', () => { const settings = loadSettings(); settings.activeProvider = 'minimax'; @@ -179,6 +192,15 @@ describe('getActiveProviderConfig', () => { expect(getActiveProviderConfig()).toBeNull(); }); + + it('returns null for orcarouter with empty API key', () => { + const settings = loadSettings(); + settings.activeProvider = 'orcarouter'; + settings.orcarouter = { ...settings.orcarouter, apiKey: ' ' }; + saveSettings(settings); + + expect(getActiveProviderConfig()).toBeNull(); + }); }); describe('isProviderConfigured', () => { @@ -207,6 +229,7 @@ describe('getProviderDisplayName', () => { expect(getProviderDisplayName('ollama')).toBe('Ollama (Local)'); expect(getProviderDisplayName('openrouter')).toBe('OpenRouter'); expect(getProviderDisplayName('deepseek')).toBe('DeepSeek'); + expect(getProviderDisplayName('orcarouter')).toBe('OrcaRouter'); }); }); @@ -217,6 +240,7 @@ describe('getAvailableModels', () => { expect(getAvailableModels('anthropic')).toContain('claude-sonnet-4-20250514'); expect(getAvailableModels('deepseek')).toContain('deepseek-v4-flash'); expect(getAvailableModels('minimax')).toEqual([...MINIMAX_MODEL_IDS]); + expect(getAvailableModels('orcarouter')).toContain('orcarouter/auto'); }); it('describes MiniMax model input and thinking capabilities', () => {