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 <jinhao.song@myflashcloud.com>
This commit is contained in:
jinhao.song 2026-08-19 12:06:55 +00:00
parent e4b8a48042
commit 6fa96eb2d5
8 changed files with 185 additions and 8 deletions

View file

@ -371,6 +371,7 @@ export const SettingsPanel = ({
'minimax',
'glm',
'deepseek',
'orcarouter',
];
return (
@ -484,7 +485,9 @@ export const SettingsPanel = ({
? '🔮'
: provider === 'deepseek'
? '🐋'
: '☁️'}
: provider === 'orcarouter'
? '🐳'
: '☁️'}
</div>
<span className="font-medium">{getProviderDisplayName(provider)}</span>
</button>
@ -1007,6 +1010,50 @@ export const SettingsPanel = ({
</ProviderConfigCard>
)}
{/* OrcaRouter Settings */}
{settings.activeProvider === 'orcarouter' && (
<ProviderConfigCard
title="OrcaRouter"
apiKey={{
value: settings.orcarouter?.apiKey ?? '',
placeholder: t('settings:providers.orcarouter.apiKeyPlaceholder'),
helperText: t('settings:providers.openrouter.helperText'),
helperLink: 'https://www.orcarouter.ai',
helperLinkLabel: t('settings:providers.orcarouter.helperLinkLabel'),
isVisible: !!showApiKey['orcarouter'],
onChange: (value) =>
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',
}}
>
<p className="text-xs text-text-muted">
OpenAI-compatible gateway. Get your API key from{' '}
<a
href="https://www.orcarouter.ai"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline"
>
orcarouter.ai
</a>
.
</p>
</ProviderConfigCard>
)}
{/* GLM Settings */}
{settings.activeProvider === 'glm' && (
<div className="animate-fade-in space-y-4">

View file

@ -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

View file

@ -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;

View file

@ -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<LLMSettings> | null): LLMSettings =>
...DEFAULT_LLM_SETTINGS.deepseek,
...parsed?.deepseek,
},
orcarouter: {
...DEFAULT_LLM_SETTINGS.orcarouter,
...parsed?.orcarouter,
},
});
const readSettings = (storage: Storage): Partial<LLMSettings> | null => {
@ -168,7 +177,9 @@ export const updateProviderSettings = <T extends LLMProvider>(
? Partial<Omit<GLMConfig, 'provider'>>
: T extends 'deepseek'
? Partial<Omit<DeepSeekConfig, 'provider'>>
: never
: T extends 'orcarouter'
? Partial<Omit<OrcaRouterConfig, 'provider'>>
: never
>,
): LLMSettings => {
const current = loadSettings();
@ -274,6 +285,17 @@ export const updateProviderSettings = <T extends LLMProvider>(
saveSettings(updated);
return updated;
}
case 'orcarouter': {
const updated: LLMSettings = {
...current,
orcarouter: {
...(current.orcarouter ?? {}),
...(updates as Partial<Omit<OrcaRouterConfig, 'provider'>>),
},
};
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<LLMProvider, ProviderBuilder> = {
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 [];
}

View file

@ -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<Omit<MiniMaxConfig, 'provider'>>;
glm?: Partial<Omit<GLMConfig, 'provider'>>;
deepseek?: Partial<Omit<DeepSeekConfig, 'provider'>>;
orcarouter?: Partial<Omit<OrcaRouterConfig, 'provider'>>;
// 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,
},
};
/**

View file

@ -93,6 +93,10 @@
},
"glm": {
"apiKeyPlaceholder": "Enter your Z.AI API key"
},
"orcarouter": {
"apiKeyPlaceholder": "Enter your OrcaRouter API key",
"helperLinkLabel": "OrcaRouter"
}
},
"loadingModels": "Loading models...",

View file

@ -93,6 +93,10 @@
},
"glm": {
"apiKeyPlaceholder": "输入 Z.AI API Key"
},
"orcarouter": {
"apiKeyPlaceholder": "输入 OrcaRouter API Key",
"helperLinkLabel": "OrcaRouter"
}
},
"loadingModels": "正在加载模型...",

View file

@ -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', () => {