Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/consts/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class ProviderEnum(str, Enum):
MODELENGINE = "modelengine"
DASHSCOPE = "dashscope"
TOKENPONY = "tokenpony"
ORCAROUTER = "orcarouter"


# Silicon Flow
Expand All @@ -26,3 +27,7 @@ class ProviderEnum(str, Enum):

# ModelEngine
# Base URL and API key are loaded from environment variables at runtime

# OrcaRouter
ORCAROUTER_BASE_URL = "https://api.orcarouter.ai/v1/"
ORCAROUTER_GET_URL = "https://api.orcarouter.ai/v1/models"
1 change: 1 addition & 0 deletions backend/services/model_capacity_suggestion_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ class CapacitySuggestionResult:
("deepseek", "deepseek"),
("jina", "jina"),
("tokenpony", "tokenpony"),
("orcarouter", "orcarouter"),
("bytedance", "volcengine"),
)

Expand Down
1 change: 1 addition & 0 deletions backend/services/model_gateway_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"siliconflow": "siliconflow",
"openai": "openai",
"tokenpony": "tokenpony",
"orcarouter": "orcarouter",
"jina": "jina",
"cohere": "cohere",
"modelengine": "modelengine",
Expand Down
3 changes: 3 additions & 0 deletions backend/services/model_management_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
DASHSCOPE_BASE_URL,
DASHSCOPE_REALTIME_BASE_URL,
TOKENPONY_BASE_URL,
ORCAROUTER_BASE_URL,
)

from database.model_management_db import (
Expand Down Expand Up @@ -418,6 +419,8 @@ async def batch_create_models_for_tenant(user_id: str, tenant_id: str, batch_pay
model_url = DASHSCOPE_REALTIME_BASE_URL if model_type in ("stt", "tts") else DASHSCOPE_BASE_URL
elif provider == ProviderEnum.TOKENPONY.value:
model_url = TOKENPONY_BASE_URL
elif provider == ProviderEnum.ORCAROUTER.value:
model_url = ORCAROUTER_BASE_URL
else:
model_url = ""

Expand Down
4 changes: 4 additions & 0 deletions backend/services/model_provider_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from services.providers.tokenpony_provider import TokenPonyModelProvider
from services.providers.dashscope_provider import DashScopeModelProvider
from services.providers.modelengine_provider import ModelEngineProvider, get_model_engine_raw_url, MODEL_ENGINE_NORTH_PREFIX
from services.providers.orcarouter_provider import OrcaRouterModelProvider
from utils.model_name_utils import split_repo_name, add_repo_to_name

logger = logging.getLogger("model_provider")
Expand Down Expand Up @@ -48,6 +49,9 @@ async def get_provider_models(model_data: dict) -> List[dict]:
elif model_data["provider"] == ProviderEnum.TOKENPONY.value:
provider = TokenPonyModelProvider()
model_list = await provider.get_models(model_data)
elif model_data["provider"] == ProviderEnum.ORCAROUTER.value:
provider = OrcaRouterModelProvider()
model_list = await provider.get_models(model_data)

return model_list

Expand Down
2 changes: 2 additions & 0 deletions backend/services/providers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
from services.providers.base import AbstractModelProvider
from services.providers.silicon_provider import SiliconModelProvider
from services.providers.modelengine_provider import ModelEngineProvider, get_model_engine_raw_url
from services.providers.orcarouter_provider import OrcaRouterModelProvider

__all__ = [
"AbstractModelProvider",
"SiliconModelProvider",
"ModelEngineProvider",
"OrcaRouterModelProvider",
"get_model_engine_raw_url",
]
80 changes: 80 additions & 0 deletions backend/services/providers/orcarouter_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
from typing import Dict, List

import httpx
from consts.const import DEFAULT_LLM_MAX_TOKENS
from consts.provider import ORCAROUTER_GET_URL
from services.providers.base import (
AbstractModelProvider,
_classify_provider_error,
_extract_capacity_hints_from_raw,
)


# OrcaRouter is a chat-only gateway. The /v1/models catalog only carries
# models that support chat (the gateway routes to upstream LLMs on demand).
# Model IDs all use the ``orcarouter/`` namespace prefix (e.g.
# ``orcarouter/auto``, ``orcarouter/fusion``).
ORCAROUTER_CHAT_TYPES = ("llm", "vlm")


def _extract_capacity_hints(raw: Dict) -> Dict:
return _extract_capacity_hints_from_raw(raw)


class OrcaRouterModelProvider(AbstractModelProvider):
"""Concrete implementation for the OrcaRouter model gateway.

OrcaRouter is an OpenAI-compatible gateway: the chat model catalog is
fetched from ``GET /v1/models`` and every model routes through the
gateway's smart router (default model ``orcarouter/auto``).
"""

async def get_models(self, provider_config: Dict) -> List[Dict]:
"""
Fetch chat models from the OrcaRouter gateway API.

Args:
provider_config: Configuration dict containing model_type and api_key

Returns:
List of chat models with canonical fields. Returns error dict if
the API call fails.
"""
try:
model_type: str = provider_config["model_type"]
model_api_key: str = provider_config["api_key"]

# OrcaRouter only routes chat models; non-chat modalities have no
# gateway endpoints (e.g. /v1/embeddings returns 400).
if model_type not in ORCAROUTER_CHAT_TYPES:
return []

headers = {"Authorization": f"Bearer {model_api_key}"}

async with httpx.AsyncClient() as client:
response = await client.get(ORCAROUTER_GET_URL, headers=headers)
response.raise_for_status()
# OpenAI-standard response: model list under the "data" array
all_models: List[Dict] = response.json().get("data", [])

models = []
for model_obj in all_models:
model_id = model_obj.get("id", "")
cleaned_model = {
"id": model_id,
"model_tag": "chat",
"model_type": model_type,
"max_tokens": DEFAULT_LLM_MAX_TOKENS,
}
cleaned_model.update(_extract_capacity_hints(model_obj))
models.append(cleaned_model)

return models
except (httpx.HTTPStatusError, httpx.ConnectTimeout, httpx.ConnectError, Exception) as e:
status_code = e.response.status_code if isinstance(e, httpx.HTTPStatusError) and getattr(e, "response", None) else None
return _classify_provider_error(
"OrcaRouter",
status_code=status_code,
error_message=str(e),
exception=e,
)
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ Nexent supports any **OpenAI-compatible** provider, including:
- [OpenAI](https://platform.openai.com/)
- [Anthropic](https://console.anthropic.com/)
- [Moonshot](https://platform.moonshot.cn/)
- [OrcaRouter](https://www.orcarouter.ai)

Getting started:

Expand All @@ -171,6 +172,8 @@ Getting started:
3. Locate the API endpoint (usually ending with `/v1`).
4. Click **Add Custom Model** in Nexent and fill in the required fields.

> **Batch-add OrcaRouter**: in the batch-add dialog, select **OrcaRouter** as the model provider, enter your `sk-orca-…` API key, and click **Fetch Models**. The gateway model catalog is loaded automatically from `https://api.orcarouter.ai/v1/models` (e.g. `orcarouter/auto` for smart routing). OrcaRouter also runs gateway-level, zero-trust security for AI agents on the same endpoint — screening every prompt/response and governing every tool call on a default-deny basis, with no application code changes.

#### Multimodal Models

Use the same API key and URL as LLMs but specify a multimodal model name, for example **Qwen/Qwen2.5-VL-32B-Instruct** on SiliconFlow.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ Nexent 支持任何 **遵循OpenAI API规范** 的大语言模型供应商,包
- [OpenAI](https://platform.openai.com/)
- [Anthropic](https://console.anthropic.com/)
- [月之暗面](https://platform.moonshot.cn/)
- [OrcaRouter](https://www.orcarouter.ai)

可参考以下步骤进行模型接入:

Expand All @@ -189,6 +190,8 @@ Nexent 支持任何 **遵循OpenAI API规范** 的大语言模型供应商,包
3. 在文档中查看API端点(即模型URL,一般以`/v1`为结尾);
4. 在Nexent模型配置页面点击添加自定义模型,填入必备信息,即可接入。

> **批量接入 OrcaRouter**:在批量添加对话框中,将模型提供商选为 **OrcaRouter**,输入 `sk-orca-…` 开头的 API Key,点击「获取模型」即可自动从 `https://api.orcarouter.ai/v1/models` 拉取网关模型目录(例如智能路由模型 `orcarouter/auto`)。OrcaRouter 还在同一端点提供面向 AI Agent 的网关级零信任安全防护——以默认拒绝(default-deny)的方式审查每次提示词/响应并管控每次工具调用,无需修改任何应用代码。

#### 🎭 多模态模型

使用与大语言模型相同的API Key和模型URL,但指定多模态模型名称,如硅基流动提供的**Qwen/Qwen2.5-VL-32B-Instruct**。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import { useSiliconModelList } from "@/hooks/model/useSiliconModelList";
import { useDashscopeModelList } from "@/hooks/model/useDashscopeModelList";
import { useTokenPonyModelList } from "@/hooks/model/useTokenponyModelList";
import { useOrcaRouterModelList } from "@/hooks/model/useOrcaRouterModelList";
import log from "@/lib/logger";
import { publicAsset } from "@/lib/publicAsset";
import {
Expand Down Expand Up @@ -375,6 +376,14 @@
setLoadingModelList,
tenantId,
});
const orcarouterHook = useOrcaRouterModelList({
form,
setModelList,
setSelectedModelIds,
setShowModelList,
setLoadingModelList,
tenantId,
});
let getModelList;
let getProviderSelectedModalList;

Expand All @@ -385,6 +394,8 @@
({ getModelList, getProviderSelectedModalList } = dashscopeHook);
} else if (form.provider === "tokenpony") {
({ getModelList, getProviderSelectedModalList } = tokenponyHook);
} else if (form.provider === "orcarouter") {
({ getModelList, getProviderSelectedModalList } = orcarouterHook);

Check warning on line 398 in frontend/app/[locale]/models/components/model/ModelAddDialog.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this useless assignment to variable "getProviderSelectedModalList".

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AaAjMixxIautdYMKomKE&open=AaAjMixxIautdYMKomKE&pullRequest=3731
}
// Reset form to default state
const resetForm = useCallback(() => {
Expand Down Expand Up @@ -1441,6 +1452,9 @@
<Option value="silicon">{t("model.provider.silicon")}</Option>
<Option value="dashscope">{t("model.provider.dashscope")}</Option>
<Option value="tokenpony">{t("model.provider.tokenpony")}</Option>
<Option value="orcarouter">
{t("model.provider.orcarouter")}
</Option>
</Select>
{/* ModelEngine URL input (only when provider is ModelEngine) */}
{form.provider === "modelengine" && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,8 @@ export const ModelDeleteDialog = ({
return t("model.source.dashscope");
case MODEL_SOURCES.TOKENPONY:
return t("model.source.tokenpony");
case MODEL_SOURCES.ORCAROUTER:
return t("model.source.orcarouter");
case MODEL_SOURCES.VOLCENGINE:
return t("model.provider.volcengine");
default:
Expand Down Expand Up @@ -267,6 +269,12 @@ export const ModelDeleteDialog = ({
text: "text-cyan-600",
border: "border-cyan-100",
};
case MODEL_SOURCES.ORCAROUTER:
return {
bg: "bg-sky-50",
text: "text-sky-600",
border: "border-sky-100",
};
case MODEL_SOURCES.VOLCENGINE:
return {
bg: "bg-pink-50",
Expand Down Expand Up @@ -313,6 +321,14 @@ export const ModelDeleteDialog = ({
return <img src={publicAsset("/aliyuncs.png")} alt="DashScope" className="w-5 h-5" />;
case MODEL_SOURCES.TOKENPONY:
return <img src={publicAsset("/tokenpony.png")} alt="TokenPony" className="w-5 h-5" />;
case MODEL_SOURCES.ORCAROUTER:
return (
<img
src={publicAsset("/orcarouter.svg")}
alt="OrcaRouter"
className="w-5 h-5"
/>
);
case MODEL_SOURCES.VOLCENGINE:
return (
<img src={publicAsset("/volcengine.png")} alt="VolcEngine" className="w-5 h-5" />
Expand Down
51 changes: 51 additions & 0 deletions frontend/app/[locale]/models/components/model/ModelListCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@
return t("model.source.dashscope");
} else if (model.source === "tokenpony") {
return t("model.source.tokenpony");
} else if (model.source === "orcarouter") {
return t("model.source.orcarouter");
} else if (model.source === "volcengine") {
return t("model.provider.volcengine");
} else if (model.source === "OpenAI-API-Compatible") {
Expand All @@ -191,6 +193,7 @@
silicon: filteredModels.filter((m) => m.source === "silicon"),
dashscope: filteredModels.filter((m) => m.source === "dashscope"),
tokenpony: filteredModels.filter((m) => m.source === "tokenpony"),
orcarouter: filteredModels.filter((m) => m.source === "orcarouter"),
volcengine: filteredModels.filter((m) => m.source === "volcengine"),
custom: filteredModels.filter((m) => m.source === "OpenAI-API-Compatible"),
};
Expand Down Expand Up @@ -448,6 +451,54 @@
))}
</Select.OptGroup>
)}
{groupedModels.orcarouter.length > 0 && (
<Select.OptGroup label={t("model.group.orcarouter")}>
{groupedModels.orcarouter.map((model) => (
<Option
key={`${type}-${model.displayName}-orcarouter`}
value={model.displayName}
>
<div
className="flex items-center justify-between"
style={{ minWidth: 0 }}
>
<div
className="flex items-center font-medium truncate"
style={{ flex: "1 1 auto", minWidth: 0 }}
title={model.displayName}
>
<img
src={getProviderIconByUrl(model.apiUrl)}
alt="provider"
className="w-4 h-4 rounded mr-2 flex-shrink-0"
/>
<span className="truncate">{model.displayName}</span>
</div>
<div
style={{
flex: "0 0 auto",
display: "flex",
alignItems: "center",
marginLeft: "8px",
}}
>
<Tooltip title={t("model.status.tooltip")}>
<span
onClick={(e) => handleStatusClick(e, model.displayName)}
onMouseDown={(e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
}}
style={getStatusStyle(model.connect_status)}
className="status-indicator"
/>

Check warning on line 494 in frontend/app/[locale]/models/components/model/ModelListCard.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Avoid non-native interactive elements. If using native HTML is not possible, add an appropriate role and support for tabbing, mouse, keyboard, and touch inputs to an interactive content element.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AaAjMikJIautdYMKomKC&open=AaAjMikJIautdYMKomKC&pullRequest=3731

Check warning on line 494 in frontend/app/[locale]/models/components/model/ModelListCard.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Visible, non-interactive elements with click handlers must have at least one keyboard listener.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AaAjMikJIautdYMKomKD&open=AaAjMikJIautdYMKomKD&pullRequest=3731
</Tooltip>
</div>
</div>
</Option>
))}
</Select.OptGroup>
)}
{groupedModels.volcengine.length > 0 && (
<Select.OptGroup label={t("model.group.volcengine")}>
{groupedModels.volcengine.map((model) => (
Expand Down
5 changes: 5 additions & 0 deletions frontend/const/modelConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const MODEL_SOURCES = {
DASHSCOPE: "dashscope",
TOKENPONY: "tokenpony",
VOLCENGINE: "volcengine",
ORCAROUTER: "orcarouter",
} as const;

// Model status constants
Expand Down Expand Up @@ -50,6 +51,7 @@ export const MODEL_PROVIDER_KEYS = [
"tokenpony",
"dashscope",
"volcengine",
"orcarouter",
] as const;

export type ModelProviderKey = (typeof MODEL_PROVIDER_KEYS)[number];
Expand All @@ -65,6 +67,7 @@ export const PROVIDER_HINTS: Record<ModelProviderKey, string> = {
tokenpony: "tokenpony",
dashscope: "dashscope",
volcengine: "bytedance",
orcarouter: "orcarouter",
};

// Icon filenames for providers
Expand All @@ -78,6 +81,7 @@ export const PROVIDER_ICON_MAP: Record<ModelProviderKey, string> = {
dashscope: publicAsset("/aliyuncs.png"),
tokenpony: publicAsset("/tokenpony.png"),
volcengine: publicAsset("/volcengine.png"),
orcarouter: publicAsset("/orcarouter.svg"),
};

export const OFFICIAL_PROVIDER_ICON = publicAsset("/modelengine-logo.png");
Expand All @@ -96,6 +100,7 @@ export const PROVIDER_LINKS: Record<string, string> = {
dashscope: "https://dashscope.aliyun.com/",
tokenpony: "https://www.tokenpony.cn/",
volcengine: "https://www.volcengine.com/",
orcarouter: "https://www.orcarouter.ai",
};

// User role constants
Expand Down
Loading