feat: add explicit MOSS serving profiles to Gradio client

Signed-off-by: zhifu gao <[email protected]>
This commit is contained in:
zhifu gao
2026-09-07 19:12:22 +00:00
parent 4d89a1f064
commit d91d961e37
3 changed files with 312 additions and 12 deletions
+37
View File
@@ -0,0 +1,37 @@
name: Validate Gradio client
on:
pull_request:
paths:
- "examples/openai_api/gradio_app.py"
- "examples/openai_api/GRADIO*.md"
- "tests/test_gradio_app.py"
- ".github/workflows/test-gradio-client.yml"
push:
branches: [main]
paths:
- "examples/openai_api/gradio_app.py"
- "examples/openai_api/GRADIO*.md"
- "tests/test_gradio_app.py"
- ".github/workflows/test-gradio-client.yml"
permissions:
contents: read
jobs:
gradio-contract:
runs-on: ubuntu-latest
timeout-minutes: 10
env:
GRADIO_ANALYTICS_ENABLED: "False"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install the isolated client test environment
run: python -m pip install gradio==6.26.0 pytest
- name: Check client dependencies
run: python -m pip check
- name: Test real UI components and local HTTP transport without model downloads
run: python -m pytest -q tests/test_gradio_app.py
+44 -12
View File
@@ -14,6 +14,23 @@ import uuid
DEFAULT_BASE_URL = "http://localhost:8000"
DEFAULT_MODEL = "sensevoice"
BACKEND_PROFILES = {
"funasr": {
"models": (DEFAULT_MODEL, "paraformer", "paraformer-en", "fun-asr-nano", "moss-transcribe-diarize"),
"formats": ("json", "verbose_json"),
"default_format": "verbose_json",
},
"vllm": {
"models": ("moss-transcribe-diarize",),
"formats": ("json", "diarized_json"),
"default_format": "diarized_json",
},
"sglang-omni": {
"models": ("OpenMOSS-Team/MOSS-Transcribe-Diarize",),
"formats": ("verbose_json",),
"default_format": "verbose_json",
},
}
def request_json(url: str, timeout: float) -> dict:
@@ -102,25 +119,35 @@ def safe_check(base_url: str, timeout: float) -> str:
return f"Service check failed: {error}"
def build_app(default_base_url: str, default_timeout: float):
def build_app(default_base_url: str, default_timeout: float, *, backend: str = "funasr", default_model: str | None = None):
profile = BACKEND_PROFILES[backend]
models = list(profile["models"])
selected_model = default_model if default_model is not None else models[0]
if selected_model not in models:
models.append(selected_model)
model_choices = [
("MOSS-Transcribe-Diarize" if value == "OpenMOSS-Team/MOSS-Transcribe-Diarize" else value, value)
for value in models
]
try:
import gradio as gr
except ImportError as error:
raise SystemExit("Install Gradio first: pip install gradio") from error
raise SystemExit("Install Gradio in the client environment: python -m pip install gradio==6.26.0") from error
with gr.Blocks(title="FunASR OpenAI API Demo") as demo:
gr.Markdown("# FunASR OpenAI-Compatible API Demo")
gr.Markdown("Start `python server.py --model sensevoice --device cuda --port 8000`, then upload or record audio here.")
with gr.Row():
base_url = gr.Textbox(label="API base URL", value=default_base_url)
base_url = gr.Textbox(label="API base URL", value=default_base_url, min_width=240)
model = gr.Dropdown(
label="Model alias",
choices=["sensevoice", "paraformer", "paraformer-en", "fun-asr-nano"],
value=DEFAULT_MODEL,
choices=model_choices,
value=selected_model,
min_width=240,
)
response_format = gr.Radio(label="Response format", choices=["json", "verbose_json"], value="verbose_json")
timeout = gr.Number(label="Timeout seconds", value=default_timeout, precision=0)
response_format = gr.Radio(label="Response format", choices=profile["formats"], value=profile["default_format"], min_width=240)
timeout = gr.Number(label="Timeout seconds", value=default_timeout, precision=0, min_width=240)
audio = gr.Audio(label="Audio", sources=["upload", "microphone"], type="filepath")
with gr.Row():
@@ -140,16 +167,21 @@ def build_app(default_base_url: str, default_timeout: float):
return demo
def main() -> None:
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run a Gradio demo for the FunASR OpenAI-compatible API")
parser.add_argument("--base-url", default=os.getenv("BASE_URL", DEFAULT_BASE_URL), help="FunASR API base URL")
parser.add_argument("--base-url", default=os.getenv("BASE_URL", DEFAULT_BASE_URL), help="Backend origin without /v1; reached from the Gradio process")
parser.add_argument("--backend", choices=tuple(BACKEND_PROFILES), default="funasr", help="Transcription protocol profile (native profiles default to MOSS)")
parser.add_argument("--model", help="Exact served-model ID; overrides the profile's default model")
parser.add_argument("--host", default=os.getenv("GRADIO_HOST", "127.0.0.1"), help="Gradio bind host")
parser.add_argument("--port", type=int, default=int(os.getenv("GRADIO_PORT", "7860")), help="Gradio bind port")
parser.add_argument("--timeout", type=float, default=float(os.getenv("TIMEOUT", "300")), help="HTTP timeout in seconds")
parser.add_argument("--share", action="store_true", help="Create a temporary Gradio share link")
args = parser.parse_args()
return parser.parse_args(argv)
app = build_app(args.base_url, args.timeout)
def main() -> None:
args = parse_args()
app = build_app(args.base_url, args.timeout, backend=args.backend, default_model=args.model)
app.launch(server_name=args.host, server_port=args.port, share=args.share)
+231
View File
@@ -0,0 +1,231 @@
"""Model-free tests for the real Gradio UI and multipart HTTP client."""
import importlib.util
import json
from email import policy
from email.parser import BytesParser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import threading
import wave
import pytest
ROOT = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location(
"gradio_app", ROOT / "examples/openai_api/gradio_app.py"
)
app = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(app)
@pytest.fixture
def audio(tmp_path):
path = tmp_path / "sample.wav"
with wave.open(str(path), "wb") as stream:
stream.setnchannels(1)
stream.setsampwidth(2)
stream.setframerate(16000)
stream.writeframes(b"\x00\x00" * 1600)
return path
@pytest.fixture
def endpoint():
state = {
"requests": [],
"status": 200,
"metadata": True,
"payload": {
"text": "\u4f60\u597d",
"segments": [
{"start": 0, "end": 0.5, "text": "\u4f60", "speaker": "speaker_0"},
{"start": 0.5, "end": 1, "text": "\u597d", "speaker": 1},
{"text": "[S02] untouched", "speaker": None},
],
},
}
class Handler(BaseHTTPRequestHandler):
def log_message(self, *_args):
pass
def reply(self, status, payload):
body = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
state["requests"].append(("GET", self.path, {}))
self.reply(200 if state["metadata"] else 404, {"path": self.path})
def do_POST(self):
body = self.rfile.read(int(self.headers["Content-Length"]))
message = BytesParser(policy=policy.default).parsebytes(
("Content-Type: " + self.headers["Content-Type"] + "\r\n\r\n").encode() + body
)
fields = {
part.get_param("name", header="content-disposition"): part.get_payload(decode=True)
for part in message.iter_parts()
}
state["requests"].append(("POST", self.path, fields))
self.reply(state["status"], state["payload"])
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
worker = threading.Thread(target=server.serve_forever, daemon=True)
worker.start()
try:
yield f"http://127.0.0.1:{server.server_port}", state
finally:
server.shutdown()
server.server_close()
worker.join(timeout=5)
assert not worker.is_alive()
def component(demo, label):
matches = [item for item in demo.config["components"] if item["props"].get("label") == label]
assert len(matches) == 1
return matches[0]["props"]
@pytest.mark.parametrize(
"backend,model,formats,default_format",
[
("funasr", "sensevoice", ["json", "verbose_json"], "verbose_json"),
("vllm", "moss-transcribe-diarize", ["json", "diarized_json"], "diarized_json"),
("sglang-omni", "OpenMOSS-Team/MOSS-Transcribe-Diarize", ["verbose_json"], "verbose_json"),
],
)
def test_real_ui_backend_contract(backend, model, formats, default_format):
demo = app.build_app("http://127.0.0.1:8000", 300, backend=backend)
try:
model_control = component(demo, "Model alias")
assert model_control["value"] == model
choices = [choice[1] for choice in model_control["choices"]]
assert model in choices
if backend == "sglang-omni":
assert model_control["choices"] == [("MOSS-Transcribe-Diarize", model)]
if backend == "funasr":
assert choices == ["sensevoice", "paraformer", "paraformer-en", "fun-asr-nano", "moss-transcribe-diarize"]
response = component(demo, "Response format")
assert [choice[0] for choice in response["choices"]] == formats
assert response["value"] == default_format
assert component(demo, "API base URL")["value"] == "http://127.0.0.1:8000"
for label in ["API base URL", "Model alias", "Response format", "Timeout seconds"]:
assert component(demo, label)["min_width"] >= 240
assert component(demo, "Audio")["type"] == "filepath"
finally:
demo.close()
def test_original_build_app_call_remains_supported():
demo = app.build_app("http://127.0.0.1:8000", 300)
try:
assert component(demo, "Model alias")["value"] == "sensevoice"
assert component(demo, "Response format")["value"] == "verbose_json"
finally:
demo.close()
@pytest.mark.parametrize("backend", ["funasr", "vllm", "sglang-omni"])
def test_served_model_override_is_exact(backend):
demo = app.build_app("http://127.0.0.1:8000", 300, backend=backend, default_model="operator/custom-model")
try:
control = component(demo, "Model alias")
assert control["value"] == "operator/custom-model"
assert "operator/custom-model" in [choice[1] for choice in control["choices"]]
finally:
demo.close()
def test_cli_profile_and_model_defaults(monkeypatch):
for name in ["BASE_URL", "GRADIO_HOST", "GRADIO_PORT", "TIMEOUT"]:
monkeypatch.delenv(name, raising=False)
args = app.parse_args([])
assert (args.backend, args.model, args.host, args.port, args.share) == ("funasr", None, "127.0.0.1", 7860, False)
args = app.parse_args(["--backend", "vllm", "--model", "served-id", "--base-url", "http://127.0.0.1:8898"])
assert (args.backend, args.model, args.base_url) == ("vllm", "served-id", "http://127.0.0.1:8898")
with pytest.raises(SystemExit):
app.parse_args(["--backend", "not-a-backend"])
@pytest.mark.parametrize("model,format_", [
("moss-transcribe-diarize", "verbose_json"),
("moss-transcribe-diarize", "diarized_json"),
("OpenMOSS-Team/MOSS-Transcribe-Diarize", "verbose_json"),
("operator/custom-model", "json"),
])
def test_real_http_preserves_fields_and_response(endpoint, audio, model, format_):
url, state = endpoint
transcript, raw = app.transcribe_audio(url + "/", str(audio), model, format_, 5)
assert transcript == state["payload"]["text"]
assert json.loads(raw) == state["payload"]
assert state["requests"] == [("POST", "/v1/audio/transcriptions", {
"file": audio.read_bytes(), "model": model.encode(), "response_format": format_.encode(),
})]
@pytest.mark.parametrize("payload", [{"text": "only text"}, {"text": "", "segments": []}, {"segments": [{"text": "[S00] raw"}]}])
def test_no_speaker_or_segment_fabrication(endpoint, audio, payload):
url, state = endpoint
state["payload"] = payload
text, raw = app.transcribe_audio(url, str(audio), "moss-transcribe-diarize", "verbose_json", 5)
assert text == payload.get("text", "")
assert json.loads(raw) == payload
def test_optional_metadata_failure_does_not_gate_transcription(endpoint, audio):
url, state = endpoint
state["metadata"] = False
assert "HTTP 404" in app.safe_check(url, 5)
text, raw = app.safe_transcribe(url, str(audio), "moss-transcribe-diarize", "diarized_json", 5)
assert text == state["payload"]["text"]
assert json.loads(raw) == state["payload"]
def test_service_check_and_http_failure(endpoint, audio):
url, state = endpoint
assert json.loads(app.check_service(url, 5)) == {
"health": {"path": "/health"}, "models": {"path": "/v1/models"},
}
state["status"] = 422
state["payload"] = {"detail": "unsupported model"}
text, raw = app.safe_transcribe(url, str(audio), "bad-model", "verbose_json", 5)
assert text == ""
assert "HTTP 422" in raw and "unsupported model" in raw
def test_missing_audio_never_calls_backend(endpoint):
url, state = endpoint
assert app.safe_transcribe(url, None, "sensevoice", "json", 5)[0] == ""
assert state["requests"] == []
@pytest.mark.parametrize("backend,model,format_", [
("funasr", "moss-transcribe-diarize", "verbose_json"),
("vllm", "moss-transcribe-diarize", "diarized_json"),
("sglang-omni", "OpenMOSS-Team/MOSS-Transcribe-Diarize", "verbose_json"),
])
def test_live_gradio_upload_and_event_binding(endpoint, audio, backend, model, format_):
from gradio_client import Client, handle_file
url, state = endpoint
demo = app.build_app(url, 5, backend=backend, default_model=model)
try:
_, local_url, _ = demo.launch(server_name="127.0.0.1", prevent_thread_lock=True, quiet=True)
client = Client(local_url, verbose=False)
transcript, raw = client.predict(url, handle_file(str(audio)), model, format_, 5, api_name="/safe_transcribe")
assert transcript == state["payload"]["text"]
assert json.loads(raw) == state["payload"]
post = state["requests"][-1]
assert post[:2] == ("POST", "/v1/audio/transcriptions")
assert post[2]["model"] == model.encode()
assert post[2]["response_format"] == format_.encode()
assert post[2]["file"] == audio.read_bytes()
finally:
demo.close()