From c30aa39cdd822dc65f767d6d714f1bca7b6ab963 Mon Sep 17 00:00:00 2001 From: XiaoHuo888-hue Date: Fri, 21 Aug 2026 08:03:51 +0000 Subject: [PATCH] feat(llm): add OrcaRouter as a named OpenAI-compatible gateway Add a named OrcaRouter provider to the transcript-driven LLM clipping routing, mirroring the existing atlascloud/minimax gateways. Selecting any orcarouter/ model in the LLM Model Name dropdown routes requests to https://api.orcarouter.ai/v1/chat/completions with the ORCAROUTER_API_KEY environment variable (or pasted APIKEY). Co-Authored-By: Claude Signed-off-by: XiaoHuo888-hue --- README.md | 8 ++ README_zh.md | 8 ++ funclip/launch.py | 8 +- funclip/llm/openai_api.py | 16 ++++ tests/test_orcarouter_api.py | 93 +++++++++++++++++++++ tests/test_orcarouter_launch_integration.py | 74 ++++++++++++++++ 6 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 tests/test_orcarouter_api.py create mode 100644 tests/test_orcarouter_launch_integration.py 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()