diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index ae17e5e29..eb2e41cd4 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -39,6 +39,14 @@ curl -X POST "http://127.0.0.1:8000/v1/audio/transcriptions" \ -F "model=FunAudioLLM/SenseVoiceSmall" ``` +- If the curl request works but a browser reports a CORS or network error, restart the server with the browser page's exact origin (scheme, host, and port): + +```bash +funasr-server --device cpu --model sensevoice \ + --cors-origin http://localhost:3000 +``` + +- Repeat `--cors-origin` for each trusted browser origin, for example when both `localhost` and `127.0.0.1` are used. Browser CORS access is disabled by default; avoid a wildcard on machines reachable by other users. - If `/v1/audio/transcriptions` returns 4xx or 5xx, attach the startup command, full server log, request command, model id, hub, and audio duration. ## WebSocket realtime output is empty or delayed diff --git a/docs/troubleshooting_zh.md b/docs/troubleshooting_zh.md index 926a4b343..1a8e52c78 100644 --- a/docs/troubleshooting_zh.md +++ b/docs/troubleshooting_zh.md @@ -39,6 +39,14 @@ curl -X POST "http://127.0.0.1:8000/v1/audio/transcriptions" \ -F "model=FunAudioLLM/SenseVoiceSmall" ``` +- 如果 curl 成功,但浏览器报 CORS 或 network error,请按浏览器页面的精确 origin(scheme、host、port)重启服务: + +```bash +funasr-server --device cpu --model sensevoice \ + --cors-origin http://localhost:3000 +``` + +- 每个可信浏览器 origin 都要重复传入一次 `--cors-origin`,例如同时使用 `localhost` 和 `127.0.0.1` 时。浏览器 CORS 默认关闭;机器可被其他用户访问时不要使用通配符。 - 如果 `/v1/audio/transcriptions` 返回 4xx 或 5xx,请附启动命令、完整 server log、请求命令、model id、hub 和音频时长。 ## WebSocket 实时输出为空或延迟很大 diff --git a/funasr/bin/_server_app.py b/funasr/bin/_server_app.py index 94e2ba266..9c8a3b461 100644 --- a/funasr/bin/_server_app.py +++ b/funasr/bin/_server_app.py @@ -11,13 +11,14 @@ import logging import tempfile from pathlib import Path -from typing import Optional +from typing import Iterable, Optional import numpy as np import soundfile as sf try: from fastapi import FastAPI, UploadFile, File, Form, HTTPException + from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse except ImportError: raise ImportError( @@ -126,7 +127,13 @@ def prepare_audio_for_inference(audio_data, sr, target_sr=16000): return audio_data.astype(np.float32), sr -def create_app(device: str = "cuda", preload_model: str = "auto", model_path: str = None, hub: str = "ms") -> FastAPI: +def create_app( + device: str = "cuda", + preload_model: str = "auto", + model_path: str = None, + hub: str = "ms", + cors_origins: Optional[Iterable[str]] = None, +) -> FastAPI: if preload_model == "auto": preload_model = "fun-asr-nano" if device.startswith("cuda") else "sensevoice" @@ -138,6 +145,20 @@ def create_app(device: str = "cuda", preload_model: str = "auto", model_path: st app.state.model_path = model_path app.state.hub = hub + normalized_origins = [] + for origin in cors_origins or []: + origin = origin.strip() + if origin and origin not in normalized_origins: + normalized_origins.append(origin) + if normalized_origins: + app.add_middleware( + CORSMiddleware, + allow_origins=normalized_origins, + allow_credentials=False, + allow_methods=["GET", "POST", "OPTIONS"], + allow_headers=["Authorization", "Content-Type"], + ) + # Non-LLM model configs (use AutoModel, no vLLM) FALLBACK_CONFIGS = { "sensevoice": { diff --git a/funasr/bin/server.py b/funasr/bin/server.py index e55d75f28..2aa86f8ae 100644 --- a/funasr/bin/server.py +++ b/funasr/bin/server.py @@ -7,6 +7,7 @@ funasr-server --model paraformer funasr-server --model-path /path/to/local/model funasr-server --model-path username/paraformer --hub hf + funasr-server --cors-origin http://localhost:3000 """ import argparse @@ -21,7 +22,7 @@ def server_version_label(): return f"FunASR Server v{PACKAGE_VERSION}" -def main(): +def build_parser(): parser = argparse.ArgumentParser( description="FunASR OpenAI-Compatible API Server", formatter_class=argparse.RawDescriptionHelpFormatter, @@ -33,6 +34,7 @@ def main(): funasr-server --port 9000 # Custom port funasr-server --model-path /path/to/local/model # Use local model funasr-server --model-path username/model --hub hf # Use HuggingFace model + funasr-server --cors-origin http://localhost:3000 # Allow one browser origin Then use with OpenAI SDK: from openai import OpenAI @@ -46,7 +48,18 @@ def main(): parser.add_argument("--model", default="auto", help="Pre-load model: auto (GPU=fun-asr-nano, CPU=sensevoice), sensevoice, paraformer, fun-asr-nano") parser.add_argument("--model-path", default=None, help="Local model path or model ID (overrides --model)") parser.add_argument("--hub", default="ms", help="Model hub: ms (ModelScope), hf (HuggingFace) (default: ms)") - args = parser.parse_args() + parser.add_argument( + "--cors-origin", + action="append", + default=None, + metavar="ORIGIN", + help="Trusted browser origin for CORS; repeat for multiple origins (disabled by default)", + ) + return parser + + +def main(): + args = build_parser().parse_args() try: import uvicorn @@ -63,7 +76,13 @@ def main(): # Use inline app to avoid path issues from funasr.bin._server_app import create_app - app = create_app(device=args.device, preload_model=args.model, model_path=args.model_path, hub=args.hub) + app = create_app( + device=args.device, + preload_model=args.model, + model_path=args.model_path, + hub=args.hub, + cors_origins=args.cors_origin, + ) print(f"╔══════════════════════════════════════════════╗") print(f"║ {server_version_label():<44}║") diff --git a/tests/test_server_app_openai_segments.py b/tests/test_server_app_openai_segments.py index 4a735de3b..99e90f3fb 100644 --- a/tests/test_server_app_openai_segments.py +++ b/tests/test_server_app_openai_segments.py @@ -18,6 +18,10 @@ def __init__(self, *args, **kwargs): self.state = types.SimpleNamespace() self.routes = {} self.metadata = kwargs + self.middleware = [] + + def add_middleware(self, middleware_class, **kwargs): + self.middleware.append((middleware_class, kwargs)) def post(self, path, *args, **kwargs): def decorator(func): @@ -39,11 +43,24 @@ def decorator(func): fastapi_stub.File = lambda *args, **kwargs: None fastapi_stub.Form = lambda *args, **kwargs: None fastapi_stub.HTTPException = Exception + fastapi_stub.__path__ = [] + + middleware_stub = types.ModuleType("fastapi.middleware") + middleware_stub.__path__ = [] + + cors_stub = types.ModuleType("fastapi.middleware.cors") + + class DummyCORSMiddleware: + pass + + cors_stub.CORSMiddleware = DummyCORSMiddleware responses_stub = types.ModuleType("fastapi.responses") responses_stub.JSONResponse = lambda content=None: content monkeypatch.setitem(sys.modules, "fastapi", fastapi_stub) + monkeypatch.setitem(sys.modules, "fastapi.middleware", middleware_stub) + monkeypatch.setitem(sys.modules, "fastapi.middleware.cors", cors_stub) monkeypatch.setitem(sys.modules, "fastapi.responses", responses_stub) module_name = "funasr_server_app_under_test" @@ -226,6 +243,64 @@ def test_server_versions_follow_package_version(monkeypatch): assert server_module.server_version_label() == f"FunASR Server v{expected}" +def test_server_cli_collects_repeated_cors_origins(): + module = load_server_cli() + + args = module.build_parser().parse_args( + [ + "--cors-origin", + "http://localhost:3000", + "--cors-origin", + "http://127.0.0.1:3000", + ] + ) + + assert args.cors_origin == [ + "http://localhost:3000", + "http://127.0.0.1:3000", + ] + + +def test_server_cors_is_disabled_by_default(monkeypatch): + module = load_server_app(monkeypatch) + install_dummy_funasr(monkeypatch) + + app = module.create_app(device="cpu", preload_model="sensevoice") + + assert app.middleware == [] + + +def test_server_configures_normalized_trusted_origins(monkeypatch): + module = load_server_app(monkeypatch) + install_dummy_funasr(monkeypatch) + + app = module.create_app( + device="cpu", + preload_model="sensevoice", + cors_origins=[ + " http://localhost:3000 ", + "http://localhost:3000", + "http://127.0.0.1:3000", + " ", + ], + ) + + assert app.middleware == [ + ( + module.CORSMiddleware, + { + "allow_origins": [ + "http://localhost:3000", + "http://127.0.0.1:3000", + ], + "allow_credentials": False, + "allow_methods": ["GET", "POST", "OPTIONS"], + "allow_headers": ["Authorization", "Content-Type"], + }, + ) + ] + + def test_default_fun_asr_nano_uses_requested_modelscope_hub(monkeypatch): module = load_server_app(monkeypatch) DummyAutoModel = install_dummy_funasr(monkeypatch)