feat(asr): add StepFun ASR SSE transcription provider
- Add StepFunTranscriptionProvider class in nanobot/providers/transcription.py - New _post_stepfun_asr_with_retry() function handling SSE stream parsing (transcript.text.delta → transcript.text.done event sequence) - Register 'stepfun' in transcription_registry.py with default model stepaudio-2.5-asr - Reuse existing stepfun provider config (apiBase can point to Plan endpoint) - Add 17 tests covering SSE parsing, retry contract, empty-text edge case, and registry integration - Update docs/configuration.md with stepfun ASR documentation StepFun ASR uses a dedicated SSE endpoint (/v1/audio/asr/sse) rather than the chat-completions or Whisper multipart formats used by other providers. Users on Step Plan can set apiBase to the Plan endpoint.
This commit is contained in:
@@ -64,6 +64,11 @@ TRANSCRIPTION_PROVIDERS: tuple[TranscriptionProviderSpec, ...] = (
|
||||
adapter="nanobot.providers.transcription:XiaomiMiMoTranscriptionProvider",
|
||||
aliases=("mimo", "xiaomi"),
|
||||
),
|
||||
TranscriptionProviderSpec(
|
||||
name="stepfun",
|
||||
default_model="stepaudio-2.5-asr",
|
||||
adapter="nanobot.providers.transcription:StepFunTranscriptionProvider",
|
||||
),
|
||||
TranscriptionProviderSpec(
|
||||
name="assemblyai",
|
||||
default_model="universal-3-pro,universal-2",
|
||||
|
||||
@@ -219,7 +219,7 @@ class ProvidersConfig(Base):
|
||||
minimax: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
minimax_anthropic: ProviderConfig = Field(default_factory=ProviderConfig) # MiniMax Anthropic endpoint (thinking)
|
||||
mistral: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
stepfun: ProviderConfig = Field(default_factory=ProviderConfig) # Step Fun (阶跃星辰)
|
||||
stepfun: ProviderConfig = Field(default_factory=ProviderConfig) # Step Fun (阶跃星辰) — LLM + ASR (set apiBase to Plan URL for ASR)
|
||||
xiaomi_mimo: ProviderConfig = Field(default_factory=ProviderConfig) # Xiaomi MIMO (小米)
|
||||
longcat: ProviderConfig = Field(default_factory=ProviderConfig) # LongCat
|
||||
ant_ling: ProviderConfig = Field(default_factory=ProviderConfig) # Ant Ling
|
||||
|
||||
@@ -8,6 +8,7 @@ WebUI upload validation, and channel integration live in
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
@@ -306,6 +307,119 @@ async def _post_xiaomi_mimo_asr_with_retry(
|
||||
return await _post_with_retry(build_request, provider_label, _text_from_chat_payload)
|
||||
|
||||
|
||||
async def _post_stepfun_asr_with_retry(
|
||||
url: str,
|
||||
*,
|
||||
api_key: str | None,
|
||||
path: Path,
|
||||
model: str,
|
||||
provider_label: str,
|
||||
language: str | None = None,
|
||||
) -> str:
|
||||
"""POST audio to StepFun ASR SSE endpoint and collect final text."""
|
||||
try:
|
||||
data = path.read_bytes()
|
||||
except OSError as e:
|
||||
logger.exception("{} transcription error: cannot read audio file: {}", provider_label, e)
|
||||
return ""
|
||||
|
||||
suffix = path.suffix.lstrip(".").lower()
|
||||
audio_type = suffix if suffix in ("ogg", "mp3", "wav", "pcm") else "wav"
|
||||
|
||||
body: dict[str, Any] = {
|
||||
"audio": {
|
||||
"data": base64.b64encode(data).decode("ascii"),
|
||||
"input": {
|
||||
"transcription": {
|
||||
"model": model,
|
||||
"enable_itn": True,
|
||||
},
|
||||
"format": {"type": audio_type},
|
||||
},
|
||||
},
|
||||
}
|
||||
if language:
|
||||
body["audio"]["input"]["transcription"]["language"] = language
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "text/event-stream",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
for attempt in range(_MAX_RETRIES + 1):
|
||||
try:
|
||||
async with client.stream(
|
||||
"POST", url, headers=headers, json=body, timeout=60.0
|
||||
) as resp:
|
||||
if resp.status_code in _RETRYABLE_STATUS and attempt < _MAX_RETRIES:
|
||||
logger.warning(
|
||||
"{} transcription transient HTTP {} (attempt {}/{})",
|
||||
provider_label,
|
||||
resp.status_code,
|
||||
attempt + 1,
|
||||
_MAX_RETRIES + 1,
|
||||
)
|
||||
await asyncio.sleep(_BACKOFF_S[attempt])
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
final_text = None
|
||||
async for line in resp.aiter_lines():
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
payload_str = line[len("data:") :].strip()
|
||||
if not payload_str:
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(payload_str)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
event_type = payload.get("type", "")
|
||||
if event_type == "error":
|
||||
msg = payload.get("message", "unknown error")
|
||||
logger.error("{} ASR error: {}", provider_label, msg)
|
||||
return ""
|
||||
if event_type == "transcript.text.done":
|
||||
final_text = payload.get("text", "")
|
||||
break
|
||||
if final_text is not None:
|
||||
return final_text
|
||||
# Stream ended without a final event — retry if attempts remain
|
||||
if attempt < _MAX_RETRIES:
|
||||
logger.warning(
|
||||
"{} transcription: no final event (attempt {}/{})",
|
||||
provider_label,
|
||||
attempt + 1,
|
||||
_MAX_RETRIES + 1,
|
||||
)
|
||||
await asyncio.sleep(_BACKOFF_S[attempt])
|
||||
continue
|
||||
logger.error(
|
||||
"{} transcription: stream ended without final text after {} attempts",
|
||||
provider_label,
|
||||
_MAX_RETRIES + 1,
|
||||
)
|
||||
return ""
|
||||
except httpx.HTTPStatusError:
|
||||
if attempt < _MAX_RETRIES:
|
||||
await asyncio.sleep(_BACKOFF_S[attempt])
|
||||
continue
|
||||
logger.exception(
|
||||
"{} transcription failed after {} attempts",
|
||||
provider_label,
|
||||
_MAX_RETRIES + 1,
|
||||
)
|
||||
return ""
|
||||
except (httpx.RequestError, Exception):
|
||||
if attempt < _MAX_RETRIES:
|
||||
await asyncio.sleep(_BACKOFF_S[attempt])
|
||||
continue
|
||||
logger.exception("{} transcription request error", provider_label)
|
||||
return ""
|
||||
return ""
|
||||
|
||||
|
||||
async def _post_with_retry(
|
||||
build_request: Callable[[], dict[str, Any]],
|
||||
provider_label: str,
|
||||
@@ -663,3 +777,44 @@ class XiaomiMiMoTranscriptionProvider:
|
||||
provider_label="Xiaomi MiMo",
|
||||
language=self.language,
|
||||
)
|
||||
|
||||
|
||||
class StepFunTranscriptionProvider:
|
||||
"""Voice transcription provider using StepFun ASR SSE endpoint."""
|
||||
|
||||
_DEFAULT_URL = "https://api.stepfun.com/v1/audio/asr/sse"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
language: str | None = None,
|
||||
model: str | None = None,
|
||||
):
|
||||
self.api_key = api_key or os.environ.get("STEPFUN_API_KEY")
|
||||
# api_base is used verbatim; users can point to the Plan endpoint
|
||||
# (https://api.stepfun.com/step_plan/v1/audio/asr/sse) or any
|
||||
# compatible proxy.
|
||||
self.api_url = api_base or self._DEFAULT_URL
|
||||
self.language = language or None
|
||||
self.model = model or "stepaudio-2.5-asr"
|
||||
logger.debug("StepFun transcription endpoint: {}", self.api_url)
|
||||
|
||||
async def transcribe(self, file_path: str | Path) -> str:
|
||||
if not self.api_key:
|
||||
logger.warning("StepFun API key not configured for transcription")
|
||||
return ""
|
||||
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
logger.error("Audio file not found: {}", file_path)
|
||||
return ""
|
||||
|
||||
return await _post_stepfun_asr_with_retry(
|
||||
self.api_url,
|
||||
api_key=self.api_key,
|
||||
path=path,
|
||||
model=self.model,
|
||||
provider_label="StepFun",
|
||||
language=self.language,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user