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 @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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”按钮兼容。
Expand Down
6 changes: 4 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', 'runcrate', '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('runcrate/'):
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,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",
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/"

# 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
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(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"):
Expand Down
92 changes: 92 additions & 0 deletions tests/test_runcrate_api.py
Original file line number Diff line number Diff line change
@@ -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()