From 691ab54ece763a7198bceabfe455914447fc1b3d Mon Sep 17 00:00:00 2001 From: dennyho0917 Date: Thu, 8 Oct 2026 19:41:32 +0800 Subject: [PATCH] feat(llm): add API Route gateway for clipping --- README.md | 13 ++++++ README_zh.md | 11 +++++ funclip/launch.py | 5 ++- funclip/llm/openai_api.py | 12 ++++++ tests/test_api_route.py | 84 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 tests/test_api_route.py diff --git a/README.md b/README.md index c6ba6b4..fa1cbd1 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/README_zh.md b/README_zh.md index 7836eac..d92908d 100644 --- a/README_zh.md +++ b/README_zh.md @@ -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 # 下载下面命令用到的示例视频 diff --git a/funclip/launch.py b/funclip/launch.py index 08ae700..ecf2424 100644 --- a/funclip/launch.py +++ b/funclip/launch.py @@ -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'): @@ -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:]) @@ -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", diff --git a/funclip/llm/openai_api.py b/funclip/llm/openai_api.py index 66615df..261a065 100644 --- a/funclip/llm/openai_api.py +++ b/funclip/llm/openai_api.py @@ -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 @@ -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"): diff --git a/tests/test_api_route.py b/tests/test_api_route.py new file mode 100644 index 0000000..c967bf0 --- /dev/null +++ b/tests/test_api_route.py @@ -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()