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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,19 @@ 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 API Route as your LLM gateway (optional)

Select `api-route/gpt-6.1-sol` in **LLM Model Name**, enter your API Route key in
**APIKEY**, and click **LLM Inference**. You can also enter `api-route/` followed
by an available chat model ID from your authenticated catalog. FunClip removes
only the `api-route/` prefix and sends the transcript and prompts to
`https://global.api-route.com/v1/chat/completions` using its existing clipping flow.

Set `API_ROUTE_API_KEY` instead of entering the key in the UI. Optional
`API_ROUTE_API_BASE` overrides the endpoint. Model access depends on your key;
the dropdown model is an example. See the [API Route API documentation](https://github.com/DennyHo0917/api-route/blob/main/API.md)
for authentication and `GET /v1/models` discovery.

#### 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
11 changes: 11 additions & 0 deletions README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,17 @@ 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 获取。

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

在 **LLM Model Name** 中选择 `api-route/gpt-6.1-sol`,在 **APIKEY** 中填写你的
API Route key,再点击“LLM推理”。也可以输入 `api-route/` 加上鉴权模型目录中的
可用聊天模型 ID。FunClip 只去掉 `api-route/` 前缀,将字幕与 prompt 发送到
`https://global.api-route.com/v1/chat/completions`,沿用现有智能裁剪流程。

也可以设置 `API_ROUTE_API_KEY` 环境变量;可选的 `API_ROUTE_API_BASE` 用于覆盖
默认地址。模型权限取决于 key,下拉框中的模型只是示例。鉴权与 `GET /v1/models`
发现方式见 [API Route API 文档](https://github.com/DennyHo0917/api-route/blob/main/API.md)。

### B.通过命令行调用使用FunClip的相关功能
```shell
# 下载下面命令用到的示例视频
Expand Down
5 changes: 3 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', 'api-route', '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('api-route/'):
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,7 @@ 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",
"api-route/gpt-6.1-sol",
"pegasus1.5"],
value="deepseek-chat",
label="LLM Model Name",
Expand Down
12 changes: 12 additions & 0 deletions funclip/llm/openai_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
CHEAPER_INFERENCE_API_BASE = "https://api.cheaperinference.com/v1"
CHEAPER_INFERENCE_MODEL_PREFIX = "cheaperinference/"

# API Route uses exact gateway model IDs; the launcher prefix is local only.
API_ROUTE_API_BASE = "https://global.api-route.com/v1"
API_ROUTE_MODEL_PREFIX = "api-route/"


def _resolve_model_config(model):
base_url = None
Expand Down Expand Up @@ -68,6 +72,14 @@ 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(API_ROUTE_MODEL_PREFIX):
model = model[len(API_ROUTE_MODEL_PREFIX):]
if not model:
raise ValueError("Model name is empty after stripping api-route/ prefix")
base_url = os.environ.get("API_ROUTE_API_BASE", API_ROUTE_API_BASE).strip()
if not base_url:
base_url = API_ROUTE_API_BASE
api_key_env = "API_ROUTE_API_KEY"
elif model.startswith("deepseek"):
base_url = "https://api.deepseek.com"
elif model.startswith("gpt-3.5-turbo"):
Expand Down
84 changes: 84 additions & 0 deletions tests/test_api_route.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""API Route client and executable launcher routing regressions."""

import ast
import os
from pathlib import Path
import unittest
from unittest.mock import MagicMock, patch

from funclip.llm.openai_api import API_ROUTE_API_BASE, openai_call


class TestAPIRoute(unittest.TestCase):
def setUp(self):
self.env = patch.dict(os.environ, {}, clear=True)
self.env.start()
self.addCleanup(self.env.stop)
self.client = MagicMock()
self.client.chat.completions.create.return_value.choices[0].message.content = "clip plan"
self.factory = patch("funclip.llm.openai_api.OpenAI", return_value=self.client)
self.openai = self.factory.start()
self.addCleanup(self.factory.stop)

def test_exact_model_id_and_prompts_reach_gateway(self):
for model in ("gpt-6.1-sol", "custom/model-id"):
with self.subTest(model=model):
self.assertEqual(openai_call("test-key", "api-route/" + model, "subtitles", "system"), "clip plan")
self.openai.assert_called_with(api_key="test-key", base_url=API_ROUTE_API_BASE)
self.client.chat.completions.create.assert_called_with(
model=model,
messages=[{"role": "system", "content": "system"}, {"role": "user", "content": "subtitles"}],
)

def test_trimmed_environment_key(self):
os.environ["API_ROUTE_API_KEY"] = " env-key "
openai_call("", "api-route/gpt-6.1-sol", "subtitles")
self.openai.assert_called_once_with(api_key="env-key", base_url=API_ROUTE_API_BASE)

def test_explicit_key_takes_precedence(self):
os.environ["API_ROUTE_API_KEY"] = "env-key"
openai_call("explicit-key", "api-route/gpt-6.1-sol", "subtitles")
self.openai.assert_called_once_with(api_key="explicit-key", base_url=API_ROUTE_API_BASE)

def test_empty_model_fails_before_client_creation(self):
with self.assertRaisesRegex(ValueError, "Model name is empty"):
openai_call("test-key", "api-route/", "subtitles")
self.openai.assert_not_called()

def test_missing_key_does_not_use_openai_key(self):
os.environ["OPENAI_API_KEY"] = "openai-key"
with self.assertRaisesRegex(ValueError, "API_ROUTE_API_KEY"):
openai_call("", "api-route/gpt-6.1-sol", "subtitles")
self.openai.assert_not_called()

def test_endpoint_override_and_empty_fallback(self):
for value, expected in ((" https://example.com/v1 ", "https://example.com/v1"), (" ", API_ROUTE_API_BASE)):
with self.subTest(value=value):
os.environ["API_ROUTE_API_BASE"] = value
openai_call("test-key", "api-route/gpt-6.1-sol", "subtitles")
self.openai.assert_called_with(api_key="test-key", base_url=expected)

def test_launcher_preserves_subtitles_and_system_prompt(self):
path = Path(__file__).resolve().parents[1] / "funclip" / "launch.py"
tree = ast.parse(path.read_text(encoding="utf-8"))
function = next(node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) and node.name == "llm_inference")
namespace = {"openai_call": openai_call}
exec(compile(ast.Module(body=[function], type_ignores=[]), str(path), "exec"), namespace)
srt = "1\n00:00:00,000 --> 00:00:01,000\nA short clip."
result = namespace["llm_inference"]("system", "find highlights", srt, "api-route/gpt-6.1-sol", "test-key")
self.assertEqual(result, "clip plan")
self.client.chat.completions.create.assert_called_once_with(
model="gpt-6.1-sol",
messages=[{"role": "system", "content": "system"}, {"role": "user", "content": "find highlights\n" + srt}],
)

def test_model_dropdown_has_api_route_example(self):
path = Path(__file__).resolve().parents[1] / "funclip" / "launch.py"
tree = ast.parse(path.read_text(encoding="utf-8"))
dropdown = next(node for node in ast.walk(tree) if isinstance(node, ast.Assign) and any(isinstance(target, ast.Name) and target.id == "llm_model" for target in node.targets))
choices = next(keyword.value for keyword in dropdown.value.keywords if keyword.arg == "choices")
self.assertIn("api-route/gpt-6.1-sol", ast.literal_eval(choices))


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