From 4c3073ac46dfd386866b223edd6abfbf25a217cb Mon Sep 17 00:00:00 2001 From: Nirupam Bhowmick <48842933+heydryft@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:57:39 +0000 Subject: [PATCH] feat(llm): add Runcrate as a named OpenAI-compatible provider --- README.md | 6 +++ README_zh.md | 6 +++ funclip/launch.py | 6 ++- funclip/llm/openai_api.py | 16 +++++++ tests/test_runcrate_api.py | 92 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 tests/test_runcrate_api.py diff --git a/README.md b/README.md index c6ba6b4..f820b9f 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,12 @@ OrcaRouter exposes one endpoint for all frontier and open-weight models, so you 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. +#### Using Runcrate as your LLM provider (optional) + +FunClip can also send LLM-assisted clipping to open models on [Runcrate](https://www.runcrate.ai/inference?utm_source=funclip&utm_medium=directory&utm_campaign=gpu_pricing), an OpenAI-compatible API. Open models are free with your GitHub account. Select a `runcrate/` model in the **LLM Model Name** dropdown (for example `runcrate/deepseek-ai/DeepSeek-V4.1-Flash` or `runcrate/openai/gpt-oss-120b`), paste a Runcrate API key in the **APIKEY** box, and click 'LLM Inference'. FunClip removes the `runcrate/` prefix and sends the transcript and prompts to `https://api.runcrate.ai/v1/chat/completions`. The returned segments work with the existing 'AI Clip' button. + +Set `RUNCRATE_API_KEY` (and optionally `RUNCRATE_API_BASE`, which defaults to `https://api.runcrate.ai/v1`) instead of pasting the key into the UI if you prefer environment-based configuration. + #### Using Cheaper Inference as your LLM gateway (optional) FunClip can also send LLM-assisted clipping through [Cheaper Inference](https://cheaperinference.com), an OpenAI-compatible LLM gateway. One API key gives access to models from several labs. Select any `cheaperinference/` model in the **LLM Model Name** dropdown, paste a Cheaper Inference API key in the **APIKEY** box, and click 'LLM Inference'. FunClip removes the `cheaperinference/` prefix and sends the transcript and prompts to `https://api.cheaperinference.com/v1/chat/completions`. The returned segments work with the existing 'AI Clip' button. diff --git a/README_zh.md b/README_zh.md index 7836eac..9acfe99 100644 --- a/README_zh.md +++ b/README_zh.md @@ -163,6 +163,12 @@ OrcaRouter 用单一端点接入所有前沿与开源模型,无需修改 FunCl 也可以不填 UI,而是设置 `ORCAROUTER_API_KEY` 环境变量(可选 `ORCAROUTER_API_BASE`,默认为 `https://api.orcarouter.ai/v1`)。Key 可在 https://www.orcarouter.ai 获取。 +#### 使用 Runcrate 作为 LLM 提供方(可选) + +FunClip 也可以将 LLM 智能裁剪发送到 [Runcrate](https://www.runcrate.ai/inference?utm_source=funclip&utm_medium=directory&utm_campaign=gpu_pricing) 上的开放模型,Runcrate 提供 OpenAI 兼容 API。开放模型可通过 GitHub 账号免费使用。在 **LLM Model Name** 下拉框选择 `runcrate/` 模型(例如 `runcrate/deepseek-ai/DeepSeek-V4.1-Flash` 或 `runcrate/openai/gpt-oss-120b`),在 **APIKEY** 输入框粘贴 Runcrate API key,点击“LLM推理”。FunClip 会去掉 `runcrate/` 前缀,把字幕与 prompt 发送到 `https://api.runcrate.ai/v1/chat/completions`,返回的分段与现有“AI Clip”按钮兼容。 + +也可以不填 UI,而是设置 `RUNCRATE_API_KEY` 环境变量(可选 `RUNCRATE_API_BASE`,默认为 `https://api.runcrate.ai/v1`)。 + #### 使用 Cheaper Inference 作为 LLM 网关(可选) FunClip 也可以将 LLM 智能裁剪发送到 [Cheaper Inference](https://cheaperinference.com)——一个 OpenAI 兼容的 LLM 网关。一个 API key 即可使用多家厂商的模型。在 **LLM Model Name** 下拉框选择任意 `cheaperinference/` 模型,在 **APIKEY** 输入框粘贴 Cheaper Inference API key,点击“LLM推理”。FunClip 会去掉 `cheaperinference/` 前缀,把字幕与 prompt 发送到 `https://api.cheaperinference.com/v1/chat/completions`,返回的分段与现有“AI Clip”按钮兼容。 diff --git a/funclip/launch.py b/funclip/launch.py index 08ae700..e12b056 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', 'runcrate', '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('runcrate/'): return openai_call(apikey, model, user_content+'\n'+srt_text, system_content) elif model.startswith('g4f'): model = "-".join(model.split('-')[1:]) @@ -277,6 +277,8 @@ 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", + "runcrate/deepseek-ai/DeepSeek-V4.1-Flash", + "runcrate/openai/gpt-oss-120b", "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..9ab5cd0 100644 --- a/funclip/llm/openai_api.py +++ b/funclip/llm/openai_api.py @@ -24,6 +24,12 @@ CHEAPER_INFERENCE_API_BASE = "https://api.cheaperinference.com/v1" CHEAPER_INFERENCE_MODEL_PREFIX = "cheaperinference/" +# Runcrate serves open models on an OpenAI-compatible API. Its model IDs carry +# the vendor (e.g. `deepseek-ai/DeepSeek-V4.1-Flash`), so only the `runcrate/` +# prefix is stripped. +RUNCRATE_API_BASE = "https://api.runcrate.ai/v1" +RUNCRATE_MODEL_PREFIX = "runcrate/" + def _resolve_model_config(model): base_url = None @@ -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(RUNCRATE_MODEL_PREFIX): + model = model[len(RUNCRATE_MODEL_PREFIX):] + if not model: + raise ValueError( + "Model name is empty after stripping runcrate/ prefix" + ) + base_url = os.environ.get("RUNCRATE_API_BASE", RUNCRATE_API_BASE).strip() + if not base_url: + base_url = RUNCRATE_API_BASE + api_key_env = "RUNCRATE_API_KEY" elif model.startswith("deepseek"): base_url = "https://api.deepseek.com" elif model.startswith("gpt-3.5-turbo"): diff --git a/tests/test_runcrate_api.py b/tests/test_runcrate_api.py new file mode 100644 index 0000000..ee9076e --- /dev/null +++ b/tests/test_runcrate_api.py @@ -0,0 +1,92 @@ +"""Tests for Runcrate routing through the OpenAI-compatible client.""" + +import os +import unittest +from unittest.mock import MagicMock, patch + +from funclip.llm.openai_api import ( + RUNCRATE_API_BASE, + openai_call, +) + + +def _mock_completion(content="ok"): + completion = MagicMock() + completion.choices = [MagicMock()] + completion.choices[0].message.content = content + return completion + + +class TestRuncrateRouting(unittest.TestCase): + def test_runcrate_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( + "rc-key", + "runcrate/deepseek-ai/DeepSeek-V4.1-Flash", + "subtitle text", + "find highlights", + ) + + self.assertEqual(result, "clip plan") + openai_cls.assert_called_once_with( + api_key="rc-key", + base_url=RUNCRATE_API_BASE, + ) + # Only the runcrate/ prefix is stripped; the vendor stays in the id. + call_kwargs = client.chat.completions.create.call_args[1] + self.assertEqual(call_kwargs["model"], "deepseek-ai/DeepSeek-V4.1-Flash") + + def test_runcrate_api_key_falls_back_to_env(self): + client = MagicMock() + client.chat.completions.create.return_value = _mock_completion() + + with patch.dict(os.environ, {"RUNCRATE_API_KEY": "env-rc-key"}, clear=False): + with patch("funclip.llm.openai_api.OpenAI", return_value=client) as openai_cls: + openai_call("", "runcrate/deepseek-ai/DeepSeek-V4.1-Flash", "text") + + openai_cls.assert_called_once_with( + api_key="env-rc-key", + base_url=RUNCRATE_API_BASE, + ) + call_kwargs = client.chat.completions.create.call_args[1] + self.assertEqual(call_kwargs["model"], "deepseek-ai/DeepSeek-V4.1-Flash") + + def test_runcrate_api_base_env_overrides(self): + client = MagicMock() + client.chat.completions.create.return_value = _mock_completion() + + with patch.dict( + os.environ, + {"RUNCRATE_API_BASE": "https://gateway.example.com/v1"}, + clear=False, + ): + with patch("funclip.llm.openai_api.OpenAI", return_value=client) as openai_cls: + openai_call("rc-key", "runcrate/openai/gpt-oss-120b", "text") + + openai_cls.assert_called_once_with( + api_key="rc-key", + base_url="https://gateway.example.com/v1", + ) + + def test_empty_runcrate_model_raises(self): + with self.assertRaises(ValueError): + openai_call("key", "runcrate/", "text") + + def test_missing_runcrate_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, "RUNCRATE_API_KEY"): + openai_call("", "runcrate/deepseek-ai/DeepSeek-V4.1-Flash", "text") + + openai_cls.assert_not_called() + + +if __name__ == "__main__": + unittest.main()