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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,12 @@ FunClip can also send LLM-assisted clipping through [Cheaper Inference](https://

Set `CHEAPER_INFERENCE_API_KEY` (and optionally `CHEAPER_INFERENCE_API_BASE`, which defaults to `https://api.cheaperinference.com/v1`) instead of pasting the key into the UI if you prefer environment-based configuration. A key is available at https://cheaperinference.com/signup.

#### Using Opper as your LLM gateway (optional)

FunClip can also send LLM-assisted clipping through [Opper](https://opper.ai), an EU-hosted, OpenAI-compatible AI gateway. One API key gives access to 700+ models from 50+ providers. Select any `opper/` model in the **LLM Model Name** dropdown, paste an Opper API key in the **APIKEY** box, and click 'LLM Inference'. FunClip removes the `opper/` prefix and sends the transcript and prompts to `https://api.opper.ai/v3/compat/chat/completions`. The returned segments work with the existing 'AI Clip' button. Bare names such as `opper/claude-sonnet-4-6` are routed across the providers that serve the model; `opper/anthropic/claude-sonnet-4-6` pins one route.

Set `OPPER_API_KEY` (and optionally `OPPER_API_BASE`, which defaults to `https://api.opper.ai/v3/compat`) instead of pasting the key into the UI if you prefer environment-based configuration. A key is available at https://platform.opper.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.
Expand Down
6 changes: 6 additions & 0 deletions README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,12 @@ FunClip 也可以将 LLM 智能裁剪发送到 [Cheaper Inference](https://cheap

也可以不填 UI,而是设置 `CHEAPER_INFERENCE_API_KEY` 环境变量(可选 `CHEAPER_INFERENCE_API_BASE`,默认为 `https://api.cheaperinference.com/v1`)。Key 可在 https://cheaperinference.com/signup 获取。

#### 使用 Opper 作为 LLM 网关(可选)

FunClip 也可以将 LLM 智能裁剪发送到 [Opper](https://opper.ai),一个部署在欧盟、兼容 OpenAI 的 AI 网关。一个 API key 即可使用 50+ 家厂商的 700+ 个模型。在 **LLM Model Name** 下拉框选择任意 `opper/` 模型,在 **APIKEY** 输入框粘贴 Opper API key,点击“LLM推理”。FunClip 会去掉 `opper/` 前缀,把字幕与 prompt 发送到 `https://api.opper.ai/v3/compat/chat/completions`,返回的分段与现有“AI Clip”按钮兼容。`opper/claude-sonnet-4-6` 这类不带厂商前缀的名称会在提供该模型的多家厂商之间路由,`opper/anthropic/claude-sonnet-4-6` 则固定使用一条路由。

也可以不填 UI,而是设置 `OPPER_API_KEY` 环境变量(可选 `OPPER_API_BASE`,默认为 `https://api.opper.ai/v3/compat`)。Key 可在 https://platform.opper.ai 获取。

### B.通过命令行调用使用FunClip的相关功能
```shell
# 下载下面命令用到的示例视频
Expand Down
8 changes: 6 additions & 2 deletions funclip/launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,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', 'orcarouter', 'cheaperinference', 'pegasus']
SUPPORT_LLM_PREFIX = ['litellm', 'qwen', 'gpt', 'g4f', 'moonshot', 'deepseek', 'atlascloud', 'minimax', 'orcarouter', 'cheaperinference', 'opper', 'pegasus']
if model.startswith('litellm/'):
return litellm_call(apikey, model, user_content+'\n'+srt_text, system_content)
if model.startswith('pegasus'):
Expand All @@ -162,7 +162,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/') or model.startswith('orcarouter/') or model.startswith('cheaperinference/'):
if model.startswith('gpt') or model.startswith('moonshot') or model.startswith('deepseek') or model.startswith('atlascloud/') or model.startswith('minimax/') or model.startswith('orcarouter/') or model.startswith('cheaperinference/') or model.startswith('opper/'):
return openai_call(apikey, model, user_content+'\n'+srt_text, system_content)
elif model.startswith('g4f'):
model = "-".join(model.split('-')[1:])
Expand Down Expand Up @@ -277,6 +277,10 @@ def AI_clip_subti(LLM_res, dest_text, video_spk_input, start_ost, end_ost, video
"cheaperinference/gpt-5.4",
"cheaperinference/claude-sonnet-5",
"cheaperinference/deepseek-v4-flash",
"opper/claude-sonnet-4-6",
"opper/gpt-5.5",
"opper/gpt-5.4-mini",
"opper/deepseek-v4-pro",
"pegasus1.5"],
value="deepseek-chat",
label="LLM Model Name",
Expand Down
16 changes: 16 additions & 0 deletions funclip/llm/openai_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@
CHEAPER_INFERENCE_API_BASE = "https://api.cheaperinference.com/v1"
CHEAPER_INFERENCE_MODEL_PREFIX = "cheaperinference/"

# Opper is an EU-hosted, OpenAI-compatible AI gateway. Its model IDs are pool
# names (e.g. `claude-sonnet-4-6`) or `provider/model` routes, so only the
# `opper/` prefix is stripped.
OPPER_API_BASE = "https://api.opper.ai/v3/compat"
OPPER_MODEL_PREFIX = "opper/"


def _resolve_model_config(model):
base_url = None
Expand Down Expand Up @@ -68,6 +74,16 @@ def _resolve_model_config(model):
if not base_url:
base_url = CHEAPER_INFERENCE_API_BASE
api_key_env = "CHEAPER_INFERENCE_API_KEY"
elif model.startswith(OPPER_MODEL_PREFIX):
model = model[len(OPPER_MODEL_PREFIX):]
if not model:
raise ValueError(
"Model name is empty after stripping opper/ prefix"
)
base_url = os.environ.get("OPPER_API_BASE", OPPER_API_BASE).strip()
if not base_url:
base_url = OPPER_API_BASE
api_key_env = "OPPER_API_KEY"
elif model.startswith("deepseek"):
base_url = "https://api.deepseek.com"
elif model.startswith("gpt-3.5-turbo"):
Expand Down
103 changes: 103 additions & 0 deletions tests/test_opper_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Tests for Opper routing through the OpenAI-compatible client."""

import os
import unittest
from unittest.mock import MagicMock, patch

from funclip.llm.openai_api import (
OPPER_API_BASE,
openai_call,
)


def _mock_completion(content="ok"):
completion = MagicMock()
completion.choices = [MagicMock()]
completion.choices[0].message.content = content
return completion


class TestOpperRouting(unittest.TestCase):
def test_opper_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(
"opper-key",
"opper/gpt-5.4-mini",
"subtitle text",
"find highlights",
)

self.assertEqual(result, "clip plan")
openai_cls.assert_called_once_with(
api_key="opper-key",
base_url=OPPER_API_BASE,
)
# Only the opper/ prefix is stripped; the pool name is sent as is.
call_kwargs = client.chat.completions.create.call_args[1]
self.assertEqual(call_kwargs["model"], "gpt-5.4-mini")

def test_opper_api_key_falls_back_to_env(self):
client = MagicMock()
client.chat.completions.create.return_value = _mock_completion()

with patch.dict(os.environ, {"OPPER_API_KEY": "env-opper-key"}, clear=False):
with patch("funclip.llm.openai_api.OpenAI", return_value=client) as openai_cls:
openai_call("", "opper/gpt-5.4-mini", "text")

openai_cls.assert_called_once_with(
api_key="env-opper-key",
base_url=OPPER_API_BASE,
)
call_kwargs = client.chat.completions.create.call_args[1]
self.assertEqual(call_kwargs["model"], "gpt-5.4-mini")

def test_opper_provider_route_keeps_inner_prefix(self):
client = MagicMock()
client.chat.completions.create.return_value = _mock_completion()

with patch("funclip.llm.openai_api.OpenAI", return_value=client):
openai_call("opper-key", "opper/anthropic/claude-sonnet-4-6", "text")

# A provider/model id pins one route and must reach the gateway intact.
call_kwargs = client.chat.completions.create.call_args[1]
self.assertEqual(call_kwargs["model"], "anthropic/claude-sonnet-4-6")

def test_opper_api_base_env_overrides(self):
client = MagicMock()
client.chat.completions.create.return_value = _mock_completion()

with patch.dict(
os.environ,
{"OPPER_API_BASE": "https://gateway.example.com/v1"},
clear=False,
):
with patch("funclip.llm.openai_api.OpenAI", return_value=client) as openai_cls:
openai_call("opper-key", "opper/claude-sonnet-4-6", "text")

openai_cls.assert_called_once_with(
api_key="opper-key",
base_url="https://gateway.example.com/v1",
)

def test_empty_opper_model_raises(self):
with self.assertRaises(ValueError):
openai_call("key", "opper/", "text")

def test_missing_opper_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, "OPPER_API_KEY"):
openai_call("", "opper/gpt-5.4-mini", "text")

openai_cls.assert_not_called()


if __name__ == "__main__":
unittest.main()
74 changes: 74 additions & 0 deletions tests/test_opper_launch_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Regression tests for Opper 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 TestOpperLaunchIntegration(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.tree = ast.parse(LAUNCH_PATH.read_text(encoding="utf-8"))

def test_openai_compatible_route_handles_opper_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 opper/ 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("opper/", 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_opper(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("opper", prefixes)

def test_dropdown_lists_opper_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("opper/claude-sonnet-4-6", string_literals)
self.assertIn("opper/gpt-5.5", string_literals)
self.assertIn("opper/gpt-5.4-mini", string_literals)
self.assertIn("opper/deepseek-v4-pro", string_literals)


if __name__ == "__main__":
unittest.main()