Skip to content

Commit ff032af

Browse files
authored
Merge pull request #205 from XiaoHuo888-hue/add-orcarouter-provider
feat(llm): add OrcaRouter as a named OpenAI-compatible gateway
2 parents 51557f3 + 613ca5e commit ff032af

6 files changed

Lines changed: 205 additions & 2 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,14 @@ Follow the guide below to explore LLM based clipping:
148148

149149
<img src="docs/images/LLM_guide.png" width=360/>
150150

151+
#### Using OrcaRouter as your LLM gateway (optional)
152+
153+
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.
154+
155+
OrcaRouter exposes one endpoint for all frontier and open-weight models, so you can switch routing targets without changing FunClip. It also offers gateway-level, zero-trust controls for AI agents on the same endpoint. Enforcement follows the Guardrail or Firewall policy attached to the relevant scope for prompts, responses, and tool calls that traverse the gateway; select the documented `tight` posture to enable default-deny behavior. Enforcement is opt-in — this integration supplies only the base URL, API key, and model, and does not attach a policy by itself. Apply a policy via the [security quickstart](https://docs.orcarouter.ai/security/concepts/quickstart); see [Guardrails](https://docs.orcarouter.ai/features/guardrails#scoping-and-the-workspace-default) and [Firewall](https://docs.orcarouter.ai/features/firewall#scoping-and-resolution) for how coverage is scoped.
156+
157+
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.
158+
151159
#### Content-aware clipping with TwelveLabs Pegasus (optional)
152160

153161
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.

README_zh.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,14 @@ python funclip/launch.py
148148

149149
<img src="docs/images/LLM_guide.png" width=360/>
150150

151+
#### 使用 OrcaRouter 作为 LLM 网关(可选)
152+
153+
除基于字幕的 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”按钮完全兼容。
154+
155+
OrcaRouter 用单一端点接入所有前沿与开源模型,无需修改 FunClip 即可切换路由目标。它还可以在同一端点上为 AI agent 提供网关级零信任控制。对经网关转发的 prompt、response 与工具调用,实际执行方式由相关作用域附加的 Guardrail 或 Firewall 策略决定;选择文档中的 `tight` posture 才会启用默认拒绝(default-deny)。该防护为可选开启:本集成只提供 base URL、API key 与模型,本身不会附加策略。请按 [security quickstart](https://docs.orcarouter.ai/security/concepts/quickstart) 应用策略;覆盖范围详见 [Guardrails](https://docs.orcarouter.ai/features/guardrails#scoping-and-the-workspace-default)[Firewall](https://docs.orcarouter.ai/features/firewall#scoping-and-resolution)
156+
157+
也可以不填 UI,而是设置 `ORCAROUTER_API_KEY` 环境变量(可选 `ORCAROUTER_API_BASE`,默认为 `https://api.orcarouter.ai/v1`)。Key 可在 https://www.orcarouter.ai 获取。
158+
151159
### B.通过命令行调用使用FunClip的相关功能
152160
```shell
153161
# 下载下面命令用到的示例视频

funclip/launch.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ def video_clip_addsub(dest_text, video_spk_input, start_ost, end_ost, state, out
163163
)
164164

165165
def llm_inference(system_content, user_content, srt_text, model, apikey, video_input=None):
166-
SUPPORT_LLM_PREFIX = ['litellm', 'qwen', 'gpt', 'g4f', 'moonshot', 'deepseek', 'atlascloud', 'minimax', 'pegasus']
166+
SUPPORT_LLM_PREFIX = ['litellm', 'qwen', 'gpt', 'g4f', 'moonshot', 'deepseek', 'atlascloud', 'minimax', 'orcarouter', 'pegasus']
167167
if model.startswith('litellm/'):
168168
return litellm_call(apikey, model, user_content+'\n'+srt_text, system_content)
169169
if model.startswith('pegasus'):
@@ -175,7 +175,7 @@ def llm_inference(system_content, user_content, srt_text, model, apikey, video_i
175175
return call_twelvelabs_pegasus(apikey, video_input, model=model, prompt=system_content)
176176
if model.startswith('qwen'):
177177
return call_qwen_model(apikey, model, user_content+'\n'+srt_text, system_content)
178-
if model.startswith('gpt') or model.startswith('moonshot') or model.startswith('deepseek') or model.startswith('atlascloud/') or model.startswith('minimax/'):
178+
if model.startswith('gpt') or model.startswith('moonshot') or model.startswith('deepseek') or model.startswith('atlascloud/') or model.startswith('minimax/') or model.startswith('orcarouter/'):
179179
return openai_call(apikey, model, user_content+'\n'+srt_text, system_content)
180180
elif model.startswith('g4f'):
181181
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
282282
"minimax/MiniMax-M3",
283283
"minimax/MiniMax-M2.7",
284284
"minimax/MiniMax-M2.7-highspeed",
285+
"orcarouter/auto",
286+
"orcarouter/fusion",
287+
"orcarouter/fusion-flash",
288+
"orcarouter/fusion-mini",
285289
"pegasus1.5"],
286290
value="deepseek-chat",
287291
label="LLM Model Name",

funclip/llm/openai_api.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@
1212
MINIMAX_API_BASE_CN = "https://api.minimaxi.com/v1"
1313
MINIMAX_MODEL_PREFIX = "minimax/"
1414

15+
# OrcaRouter is an OpenAI-compatible smart-routing gateway: one chat
16+
# completions endpoint that routes each request to the best model for the
17+
# task. Model IDs carry an `orcarouter/` prefix (e.g. `orcarouter/auto`) and
18+
# must be sent to the gateway verbatim — a bare `auto` is not routable.
19+
ORCAROUTER_API_BASE = "https://api.orcarouter.ai/v1"
20+
ORCAROUTER_MODEL_PREFIX = "orcarouter/"
21+
1522

1623
def _resolve_model_config(model):
1724
base_url = None
@@ -37,6 +44,15 @@ def _resolve_model_config(model):
3744
if not base_url:
3845
base_url = MINIMAX_API_BASE
3946
api_key_env = "MINIMAX_API_KEY"
47+
elif model.startswith(ORCAROUTER_MODEL_PREFIX):
48+
if len(model) <= len(ORCAROUTER_MODEL_PREFIX):
49+
raise ValueError(
50+
"Model name is empty after stripping orcarouter/ prefix"
51+
)
52+
base_url = os.environ.get("ORCAROUTER_API_BASE", ORCAROUTER_API_BASE).strip()
53+
if not base_url:
54+
base_url = ORCAROUTER_API_BASE
55+
api_key_env = "ORCAROUTER_API_KEY"
4056
elif model.startswith("deepseek"):
4157
base_url = "https://api.deepseek.com"
4258
elif model.startswith("gpt-3.5-turbo"):

tests/test_orcarouter_api.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Tests for OrcaRouter routing through the OpenAI-compatible client."""
2+
3+
import os
4+
import unittest
5+
from unittest.mock import MagicMock, patch
6+
7+
from funclip.llm.openai_api import (
8+
ORCAROUTER_API_BASE,
9+
openai_call,
10+
)
11+
12+
13+
def _mock_completion(content="ok"):
14+
completion = MagicMock()
15+
completion.choices = [MagicMock()]
16+
completion.choices[0].message.content = content
17+
return completion
18+
19+
20+
class TestOrcaRouterRouting(unittest.TestCase):
21+
def test_orcarouter_prefix_uses_gateway_base_url(self):
22+
client = MagicMock()
23+
client.chat.completions.create.return_value = _mock_completion("clip plan")
24+
25+
with patch("funclip.llm.openai_api.OpenAI", return_value=client) as openai_cls:
26+
result = openai_call(
27+
"orca-key",
28+
"orcarouter/auto",
29+
"subtitle text",
30+
"find highlights",
31+
)
32+
33+
self.assertEqual(result, "clip plan")
34+
openai_cls.assert_called_once_with(
35+
api_key="orca-key",
36+
base_url=ORCAROUTER_API_BASE,
37+
)
38+
# OrcaRouter is a multi-provider gateway: the model ID must keep the
39+
# `orcarouter/` prefix so the router knows which namespace to route to.
40+
call_kwargs = client.chat.completions.create.call_args[1]
41+
self.assertEqual(call_kwargs["model"], "orcarouter/auto")
42+
43+
def test_orcarouter_api_key_falls_back_to_env(self):
44+
client = MagicMock()
45+
client.chat.completions.create.return_value = _mock_completion()
46+
47+
with patch.dict(os.environ, {"ORCAROUTER_API_KEY": "env-orca-key"}, clear=False):
48+
with patch("funclip.llm.openai_api.OpenAI", return_value=client) as openai_cls:
49+
openai_call("", "orcarouter/auto", "text")
50+
51+
openai_cls.assert_called_once_with(
52+
api_key="env-orca-key",
53+
base_url=ORCAROUTER_API_BASE,
54+
)
55+
call_kwargs = client.chat.completions.create.call_args[1]
56+
self.assertEqual(call_kwargs["model"], "orcarouter/auto")
57+
58+
def test_orcarouter_api_base_env_overrides(self):
59+
client = MagicMock()
60+
client.chat.completions.create.return_value = _mock_completion()
61+
62+
with patch.dict(
63+
os.environ,
64+
{"ORCAROUTER_API_BASE": "https://gateway.example.com/v1"},
65+
clear=False,
66+
):
67+
with patch("funclip.llm.openai_api.OpenAI", return_value=client) as openai_cls:
68+
openai_call("orca-key", "orcarouter/fusion", "text")
69+
70+
openai_cls.assert_called_once_with(
71+
api_key="orca-key",
72+
base_url="https://gateway.example.com/v1",
73+
)
74+
75+
def test_empty_orcarouter_model_raises(self):
76+
with self.assertRaises(ValueError):
77+
openai_call("key", "orcarouter/", "text")
78+
79+
def test_missing_orcarouter_key_does_not_fall_back_to_openai_key(self):
80+
with patch.dict(
81+
os.environ,
82+
{"OPENAI_API_KEY": "openai-only-key"},
83+
clear=True,
84+
):
85+
with patch("funclip.llm.openai_api.OpenAI") as openai_cls:
86+
with self.assertRaisesRegex(ValueError, "ORCAROUTER_API_KEY"):
87+
openai_call("", "orcarouter/auto", "text")
88+
89+
openai_cls.assert_not_called()
90+
91+
92+
if __name__ == "__main__":
93+
unittest.main()
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Regression tests for OrcaRouter choices and prompt routing in the launcher."""
2+
3+
import ast
4+
import unittest
5+
from pathlib import Path
6+
7+
8+
LAUNCH_PATH = Path(__file__).resolve().parents[1] / "funclip" / "launch.py"
9+
10+
11+
class TestOrcaRouterLaunchIntegration(unittest.TestCase):
12+
@classmethod
13+
def setUpClass(cls):
14+
cls.tree = ast.parse(LAUNCH_PATH.read_text(encoding="utf-8"))
15+
16+
def test_openai_compatible_route_handles_orcarouter_prefix(self):
17+
llm_inference = next(
18+
node
19+
for node in ast.walk(self.tree)
20+
if isinstance(node, ast.FunctionDef) and node.name == "llm_inference"
21+
)
22+
openai_call = next(
23+
node
24+
for node in ast.walk(llm_inference)
25+
if isinstance(node, ast.Call)
26+
and isinstance(node.func, ast.Name)
27+
and node.func.id == "openai_call"
28+
)
29+
# The openai_call dispatch branch must also cover orcarouter/ models.
30+
dispatch_condition = next(
31+
node
32+
for node in ast.walk(llm_inference)
33+
if isinstance(node, ast.If) and isinstance(node.test, ast.BoolOp)
34+
)
35+
self.assertIn("orcarouter/", ast.unparse(dispatch_condition.test))
36+
self.assertEqual(ast.unparse(openai_call.args[2]), "user_content + '\\n' + srt_text")
37+
self.assertEqual(ast.unparse(openai_call.args[3]), "system_content")
38+
39+
def test_support_prefix_list_includes_orcarouter(self):
40+
llm_inference = next(
41+
node
42+
for node in ast.walk(self.tree)
43+
if isinstance(node, ast.FunctionDef) and node.name == "llm_inference"
44+
)
45+
assigned = [
46+
node
47+
for node in ast.walk(llm_inference)
48+
if isinstance(node, ast.Assign) and node.targets[0].id == "SUPPORT_LLM_PREFIX"
49+
]
50+
self.assertEqual(len(assigned), 1)
51+
prefix_list = assigned[0].value
52+
self.assertIsInstance(prefix_list, ast.List)
53+
prefixes = {
54+
node.value
55+
for node in ast.walk(prefix_list)
56+
if isinstance(node, ast.Constant) and isinstance(node.value, str)
57+
}
58+
self.assertIn("orcarouter", prefixes)
59+
60+
def test_dropdown_lists_orcarouter_gateway_models(self):
61+
string_literals = {
62+
node.value
63+
for node in ast.walk(self.tree)
64+
if isinstance(node, ast.Constant) and isinstance(node.value, str)
65+
}
66+
67+
self.assertIn("orcarouter/auto", string_literals)
68+
self.assertIn("orcarouter/fusion", string_literals)
69+
self.assertIn("orcarouter/fusion-flash", string_literals)
70+
self.assertIn("orcarouter/fusion-mini", string_literals)
71+
72+
73+
if __name__ == "__main__":
74+
unittest.main()

0 commit comments

Comments
 (0)