mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-12 23:02:45 +00:00
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:
parent
e4b8a48042
commit
6fa96eb2d5
8 changed files with 185 additions and 8 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -93,6 +93,10 @@
|
|||
},
|
||||
"glm": {
|
||||
"apiKeyPlaceholder": "Enter your Z.AI API key"
|
||||
},
|
||||
"orcarouter": {
|
||||
"apiKeyPlaceholder": "Enter your OrcaRouter API key",
|
||||
"helperLinkLabel": "OrcaRouter"
|
||||
}
|
||||
},
|
||||
"loadingModels": "Loading models...",
|
||||
|
|
|
|||
|
|
@ -93,6 +93,10 @@
|
|||
},
|
||||
"glm": {
|
||||
"apiKeyPlaceholder": "输入 Z.AI API Key"
|
||||
},
|
||||
"orcarouter": {
|
||||
"apiKeyPlaceholder": "输入 OrcaRouter API Key",
|
||||
"helperLinkLabel": "OrcaRouter"
|
||||
}
|
||||
},
|
||||
"loadingModels": "正在加载模型...",
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue