Compare commits
5 commits
main
...
studio/ext
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b33cd70ba | ||
|
|
4c29c5e9d3 | ||
|
|
cbb4929139 | ||
|
|
58523dc4a9 | ||
|
|
51321be31a |
10 changed files with 421 additions and 67 deletions
|
|
@ -302,6 +302,28 @@ class AudioCodecManager:
|
||||||
waveform = audio.squeeze().cpu().numpy()
|
waveform = audio.squeeze().cpu().numpy()
|
||||||
return _numpy_to_wav_bytes(waveform, 24000), 24000
|
return _numpy_to_wav_bytes(waveform, 24000), 24000
|
||||||
|
|
||||||
|
def decode(
|
||||||
|
self,
|
||||||
|
audio_type: str,
|
||||||
|
device: str,
|
||||||
|
token_ids: Optional[list] = None,
|
||||||
|
text: Optional[str] = None,
|
||||||
|
) -> Tuple[bytes, int]:
|
||||||
|
"""Unified decode — dispatches to the right codec decoder."""
|
||||||
|
if audio_type == "snac":
|
||||||
|
if not token_ids:
|
||||||
|
raise ValueError("SNAC decoding requires token_ids")
|
||||||
|
return self.decode_snac(torch.tensor([token_ids], dtype = torch.long), device)
|
||||||
|
elif audio_type == "bicodec":
|
||||||
|
if not text:
|
||||||
|
raise ValueError("BiCodec decoding requires text")
|
||||||
|
return self.decode_bicodec(text, device)
|
||||||
|
elif audio_type == "dac":
|
||||||
|
if not text:
|
||||||
|
raise ValueError("DAC decoding requires text")
|
||||||
|
return self.decode_dac(text, device)
|
||||||
|
raise ValueError(f"Cannot decode audio_type: {audio_type}")
|
||||||
|
|
||||||
# ── Cleanup ──────────────────────────────────────────────────
|
# ── Cleanup ──────────────────────────────────────────────────
|
||||||
|
|
||||||
def unload(self) -> None:
|
def unload(self) -> None:
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ through its OpenAI-compatible /v1/chat/completions endpoint.
|
||||||
|
|
||||||
import atexit
|
import atexit
|
||||||
import json
|
import json
|
||||||
|
import struct
|
||||||
import structlog
|
import structlog
|
||||||
from loggers import get_logger
|
from loggers import get_logger
|
||||||
import shutil
|
import shutil
|
||||||
|
|
@ -45,6 +46,8 @@ class LlamaCppBackend:
|
||||||
self._hf_variant: Optional[str] = None
|
self._hf_variant: Optional[str] = None
|
||||||
self._is_vision: bool = False
|
self._is_vision: bool = False
|
||||||
self._healthy = False
|
self._healthy = False
|
||||||
|
self._context_length: Optional[int] = None
|
||||||
|
self._chat_template: Optional[str] = None
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._stdout_lines: list[str] = []
|
self._stdout_lines: list[str] = []
|
||||||
self._stdout_thread: Optional[threading.Thread] = None
|
self._stdout_thread: Optional[threading.Thread] = None
|
||||||
|
|
@ -80,6 +83,14 @@ class LlamaCppBackend:
|
||||||
def hf_variant(self) -> Optional[str]:
|
def hf_variant(self) -> Optional[str]:
|
||||||
return self._hf_variant
|
return self._hf_variant
|
||||||
|
|
||||||
|
@property
|
||||||
|
def context_length(self) -> Optional[int]:
|
||||||
|
return self._context_length
|
||||||
|
|
||||||
|
@property
|
||||||
|
def chat_template(self) -> Optional[str]:
|
||||||
|
return self._chat_template
|
||||||
|
|
||||||
# ── Binary discovery ──────────────────────────────────────────
|
# ── Binary discovery ──────────────────────────────────────────
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -371,6 +382,99 @@ class LlamaCppBackend:
|
||||||
# Pipe closed — process is terminating
|
# Pipe closed — process is terminating
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# GGUF KV type sizes for fast skipping
|
||||||
|
_GGUF_TYPE_SIZE = {
|
||||||
|
0: 1,
|
||||||
|
1: 1,
|
||||||
|
2: 2,
|
||||||
|
3: 2,
|
||||||
|
4: 4,
|
||||||
|
5: 4,
|
||||||
|
6: 4,
|
||||||
|
7: 1,
|
||||||
|
10: 8,
|
||||||
|
11: 8,
|
||||||
|
12: 8,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _gguf_skip_value(f, vtype: int) -> None:
|
||||||
|
"""Skip a GGUF KV value without reading it."""
|
||||||
|
sz = LlamaCppBackend._GGUF_TYPE_SIZE.get(vtype)
|
||||||
|
if sz is not None:
|
||||||
|
f.seek(sz, 1)
|
||||||
|
elif vtype == 8: # STRING
|
||||||
|
slen = struct.unpack("<Q", f.read(8))[0]
|
||||||
|
f.seek(slen, 1)
|
||||||
|
elif vtype == 9: # ARRAY
|
||||||
|
atype = struct.unpack("<I", f.read(4))[0]
|
||||||
|
alen = struct.unpack("<Q", f.read(8))[0]
|
||||||
|
elem_sz = LlamaCppBackend._GGUF_TYPE_SIZE.get(atype)
|
||||||
|
if elem_sz is not None:
|
||||||
|
f.seek(elem_sz * alen, 1)
|
||||||
|
elif atype == 8:
|
||||||
|
for _ in range(alen):
|
||||||
|
slen = struct.unpack("<Q", f.read(8))[0]
|
||||||
|
f.seek(slen, 1)
|
||||||
|
else:
|
||||||
|
for _ in range(alen):
|
||||||
|
LlamaCppBackend._gguf_skip_value(f, atype)
|
||||||
|
|
||||||
|
def _read_gguf_metadata(self, gguf_path: str) -> None:
|
||||||
|
"""Read context_length and chat_template from a GGUF file's KV header.
|
||||||
|
|
||||||
|
Parses only the KV pairs we need (~30ms even for multi-GB files).
|
||||||
|
For split GGUFs, metadata is always in shard 1.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
WANTED = {"general.architecture", "tokenizer.chat_template"}
|
||||||
|
arch = None
|
||||||
|
ctx_key = None
|
||||||
|
|
||||||
|
with open(gguf_path, "rb") as f:
|
||||||
|
magic = struct.unpack("<I", f.read(4))[0]
|
||||||
|
if magic != 0x46554747: # b"GGUF" as little-endian u32
|
||||||
|
return
|
||||||
|
_version = struct.unpack("<I", f.read(4))[0]
|
||||||
|
_tensor_count, kv_count = struct.unpack("<QQ", f.read(16))
|
||||||
|
|
||||||
|
for _ in range(kv_count):
|
||||||
|
key_len = struct.unpack("<Q", f.read(8))[0]
|
||||||
|
key = f.read(key_len).decode("utf-8")
|
||||||
|
vtype = struct.unpack("<I", f.read(4))[0]
|
||||||
|
|
||||||
|
if key in WANTED or (ctx_key and key == ctx_key):
|
||||||
|
# Read this value
|
||||||
|
if vtype == 8: # STRING
|
||||||
|
slen = struct.unpack("<Q", f.read(8))[0]
|
||||||
|
val_s = f.read(slen).decode("utf-8")
|
||||||
|
if key == "general.architecture":
|
||||||
|
arch = val_s
|
||||||
|
ctx_key = f"{arch}.context_length"
|
||||||
|
elif key == "tokenizer.chat_template":
|
||||||
|
self._chat_template = val_s
|
||||||
|
elif vtype == 4: # UINT32
|
||||||
|
val_i = struct.unpack("<I", f.read(4))[0]
|
||||||
|
if ctx_key and key == ctx_key:
|
||||||
|
self._context_length = val_i
|
||||||
|
elif vtype == 10: # UINT64
|
||||||
|
val_i = struct.unpack("<Q", f.read(8))[0]
|
||||||
|
if ctx_key and key == ctx_key:
|
||||||
|
self._context_length = val_i
|
||||||
|
else:
|
||||||
|
self._gguf_skip_value(f, vtype)
|
||||||
|
else:
|
||||||
|
self._gguf_skip_value(f, vtype)
|
||||||
|
|
||||||
|
if self._context_length:
|
||||||
|
logger.info(f"GGUF metadata: context_length={self._context_length}")
|
||||||
|
if self._chat_template:
|
||||||
|
logger.info(
|
||||||
|
f"GGUF metadata: chat_template={len(self._chat_template)} chars"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to read GGUF metadata: {e}")
|
||||||
|
|
||||||
# ── HF download (no lock held) ───────────────────────────────
|
# ── HF download (no lock held) ───────────────────────────────
|
||||||
|
|
||||||
def _download_gguf(
|
def _download_gguf(
|
||||||
|
|
@ -642,6 +746,9 @@ class LlamaCppBackend:
|
||||||
else:
|
else:
|
||||||
raise ValueError("Either gguf_path or hf_repo must be provided")
|
raise ValueError("Either gguf_path or hf_repo must be provided")
|
||||||
|
|
||||||
|
# Read GGUF metadata (context_length, chat_template) -- fast, header only
|
||||||
|
self._read_gguf_metadata(model_path)
|
||||||
|
|
||||||
# Check cancel after download
|
# Check cancel after download
|
||||||
if self._cancel_event.is_set():
|
if self._cancel_event.is_set():
|
||||||
logger.info("Load cancelled after download phase")
|
logger.info("Load cancelled after download phase")
|
||||||
|
|
@ -779,8 +886,20 @@ class LlamaCppBackend:
|
||||||
self._hf_repo = None
|
self._hf_repo = None
|
||||||
self._hf_variant = None
|
self._hf_variant = None
|
||||||
self._is_vision = False
|
self._is_vision = False
|
||||||
|
self._is_audio = False
|
||||||
|
self._audio_type = None
|
||||||
self._port = None
|
self._port = None
|
||||||
self._healthy = False
|
self._healthy = False
|
||||||
|
self._context_length = None
|
||||||
|
self._chat_template = None
|
||||||
|
# Free audio codec GPU memory
|
||||||
|
if LlamaCppBackend._codec_mgr is not None:
|
||||||
|
LlamaCppBackend._codec_mgr.unload()
|
||||||
|
LlamaCppBackend._codec_mgr = None
|
||||||
|
import torch
|
||||||
|
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
torch.cuda.empty_cache()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _kill_process(self):
|
def _kill_process(self):
|
||||||
|
|
@ -1028,3 +1147,149 @@ class LlamaCppBackend:
|
||||||
if cancel_event is not None and cancel_event.is_set():
|
if cancel_event is not None and cancel_event.is_set():
|
||||||
return
|
return
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
# ── TTS support ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def detect_audio_type(self) -> Optional[str]:
|
||||||
|
"""Detect audio/TTS codec by probing the loaded model's vocabulary."""
|
||||||
|
if not self.is_loaded:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout = 10) as client:
|
||||||
|
|
||||||
|
def _detok(tid: int) -> str:
|
||||||
|
r = client.post(
|
||||||
|
f"{self.base_url}/detokenize", json = {"tokens": [tid]}
|
||||||
|
)
|
||||||
|
return r.json().get("content", "") if r.status_code == 200 else ""
|
||||||
|
|
||||||
|
def _tok(text: str) -> list[int]:
|
||||||
|
r = client.post(
|
||||||
|
f"{self.base_url}/tokenize",
|
||||||
|
json = {"content": text, "add_special": False},
|
||||||
|
)
|
||||||
|
return r.json().get("tokens", []) if r.status_code == 200 else []
|
||||||
|
|
||||||
|
# Check codec-specific tokens (not generic ones that may exist in non-audio models)
|
||||||
|
if "<custom_token_" in _detok(128258) and "<custom_token_" in _detok(
|
||||||
|
128259
|
||||||
|
):
|
||||||
|
return "snac"
|
||||||
|
if len(_tok("<|AUDIO|>")) == 1 and len(_tok("<|audio_eos|>")) == 1:
|
||||||
|
return "csm"
|
||||||
|
if len(_tok("<|startoftranscript|>")) == 1:
|
||||||
|
return "whisper"
|
||||||
|
if (
|
||||||
|
len(_tok("<|bicodec_semantic_0|>")) == 1
|
||||||
|
and len(_tok("<|bicodec_global_0|>")) == 1
|
||||||
|
):
|
||||||
|
return "bicodec"
|
||||||
|
if len(_tok("<|c1_0|>")) == 1 and len(_tok("<|c2_0|>")) == 1:
|
||||||
|
return "dac"
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Audio type detection failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Prompt format per codec: (template, stop_tokens, needs_token_ids)
|
||||||
|
# Matches prompts in InferenceBackend._generate_snac/bicodec/dac
|
||||||
|
_TTS_PROMPTS = {
|
||||||
|
"snac": (
|
||||||
|
"<custom_token_3>{text}<|eot_id|><custom_token_4>",
|
||||||
|
["<custom_token_2>"],
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
"bicodec": (
|
||||||
|
"<|task_tts|><|start_content|>{text}<|end_content|><|start_global_token|>",
|
||||||
|
["<|im_end|>", "</s>"],
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
"dac": (
|
||||||
|
"<|im_start|>\n<|text_start|>{text}<|text_end|>\n<|audio_start|><|global_features_start|>\n",
|
||||||
|
["<|im_end|>", "<|audio_end|>"],
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
_codec_mgr = None # Shared AudioCodecManager instance
|
||||||
|
|
||||||
|
def init_audio_codec(self, audio_type: str) -> None:
|
||||||
|
"""Load the audio codec at model load time (mirrors non-GGUF path)."""
|
||||||
|
import torch
|
||||||
|
from core.inference.audio_codecs import AudioCodecManager
|
||||||
|
|
||||||
|
if LlamaCppBackend._codec_mgr is None:
|
||||||
|
LlamaCppBackend._codec_mgr = AudioCodecManager()
|
||||||
|
|
||||||
|
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
model_repo_path = None
|
||||||
|
|
||||||
|
# BiCodec needs a repo with BiCodec/ weights — download canonical SparkTTS
|
||||||
|
if audio_type == "bicodec":
|
||||||
|
from huggingface_hub import snapshot_download
|
||||||
|
import os
|
||||||
|
|
||||||
|
repo_path = snapshot_download(
|
||||||
|
"unsloth/Spark-TTS-0.5B", local_dir = "Spark-TTS-0.5B"
|
||||||
|
)
|
||||||
|
model_repo_path = os.path.abspath(repo_path)
|
||||||
|
|
||||||
|
LlamaCppBackend._codec_mgr.load_codec(
|
||||||
|
audio_type, device, model_repo_path = model_repo_path
|
||||||
|
)
|
||||||
|
logger.info(f"Loaded audio codec for GGUF TTS: {audio_type}")
|
||||||
|
|
||||||
|
def generate_audio_response(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
audio_type: str,
|
||||||
|
temperature: float = 0.6,
|
||||||
|
top_p: float = 0.95,
|
||||||
|
top_k: int = 50,
|
||||||
|
min_p: float = 0.0,
|
||||||
|
max_new_tokens: int = 2048,
|
||||||
|
repetition_penalty: float = 1.1,
|
||||||
|
) -> tuple:
|
||||||
|
"""
|
||||||
|
Generate TTS audio via llama-server /completion + codec decoding.
|
||||||
|
Returns (wav_bytes, sample_rate).
|
||||||
|
"""
|
||||||
|
if audio_type not in self._TTS_PROMPTS:
|
||||||
|
raise RuntimeError(f"GGUF TTS does not support '{audio_type}' codec.")
|
||||||
|
|
||||||
|
tpl, stop, need_ids = self._TTS_PROMPTS[audio_type]
|
||||||
|
|
||||||
|
payload: dict = {
|
||||||
|
"prompt": tpl.format(text = text),
|
||||||
|
"stream": False,
|
||||||
|
"n_predict": max_new_tokens,
|
||||||
|
"temperature": temperature,
|
||||||
|
"top_p": top_p,
|
||||||
|
"top_k": top_k if top_k >= 0 else 0,
|
||||||
|
"min_p": min_p,
|
||||||
|
"repeat_penalty": repetition_penalty,
|
||||||
|
}
|
||||||
|
if stop:
|
||||||
|
payload["stop"] = stop
|
||||||
|
if need_ids:
|
||||||
|
payload["n_probs"] = 1
|
||||||
|
|
||||||
|
with httpx.Client(timeout = httpx.Timeout(300, connect = 10)) as client:
|
||||||
|
resp = client.post(f"{self.base_url}/completion", json = payload)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"llama-server returned {resp.status_code}: {resp.text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
token_ids = (
|
||||||
|
[p["id"] for p in data.get("completion_probabilities", []) if "id" in p]
|
||||||
|
if need_ids
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
import torch
|
||||||
|
|
||||||
|
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
return LlamaCppBackend._codec_mgr.decode(
|
||||||
|
audio_type, device, token_ids = token_ids, text = data.get("content", "")
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,9 @@ class LoadResponse(BaseModel):
|
||||||
inference: dict = Field(
|
inference: dict = Field(
|
||||||
..., description = "Inference parameters (temperature, top_p, top_k, min_p)"
|
..., description = "Inference parameters (temperature, top_p, top_k, min_p)"
|
||||||
)
|
)
|
||||||
|
context_length: Optional[int] = Field(
|
||||||
|
None, description = "Model's native context length (from GGUF metadata)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class UnloadResponse(BaseModel):
|
class UnloadResponse(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,17 @@ async def load_model(
|
||||||
|
|
||||||
logger.info(f"Loaded GGUF model via llama-server: {config.identifier}")
|
logger.info(f"Loaded GGUF model via llama-server: {config.identifier}")
|
||||||
|
|
||||||
|
# Detect TTS audio by probing the loaded model's vocabulary
|
||||||
|
from utils.models import is_audio_input_type
|
||||||
|
|
||||||
|
_gguf_audio = llama_backend.detect_audio_type()
|
||||||
|
_gguf_is_audio = _gguf_audio in ("snac", "bicodec", "dac")
|
||||||
|
llama_backend._is_audio = _gguf_is_audio
|
||||||
|
llama_backend._audio_type = _gguf_audio
|
||||||
|
if _gguf_is_audio:
|
||||||
|
logger.info(f"GGUF model detected as audio: audio_type={_gguf_audio}")
|
||||||
|
await asyncio.to_thread(llama_backend.init_audio_codec, _gguf_audio)
|
||||||
|
|
||||||
inference_config = load_inference_config(config.identifier)
|
inference_config = load_inference_config(config.identifier)
|
||||||
|
|
||||||
return LoadResponse(
|
return LoadResponse(
|
||||||
|
|
@ -164,7 +175,11 @@ async def load_model(
|
||||||
is_vision = config.is_vision,
|
is_vision = config.is_vision,
|
||||||
is_lora = False,
|
is_lora = False,
|
||||||
is_gguf = True,
|
is_gguf = True,
|
||||||
|
is_audio = _gguf_is_audio,
|
||||||
|
audio_type = _gguf_audio,
|
||||||
|
has_audio_input = is_audio_input_type(_gguf_audio),
|
||||||
inference = inference_config,
|
inference = inference_config,
|
||||||
|
context_length = llama_backend.context_length,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Standard path: load via Unsloth/transformers ──────────
|
# ── Standard path: load via Unsloth/transformers ──────────
|
||||||
|
|
@ -473,6 +488,8 @@ async def get_status(
|
||||||
is_vision = llama_backend.is_vision,
|
is_vision = llama_backend.is_vision,
|
||||||
is_gguf = True,
|
is_gguf = True,
|
||||||
gguf_variant = llama_backend.hf_variant,
|
gguf_variant = llama_backend.hf_variant,
|
||||||
|
is_audio = getattr(llama_backend, "_is_audio", False),
|
||||||
|
audio_type = getattr(llama_backend, "_audio_type", None),
|
||||||
loading = [],
|
loading = [],
|
||||||
loaded = [llama_backend.model_identifier],
|
loaded = [llama_backend.model_identifier],
|
||||||
)
|
)
|
||||||
|
|
@ -521,78 +538,84 @@ async def generate_audio(
|
||||||
"""
|
"""
|
||||||
Generate audio (TTS) from the latest user message.
|
Generate audio (TTS) from the latest user message.
|
||||||
Returns a JSON response with base64-encoded WAV audio.
|
Returns a JSON response with base64-encoded WAV audio.
|
||||||
Only works when an audio model is loaded.
|
Works with both GGUF (llama-server) and Unsloth/transformers backends.
|
||||||
"""
|
"""
|
||||||
import base64
|
import base64
|
||||||
|
|
||||||
backend = get_inference_backend()
|
|
||||||
if not backend.active_model_name:
|
|
||||||
raise HTTPException(status_code = 400, detail = "No model loaded.")
|
|
||||||
|
|
||||||
model_info = backend.models.get(backend.active_model_name, {})
|
|
||||||
if not model_info.get("is_audio"):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code = 400, detail = "Active model is not an audio model."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Extract text from the last user message
|
# Extract text from the last user message
|
||||||
_, chat_messages, _ = _extract_content_parts(payload.messages)
|
_, chat_messages, _ = _extract_content_parts(payload.messages)
|
||||||
if not chat_messages:
|
if not chat_messages:
|
||||||
raise HTTPException(status_code = 400, detail = "No messages provided.")
|
raise HTTPException(status_code = 400, detail = "No messages provided.")
|
||||||
|
|
||||||
last_user_msg = next(
|
last_user_msg = next(
|
||||||
(m for m in reversed(chat_messages) if m["role"] == "user"), None
|
(m for m in reversed(chat_messages) if m["role"] == "user"), None
|
||||||
)
|
)
|
||||||
if not last_user_msg:
|
if not last_user_msg:
|
||||||
raise HTTPException(status_code = 400, detail = "No user message found.")
|
raise HTTPException(status_code = 400, detail = "No user message found.")
|
||||||
|
|
||||||
text = last_user_msg["content"]
|
text = last_user_msg["content"]
|
||||||
|
|
||||||
|
# Pick backend — both return (wav_bytes, sample_rate)
|
||||||
|
llama_backend = get_llama_cpp_backend()
|
||||||
|
if llama_backend.is_loaded and getattr(llama_backend, "_is_audio", False):
|
||||||
|
model_name = llama_backend.model_identifier
|
||||||
|
gen = lambda: llama_backend.generate_audio_response(
|
||||||
|
text = text,
|
||||||
|
audio_type = llama_backend._audio_type,
|
||||||
|
temperature = payload.temperature,
|
||||||
|
top_p = payload.top_p,
|
||||||
|
top_k = payload.top_k,
|
||||||
|
min_p = payload.min_p,
|
||||||
|
max_new_tokens = payload.max_tokens or 2048,
|
||||||
|
repetition_penalty = payload.repetition_penalty,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
backend = get_inference_backend()
|
||||||
|
if not backend.active_model_name:
|
||||||
|
raise HTTPException(status_code = 400, detail = "No model loaded.")
|
||||||
|
model_info = backend.models.get(backend.active_model_name, {})
|
||||||
|
if not model_info.get("is_audio"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code = 400, detail = "Active model is not an audio model."
|
||||||
|
)
|
||||||
|
model_name = backend.active_model_name
|
||||||
|
gen = lambda: backend.generate_audio_response(
|
||||||
|
text = text,
|
||||||
|
temperature = payload.temperature,
|
||||||
|
top_p = payload.top_p,
|
||||||
|
top_k = payload.top_k,
|
||||||
|
min_p = payload.min_p,
|
||||||
|
max_new_tokens = payload.max_tokens or 2048,
|
||||||
|
repetition_penalty = payload.repetition_penalty,
|
||||||
|
use_adapter = payload.use_adapter,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
wav_bytes, sample_rate = await asyncio.get_event_loop().run_in_executor(
|
wav_bytes, sample_rate = await asyncio.get_event_loop().run_in_executor(
|
||||||
None,
|
None, gen
|
||||||
lambda: backend.generate_audio_response(
|
|
||||||
text = text,
|
|
||||||
temperature = payload.temperature,
|
|
||||||
top_p = payload.top_p,
|
|
||||||
top_k = payload.top_k,
|
|
||||||
min_p = payload.min_p,
|
|
||||||
max_new_tokens = payload.max_tokens or 2048,
|
|
||||||
repetition_penalty = payload.repetition_penalty,
|
|
||||||
use_adapter = payload.use_adapter,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
audio_b64 = base64.b64encode(wav_bytes).decode("ascii")
|
|
||||||
completion_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
|
|
||||||
|
|
||||||
return JSONResponse(
|
|
||||||
content = {
|
|
||||||
"id": completion_id,
|
|
||||||
"object": "chat.completion.audio",
|
|
||||||
"model": backend.active_model_name,
|
|
||||||
"audio": {
|
|
||||||
"data": audio_b64,
|
|
||||||
"format": "wav",
|
|
||||||
"sample_rate": sample_rate,
|
|
||||||
},
|
|
||||||
"choices": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"message": {
|
|
||||||
"role": "assistant",
|
|
||||||
"content": f'[Generated audio from: "{text[:100]}"]',
|
|
||||||
},
|
|
||||||
"finish_reason": "stop",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Audio generation error: {e}", exc_info = True)
|
logger.error(f"Audio generation error: {e}", exc_info = True)
|
||||||
raise HTTPException(status_code = 500, detail = str(e))
|
raise HTTPException(status_code = 500, detail = str(e))
|
||||||
|
|
||||||
|
audio_b64 = base64.b64encode(wav_bytes).decode("ascii")
|
||||||
|
return JSONResponse(
|
||||||
|
content = {
|
||||||
|
"id": f"chatcmpl-{uuid.uuid4().hex[:12]}",
|
||||||
|
"object": "chat.completion.audio",
|
||||||
|
"model": model_name,
|
||||||
|
"audio": {"data": audio_b64, "format": "wav", "sample_rate": sample_rate},
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": f'[Generated audio from: "{text[:100]}"]',
|
||||||
|
},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
# OpenAI-Compatible Chat Completions (/chat/completions)
|
# OpenAI-Compatible Chat Completions (/chat/completions)
|
||||||
|
|
@ -714,6 +737,8 @@ async def openai_chat_completions(
|
||||||
# ── Determine which backend is active ─────────────────────
|
# ── Determine which backend is active ─────────────────────
|
||||||
if using_gguf:
|
if using_gguf:
|
||||||
model_name = llama_backend.model_identifier or payload.model
|
model_name = llama_backend.model_identifier or payload.model
|
||||||
|
if getattr(llama_backend, "_is_audio", False):
|
||||||
|
return await generate_audio(payload, request)
|
||||||
else:
|
else:
|
||||||
backend = get_inference_backend()
|
backend = get_inference_backend()
|
||||||
if not backend.active_model_name:
|
if not backend.active_model_name:
|
||||||
|
|
|
||||||
|
|
@ -304,6 +304,22 @@ async def list_models(
|
||||||
)
|
)
|
||||||
loaded_models.append(model_info)
|
loaded_models.append(model_info)
|
||||||
|
|
||||||
|
# Include active GGUF model (loaded via llama-server)
|
||||||
|
from routes.inference import get_llama_cpp_backend
|
||||||
|
|
||||||
|
llama_backend = get_llama_cpp_backend()
|
||||||
|
if llama_backend.is_loaded and llama_backend.model_identifier:
|
||||||
|
loaded_models.append(
|
||||||
|
ModelDetails(
|
||||||
|
id = llama_backend.model_identifier,
|
||||||
|
name = llama_backend.model_identifier.split("/")[-1],
|
||||||
|
is_gguf = True,
|
||||||
|
is_vision = llama_backend.is_vision,
|
||||||
|
is_audio = getattr(llama_backend, "_is_audio", False),
|
||||||
|
audio_type = getattr(llama_backend, "_audio_type", None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Combine default and loaded models
|
# Combine default and loaded models
|
||||||
all_models = []
|
all_models = []
|
||||||
seen_ids = set()
|
seen_ids = set()
|
||||||
|
|
|
||||||
|
|
@ -340,6 +340,14 @@ function isGgufRepo(id: string): boolean {
|
||||||
return id.toUpperCase().includes("-GGUF");
|
return id.toUpperCase().includes("-GGUF");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Extract param count label from model name (e.g. "Qwen3-0.6B" -> "0.6B"). */
|
||||||
|
function extractParamLabel(id: string): string | undefined {
|
||||||
|
// Match patterns like "0.6B", "1B", "4B", "3.5B", "70B", "1.5B" etc.
|
||||||
|
const name = id.split("/").pop() ?? id;
|
||||||
|
const match = name.match(/(?:^|[-_])(\d+(?:\.\d+)?)[Bb](?:[-_]|$)/);
|
||||||
|
return match ? `${match[1]}B` : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// Module-level caches so re-mounting the popover shows results instantly
|
// Module-level caches so re-mounting the popover shows results instantly
|
||||||
let _cachedGgufCache: CachedGgufRepo[] = [];
|
let _cachedGgufCache: CachedGgufRepo[] = [];
|
||||||
let _cachedModelsCache: CachedModelRepo[] = [];
|
let _cachedModelsCache: CachedModelRepo[] = [];
|
||||||
|
|
@ -558,7 +566,7 @@ export function HubModelPicker({
|
||||||
meta={
|
meta={
|
||||||
isGgufRepo(id)
|
isGgufRepo(id)
|
||||||
? "GGUF"
|
? "GGUF"
|
||||||
: vram?.detail ?? undefined
|
: vram?.detail ?? extractParamLabel(id)
|
||||||
}
|
}
|
||||||
selected={value === id}
|
selected={value === id}
|
||||||
onClick={() => handleModelClick(id)}
|
onClick={() => handleModelClick(id)}
|
||||||
|
|
@ -593,7 +601,7 @@ export function HubModelPicker({
|
||||||
meta={
|
meta={
|
||||||
isGgufRepo(id)
|
isGgufRepo(id)
|
||||||
? "GGUF"
|
? "GGUF"
|
||||||
: metricsById.get(id)
|
: metricsById.get(id) ?? extractParamLabel(id)
|
||||||
}
|
}
|
||||||
selected={value === id}
|
selected={value === id}
|
||||||
onClick={() => handleModelClick(id)}
|
onClick={() => handleModelClick(id)}
|
||||||
|
|
|
||||||
|
|
@ -86,10 +86,17 @@ function toOpenAIMessage(message: RunMessage): {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
let content = collectTextParts(message).join("\n");
|
||||||
role: message.role,
|
// Strip inline audio base64 from prior assistant messages to avoid
|
||||||
content: collectTextParts(message).join("\n"),
|
// inflating token counts (e.g. audio-player responses with embedded WAV).
|
||||||
};
|
if (message.role === "assistant") {
|
||||||
|
content = content.replace(
|
||||||
|
/data:audio\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]+/g,
|
||||||
|
"[audio]",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { role: message.role, content };
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractImageBase64(input: string): string | undefined {
|
function extractImageBase64(input: string): string | undefined {
|
||||||
|
|
@ -194,7 +201,8 @@ function waitForModelReady(abortSignal?: AbortSignal): Promise<void> {
|
||||||
async function autoLoadSmallestModel(): Promise<boolean> {
|
async function autoLoadSmallestModel(): Promise<boolean> {
|
||||||
const toastId = toast("Loading a model…", {
|
const toastId = toast("Loading a model…", {
|
||||||
description: "Auto-selecting the smallest downloaded model.",
|
description: "Auto-selecting the smallest downloaded model.",
|
||||||
duration: Infinity,
|
duration: 5000,
|
||||||
|
closeButton: true,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const [ggufRepos, modelRepos] = await Promise.all([
|
const [ggufRepos, modelRepos] = await Promise.all([
|
||||||
|
|
@ -214,7 +222,7 @@ async function autoLoadSmallestModel(): Promise<boolean> {
|
||||||
.sort((a, b) => a.size_bytes - b.size_bytes);
|
.sort((a, b) => a.size_bytes - b.size_bytes);
|
||||||
if (downloaded.length > 0) {
|
if (downloaded.length > 0) {
|
||||||
const variant = downloaded[0];
|
const variant = downloaded[0];
|
||||||
await loadModel({
|
const loadResp = await loadModel({
|
||||||
model_path: repo.repo_id,
|
model_path: repo.repo_id,
|
||||||
hf_token: null,
|
hf_token: null,
|
||||||
max_seq_length: 4096,
|
max_seq_length: 4096,
|
||||||
|
|
@ -223,7 +231,9 @@ async function autoLoadSmallestModel(): Promise<boolean> {
|
||||||
gguf_variant: variant.quant,
|
gguf_variant: variant.quant,
|
||||||
trust_remote_code: false,
|
trust_remote_code: false,
|
||||||
});
|
});
|
||||||
useChatRuntimeStore.getState().setCheckpoint(repo.repo_id, variant.quant);
|
const store = useChatRuntimeStore.getState();
|
||||||
|
store.setCheckpoint(repo.repo_id, variant.quant);
|
||||||
|
store.setParams({ ...store.params, maxTokens: loadResp.context_length ?? 131072 });
|
||||||
toast.success(`Loaded ${repo.repo_id} (${variant.quant})`, { id: toastId });
|
toast.success(`Loaded ${repo.repo_id} (${variant.quant})`, { id: toastId });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -247,7 +257,9 @@ async function autoLoadSmallestModel(): Promise<boolean> {
|
||||||
gguf_variant: null,
|
gguf_variant: null,
|
||||||
trust_remote_code: false,
|
trust_remote_code: false,
|
||||||
});
|
});
|
||||||
useChatRuntimeStore.getState().setCheckpoint(repo.repo_id);
|
const store = useChatRuntimeStore.getState();
|
||||||
|
store.setCheckpoint(repo.repo_id);
|
||||||
|
store.setParams({ ...store.params, maxTokens: 4096 });
|
||||||
toast.success(`Loaded ${repo.repo_id}`, { id: toastId });
|
toast.success(`Loaded ${repo.repo_id}`, { id: toastId });
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -340,10 +340,10 @@ export function ChatSettingsPanel({
|
||||||
label="Max Tokens"
|
label="Max Tokens"
|
||||||
value={params.maxTokens}
|
value={params.maxTokens}
|
||||||
min={64}
|
min={64}
|
||||||
max={isGguf ? 131072 : 32768}
|
max={isGguf ? params.maxTokens : 32768}
|
||||||
step={64}
|
step={isGguf ? params.maxTokens : 64}
|
||||||
onChange={set("maxTokens")}
|
onChange={set("maxTokens")}
|
||||||
displayValue={isGguf && params.maxTokens >= 131072 ? "Max" : undefined}
|
displayValue={isGguf ? "Max" : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
|
||||||
|
|
@ -132,9 +132,11 @@ function mergeRecommendedInference(
|
||||||
modelId: string,
|
modelId: string,
|
||||||
): InferenceParams {
|
): InferenceParams {
|
||||||
const inference = response.inference;
|
const inference = response.inference;
|
||||||
// GGUF: max tokens = 131072 (effectively unlimited, model decides)
|
// GGUF: use actual context length from GGUF metadata, fallback to 131072
|
||||||
// Non-GGUF: max tokens = 4096
|
// Non-GGUF: 4096
|
||||||
const defaultMaxTokens = response.is_gguf ? 131072 : 4096;
|
const defaultMaxTokens = response.is_gguf
|
||||||
|
? (response.context_length ?? 131072)
|
||||||
|
: 4096;
|
||||||
return {
|
return {
|
||||||
...current,
|
...current,
|
||||||
checkpoint: modelId,
|
checkpoint: modelId,
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,7 @@ export interface LoadModelResponse {
|
||||||
min_p?: number;
|
min_p?: number;
|
||||||
trust_remote_code?: boolean;
|
trust_remote_code?: boolean;
|
||||||
};
|
};
|
||||||
|
context_length?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UnloadModelRequest {
|
export interface UnloadModelRequest {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue