diff --git a/README.md b/README.md
index 245f07c..2404471 100644
--- a/README.md
+++ b/README.md
@@ -148,6 +148,14 @@ Follow the guide below to explore LLM based clipping:
+#### Using OrcaRouter as your LLM gateway (optional)
+
+Besides the transcript-based LLMs above, FunClip can route LLM-assisted clipping through [OrcaRouter](https://www.orcarouter.ai), an OpenAI-compatible smart-routing gateway. Select any `orcarouter/` model in the **LLM Model Name** dropdown (`orcarouter/auto` routes each request to the best model for the task), paste an OrcaRouter API key in the **APIKEY** box, and click 'LLM Inference' — FunClip sends the transcript and prompts to `https://api.orcarouter.ai/v1/chat/completions`, and the returned segments work with the existing 'AI Clip' button unchanged.
+
+OrcaRouter exposes one endpoint for all frontier and open-weight models, so you can switch routing targets without changing FunClip. It 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.
+
+Set `ORCAROUTER_API_KEY` (and optionally `ORCAROUTER_API_BASE`, which defaults to `https://api.orcarouter.ai/v1`) instead of pasting the key into the UI if you prefer environment-based configuration. A key is available at https://www.orcarouter.ai.
+
#### Content-aware clipping with TwelveLabs Pegasus (optional)
Besides the transcript-based LLMs above, FunClip can optionally use [TwelveLabs](https://twelvelabs.io) Pegasus, a video understanding model that reasons over the actual video (visuals + audio) rather than only the ASR transcript. This helps pick highlight segments even when the transcript alone is ambiguous (e.g. action, scene changes, on-screen events). To use it, select the `pegasus1.5` model name, paste your TwelveLabs API key, upload a video, and click 'LLM Inference' — Pegasus returns segments in the same `N. [start-end] text` format, so the existing 'AI Clip' button works unchanged. It needs `pip install twelvelabs`, and a free API key is available at https://twelvelabs.io.
diff --git a/README_zh.md b/README_zh.md
index c00ba83..d823c39 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -148,6 +148,14 @@ python funclip/launch.py
+#### 使用 OrcaRouter 作为 LLM 网关(可选)
+
+除基于字幕的 LLM 外,FunClip 也可以将 LLM 智能裁剪路由到 [OrcaRouter](https://www.orcarouter.ai)——一个 OpenAI 兼容的智能路由网关。在 **LLM Model Name** 下拉框选择任意 `orcarouter/` 模型(`orcarouter/auto` 会自动为任务选择最佳模型),在 **APIKEY** 输入框粘贴 OrcaRouter API key,点击“LLM推理”——FunClip 会把字幕与 prompt 发送到 `https://api.orcarouter.ai/v1/chat/completions`,返回的分段与现有“AI Clip”按钮完全兼容。
+
+OrcaRouter 用单一端点接入所有前沿与开源模型,无需修改 FunClip 即可切换路由目标。它还在同一端点上为 AI agent 提供网关级零信任安全防护——在默认拒绝(default-deny)基础上审查每一条 prompt/response 并管控每一次工具调用,且无需任何应用代码改动。
+
+也可以不填 UI,而是设置 `ORCAROUTER_API_KEY` 环境变量(可选 `ORCAROUTER_API_BASE`,默认为 `https://api.orcarouter.ai/v1`)。Key 可在 https://www.orcarouter.ai 获取。
+
### B.通过命令行调用使用FunClip的相关功能
```shell
# 下载下面命令用到的示例视频
diff --git a/funclip/launch.py b/funclip/launch.py
index 8fdf648..21d0734 100644
--- a/funclip/launch.py
+++ b/funclip/launch.py
@@ -163,7 +163,7 @@ def video_clip_addsub(dest_text, video_spk_input, start_ost, end_ost, state, out
)
def llm_inference(system_content, user_content, srt_text, model, apikey, video_input=None):
- SUPPORT_LLM_PREFIX = ['litellm', 'qwen', 'gpt', 'g4f', 'moonshot', 'deepseek', 'atlascloud', 'minimax', 'pegasus']
+ SUPPORT_LLM_PREFIX = ['litellm', 'qwen', 'gpt', 'g4f', 'moonshot', 'deepseek', 'atlascloud', 'minimax', 'orcarouter', 'pegasus']
if model.startswith('litellm/'):
return litellm_call(apikey, model, user_content+'\n'+srt_text, system_content)
if model.startswith('pegasus'):
@@ -175,7 +175,7 @@ def llm_inference(system_content, user_content, srt_text, model, apikey, video_i
return call_twelvelabs_pegasus(apikey, video_input, model=model, prompt=system_content)
if model.startswith('qwen'):
return call_qwen_model(apikey, model, user_content+'\n'+srt_text, system_content)
- if model.startswith('gpt') or model.startswith('moonshot') or model.startswith('deepseek') or model.startswith('atlascloud/') or model.startswith('minimax/'):
+ if model.startswith('gpt') or model.startswith('moonshot') or model.startswith('deepseek') or model.startswith('atlascloud/') or model.startswith('minimax/') or model.startswith('orcarouter/'):
return openai_call(apikey, model, user_content+'\n'+srt_text, system_content)
elif model.startswith('g4f'):
model = "-".join(model.split('-')[1:])
@@ -282,6 +282,10 @@ def AI_clip_subti(LLM_res, dest_text, video_spk_input, start_ost, end_ost, video
"minimax/MiniMax-M3",
"minimax/MiniMax-M2.7",
"minimax/MiniMax-M2.7-highspeed",
+ "orcarouter/auto",
+ "orcarouter/fusion",
+ "orcarouter/fusion-flash",
+ "orcarouter/fusion-mini",
"pegasus1.5"],
value="deepseek-chat",
label="LLM Model Name",
diff --git a/funclip/llm/openai_api.py b/funclip/llm/openai_api.py
index a4cee62..4882558 100644
--- a/funclip/llm/openai_api.py
+++ b/funclip/llm/openai_api.py
@@ -12,6 +12,13 @@
MINIMAX_API_BASE_CN = "https://api.minimaxi.com/v1"
MINIMAX_MODEL_PREFIX = "minimax/"
+# OrcaRouter is an OpenAI-compatible smart-routing gateway: one chat
+# completions endpoint that routes each request to the best model for the
+# task. Model IDs carry an `orcarouter/` prefix (e.g. `orcarouter/auto`) and
+# must be sent to the gateway verbatim — a bare `auto` is not routable.
+ORCAROUTER_API_BASE = "https://api.orcarouter.ai/v1"
+ORCAROUTER_MODEL_PREFIX = "orcarouter/"
+
def _resolve_model_config(model):
base_url = None
@@ -37,6 +44,15 @@ def _resolve_model_config(model):
if not base_url:
base_url = MINIMAX_API_BASE
api_key_env = "MINIMAX_API_KEY"
+ elif model.startswith(ORCAROUTER_MODEL_PREFIX):
+ if len(model) <= len(ORCAROUTER_MODEL_PREFIX):
+ raise ValueError(
+ "Model name is empty after stripping orcarouter/ prefix"
+ )
+ base_url = os.environ.get("ORCAROUTER_API_BASE", ORCAROUTER_API_BASE).strip()
+ if not base_url:
+ base_url = ORCAROUTER_API_BASE
+ api_key_env = "ORCAROUTER_API_KEY"
elif model.startswith("deepseek"):
base_url = "https://api.deepseek.com"
elif model.startswith("gpt-3.5-turbo"):
diff --git a/tests/test_orcarouter_api.py b/tests/test_orcarouter_api.py
new file mode 100644
index 0000000..25ef519
--- /dev/null
+++ b/tests/test_orcarouter_api.py
@@ -0,0 +1,93 @@
+"""Tests for OrcaRouter routing through the OpenAI-compatible client."""
+
+import os
+import unittest
+from unittest.mock import MagicMock, patch
+
+from funclip.llm.openai_api import (
+ ORCAROUTER_API_BASE,
+ openai_call,
+)
+
+
+def _mock_completion(content="ok"):
+ completion = MagicMock()
+ completion.choices = [MagicMock()]
+ completion.choices[0].message.content = content
+ return completion
+
+
+class TestOrcaRouterRouting(unittest.TestCase):
+ def test_orcarouter_prefix_uses_gateway_base_url(self):
+ client = MagicMock()
+ client.chat.completions.create.return_value = _mock_completion("clip plan")
+
+ with patch("funclip.llm.openai_api.OpenAI", return_value=client) as openai_cls:
+ result = openai_call(
+ "orca-key",
+ "orcarouter/auto",
+ "subtitle text",
+ "find highlights",
+ )
+
+ self.assertEqual(result, "clip plan")
+ openai_cls.assert_called_once_with(
+ api_key="orca-key",
+ base_url=ORCAROUTER_API_BASE,
+ )
+ # OrcaRouter is a multi-provider gateway: the model ID must keep the
+ # `orcarouter/` prefix so the router knows which namespace to route to.
+ call_kwargs = client.chat.completions.create.call_args[1]
+ self.assertEqual(call_kwargs["model"], "orcarouter/auto")
+
+ def test_orcarouter_api_key_falls_back_to_env(self):
+ client = MagicMock()
+ client.chat.completions.create.return_value = _mock_completion()
+
+ with patch.dict(os.environ, {"ORCAROUTER_API_KEY": "env-orca-key"}, clear=False):
+ with patch("funclip.llm.openai_api.OpenAI", return_value=client) as openai_cls:
+ openai_call("", "orcarouter/auto", "text")
+
+ openai_cls.assert_called_once_with(
+ api_key="env-orca-key",
+ base_url=ORCAROUTER_API_BASE,
+ )
+ call_kwargs = client.chat.completions.create.call_args[1]
+ self.assertEqual(call_kwargs["model"], "orcarouter/auto")
+
+ def test_orcarouter_api_base_env_overrides(self):
+ client = MagicMock()
+ client.chat.completions.create.return_value = _mock_completion()
+
+ with patch.dict(
+ os.environ,
+ {"ORCAROUTER_API_BASE": "https://gateway.example.com/v1"},
+ clear=False,
+ ):
+ with patch("funclip.llm.openai_api.OpenAI", return_value=client) as openai_cls:
+ openai_call("orca-key", "orcarouter/fusion", "text")
+
+ openai_cls.assert_called_once_with(
+ api_key="orca-key",
+ base_url="https://gateway.example.com/v1",
+ )
+
+ def test_empty_orcarouter_model_raises(self):
+ with self.assertRaises(ValueError):
+ openai_call("key", "orcarouter/", "text")
+
+ def test_missing_orcarouter_key_does_not_fall_back_to_openai_key(self):
+ with patch.dict(
+ os.environ,
+ {"OPENAI_API_KEY": "openai-only-key"},
+ clear=True,
+ ):
+ with patch("funclip.llm.openai_api.OpenAI") as openai_cls:
+ with self.assertRaisesRegex(ValueError, "ORCAROUTER_API_KEY"):
+ openai_call("", "orcarouter/auto", "text")
+
+ openai_cls.assert_not_called()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_orcarouter_launch_integration.py b/tests/test_orcarouter_launch_integration.py
new file mode 100644
index 0000000..518a1aa
--- /dev/null
+++ b/tests/test_orcarouter_launch_integration.py
@@ -0,0 +1,74 @@
+"""Regression tests for OrcaRouter choices and prompt routing in the launcher."""
+
+import ast
+import unittest
+from pathlib import Path
+
+
+LAUNCH_PATH = Path(__file__).resolve().parents[1] / "funclip" / "launch.py"
+
+
+class TestOrcaRouterLaunchIntegration(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.tree = ast.parse(LAUNCH_PATH.read_text(encoding="utf-8"))
+
+ def test_openai_compatible_route_handles_orcarouter_prefix(self):
+ llm_inference = next(
+ node
+ for node in ast.walk(self.tree)
+ if isinstance(node, ast.FunctionDef) and node.name == "llm_inference"
+ )
+ openai_call = next(
+ node
+ for node in ast.walk(llm_inference)
+ if isinstance(node, ast.Call)
+ and isinstance(node.func, ast.Name)
+ and node.func.id == "openai_call"
+ )
+ # The openai_call dispatch branch must also cover orcarouter/ models.
+ dispatch_condition = next(
+ node
+ for node in ast.walk(llm_inference)
+ if isinstance(node, ast.If) and isinstance(node.test, ast.BoolOp)
+ )
+ self.assertIn("orcarouter/", ast.unparse(dispatch_condition.test))
+ self.assertEqual(ast.unparse(openai_call.args[2]), "user_content + '\\n' + srt_text")
+ self.assertEqual(ast.unparse(openai_call.args[3]), "system_content")
+
+ def test_support_prefix_list_includes_orcarouter(self):
+ llm_inference = next(
+ node
+ for node in ast.walk(self.tree)
+ if isinstance(node, ast.FunctionDef) and node.name == "llm_inference"
+ )
+ assigned = [
+ node
+ for node in ast.walk(llm_inference)
+ if isinstance(node, ast.Assign) and node.targets[0].id == "SUPPORT_LLM_PREFIX"
+ ]
+ self.assertEqual(len(assigned), 1)
+ prefix_list = assigned[0].value
+ self.assertIsInstance(prefix_list, ast.List)
+ prefixes = {
+ node.value
+ for node in ast.walk(prefix_list)
+ if isinstance(node, ast.Constant) and isinstance(node.value, str)
+ }
+ self.assertIn("orcarouter", prefixes)
+
+ def test_dropdown_lists_orcarouter_gateway_models(self):
+ string_literals = {
+ node.value
+ for node in ast.walk(self.tree)
+ if isinstance(node, ast.Constant) and isinstance(node.value, str)
+ }
+
+ self.assertIn("orcarouter/auto", string_literals)
+ self.assertIn("orcarouter/fusion", string_literals)
+ self.assertIn("orcarouter/fusion-flash", string_literals)
+ self.assertIn("orcarouter/fusion-mini", string_literals)
+
+
+if __name__ == "__main__":
+ unittest.main()