Files
ragflow/rag/llm/sequence2txt_model.py
Zane eeb59ec4f2 feat(stt): support Fun-ASR-Flash in Tongyi-Qianwen provider (#16844)
## What this PR does

Adds support for Alibaba Cloud's hosted Fun-ASR-Flash snapshots to the
existing Tongyi-Qianwen speech-to-text provider.

- registers `fun-asr-flash-2026-06-15` as a speech-to-text model;
- routes only `fun-asr-flash*` models to the documented workspace-native
multimodal-generation endpoint;
- supports local audio through size-checked data URIs as well as
URL/data-URI inputs;
- uses the documented SSE response mode for incremental streaming
transcription;
- closes the streamed HTTP response on completion, failure, or early
consumer cancellation;
- preserves the existing `dashscope.MultiModalConversation` path for all
other Qwen audio models;
- keeps RAGFlow's existing synchronous and streaming adapter interfaces.

## Why

Fun-ASR-Flash does not use the legacy Qwen audio request shape currently
used by `QWenSeq2txt`. Its synchronous API expects `input_audio` at:

`/api/v1/services/aigc/multimodal-generation/generation`

Without a narrowly scoped adapter path, the hosted model cannot be
selected successfully through RAGFlow's Tongyi-Qianwen speech-to-text
provider.

Closes #16843.

## Compatibility

The new behavior is gated by the `fun-asr-flash` model-name prefix.
Existing Qwen audio models continue through the original code path
unchanged.

## Validation

- `pytest test/unit_test/rag/llm/test_sequence2txt_model.py`: 10 passed
- Ruff check: passed
- Ruff format check: passed
- `llm_factories.json` validation: passed
- Real hosted-API validation with WAV audio
- Real RAGFlow upload/indexing validation with MP3 audio

The unit tests cover the native Fun-ASR-Flash request, regression
behavior for the legacy Qwen path, SSE streaming, and early response
cleanup.

## Documentation

- https://help.aliyun.com/document_detail/2979031.html
- https://help.aliyun.com/document_detail/2869541.html
### Why a dedicated adapter path is necessary (official evidence)

Alibaba Cloud's [Fun-ASR RESTful API
reference](https://help.aliyun.com/en/model-studio/fun-asr-recorded-speech-recognition-http-api)
makes the incompatibilities with RAGFlow's existing Qwen audio path
explicit:

| Adapter change | Official API requirement | Why the existing path is
insufficient |
| --- | --- | --- |
| Call the workspace-native HTTP endpoint | The Fun-ASR-Flash
synchronous section states that SDK calls are not supported and
specifies `POST /api/v1/services/aigc/multimodal-generation/generation`.
| The existing adapter calls `dashscope.MultiModalConversation`, so a
direct HTTP path is required. |
| Use the `input_audio` message shape | `input.messages`, `content`,
`type: input_audio`, `input_audio`, and `input_audio.data` are
documented as required for an audio request. | The existing Qwen path
sends the legacy `audio` content shape, which does not match this API
contract. |
| Send `parameters.format` | The request schema marks `parameters` and
`format` as **Required**, and says the value must match the actual audio
format. | The legacy request has no Fun-ASR-Flash `parameters.format`
field, so the adapter must derive and send it. |
| Encode local files as Data URIs | `input_audio.data` accepts either a
public URL or a Base64 Data URI; the reference gives the exact
`data:{MIME_TYPE};base64,...` form. | RAGFlow supplies local file paths,
which the remote API cannot read directly. |
| Parse `output.text` | The documented non-streaming response returns
the accumulated transcription in `output.text`. | The legacy Qwen
response parser reads `output.choices[].message.content`, so a separate
response parser is required. |
| Enforce the Base64 input limit | The reference requires the
Base64-encoded audio to remain within the 10 MB input limit. | The
adapter checks encoded size before reading/sending local audio and
directs oversized inputs to the existing public-URL path. |
| Use SSE for streaming | The reference specifies `X-DashScope-SSE:
enable` and documents intermediate and final SSE events. | The adapter
parses those events instead of wrapping one blocking response as a
synthetic stream. |
| Release streamed responses | Streaming responses must be closed when
iteration completes or stops early. | A `finally` cleanup releases the
HTTP response on completion, errors, and consumer cancellation. |

`sample_rate` is documented as **Optional**. The implementation omits it
instead of declaring a fixed value that may not match remote or
compressed audio.

The [official speech-to-text model
list](https://help.aliyun.com/en/model-studio/asr-model/) separately
confirms that `fun-asr-flash-2026-06-15` is an offline HTTP model with a
five-minute audio limit.

---------

Signed-off-by: LauraGPT <LauraGPT@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: LauraGPT <LauraGPT@users.noreply.github.com>
2026-07-16 09:37:37 +08:00

564 lines
21 KiB
Python

#
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
import base64
import io
import json
import os
import re
from abc import ABC
import tempfile
import logging
from urllib.parse import urlparse
import requests
from openai import OpenAI
from openai.lib.azure import AzureOpenAI
from common.token_utils import num_tokens_from_string
from rag.utils.url_utils import ensure_v1
class Base(ABC):
def __init__(self, key, model_name, **kwargs):
"""
Abstract base class constructor.
Parameters are not stored; initialization is left to subclasses.
"""
pass
def transcription(self, audio_path, **kwargs):
with open(audio_path, "rb") as audio_file:
transcription = self.client.audio.transcriptions.create(model=self.model_name, file=audio_file)
return transcription.text.strip(), num_tokens_from_string(transcription.text.strip())
def audio2base64(self, audio):
if isinstance(audio, bytes):
return base64.b64encode(audio).decode("utf-8")
if isinstance(audio, io.BytesIO):
return base64.b64encode(audio.getvalue()).decode("utf-8")
raise TypeError("The input audio file should be in binary format.")
class GPTSeq2txt(Base):
_FACTORY_NAME = "OpenAI"
def __init__(self, key, model_name="whisper-1", base_url="https://api.openai.com/v1", **kwargs):
if not base_url:
base_url = "https://api.openai.com/v1"
self.base_url = ensure_v1(base_url)
self.client = OpenAI(api_key=key, base_url=self.base_url)
self.model_name = model_name
class StepFunSeq2txt(GPTSeq2txt):
_FACTORY_NAME = "StepFun"
def __init__(self, key, model_name="step-asr", lang="Chinese", base_url="https://api.stepfun.com/v1", **kwargs):
if not base_url:
base_url = "https://api.stepfun.com/v1"
super().__init__(key, model_name=model_name, base_url=base_url, **kwargs)
class FuturMixSeq2txt(GPTSeq2txt):
_FACTORY_NAME = "FuturMix"
def __init__(self, key, model_name="whisper-1", base_url="https://futurmix.ai/v1", **kwargs):
if not base_url:
base_url = "https://futurmix.ai/v1"
super().__init__(key, model_name=model_name, base_url=base_url, **kwargs)
logging.info("[FuturMix] Speech2Text initialized with model %s", model_name)
class QWenSeq2txt(Base):
_FACTORY_NAME = "Tongyi-Qianwen"
_FUN_ASR_FLASH_PREFIX = "fun-asr-flash"
_FUN_ASR_BASE64_MAX_SIZE = 10 * 1024 * 1024
_DASHSCOPE_API_BASE = "https://dashscope.aliyuncs.com/api/v1"
_AUDIO_MIME_FORMATS = {
"audio/mpeg": "mp3",
"audio/mp3": "mp3",
"audio/wav": "wav",
"audio/wave": "wav",
"audio/x-wav": "wav",
}
def __init__(self, key, model_name="qwen-audio-asr", base_url=None, **kwargs):
import dashscope
dashscope.api_key = key
self.api_key = key
self.model_name = model_name
self.base_url = (base_url or self._DASHSCOPE_API_BASE).rstrip("/")
def transcription(self, audio_path):
# Fun-ASR-Flash uses DashScope's workspace-scoped native multimodal
# endpoint and payload instead of MultiModalConversation.
if self.model_name.startswith(self._FUN_ASR_FLASH_PREFIX):
return self._transcribe_fun_asr_flash(audio_path)
return self._transcribe_qwen_audio(audio_path)
def _transcribe_qwen_audio(self, audio_path):
import dashscope
if audio_path.startswith("http"):
audio_input = audio_path
else:
audio_input = f"file://{audio_path}"
messages = [{"role": "system", "content": [{"text": ""}]}, {"role": "user", "content": [{"audio": audio_input}]}]
resp = dashscope.MultiModalConversation.call(model=self.model_name, messages=messages, result_format="message", asr_options={"enable_lid": True, "enable_itn": False})
try:
text = resp["output"]["choices"][0]["message"].content[0]["text"]
except Exception as e:
text = "**ERROR**: " + str(e)
return text, num_tokens_from_string(text)
@classmethod
def _fun_asr_audio_format(cls, audio_path):
"""Derive the Fun-ASR audio format from a data URI, URL, or path."""
if audio_path.startswith("data:"):
mime_type = audio_path[5:].split(";", 1)[0].lower()
if not mime_type.startswith("audio/"):
raise ValueError(f"Unsupported audio data URI MIME type: {mime_type or 'missing'}")
audio_format = cls._AUDIO_MIME_FORMATS.get(mime_type, mime_type.split("/", 1)[1].removeprefix("x-"))
else:
path = urlparse(audio_path).path if audio_path.startswith(("http://", "https://")) else audio_path
audio_format = os.path.splitext(path)[1].lower().lstrip(".")
if audio_format == "wave":
audio_format = "wav"
if not audio_format:
raise ValueError("Cannot determine audio format; use a URL/path extension or an audio data URI MIME type")
return audio_format
@classmethod
def _validate_fun_asr_base64_size(cls, encoded_size):
if encoded_size > cls._FUN_ASR_BASE64_MAX_SIZE:
raise ValueError("Fun-ASR-Flash Base64 audio exceeds the 10 MB encoded-input limit; provide a publicly accessible URL (for example, OSS) instead")
def _fun_asr_flash_request(self, audio_path, *, stream=False):
audio_format = self._fun_asr_audio_format(audio_path)
if audio_path.startswith(("http://", "https://")):
audio_input = audio_path
elif audio_path.startswith("data:"):
_, separator, encoded_audio = audio_path.partition(",")
if not separator:
raise ValueError("Invalid audio data URI: missing Base64 payload")
self._validate_fun_asr_base64_size(len(encoded_audio.encode("utf-8")))
audio_input = audio_path
else:
file_size = os.path.getsize(audio_path)
encoded_size = 4 * ((file_size + 2) // 3)
self._validate_fun_asr_base64_size(encoded_size)
mime_type = "audio/mpeg" if audio_format == "mp3" else f"audio/{audio_format}"
with open(audio_path, "rb") as audio_file:
audio_input = f"data:{mime_type};base64,{base64.b64encode(audio_file.read()).decode('utf-8')}"
api_base = self.base_url
if api_base.endswith("/compatible-mode/v1"):
api_base = api_base[: -len("/compatible-mode/v1")] + "/api/v1"
url = f"{api_base}/services/aigc/multimodal-generation/generation"
payload = {
"model": self.model_name,
"input": {
"messages": [
{
"role": "user",
"content": [{"type": "input_audio", "input_audio": {"data": audio_input}}],
}
]
},
# sample_rate is optional in the Fun-ASR-Flash API. Omitting it
# avoids declaring incorrect metadata for remote or compressed audio.
"parameters": {"format": audio_format},
}
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
"X-DashScope-SSE": "enable" if stream else "disable",
}
return url, headers, payload
def _transcribe_fun_asr_flash(self, audio_path):
try:
url, headers, payload = self._fun_asr_flash_request(audio_path)
response = requests.post(url, headers=headers, json=payload, timeout=60)
response.raise_for_status()
result = response.json()
text = result.get("text") or result.get("output", {}).get("text")
if not text:
raise ValueError("Missing transcription text in Fun-ASR-Flash response")
text = text.strip()
return text, num_tokens_from_string(text)
except Exception as e:
logging.exception("Fun-ASR-Flash transcription failed for model %s", self.model_name)
return "**ERROR**: " + str(e), 0
def _stream_fun_asr_flash(self, audio_path):
response = None
try:
url, headers, payload = self._fun_asr_flash_request(audio_path, stream=True)
response = requests.post(url, headers=headers, json=payload, timeout=60, stream=True)
response.raise_for_status()
full = ""
for line in response.iter_lines(decode_unicode=True):
if not line or not line.startswith("data:"):
continue
event_data = line[5:].strip()
if not event_data or event_data == "[DONE]":
continue
result = json.loads(event_data)
text = result.get("text") or result.get("output", {}).get("text")
if not text:
continue
full = text.strip()
yield {"event": "delta", "text": full}
if not full:
raise ValueError("Missing transcription text in Fun-ASR-Flash stream")
yield {"event": "final", "text": full}
except Exception as e:
logging.exception("Fun-ASR-Flash streaming transcription failed for model %s", self.model_name)
yield {"event": "error", "text": "**ERROR**: " + str(e)}
finally:
if response is not None:
response.close()
def stream_transcription(self, audio_path):
if self.model_name.startswith(self._FUN_ASR_FLASH_PREFIX):
yield from self._stream_fun_asr_flash(audio_path)
return
import dashscope
if audio_path.startswith("http"):
audio_input = audio_path
else:
audio_input = f"file://{audio_path}"
messages = [{"role": "system", "content": [{"text": ""}]}, {"role": "user", "content": [{"audio": audio_input}]}]
stream = dashscope.MultiModalConversation.call(model=self.model_name, messages=messages, result_format="message", stream=True, asr_options={"enable_lid": True, "enable_itn": False})
full = ""
for chunk in stream:
try:
piece = chunk["output"]["choices"][0]["message"].content[0]["text"]
full = piece
yield {"event": "delta", "text": piece}
except Exception as e:
yield {"event": "error", "text": str(e)}
yield {"event": "final", "text": full}
class AzureSeq2txt(Base):
_FACTORY_NAME = "Azure-OpenAI"
def __init__(self, key, model_name, lang="Chinese", **kwargs):
self.base_url = ensure_v1(kwargs["base_url"])
self.client = AzureOpenAI(api_key=key, azure_endpoint=self.base_url, api_version="2024-02-01")
self.model_name = model_name
self.lang = lang
class XinferenceSeq2txt(Base):
_FACTORY_NAME = "Xinference"
def __init__(self, key, model_name="whisper-small", **kwargs):
self.base_url = ensure_v1(kwargs["base_url"]) if kwargs.get("base_url") else None
self.model_name = model_name
self.key = key
def transcription(self, audio, language="zh", prompt=None, response_format="json", temperature=0.7):
if isinstance(audio, str):
with open(audio, "rb") as audio_file:
audio_data = audio_file.read()
audio_file_name = audio.split("/")[-1]
else:
audio_data = audio
audio_file_name = "audio.wav"
payload = {"model": self.model_name, "language": language, "prompt": prompt, "response_format": response_format, "temperature": temperature}
files = {"file": (audio_file_name, audio_data, "audio/wav")}
try:
response = requests.post(f"{self.base_url}/v1/audio/transcriptions", files=files, data=payload, timeout=60)
response.raise_for_status()
result = response.json()
if "text" in result:
transcription_text = result["text"].strip()
return transcription_text, num_tokens_from_string(transcription_text)
else:
return "**ERROR**: Failed to retrieve transcription.", 0
except requests.exceptions.RequestException as e:
return f"**ERROR**: {str(e)}", 0
class TencentCloudSeq2txt(Base):
_FACTORY_NAME = "Tencent Cloud"
def __init__(self, key, model_name="16k_zh", base_url="https://asr.tencentcloudapi.com"):
from tencentcloud.asr.v20190614 import asr_client
from tencentcloud.common import credential
key = json.loads(key)
sid = key.get("tencent_cloud_sid", "")
sk = key.get("tencent_cloud_sk", "")
cred = credential.Credential(sid, sk)
self.client = asr_client.AsrClient(cred, "")
self.model_name = model_name
def transcription(self, audio, max_retries=60, retry_interval=5):
import time
from tencentcloud.asr.v20190614 import models
from tencentcloud.common.exception.tencent_cloud_sdk_exception import (
TencentCloudSDKException,
)
b64 = self.audio2base64(audio)
try:
# dispatch disk
req = models.CreateRecTaskRequest()
params = {
"EngineModelType": self.model_name,
"ChannelNum": 1,
"ResTextFormat": 0,
"SourceType": 1,
"Data": b64,
}
req.from_json_string(json.dumps(params))
resp = self.client.CreateRecTask(req)
# loop query
req = models.DescribeTaskStatusRequest()
params = {"TaskId": resp.Data.TaskId}
req.from_json_string(json.dumps(params))
retries = 0
while retries < max_retries:
resp = self.client.DescribeTaskStatus(req)
if resp.Data.StatusStr == "success":
text = re.sub(r"\[\d+:\d+\.\d+,\d+:\d+\.\d+\]\s*", "", resp.Data.Result).strip()
return text, num_tokens_from_string(text)
elif resp.Data.StatusStr == "failed":
return (
"**ERROR**: Failed to retrieve speech recognition results.",
0,
)
else:
time.sleep(retry_interval)
retries += 1
return "**ERROR**: Max retries exceeded. Task may still be processing.", 0
except TencentCloudSDKException as e:
return "**ERROR**: " + str(e), 0
except Exception as e:
return "**ERROR**: " + str(e), 0
class GPUStackSeq2txt(Base):
_FACTORY_NAME = "GPUStack"
def __init__(self, key, model_name, base_url):
if not base_url:
raise ValueError("url cannot be None")
if base_url.split("/")[-1] != "v1":
base_url = os.path.join(base_url, "v1")
self.base_url = base_url
self.model_name = model_name
self.key = key
class GiteeSeq2txt(Base):
_FACTORY_NAME = "GiteeAI"
def __init__(self, key, model_name="whisper-1", base_url="https://ai.gitee.com/v1/", **kwargs):
if not base_url:
base_url = "https://ai.gitee.com/v1/"
self.base_url = ensure_v1(base_url)
self.client = OpenAI(api_key=key, base_url=self.base_url)
self.model_name = model_name
class DeepInfraSeq2txt(Base):
_FACTORY_NAME = "DeepInfra"
def __init__(self, key, model_name, base_url="https://api.deepinfra.com/v1/openai", **kwargs):
if not base_url:
base_url = "https://api.deepinfra.com/v1/openai"
self.base_url = ensure_v1(base_url)
self.client = OpenAI(api_key=key, base_url=self.base_url)
self.model_name = model_name
class CometAPISeq2txt(Base):
_FACTORY_NAME = "CometAPI"
def __init__(self, key, model_name="whisper-1", base_url="https://api.cometapi.com/v1", **kwargs):
if not base_url:
base_url = "https://api.cometapi.com/v1"
self.base_url = ensure_v1(base_url)
self.client = OpenAI(api_key=key, base_url=self.base_url)
self.model_name = model_name
class DeerAPISeq2txt(Base):
_FACTORY_NAME = "DeerAPI"
def __init__(self, key, model_name="whisper-1", base_url="https://api.deerapi.com/v1", **kwargs):
if not base_url:
base_url = "https://api.deerapi.com/v1"
self.base_url = ensure_v1(base_url)
self.client = OpenAI(api_key=key, base_url=self.base_url)
self.model_name = model_name
class ZhipuSeq2txt(Base):
_FACTORY_NAME = "ZHIPU-AI"
def __init__(self, key, model_name="glm-asr", base_url="https://open.bigmodel.cn/api/paas/v4", **kwargs):
if not base_url:
base_url = "https://open.bigmodel.cn/api/paas/v4"
self.base_url = base_url
self.api_key = key
self.model_name = model_name
self.gen_conf = kwargs.get("gen_conf", {})
self.stream = kwargs.get("stream", False)
def _convert_to_wav(self, input_path):
ext = os.path.splitext(input_path)[1].lower()
if ext in [".wav", ".mp3"]:
return input_path
fd, out_path = tempfile.mkstemp(suffix=".wav")
os.close(fd)
try:
import ffmpeg
import imageio_ffmpeg as ffmpeg_exe
ffmpeg_path = ffmpeg_exe.get_ffmpeg_exe()
(ffmpeg.input(input_path).output(out_path, ar=16000, ac=1).overwrite_output().run(cmd=ffmpeg_path, quiet=True))
return out_path
except Exception as e:
raise RuntimeError(f"audio convert failed: {e}")
def transcription(self, audio_path):
payload = {
"model": self.model_name,
"temperature": str(self.gen_conf.get("temperature", 0.75)) or "0.75",
"stream": self.stream,
}
headers = {"Authorization": f"Bearer {self.api_key}"}
converted = self._convert_to_wav(audio_path)
with open(converted, "rb") as audio_file:
files = {"file": audio_file}
try:
response = requests.post(
url=f"{self.base_url}/audio/transcriptions",
data=payload,
files=files,
headers=headers,
timeout=60,
)
body = response.json()
if response.status_code == 200:
full_content = body["text"]
return full_content, num_tokens_from_string(full_content)
else:
error = body["error"]
return f"**ERROR**: code: {error['code']}, message: {error['message']}", 0
except Exception as e:
return "**ERROR**: " + str(e), 0
class RAGconSeq2txt(Base):
"""
RAGcon Sequence2Text Provider - routes through LiteLLM proxy
Speech-to-text models routed through LiteLLM.
Default Base URL: https://connect.ragcon.com/v1
"""
_FACTORY_NAME = "RAGcon"
def __init__(self, key, model_name, base_url=None, lang="English", **kwargs):
# Use provided base_url or fallback to default
if not base_url:
base_url = "https://connect.ragcon.com/v1"
self.base_url = ensure_v1(base_url)
self.model_name = model_name
self.key = key
self.lang = lang
self.client = OpenAI(api_key=key, base_url=self.base_url)
def transcription(self, audio_path, **kwargs):
"""
Transcribe audio file using RAGcon's OpenAI-compatible API.
Uses Whisper's automatic language detection for German and English audio.
Args:
audio_path: Path to the audio file
**kwargs: Additional parameters (currently unused but maintained for compatibility)
Returns:
tuple: (transcribed_text, token_count)
"""
with open(audio_path, "rb") as audio_file:
# Call RAGcon API - Whisper will auto-detect language
transcription = self.client.audio.transcriptions.create(model=self.model_name, file=audio_file)
# Return text and token count
text = transcription.text.strip()
return text, num_tokens_from_string(text)
class NewAPISeq2txt(GPTSeq2txt):
_FACTORY_NAME = "New API"
def __init__(self, key, model_name="whisper-1", base_url="", **kwargs):
if not base_url:
raise ValueError("url cannot be None")
model_name = model_name.split("___")[0]
super().__init__(key, model_name=model_name, base_url=base_url, **kwargs)
class FunASRSeq2txt(GPTSeq2txt):
"""FunASR speech-to-text provider for its OpenAI-compatible API."""
_FACTORY_NAME = "FunASR"
def __init__(self, key, model_name="sensevoice", base_url="http://localhost:8000/v1", **kwargs):
"""Initialize a client for a FunASR OpenAI-compatible endpoint."""
if not base_url:
base_url = "http://localhost:8000/v1"
super().__init__(key=key or "funasr", model_name=model_name, base_url=base_url, **kwargs)
logging.info("[FunASR] Speech2Text initialized with model %s at %s", model_name, self.base_url)