diff --git a/backend/consts/provider.py b/backend/consts/provider.py index fe49332b7c..d6a6a2dc2b 100644 --- a/backend/consts/provider.py +++ b/backend/consts/provider.py @@ -8,6 +8,7 @@ class ProviderEnum(str, Enum): MODELENGINE = "modelengine" DASHSCOPE = "dashscope" TOKENPONY = "tokenpony" + ORCAROUTER = "orcarouter" # Silicon Flow @@ -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" diff --git a/backend/services/model_capacity_suggestion_service.py b/backend/services/model_capacity_suggestion_service.py index 8fa9b20634..a2494b9f2b 100644 --- a/backend/services/model_capacity_suggestion_service.py +++ b/backend/services/model_capacity_suggestion_service.py @@ -128,6 +128,7 @@ class CapacitySuggestionResult: ("deepseek", "deepseek"), ("jina", "jina"), ("tokenpony", "tokenpony"), + ("orcarouter", "orcarouter"), ("bytedance", "volcengine"), ) diff --git a/backend/services/model_gateway_service.py b/backend/services/model_gateway_service.py index 0b0c3667ac..32314d3322 100644 --- a/backend/services/model_gateway_service.py +++ b/backend/services/model_gateway_service.py @@ -38,6 +38,7 @@ "siliconflow": "siliconflow", "openai": "openai", "tokenpony": "tokenpony", + "orcarouter": "orcarouter", "jina": "jina", "cohere": "cohere", "modelengine": "modelengine", diff --git a/backend/services/model_management_service.py b/backend/services/model_management_service.py index 0044db2b1b..16fd166071 100644 --- a/backend/services/model_management_service.py +++ b/backend/services/model_management_service.py @@ -16,6 +16,7 @@ DASHSCOPE_BASE_URL, DASHSCOPE_REALTIME_BASE_URL, TOKENPONY_BASE_URL, + ORCAROUTER_BASE_URL, ) from database.model_management_db import ( @@ -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 = "" diff --git a/backend/services/model_provider_service.py b/backend/services/model_provider_service.py index 32ca5a5323..f4fe886494 100644 --- a/backend/services/model_provider_service.py +++ b/backend/services/model_provider_service.py @@ -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") @@ -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 diff --git a/backend/services/providers/__init__.py b/backend/services/providers/__init__.py index 9478043c27..607e1beca8 100644 --- a/backend/services/providers/__init__.py +++ b/backend/services/providers/__init__.py @@ -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", ] diff --git a/backend/services/providers/orcarouter_provider.py b/backend/services/providers/orcarouter_provider.py new file mode 100644 index 0000000000..bceb71ca2f --- /dev/null +++ b/backend/services/providers/orcarouter_provider.py @@ -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, + ) diff --git a/doc/docs/en/user-guide/agent-development/model-configuration.md b/doc/docs/en/user-guide/agent-development/model-configuration.md index d5a3d75da7..e3e337ba69 100644 --- a/doc/docs/en/user-guide/agent-development/model-configuration.md +++ b/doc/docs/en/user-guide/agent-development/model-configuration.md @@ -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: @@ -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. diff --git a/doc/docs/zh/user-guide/agent-development/model-configuration.md b/doc/docs/zh/user-guide/agent-development/model-configuration.md index 6630f02ff3..20dcce9424 100644 --- a/doc/docs/zh/user-guide/agent-development/model-configuration.md +++ b/doc/docs/zh/user-guide/agent-development/model-configuration.md @@ -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) 可参考以下步骤进行模型接入: @@ -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**。 diff --git a/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx b/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx index 36f19c9941..6db8726609 100644 --- a/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx +++ b/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx @@ -33,6 +33,7 @@ import { MODEL_TYPES, PROVIDER_LINKS } from "@/const/modelConfig"; 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 { @@ -375,6 +376,14 @@ export const ModelAddDialog = ({ setLoadingModelList, tenantId, }); + const orcarouterHook = useOrcaRouterModelList({ + form, + setModelList, + setSelectedModelIds, + setShowModelList, + setLoadingModelList, + tenantId, + }); let getModelList; let getProviderSelectedModalList; @@ -385,6 +394,8 @@ export const ModelAddDialog = ({ ({ getModelList, getProviderSelectedModalList } = dashscopeHook); } else if (form.provider === "tokenpony") { ({ getModelList, getProviderSelectedModalList } = tokenponyHook); + } else if (form.provider === "orcarouter") { + ({ getModelList, getProviderSelectedModalList } = orcarouterHook); } // Reset form to default state const resetForm = useCallback(() => { @@ -1441,6 +1452,9 @@ export const ModelAddDialog = ({ + {/* ModelEngine URL input (only when provider is ModelEngine) */} {form.provider === "modelengine" && ( diff --git a/frontend/app/[locale]/models/components/model/ModelDeleteDialog.tsx b/frontend/app/[locale]/models/components/model/ModelDeleteDialog.tsx index 3ee779fdab..b41e19089c 100644 --- a/frontend/app/[locale]/models/components/model/ModelDeleteDialog.tsx +++ b/frontend/app/[locale]/models/components/model/ModelDeleteDialog.tsx @@ -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: @@ -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", @@ -313,6 +321,14 @@ export const ModelDeleteDialog = ({ return DashScope; case MODEL_SOURCES.TOKENPONY: return TokenPony; + case MODEL_SOURCES.ORCAROUTER: + return ( + OrcaRouter + ); case MODEL_SOURCES.VOLCENGINE: return ( VolcEngine diff --git a/frontend/app/[locale]/models/components/model/ModelListCard.tsx b/frontend/app/[locale]/models/components/model/ModelListCard.tsx index b6982883eb..e1d8a72371 100644 --- a/frontend/app/[locale]/models/components/model/ModelListCard.tsx +++ b/frontend/app/[locale]/models/components/model/ModelListCard.tsx @@ -174,6 +174,8 @@ export const ModelListCard = ({ 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") { @@ -191,6 +193,7 @@ export const ModelListCard = ({ 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"), }; @@ -448,6 +451,54 @@ export const ModelListCard = ({ ))} )} + {groupedModels.orcarouter.length > 0 && ( + + {groupedModels.orcarouter.map((model) => ( + + ))} + + )} {groupedModels.volcengine.length > 0 && ( {groupedModels.volcengine.map((model) => ( diff --git a/frontend/const/modelConfig.ts b/frontend/const/modelConfig.ts index 7bfa6c6c8a..9dcf7d4ac8 100644 --- a/frontend/const/modelConfig.ts +++ b/frontend/const/modelConfig.ts @@ -23,6 +23,7 @@ export const MODEL_SOURCES = { DASHSCOPE: "dashscope", TOKENPONY: "tokenpony", VOLCENGINE: "volcengine", + ORCAROUTER: "orcarouter", } as const; // Model status constants @@ -50,6 +51,7 @@ export const MODEL_PROVIDER_KEYS = [ "tokenpony", "dashscope", "volcengine", + "orcarouter", ] as const; export type ModelProviderKey = (typeof MODEL_PROVIDER_KEYS)[number]; @@ -65,6 +67,7 @@ export const PROVIDER_HINTS: Record = { tokenpony: "tokenpony", dashscope: "dashscope", volcengine: "bytedance", + orcarouter: "orcarouter", }; // Icon filenames for providers @@ -78,6 +81,7 @@ export const PROVIDER_ICON_MAP: Record = { 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"); @@ -96,6 +100,7 @@ export const PROVIDER_LINKS: Record = { dashscope: "https://dashscope.aliyun.com/", tokenpony: "https://www.tokenpony.cn/", volcengine: "https://www.volcengine.com/", + orcarouter: "https://www.orcarouter.ai", }; // User role constants diff --git a/frontend/hooks/model/useOrcaRouterModelList.ts b/frontend/hooks/model/useOrcaRouterModelList.ts new file mode 100644 index 0000000000..d1926be1eb --- /dev/null +++ b/frontend/hooks/model/useOrcaRouterModelList.ts @@ -0,0 +1,140 @@ +import { useEffect } from "react"; +import { message } from "antd"; +import { useTranslation } from "react-i18next"; +import { modelService } from "@/services/modelService"; +import { ModelType } from "@/types/modelConfig"; +import { processProviderResponse } from "@/lib/providerError"; +import log from "@/lib/logger"; + +interface UseOrcaRouterModelListProps { + form: { + type: ModelType; + isBatchImport: boolean; + apiKey: string; + provider: string; // Expected to be "orcarouter" + maxTokens: string; + isMultimodal: boolean; + }; + setModelList: (models: any[]) => void; + setSelectedModelIds: (ids: Set) => void; + setShowModelList: (show: boolean) => void; + setLoadingModelList: (loading: boolean) => void; + tenantId?: string; // Optional tenant ID for manage operations +} + +export const useOrcaRouterModelList = ({ + form, + setModelList, + setSelectedModelIds, + setShowModelList, + setLoadingModelList, + tenantId, +}: UseOrcaRouterModelListProps) => { + const { t } = useTranslation(); + + const getModelList = async () => { + setShowModelList(true); + setLoadingModelList(true); + + const modelType = + form.type === "embedding" && form.isMultimodal + ? ("multi_embedding" as ModelType) + : form.type === "vlm2" || form.type === "vlm3" + ? ("vlm" as ModelType) + : form.type; + + try { + // Use manage interface if tenantId is provided (for super admin) + const result = tenantId + ? await modelService.addManageProviderModel({ + tenantId, + provider: form.provider, + type: modelType, + apiKey: form.apiKey.trim() === "" ? "sk-no-api-key" : form.apiKey, + }) + : await modelService.addProviderModel({ + provider: form.provider, + type: modelType, + apiKey: form.apiKey.trim() === "" ? "sk-no-api-key" : form.apiKey, + }); + + // Use centralized error processing + const { models, error } = processProviderResponse( + result, + form.provider, + t + ); + + if (error) { + message.error(error); + setModelList([]); + setSelectedModelIds(new Set()); + setLoadingModelList(false); + return; + } + + // Ensure token-based models have a default max_tokens value. + const modelsWithDefaults = models.map((model: any) => ({ + ...model, + max_tokens: model.max_tokens || parseInt(form.maxTokens) || 4096, + })); + setModelList(modelsWithDefaults); + + const selectedModels = (await getProviderSelectedModalList()) || []; + + // Key logic: Sync previously selected models + if (!selectedModels.length) { + // Select none + setSelectedModelIds(new Set()); + } else { + // Only select selectedModels + setSelectedModelIds(new Set(selectedModels.map((m: any) => m.id))); + } + } catch (error) { + message.error(t("model.dialog.error.addFailed", { error })); + log.error(t("model.dialog.error.addFailedLog"), error); + } finally { + setLoadingModelList(false); + } + }; + + const getProviderSelectedModalList = async () => { + const modelType = + form.type === "embedding" && form.isMultimodal + ? ("multi_embedding" as ModelType) + : form.type; + + // Use manage interface if tenantId is provided (for super admin) + const result = tenantId + ? await modelService.getManageProviderSelectedModalList({ + tenantId, + provider: form.provider, + type: modelType, + }) + : await modelService.getProviderSelectedModalList({ + provider: form.provider, + type: modelType, + api_key: form.apiKey.trim() === "" ? "sk-no-api-key" : form.apiKey, + }); + + return result; + }; + + // Auto-fetch model list when batch import is enabled and API key is provided + useEffect(() => { + // Only execute if this hook matches the current provider + if (form.provider !== "orcarouter") { + return; + } + + if (form.isBatchImport && form.apiKey.trim() !== "") { + getModelList(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [form.type, form.isBatchImport, form.provider]); + + return { + getModelList, + getProviderSelectedModalList, + }; +}; diff --git a/frontend/lib/providerError.ts b/frontend/lib/providerError.ts index 4be5cd703a..a1c2cb5da4 100644 --- a/frontend/lib/providerError.ts +++ b/frontend/lib/providerError.ts @@ -38,6 +38,7 @@ export const PROVIDER_DISPLAY_NAMES: Record = { modelengine: "ModelEngine", silicon: "SiliconFlow", openai: "OpenAI", + orcarouter: "OrcaRouter", default: "Provider", }; diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index fba37a444a..5daf24dbeb 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -1165,6 +1165,7 @@ "model.provider.tokenpony": "TokenPony", "model.provider.modelengine": "ModelEngine", "model.provider.volcengine": "VolcEngine", + "model.provider.orcarouter": "OrcaRouter", "model.dialog.modelList.title": "Show Models", "model.dialog.modelList.searchPlaceholder": "Search models by name", "model.dialog.modelList.noResults": "No models match your search", @@ -1242,6 +1243,7 @@ "model.source.silicon": "Silicon Flow", "model.source.dashscope": "DashScope", "model.source.tokenpony": "TokenPony", + "model.source.orcarouter": "OrcaRouter", "model.source.unknown": "Unknown Source", "model.warning.updateNotFound": "Model not found for update: {{displayName}}, type: {{type}}", "model.type.main": "LLM Model", @@ -1250,6 +1252,7 @@ "model.group.silicon": "Silicon Flow Models", "model.group.dashscope": "DashScope Models", "model.group.tokenpony": "TokenPony Models", + "model.group.orcarouter": "OrcaRouter Models", "model.group.volcengine": "VolcEngine Models", "model.group.custom": "Custom Models", "model.status.tooltip": "Click to verify connectivity", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 9f964a542b..bfcf1bd8ae 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -1133,6 +1133,7 @@ "model.provider.tokenpony": "小马算力", "model.provider.modelengine": "ModelEngine", "model.provider.volcengine": "火山引擎", + "model.provider.orcarouter": "OrcaRouter", "model.dialog.modelList.title": "显示模型", "model.dialog.modelList.searchPlaceholder": "按名称搜索模型", "model.dialog.modelList.noResults": "没有匹配的模型", @@ -1210,6 +1211,7 @@ "model.source.silicon": "硅基流动", "model.source.dashscope": "阿里灵积", "model.source.tokenpony": "小马算力", + "model.source.orcarouter": "OrcaRouter", "model.warning.updateNotFound": "未找到要更新的模型: {{displayName}}, 类型: {{type}}", "model.type.main": "大语言模型", "model.select.placeholder": "选择模型", @@ -1217,6 +1219,7 @@ "model.group.silicon": "硅基流动模型", "model.group.dashscope": "阿里灵积模型", "model.group.tokenpony": "小马算力模型", + "model.group.orcarouter": "OrcaRouter模型", "model.group.volcengine": "火山引擎模型", "model.group.custom": "自定义模型", "model.status.tooltip": "点击可验证连通性", diff --git a/frontend/public/orcarouter.svg b/frontend/public/orcarouter.svg new file mode 100644 index 0000000000..506a36ef91 --- /dev/null +++ b/frontend/public/orcarouter.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + diff --git a/frontend/types/modelConfig.ts b/frontend/types/modelConfig.ts index df195c0183..9f33a87fc7 100644 --- a/frontend/types/modelConfig.ts +++ b/frontend/types/modelConfig.ts @@ -21,7 +21,8 @@ export type ModelSource = | "tokenpony" | "OpenAI-API-Compatible" | "modelengine" - | "volcengine"; + | "volcengine" + | "orcarouter"; // Model type export type ModelType = diff --git a/test/backend/services/providers/test_orcarouter_provider.py b/test/backend/services/providers/test_orcarouter_provider.py new file mode 100644 index 0000000000..1cec0e0586 --- /dev/null +++ b/test/backend/services/providers/test_orcarouter_provider.py @@ -0,0 +1,423 @@ +"""Unit tests for OrcaRouterModelProvider module. + +Tests cover model fetching, chat-only type filtering, and error handling. +""" + +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest +from pytest_mock import MockFixture + +from backend.services.providers.orcarouter_provider import OrcaRouterModelProvider + + +class TestOrcaRouterModelProvider: + """Tests for OrcaRouterModelProvider class.""" + + @pytest.mark.asyncio + async def test_get_models_llm_success(self, mocker: MockFixture): + """Test successful model retrieval for LLM models.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": [ + { + "id": "orcarouter/auto", + "object": "model", + "owned_by": "orcarouter" + }, + { + "id": "orcarouter/fusion", + "object": "model", + "owned_by": "orcarouter" + } + ] + } + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + + mock_cm = MagicMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_client) + mock_cm.__aexit__ = AsyncMock(return_value=None) + + mocker.patch( + "backend.services.providers.orcarouter_provider.httpx.AsyncClient", + return_value=mock_cm + ) + mocker.patch( + "backend.services.providers.orcarouter_provider.ORCAROUTER_GET_URL", + "https://api.orcarouter.ai/v1/models" + ) + mocker.patch( + "backend.services.providers.orcarouter_provider.DEFAULT_LLM_MAX_TOKENS", + 4096 + ) + + provider = OrcaRouterModelProvider() + provider_config = { + "model_type": "llm", + "api_key": "test-api-key" + } + + result = await provider.get_models(provider_config) + + assert len(result) == 2 + assert result[0]["id"] == "orcarouter/auto" + assert result[0]["model_type"] == "llm" + assert result[0]["model_tag"] == "chat" + assert result[0]["max_tokens"] == 4096 + assert "capacity_source" not in result[0] + + @pytest.mark.asyncio + async def test_get_models_llm_surfaces_capacity_hints(self, mocker: MockFixture): + """Provider token metadata is returned as advisory capacity hints.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": [ + { + "id": "orcarouter/fusion", + "object": "model", + "owned_by": "orcarouter", + "context_window": 1000000, + "max_completion_tokens": "32768", + "tokenizer_family": "o200k_base", + } + ] + } + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + + mock_cm = MagicMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_client) + mock_cm.__aexit__ = AsyncMock(return_value=None) + + mocker.patch( + "backend.services.providers.orcarouter_provider.httpx.AsyncClient", + return_value=mock_cm + ) + + provider = OrcaRouterModelProvider() + result = await provider.get_models({ + "model_type": "llm", + "api_key": "test-api-key", + }) + + assert result[0]["context_window_tokens"] == 1000000 + assert result[0]["max_output_tokens"] == 32768 + assert result[0]["tokenizer_family"] == "o200k_base" + assert result[0]["capacity_source"] == "provider_candidate" + + @pytest.mark.asyncio + async def test_get_models_vlm_success(self, mocker: MockFixture): + """Test successful model retrieval for VLM models.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": [ + { + "id": "orcarouter/fusion", + "object": "model", + "owned_by": "orcarouter" + } + ] + } + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + + mock_cm = MagicMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_client) + mock_cm.__aexit__ = AsyncMock(return_value=None) + + mocker.patch( + "backend.services.providers.orcarouter_provider.httpx.AsyncClient", + return_value=mock_cm + ) + mocker.patch( + "backend.services.providers.orcarouter_provider.ORCAROUTER_GET_URL", + "https://api.orcarouter.ai/v1/models" + ) + + provider = OrcaRouterModelProvider() + provider_config = { + "model_type": "vlm", + "api_key": "test-api-key" + } + + result = await provider.get_models(provider_config) + + assert len(result) == 1 + assert result[0]["id"] == "orcarouter/fusion" + assert result[0]["model_type"] == "vlm" + assert result[0]["model_tag"] == "chat" + + @pytest.mark.asyncio + async def test_get_models_non_chat_type_returns_empty(self, mocker: MockFixture): + """OrcaRouter is a chat-only gateway; non-chat types return empty list.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": [ + { + "id": "orcarouter/auto", + "object": "model", + "owned_by": "orcarouter" + } + ] + } + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + + mock_cm = MagicMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_client) + mock_cm.__aexit__ = AsyncMock(return_value=None) + + mocker.patch( + "backend.services.providers.orcarouter_provider.httpx.AsyncClient", + return_value=mock_cm + ) + mocker.patch( + "backend.services.providers.orcarouter_provider.ORCAROUTER_GET_URL", + "https://api.orcarouter.ai/v1/models" + ) + + provider = OrcaRouterModelProvider() + provider_config = { + "model_type": "embedding", + "api_key": "test-api-key" + } + + result = await provider.get_models(provider_config) + + assert result == [] + + @pytest.mark.asyncio + async def test_get_models_empty_response(self, mocker: MockFixture): + """Test handling of empty model list from API.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": []} + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + + mock_cm = MagicMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_client) + mock_cm.__aexit__ = AsyncMock(return_value=None) + + mocker.patch( + "backend.services.providers.orcarouter_provider.httpx.AsyncClient", + return_value=mock_cm + ) + mocker.patch( + "backend.services.providers.orcarouter_provider.ORCAROUTER_GET_URL", + "https://api.orcarouter.ai/v1/models" + ) + + provider = OrcaRouterModelProvider() + provider_config = { + "model_type": "llm", + "api_key": "test-api-key" + } + + result = await provider.get_models(provider_config) + + assert result == [] + + @pytest.mark.asyncio + async def test_get_models_http_error(self, mocker: MockFixture): + """Test handling of HTTP error.""" + mock_client = AsyncMock() + mock_client.get.side_effect = httpx.HTTPStatusError( + "Error", + request=MagicMock(), + response=MagicMock(status_code=500) + ) + + mock_cm = MagicMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_client) + mock_cm.__aexit__ = AsyncMock(return_value=None) + + mocker.patch( + "backend.services.providers.orcarouter_provider.httpx.AsyncClient", + return_value=mock_cm + ) + mocker.patch( + "backend.services.providers.orcarouter_provider.ORCAROUTER_GET_URL", + "https://api.orcarouter.ai/v1/models" + ) + + provider = OrcaRouterModelProvider() + provider_config = { + "model_type": "llm", + "api_key": "test-api-key" + } + + result = await provider.get_models(provider_config) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0]["_error"] == "server_error" + + @pytest.mark.asyncio + async def test_get_models_401_returns_authentication_failed(self, mocker: MockFixture): + """401 from provider surfaces the authentication_failed error code.""" + mock_client = AsyncMock() + mock_client.get.side_effect = httpx.HTTPStatusError( + "Unauthorized", + request=MagicMock(), + response=MagicMock(status_code=401), + ) + + mock_cm = MagicMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_client) + mock_cm.__aexit__ = AsyncMock(return_value=None) + + mocker.patch( + "backend.services.providers.orcarouter_provider.httpx.AsyncClient", + return_value=mock_cm + ) + mocker.patch( + "backend.services.providers.orcarouter_provider.ORCAROUTER_GET_URL", + "https://api.orcarouter.ai/v1/models" + ) + + provider = OrcaRouterModelProvider() + provider_config = { + "model_type": "llm", + "api_key": "test-api-key" + } + + result = await provider.get_models(provider_config) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0]["_error"] == "authentication_failed" + + @pytest.mark.asyncio + async def test_get_models_connect_error(self, mocker: MockFixture): + """Test handling of connection error.""" + mock_client = AsyncMock() + mock_client.get.side_effect = httpx.ConnectError("Connection failed") + + mock_cm = MagicMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_client) + mock_cm.__aexit__ = AsyncMock(return_value=None) + + mocker.patch( + "backend.services.providers.orcarouter_provider.httpx.AsyncClient", + return_value=mock_cm + ) + mocker.patch( + "backend.services.providers.orcarouter_provider.ORCAROUTER_GET_URL", + "https://api.orcarouter.ai/v1/models" + ) + + provider = OrcaRouterModelProvider() + provider_config = { + "model_type": "llm", + "api_key": "test-api-key" + } + + result = await provider.get_models(provider_config) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0]["_error"] == "connection_failed" + + @pytest.mark.asyncio + async def test_get_models_authorization_header(self, mocker: MockFixture): + """Test that Authorization header is correctly set.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": [ + { + "id": "orcarouter/auto", + "object": "model", + "owned_by": "orcarouter" + } + ] + } + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + + mock_cm = MagicMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_client) + mock_cm.__aexit__ = AsyncMock(return_value=None) + + mocker.patch( + "backend.services.providers.orcarouter_provider.httpx.AsyncClient", + return_value=mock_cm + ) + mocker.patch( + "backend.services.providers.orcarouter_provider.ORCAROUTER_GET_URL", + "https://api.orcarouter.ai/v1/models" + ) + + provider = OrcaRouterModelProvider() + provider_config = { + "model_type": "llm", + "api_key": "my-secret-key" + } + + await provider.get_models(provider_config) + + # Verify Authorization header + call_args = mock_client.get.call_args + headers = call_args[1]["headers"] + assert headers["Authorization"] == "Bearer my-secret-key" + + @pytest.mark.asyncio + async def test_get_models_unknown_type_returns_empty(self, mocker: MockFixture): + """Test that unknown model type returns empty list.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": [ + { + "id": "orcarouter/auto", + "object": "model", + "owned_by": "orcarouter" + } + ] + } + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + + mock_cm = MagicMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_client) + mock_cm.__aexit__ = AsyncMock(return_value=None) + + mocker.patch( + "backend.services.providers.orcarouter_provider.httpx.AsyncClient", + return_value=mock_cm + ) + mocker.patch( + "backend.services.providers.orcarouter_provider.ORCAROUTER_GET_URL", + "https://api.orcarouter.ai/v1/models" + ) + + provider = OrcaRouterModelProvider() + provider_config = { + "model_type": "unknown_type", + "api_key": "test-api-key" + } + + result = await provider.get_models(provider_config) + + assert result == [] diff --git a/test/backend/services/test_model_management_service.py b/test/backend/services/test_model_management_service.py index 1f1e2e1d8b..379641b3a1 100644 --- a/test/backend/services/test_model_management_service.py +++ b/test/backend/services/test_model_management_service.py @@ -156,6 +156,7 @@ class _ProviderEnum: MODELENGINE = _EnumItem("modelengine") DASHSCOPE = _EnumItem("dashscope") TOKENPONY = _EnumItem("tokenpony") + ORCAROUTER = _EnumItem("orcarouter") consts_provider_mod.ProviderEnum = _ProviderEnum @@ -164,6 +165,7 @@ class _ProviderEnum: consts_provider_mod.DASHSCOPE_REALTIME_BASE_URL = "wss://dashscope.aliyuncs.com/api-ws/v1/realtime" consts_provider_mod.DASHSCOPE_STT_BASE_URL = consts_provider_mod.DASHSCOPE_REALTIME_BASE_URL consts_provider_mod.TOKENPONY_BASE_URL = "https://api.tokenpony.cn/v1/" +consts_provider_mod.ORCAROUTER_BASE_URL = "https://api.orcarouter.ai/v1/" sys.modules["consts.provider"] = consts_provider_mod # Stub services.model_provider_service used by service diff --git a/test/backend/services/test_model_provider_service.py b/test/backend/services/test_model_provider_service.py index 1df83a45df..6eddfa4f02 100644 --- a/test/backend/services/test_model_provider_service.py +++ b/test/backend/services/test_model_provider_service.py @@ -195,6 +195,7 @@ class _ProviderEnumStub: MODELENGINE = mock.Mock(value="modelengine") DASHSCOPE = mock.Mock(value="dashscope") TOKENPONY = mock.Mock(value="tokenpony") + ORCAROUTER = mock.Mock(value="orcarouter") sys.modules["consts.provider"].ProviderEnum = _ProviderEnumStub @@ -2656,6 +2657,60 @@ async def test_get_provider_models_tokenpony_empty_result(): mock_provider_instance.get_models.assert_called_once_with(model_data) +@pytest.mark.asyncio +async def test_get_provider_models_orcarouter_success(): + """Should successfully get models from OrcaRouter provider.""" + model_data = { + "provider": "orcarouter", + "model_type": "llm", + "api_key": "test-key", + } + + expected_models = [ + { + "id": "orcarouter/auto", + "model_tag": "chat", + "model_type": "llm", + "max_tokens": sys.modules["consts.const"].DEFAULT_LLM_MAX_TOKENS, + } + ] + + with mock.patch( + "backend.services.model_provider_service.OrcaRouterModelProvider" + ) as mock_provider_class: + mock_provider_instance = mock.AsyncMock() + mock_provider_instance.get_models.return_value = expected_models + mock_provider_class.return_value = mock_provider_instance + + result = await get_provider_models(model_data) + + assert result == expected_models + mock_provider_class.assert_called_once() + mock_provider_instance.get_models.assert_called_once_with(model_data) + + +@pytest.mark.asyncio +async def test_get_provider_models_orcarouter_empty_result(): + """Should handle empty result from OrcaRouter provider.""" + model_data = { + "provider": "orcarouter", + "model_type": "llm", + "api_key": "test-key", + } + + with mock.patch( + "backend.services.model_provider_service.OrcaRouterModelProvider" + ) as mock_provider_class: + mock_provider_instance = mock.AsyncMock() + mock_provider_instance.get_models.return_value = [] + mock_provider_class.return_value = mock_provider_instance + + result = await get_provider_models(model_data) + + assert result == [] + mock_provider_instance.get_models.assert_called_once_with(model_data) + + # ============================================================================ # Test-cases for uncovered lines in prepare_model_dict (embedding URL edge cases) # ============================================================================