Files

897 lines
29 KiB
Python

"""
Main FastAPI application for Qwen3-TTS OpenAI-like API.
Provides streaming and standard TTS endpoints.
"""
import io
import os
import json
import torch
import logging
import soundfile as sf
from contextlib import asynccontextmanager
from pathlib import Path
from typing import AsyncGenerator, Optional, Dict, List
from datetime import datetime, timedelta
from collections import defaultdict
import uuid
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks, UploadFile, File
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from qwen_tts import Qwen3TTSModel
from .models import (
TTSRequest, StreamingTTSRequest, TTSResponse, ErrorResponse,
HealthResponse, ModelInfoResponse, ModelsListResponse, VoiceCloneMode,
VoiceResponse, VoicesListResponse, TextValidationRequest, TextValidationResponse,
CreateBatchRequest, BatchJobResponse, BatchResultsResponse, BatchResultItem, BatchJobStatus,
UsageResponse, QuotaResponse, AudioFormat, ConvertAudioRequest, ConvertAudioResponse,
ModelConfigResponse
)
from .utils import (
generate_request_id, get_unix_timestamp, audio_to_base64,
get_audio_duration, load_voice_clone_files, concatenate_audio_chunks,
base64_to_audio
)
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# ============== Storage Classes ==============
class VoiceStorage:
"""Storage for custom voice clones."""
def __init__(self):
self.voices: Dict[str, Dict] = {}
self.storage_dir = Path(__file__).parent.parent / "assets" / "custom_voices"
self.storage_dir.mkdir(parents=True, exist_ok=True)
def create_voice(self, name: str, language: str, audio_data: bytes, ref_text: str) -> str:
"""Create and store a custom voice."""
voice_id = f"voice_{uuid.uuid4().hex[:12]}"
voice_info = {
"id": voice_id,
"name": name,
"language": language,
"created_at": get_unix_timestamp(),
}
# Save voice files
voice_dir = self.storage_dir / voice_id
voice_dir.mkdir(parents=True, exist_ok=True)
with open(voice_dir / "audio.wav", "wb") as f:
f.write(audio_data)
with open(voice_dir / "text.txt", "w", encoding="utf-8") as f:
f.write(ref_text)
with open(voice_dir / "metadata.json", "w") as f:
json.dump(voice_info, f)
self.voices[voice_id] = voice_info
return voice_id
def get_voice(self, voice_id: str) -> Optional[Dict]:
"""Get voice information."""
return self.voices.get(voice_id)
def list_voices(self) -> List[Dict]:
"""List all custom voices."""
return list(self.voices.values())
def delete_voice(self, voice_id: str) -> bool:
"""Delete a custom voice."""
if voice_id not in self.voices:
return False
del self.voices[voice_id]
voice_dir = self.storage_dir / voice_id
if voice_dir.exists():
import shutil
shutil.rmtree(voice_dir)
return True
def get_voice_prompt(self, voice_id: str, model) -> Optional[any]:
"""Load voice clone prompt from storage."""
if voice_id not in self.voices:
return None
voice_dir = self.storage_dir / voice_id
audio_path = voice_dir / "audio.wav"
text_path = voice_dir / "text.txt"
if not audio_path.exists() or not text_path.exists():
return None
with open(text_path, "r", encoding="utf-8") as f:
ref_text = f.read().strip()
try:
return model.create_voice_clone_prompt(
ref_audio=str(audio_path),
ref_text=ref_text,
)
except Exception as e:
logger.error(f"Error creating voice prompt: {e}")
return None
class BatchJobStorage:
"""Storage for batch jobs."""
def __init__(self):
self.jobs: Dict[str, Dict] = {}
self.storage_dir = Path(__file__).parent.parent / "assets" / "batch_jobs"
self.storage_dir.mkdir(parents=True, exist_ok=True)
def create_job(self, items: list, model: str) -> str:
"""Create a new batch job."""
job_id = f"batch_{uuid.uuid4().hex[:12]}"
timestamp = get_unix_timestamp()
job_info = {
"id": job_id,
"status": BatchJobStatus.PENDING.value,
"created_at": timestamp,
"updated_at": timestamp,
"model": model,
"items": items,
"results": {},
"request_counts": {
"total": len(items),
"processing": 0,
"completed": 0,
"failed": 0,
}
}
self.jobs[job_id] = job_info
# Save to disk
job_dir = self.storage_dir / job_id
job_dir.mkdir(parents=True, exist_ok=True)
with open(job_dir / "job.json", "w") as f:
json.dump(job_info, f, default=str)
return job_id
def get_job(self, job_id: str) -> Optional[Dict]:
"""Get job information."""
return self.jobs.get(job_id)
def update_job_status(self, job_id: str, status: str) -> bool:
"""Update job status."""
if job_id not in self.jobs:
return False
self.jobs[job_id]["status"] = status
self.jobs[job_id]["updated_at"] = get_unix_timestamp()
return True
def add_result(self, job_id: str, index: int, result: Dict) -> bool:
"""Add a result to the job."""
if job_id not in self.jobs:
return False
self.jobs[job_id]["results"][str(index)] = result
return True
class UsageTracker:
"""Track API usage."""
def __init__(self):
self.requests_made = 0
self.audio_seconds_generated = 0.0
self.requests_by_model: Dict[str, int] = defaultdict(int)
self.requests_by_language: Dict[str, int] = defaultdict(int)
self.request_times: List[int] = []
def record_request(self, model: str, language: str, duration: float = 0.0):
"""Record a TTS request."""
self.requests_made += 1
self.audio_seconds_generated += duration
self.requests_by_model[model] += 1
self.requests_by_language[language] += 1
self.request_times.append(get_unix_timestamp())
# Keep only last hour of requests for rate limiting
cutoff = get_unix_timestamp() - 3600
self.request_times = [t for t in self.request_times if t > cutoff]
def get_usage(self) -> Dict:
"""Get usage statistics."""
return {
"requests_made": self.requests_made,
"audio_generated_seconds": self.audio_seconds_generated,
"audio_generated_minutes": self.audio_seconds_generated / 60.0,
"requests_by_model": dict(self.requests_by_model),
"requests_by_language": dict(self.requests_by_language),
}
def get_remaining_requests(self, limit_per_minute: int = 60) -> int:
"""Get remaining requests in current minute."""
cutoff = get_unix_timestamp() - 60
recent = len([t for t in self.request_times if t > cutoff])
return max(0, limit_per_minute - recent)
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Global model state
class ModelState:
"""Global state for the TTS model."""
def __init__(self):
self.model: Optional[Qwen3TTSModel] = None
self.voice_clone_prompt = None
self.device = "cuda" if torch.cuda.is_available() else "cpu"
self.model_name = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
self.ready = False
# Storage instances
self.voice_storage = VoiceStorage()
self.batch_storage = BatchJobStorage()
self.usage_tracker = UsageTracker()
model_state = ModelState()
async def load_model():
"""Load the TTS model asynchronously."""
try:
logger.info(f"Loading model: {model_state.model_name} on device: {model_state.device}")
# Determine device_map and dtype based on device
if model_state.device == "cuda":
device_map = "cuda:0"
dtype = torch.bfloat16
attn_impl = "flash_attention_2"
else:
device_map = "cpu"
dtype = torch.float32
attn_impl = "eager"
model_state.model = Qwen3TTSModel.from_pretrained(
model_state.model_name,
device_map=device_map,
dtype=dtype,
attn_implementation=attn_impl,
)
# Enable streaming optimizations
model_state.model.enable_streaming_optimizations(
decode_window_frames=80,
use_compile=False, # Set to True if your system supports it
)
# Load voice cloning reference audio if available
voice_clone_dir = Path(__file__).parent.parent / "assets" / "voice_cloning"
if voice_clone_dir.exists():
ref_audio_path, ref_text = load_voice_clone_files(str(voice_clone_dir))
if ref_audio_path and ref_text:
logger.info(f"Creating voice clone prompt from: {ref_audio_path}")
try:
model_state.voice_clone_prompt = model_state.model.create_voice_clone_prompt(
ref_audio=ref_audio_path,
ref_text=ref_text,
)
logger.info("Voice clone prompt created successfully")
except Exception as e:
logger.warning(f"Failed to create voice clone prompt: {e}")
else:
logger.info("Voice cloning files (ref_audio.wav, ref_text.txt) not found in assets/voice_cloning")
model_state.ready = True
logger.info("Model loaded and ready")
except Exception as e:
logger.error(f"Failed to load model: {e}")
model_state.ready = False
raise
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Lifespan context manager for FastAPI app."""
# Startup
await load_model()
yield
# Shutdown
logger.info("Shutting down")
# Create FastAPI app
app = FastAPI(
title="Qwen3-TTS API",
description="OpenAI-like API for Qwen3 Text-to-Speech streaming",
version="0.1.0",
lifespan=lifespan,
)
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ============== Error Handlers ==============
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
"""Handle HTTP exceptions."""
return JSONResponse(
status_code=exc.status_code,
content=ErrorResponse(
error=exc.detail,
code=f"http_{exc.status_code}",
).dict(),
)
@app.exception_handler(ValueError)
async def value_error_handler(request: Request, exc: ValueError):
"""Handle value errors."""
return JSONResponse(
status_code=400,
content=ErrorResponse(
error=str(exc),
code="validation_error",
).dict(),
)
# ============== Health Check Endpoint ==============
@app.get("/v1/health", response_model=HealthResponse)
async def health_check():
"""Check API health and model readiness."""
return HealthResponse(
status="healthy" if model_state.ready else "unavailable",
model=model_state.model_name,
device=model_state.device,
ready=model_state.ready,
)
# ============== Models Endpoint ==============
@app.get("/v1/models", response_model=ModelsListResponse)
async def list_models():
"""List available TTS models."""
models = [
ModelInfoResponse(
id="Qwen/Qwen3-TTS-12Hz-1.7B-Base",
supported_languages=["English", "Russian", "Chinese", "Japanese", "Korean"],
supports_streaming=True,
supports_voice_clone=True if model_state.voice_clone_prompt else False,
),
]
return ModelsListResponse(data=models)
# ============== Standard TTS Endpoint ==============
@app.post("/v1/audio/speech", response_model=TTSResponse)
async def text_to_speech(request: TTSRequest):
"""
Convert text to speech.
Returns audio as base64-encoded WAV in response.
"""
if not model_state.ready:
raise HTTPException(status_code=503, detail="Model not ready")
request_id = generate_request_id()
logger.info(f"[{request_id}] TTS request: {request.text[:50]}...")
try:
# Prepare voice clone prompt if mode is enabled
voice_clone_prompt = None
if request.voice_clone_mode == VoiceCloneMode.REFERENCE_AUDIO:
if model_state.voice_clone_prompt is None:
raise ValueError("Voice cloning not available: no reference audio loaded")
voice_clone_prompt = model_state.voice_clone_prompt
# Generate speech (voice_clone_prompt can be None for base model)
wavs, sample_rate = model_state.model.generate_voice_clone(
text=request.text,
language=request.language,
voice_clone_prompt=voice_clone_prompt,
)
audio = wavs[0]
duration = get_audio_duration(audio, sample_rate)
audio_base64 = audio_to_base64(audio, sample_rate)
# Track usage
model_state.usage_tracker.record_request(
model=str(request.model),
language=request.language,
duration=duration,
)
logger.info(f"[{request_id}] TTS complete: {duration:.2f}s audio generated")
return TTSResponse(
id=request_id,
created=get_unix_timestamp(),
model=request.model,
audio_base64=audio_base64,
duration=duration,
sample_rate=sample_rate,
language=request.language,
)
except Exception as e:
logger.error(f"[{request_id}] Error during TTS: {e}")
raise HTTPException(status_code=500, detail=f"TTS generation failed: {str(e)}")
# ============== Streaming TTS Endpoint ==============
async def stream_audio_chunks(request: StreamingTTSRequest) -> AsyncGenerator[bytes, None]:
"""
Generator for streaming audio chunks as WAV frames.
Uses chunked encoding to stream audio as it's being generated.
"""
request_id = generate_request_id()
logger.info(f"[{request_id}] Streaming TTS request: {request.text[:50]}...")
try:
# Prepare voice clone prompt if mode is enabled
voice_clone_prompt = None
if request.voice_clone_mode == VoiceCloneMode.REFERENCE_AUDIO:
if model_state.voice_clone_prompt is None:
logger.warning(f"[{request_id}] Voice cloning not available, using base model")
else:
voice_clone_prompt = model_state.voice_clone_prompt
# Stream generation (voice_clone_prompt can be None for base model)
chunk_count = 0
first_chunk_time = None
import time
start_time = time.time()
stream_gen = model_state.model.stream_generate_voice_clone(
text=request.text,
language=request.language,
voice_clone_prompt=voice_clone_prompt,
emit_every_frames=request.stream_options.emit_every_frames,
decode_window_frames=request.stream_options.decode_window_frames,
overlap_samples=request.stream_options.overlap_samples,
)
for chunk, sample_rate in stream_gen:
chunk_count += 1
if first_chunk_time is None:
first_chunk_time = time.time() - start_time
logger.info(f"[{request_id}] First chunk in {first_chunk_time:.2f}s")
# Convert chunk to WAV bytes
with io.BytesIO() as wav_buffer:
sf.write(wav_buffer, chunk, sample_rate, format='WAV')
wav_bytes = wav_buffer.getvalue()
# Write chunk size as 4 bytes (big-endian)
yield len(wav_bytes).to_bytes(4, byteorder='big')
yield wav_bytes
total_time = time.time() - start_time
logger.info(f"[{request_id}] Streaming complete: {chunk_count} chunks in {total_time:.2f}s")
except Exception as e:
logger.error(f"[{request_id}] Error during streaming: {e}")
raise
@app.post("/v1/audio/speech/stream")
async def stream_text_to_speech(request: StreamingTTSRequest):
"""
Stream text-to-speech audio chunks.
Returns audio chunks with frame length prefixes for streaming consumption.
"""
if not model_state.ready:
raise HTTPException(status_code=503, detail="Model not ready")
return StreamingResponse(
stream_audio_chunks(request),
media_type="application/octet-stream",
headers={
"Content-Disposition": "attachment; filename=audio_stream.bin",
"X-Accel-Buffering": "no", # Disable proxy buffering
}
)
# ============== Voice Management Endpoints ==============
@app.post("/v1/voices/upload", response_model=VoiceResponse)
async def upload_voice(
name: str,
language: str,
audio: UploadFile = File(...),
ref_text: str = None,
):
"""
Upload and register a custom voice clone.
Args:
name: Name for the voice
language: Language of the voice
audio: WAV audio file
ref_text: Reference transcription text
"""
if not model_state.ready:
raise HTTPException(status_code=503, detail="Model not ready")
if not ref_text:
raise HTTPException(status_code=400, detail="ref_text is required")
try:
# Read audio file
audio_data = await audio.read()
# Validate audio
try:
voice_audio, sr = sf.read(io.BytesIO(audio_data), dtype="float32")
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid audio file: {str(e)}")
# Create and store voice
voice_id = model_state.voice_storage.create_voice(
name=name,
language=language,
audio_data=audio_data,
ref_text=ref_text,
)
voice = model_state.voice_storage.get_voice(voice_id)
logger.info(f"Voice created: {voice_id} ({name})")
return VoiceResponse(
id=voice["id"],
name=voice["name"],
language=voice["language"],
created_at=voice["created_at"],
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error uploading voice: {e}")
raise HTTPException(status_code=500, detail=f"Failed to upload voice: {str(e)}")
@app.get("/v1/voices", response_model=VoicesListResponse)
async def list_voices():
"""List all custom voices."""
voices = model_state.voice_storage.list_voices()
return VoicesListResponse(
data=[
VoiceResponse(
id=v["id"],
name=v["name"],
language=v["language"],
created_at=v["created_at"],
)
for v in voices
]
)
@app.delete("/v1/voices/{voice_id}")
async def delete_voice(voice_id: str):
"""Delete a custom voice."""
if not model_state.voice_storage.delete_voice(voice_id):
raise HTTPException(status_code=404, detail="Voice not found")
return {"object": "voice.deleted", "id": voice_id}
# ============== Text Validation Endpoint ==============
@app.post("/v1/text/validate", response_model=TextValidationResponse)
async def validate_text(request: TextValidationRequest):
"""
Validate text for TTS synthesis.
Checks character count, language compatibility, and estimates duration.
"""
text = request.text.strip()
if not text:
raise HTTPException(status_code=400, detail="Text cannot be empty")
if len(text) > 10000:
raise HTTPException(status_code=400, detail="Text exceeds maximum length of 10000 characters")
warnings = []
# Estimate duration (rough: ~150 words per minute, avg 5 chars per word)
estimated_words = len(text) / 5
estimated_seconds = estimated_words / 2.5 # ~150 wpm
# Check for potential issues
if len(text) < 3:
warnings.append("Text is very short, may result in poor quality")
if len(text) > 5000:
warnings.append("Text is very long, generation may take several minutes")
# Check for unsupported characters (basic check)
if "😀" in text or "🎵" in text:
warnings.append("Text contains emoji which may be handled differently")
return TextValidationResponse(
valid=True,
language=request.language,
character_count=len(text),
estimated_duration=max(0.5, estimated_seconds),
warnings=warnings,
)
# ============== Batch Processing Endpoints ==============
@app.post("/v1/batch/create", response_model=BatchJobResponse)
async def create_batch_job(request: CreateBatchRequest):
"""
Create a batch job for processing multiple texts.
Batch processing is asynchronous. Use /v1/batch/{job_id} to check status.
"""
if not model_state.ready:
raise HTTPException(status_code=503, detail="Model not ready")
if len(request.items) > 100:
raise HTTPException(status_code=400, detail="Maximum batch size is 100 items")
job_id = model_state.batch_storage.create_job(
items=[item.dict() for item in request.items],
model=str(request.model),
)
job = model_state.batch_storage.get_job(job_id)
logger.info(f"Batch job created: {job_id} with {len(request.items)} items")
return BatchJobResponse(
id=job["id"],
status=BatchJobStatus(job["status"]),
created_at=job["created_at"],
updated_at=job["updated_at"],
request_counts=job["request_counts"],
)
@app.get("/v1/batch/{job_id}", response_model=BatchJobResponse)
async def get_batch_job(job_id: str):
"""Check the status of a batch job."""
job = model_state.batch_storage.get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Batch job not found")
return BatchJobResponse(
id=job["id"],
status=BatchJobStatus(job["status"]),
created_at=job["created_at"],
updated_at=job["updated_at"],
request_counts=job["request_counts"],
output_file_id=job.get("output_file_id"),
)
@app.get("/v1/batch/{job_id}/results", response_model=BatchResultsResponse)
async def get_batch_results(job_id: str):
"""Get results from a completed batch job."""
job = model_state.batch_storage.get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Batch job not found")
if job["status"] == BatchJobStatus.PENDING.value:
raise HTTPException(status_code=400, detail="Batch job is still pending")
results = [
BatchResultItem(
index=int(idx),
status="success" if "audio_base64" in result else "error",
error=result.get("error"),
audio_base64=result.get("audio_base64"),
duration=result.get("duration"),
)
for idx, result in job["results"].items()
]
return BatchResultsResponse(
job_id=job_id,
status=BatchJobStatus(job["status"]),
data=results,
)
# ============== Usage & Quota Endpoints ==============
@app.get("/v1/usage", response_model=UsageResponse)
async def get_usage():
"""Get API usage statistics."""
usage = model_state.usage_tracker.get_usage()
return UsageResponse(
requests_made=usage["requests_made"],
audio_generated_seconds=usage["audio_generated_seconds"],
audio_generated_minutes=usage["audio_generated_minutes"],
requests_by_model=usage["requests_by_model"],
requests_by_language=usage["requests_by_language"],
)
@app.get("/v1/quota", response_model=QuotaResponse)
async def get_quota():
"""Get current quota and rate limits."""
remaining = model_state.usage_tracker.get_remaining_requests(limit_per_minute=60)
return QuotaResponse(
requests_per_minute=60,
max_text_length=10000,
max_batch_size=100,
concurrent_requests=4,
remaining_requests=remaining,
)
# ============== Audio Conversion Endpoint ==============
@app.post("/v1/audio/convert", response_model=ConvertAudioResponse)
async def convert_audio(request: ConvertAudioRequest):
"""
Convert audio format or sample rate.
Supports: WAV, MP3, OGG, FLAC
"""
try:
from .utils import base64_to_audio
# Decode audio
audio, sr = base64_to_audio(request.audio_base64)
# Resample if needed
target_sr = request.target_sample_rate or sr
if target_sr != sr:
import librosa
audio = librosa.resample(audio, orig_sr=sr, target_sr=target_sr)
sr = target_sr
# Convert format (for now, we'll output WAV internally and encode)
# In production, you'd use pydub or ffmpeg for actual format conversion
output_base64 = audio_to_base64(audio, sr)
request_id = generate_request_id()
return ConvertAudioResponse(
id=request_id,
created=get_unix_timestamp(),
format=request.target_format.value,
sample_rate=sr,
audio_base64=output_base64,
duration=get_audio_duration(audio, sr),
)
except Exception as e:
logger.error(f"Error converting audio: {e}")
raise HTTPException(status_code=500, detail=f"Audio conversion failed: {str(e)}")
# ============== Model Configuration Endpoint ==============
@app.get("/v1/models/{model_id}/config", response_model=ModelConfigResponse)
async def get_model_config(model_id: str):
"""Get detailed configuration for a model."""
if model_id != "Qwen/Qwen3-TTS-12Hz-1.7B-Base":
raise HTTPException(status_code=404, detail="Model not found")
return ModelConfigResponse(
id=model_id,
size="1.7B",
tokenizer_type="Qwen3TTSTokenizer",
languages=["English", "Russian", "Chinese", "Japanese", "Korean"],
max_text_length=10000,
output_sample_rate=24000,
streaming_supported=True,
voice_cloning_supported=True,
recommended_parameters={
"temperature": 0.9,
"top_p": 1.0,
"top_k": 50,
"do_sample": True,
"repetition_penalty": 1.05,
"emit_every_frames": 8,
"decode_window_frames": 80,
},
)
@app.get("/v1/models/{model_id}/languages")
async def get_model_languages(model_id: str):
"""Get supported languages for a model."""
if model_id != "Qwen/Qwen3-TTS-12Hz-1.7B-Base":
raise HTTPException(status_code=404, detail="Model not found")
return {
"object": "list",
"model": model_id,
"languages": [
{"code": "en", "name": "English"},
{"code": "ru", "name": "Russian"},
{"code": "zh", "name": "Chinese"},
{"code": "ja", "name": "Japanese"},
{"code": "ko", "name": "Korean"},
{"code": "auto", "name": "Auto-detect"},
]
}
# ============== Root Endpoint ==============
@app.get("/")
async def root():
"""Root endpoint with API information."""
return {
"name": "Qwen3-TTS API",
"version": "0.2.0",
"description": "OpenAI-like API for Qwen3 Text-to-Speech streaming",
"documentation": "/docs",
"endpoints": {
"health": "/v1/health",
"models": {
"list": "/v1/models",
"config": "/v1/models/{model_id}/config",
"languages": "/v1/models/{model_id}/languages",
},
"speech": {
"generate": "/v1/audio/speech",
"stream": "/v1/audio/speech/stream",
"convert": "/v1/audio/convert",
},
"voices": {
"upload": "POST /v1/voices/upload",
"list": "/v1/voices",
"delete": "DELETE /v1/voices/{voice_id}",
},
"text": {
"validate": "POST /v1/text/validate",
},
"batch": {
"create": "POST /v1/batch/create",
"status": "/v1/batch/{job_id}",
"results": "/v1/batch/{job_id}/results",
},
"usage": {
"statistics": "/v1/usage",
"quota": "/v1/quota",
}
}
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(
app,
host="0.0.0.0",
port=8000,
log_level="info",
)