diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8d67fa3..64a9cb7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,6 +28,7 @@ jobs: run: | python -m pip install --disable-pip-version-check \ pytest==8.3.5 \ + openai \ "gradio==4.44.1" \ "huggingface_hub<1.0" \ "starlette<1.0" @@ -35,3 +36,8 @@ jobs: - name: Test runtime, installation, and CLI help run: python -m pytest -q tests/test_gradio_runtime_compat.py tests/test_installation_docs.py tests/test_cli_stage_help.py + + - name: Test MiniMax presets and offline SDK requests + env: + GRADIO_ANALYTICS_ENABLED: "False" + run: python -m pytest -q tests/test_minimax_api.py tests/test_minimax_launch_integration.py tests/test_minimax_http.py diff --git a/README.md b/README.md index c6ba6b4..feeed45 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,10 @@ Follow the guide below to explore LLM based clipping: +#### Using MiniMax for transcript-based clipping (optional) + +The **LLM Model Name** dropdown lists `minimax/MiniMax-M3.1-Flash` first among the MiniMax presets, followed by `minimax/MiniMax-M3`. Select a model and enter your API key in **APIKEY**, or set `MINIMAX_API_KEY`. Requests use `https://api.minimax.io/v1` by default; set `MINIMAX_API_BASE=https://api.minimaxi.com/v1` for the mainland China endpoint. You can still enter an explicit legacy or custom `minimax/` model ID in the dropdown; FunClip passes it through without replacing it. See the [MiniMax model guide](https://platform.minimax.io/docs/guides/models-intro.md) for availability. + #### Using OrcaRouter as your LLM gateway (optional) Besides the transcript-based LLMs above, FunClip can route LLM-assisted clipping through [OrcaRouter](https://www.orcarouter.ai), an OpenAI-compatible smart-routing gateway. Select any `orcarouter/` model in the **LLM Model Name** dropdown (`orcarouter/auto` routes each request to the best model for the task), paste an OrcaRouter API key in the **APIKEY** box, and click 'LLM Inference' — FunClip sends the transcript and prompts to `https://api.orcarouter.ai/v1/chat/completions`, and the returned segments work with the existing 'AI Clip' button unchanged. diff --git a/funclip/launch.py b/funclip/launch.py index 08ae700..dda5098 100644 --- a/funclip/launch.py +++ b/funclip/launch.py @@ -266,9 +266,8 @@ def AI_clip_subti(LLM_res, dest_text, video_spk_input, start_ost, end_ost, video "litellm/anthropic/claude-sonnet-4-6", "atlascloud/qwen/qwen3.5-flash", "atlascloud/deepseek-ai/deepseek-v4-pro", + "minimax/MiniMax-M3.1-Flash", "minimax/MiniMax-M3", - "minimax/MiniMax-M2.7", - "minimax/MiniMax-M2.7-highspeed", "orcarouter/auto", "orcarouter/fusion", "orcarouter/fusion-flash", diff --git a/tests/test_minimax_http.py b/tests/test_minimax_http.py new file mode 100644 index 0000000..3acbb3b --- /dev/null +++ b/tests/test_minimax_http.py @@ -0,0 +1,128 @@ +"""Exercise the launcher dispatcher through the real SDK's HTTP boundary.""" + +import ast +import json +from pathlib import Path + +import httpx +import pytest +from openai import OpenAI + +from funclip.llm import openai_api + + +LAUNCH_PATH = Path(__file__).resolve().parents[1] / "funclip" / "launch.py" + + +@pytest.fixture +def launcher(): + tree = ast.parse(LAUNCH_PATH.read_text(encoding="utf-8")) + dispatcher = next(node for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) + and node.name == "llm_inference") + dropdown = next(node.value 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)) + options = {keyword.arg: ast.literal_eval(keyword.value) + for keyword in dropdown.keywords} + # As in the CLI parser tests, execute the actual callback without loading ASR. + namespace = {"openai_call": openai_api.openai_call} + exec(compile(ast.Module(body=[dispatcher], type_ignores=[]), + str(LAUNCH_PATH), "exec"), namespace) + return namespace["llm_inference"], options + + +@pytest.fixture +def requests(monkeypatch): + captured = [] + clients = [] + + def respond(request): + captured.append(request) + return httpx.Response(200, json={ + "id": "offline-completion", "object": "chat.completion", "created": 0, + "model": json.loads(request.content)["model"], + "choices": [{"index": 0, "finish_reason": "stop", + "message": {"role": "assistant", "content": "clip plan"}}], + }) + + def client_factory(**kwargs): + client = OpenAI(**kwargs, max_retries=0, + http_client=httpx.Client(transport=httpx.MockTransport(respond))) + clients.append(client) + return client + + monkeypatch.setattr(openai_api, "OpenAI", client_factory) + monkeypatch.delenv("MINIMAX_API_BASE", raising=False) + monkeypatch.setenv("MINIMAX_API_KEY", "offline-env-key") + yield captured + for client in clients: + client.close() + + +@pytest.mark.parametrize("base", [None, " ", "https://api.minimaxi.com/v1", + "https://custom.example/v1"]) +@pytest.mark.parametrize("model", [None, "MiniMax-M3", "MiniMax-M2.7", + "MiniMax-M2.7-highspeed", "custom/saved-model"]) +@pytest.mark.parametrize("apikey", ["", "offline-explicit-key"]) +def test_launcher_serializes_selected_and_custom_models(launcher, requests, monkeypatch, + base, model, apikey): + dispatch, options = launcher + if base is not None: + monkeypatch.setenv("MINIMAX_API_BASE", base) + if model is None: + selected = next(choice for choice in options["choices"] + if choice.startswith("minimax/")) + assert selected == "minimax/MiniMax-M3.1-Flash" + model = "MiniMax-M3.1-Flash" + else: + selected = "minimax/" + model + assert dispatch("find highlights", "analyze subtitles", "00:00 sample", + selected, apikey) == "clip plan" + assert len(requests) == 1 + request = requests[0] + expected_base = (base or "").strip() or "https://api.minimax.io/v1" + assert str(request.url) == expected_base + "/chat/completions" + assert request.headers["authorization"] == "Bearer " + (apikey or "offline-env-key") + # Exact payload also guards against unsupported thinking/effort/tier fields. + assert json.loads(request.content) == { + "model": model, + "messages": [{"role": "system", "content": "find highlights"}, + {"role": "user", "content": "analyze subtitles\n00:00 sample"}], + } + + +def test_dropdown_accepts_current_and_explicit_legacy_values(launcher): + import gradio + from starlette.testclient import TestClient + + _, options = launcher + with gradio.Blocks() as demo: + dropdown = gradio.Dropdown(**options) + assert dropdown.value == options["value"] + for model in ("MiniMax-M3.1-Flash", "MiniMax-M3", "MiniMax-M2.7", + "MiniMax-M2.7-highspeed", "custom/saved-model"): + selected = "minimax/" + model + assert dropdown.preprocess(selected) == selected + response = TestClient(gradio.routes.App.create_app(demo)).get("/") + assert response.status_code == 200 + assert "minimax/MiniMax-M3.1-Flash" in response.text + + +def test_existing_default_route_is_preserved(launcher, requests): + dispatch, options = launcher + assert dispatch("system", "user", "subtitle", options["value"], + "offline-explicit-key") == "clip plan" + assert str(requests[0].url) == "https://api.deepseek.com/chat/completions" + assert json.loads(requests[0].content)["model"] == options["value"] + + +def test_explicit_content_parts_are_preserved(requests): + content = [{"type": "text", "text": "describe the frame"}, + {"type": "image_url", "image_url": {"url": "https://example.com/frame.png"}}] + assert openai_api.openai_call("offline-explicit-key", "minimax/MiniMax-M3", + content) == "clip plan" + assert json.loads(requests[0].content) == { + "model": "MiniMax-M3", "messages": [{"role": "user", "content": content}], + } diff --git a/tests/test_minimax_launch_integration.py b/tests/test_minimax_launch_integration.py index a3f367b..ae1dea0 100644 --- a/tests/test_minimax_launch_integration.py +++ b/tests/test_minimax_launch_integration.py @@ -41,15 +41,21 @@ def test_openai_compatible_route_preserves_prompt_roles(self): self.assertEqual(ast.unparse(g4f_call.args[2]), "system_content") def test_dropdown_only_lists_documented_minimax_models(self): - string_literals = { + dropdown = next( node.value for node in ast.walk(self.tree) - if isinstance(node, ast.Constant) and isinstance(node.value, str) - } - - self.assertIn("minimax/MiniMax-M3", string_literals) - self.assertIn("minimax/MiniMax-M2.7", string_literals) - self.assertIn("minimax/MiniMax-M2.7-highspeed", string_literals) + if isinstance(node, ast.Assign) + and any(isinstance(target, ast.Name) and target.id == "llm_model" + for target in node.targets) + ) + options = {keyword.arg: ast.literal_eval(keyword.value) + for keyword in dropdown.keywords} + self.assertEqual( + [model for model in options["choices"] if model.startswith("minimax/")], + ["minimax/MiniMax-M3.1-Flash", "minimax/MiniMax-M3"], + ) + self.assertTrue(options["allow_custom_value"]) + self.assertEqual(options["value"], "deepseek-chat") if __name__ == "__main__":