Skip to content

feat(servers): wrappers Kyutai/Cohere locaux + fix chargement Cohere#44

Open
benoitvx wants to merge 3 commits into
mainfrom
feat/local-wrapper-servers
Open

feat(servers): wrappers Kyutai/Cohere locaux + fix chargement Cohere#44
benoitvx wants to merge 3 commits into
mainfrom
feat/local-wrapper-servers

Conversation

@benoitvx

Copy link
Copy Markdown
Contributor

Contexte

Les modèles locaux du bench (Kyutai, Cohere) tournent derrière des petits serveurs OpenAI-compatibles, jusqu'ici non versionnés (~/Dev/kyutai-server). Le README.md principal les documente sans les fournir. Cette PR les intègre sous servers/ pour rendre le bench des modèles locaux reproductible.

Contenu

  • servers/kyutai_server.py — wrapper Kyutai stt-1b-en_fr (chunking ~30 s aligné silences, generate() neuf par segment).
  • servers/cohere_server.py — wrapper Cohere Transcribe 03-2026.
  • servers/pyproject.toml, servers/README.md.

Fix Cohere (le cœur de la PR)

Chargement via la classe native CohereAsrForConditionalGeneration au lieu du chemin remote-code (AutoModelForSpeechSeq2Seq + trust_remote_code).

Symptôme : sous transformers 5.9, le chemin remote-code applique mal la generation_config (decoder_start_token_id) → le modèle sort du texte multilingue aberrant en ignorant l'audio (reproduit sur CPU comme MPS).

Correctif : la classe native (intégrée à transformers ≥ 5.x) applique correctement la config → transcription attendue. Validé sur extrait FR. float32 conservé (le masque d'attention à -1e9 déborde float16).

Test

Bench complet rejoué sur les 8 samples du corpus public : Cohere produit des transcriptions FR correctes (8/8), poussées sur AgentPublic/eval-stt-results.

🤖 Generated with Claude Code

…oading

Add the two local OpenAI-compatible wrapper servers (referenced by the main
README's Kyutai/Cohere sections) under servers/, so the benchmark's local models
are versioned and reproducible:
- kyutai_server.py : Kyutai stt-1b-en_fr, 30s silence-aligned chunking
- cohere_server.py : Cohere Transcribe 03-2026

Cohere fix: load via the native CohereAsrForConditionalGeneration class instead
of the remote-code path (AutoModelForSpeechSeq2Seq + trust_remote_code). Under
transformers 5.9 the remote-code path misapplies generation_config
(decoder_start_token_id) → multilingual garbage that ignores the audio. The
native class applies the config correctly. float32 kept (the -1e9 attention mask
overflows float16).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces two minimal, OpenAI-compatible HTTP servers wrapping local ASR models (Kyutai STT and Cohere Transcribe) for benchmarking, along with documentation and dependency configuration. Feedback on the implementation highlights a critical type mismatch in kyutai_server.py when using float16 on MPS, which requires explicitly casting input tensors to the model's dtype. Additionally, both servers define their transcription endpoints as asynchronous despite performing blocking CPU-bound operations; these should be refactored to synchronous functions with robust temporary file cleanup to prevent event loop blocking and resource leaks. Finally, it is recommended to move generated tokens to the CPU before decoding to avoid potential device compatibility issues.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread servers/kyutai_server.py
if len(chunk) < min_seg:
continue
inputs = processor(audio=chunk, sampling_rate=SR, return_tensors="pt")
inputs = inputs.to(DEVICE)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Lorsque le modèle est chargé en float16 (sur MPS), les tenseurs d'entrée renvoyés par le processeur (qui sont en float32 par défaut) doivent être convertis dans le même type que le modèle pour éviter une erreur de type (RuntimeError: Input type and weight type should be the same). Il est nécessaire de passer explicitement dtype=model.dtype lors de l'appel à .to().

Suggested change
inputs = inputs.to(DEVICE)
inputs = inputs.to(DEVICE, dtype=model.dtype)

Comment thread servers/kyutai_server.py
Comment on lines +110 to +125
@app.post("/v1/audio/transcriptions")
async def transcribe(
file: UploadFile = File(...),
model: str | None = Form(None),
language: str | None = Form(None),
response_format: str | None = Form(None),
):
suffix = Path(file.filename or "audio.wav").suffix or ".wav"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
tmp.write(await file.read())
tmp_path = tmp.name
try:
text = transcribe_path(tmp_path)
finally:
os.unlink(tmp_path)
return {"text": text}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

La fonction transcribe est définie comme async def, mais elle appelle transcribe_path qui effectue des calculs lourds et bloquants (chargement audio, inférence). Cela bloque complètement la boucle d'événements de FastAPI, empêchant le serveur de traiter d'autres requêtes en parallèle. De plus, si une exception survient pendant await file.read(), le fichier temporaire créé avec delete=False ne sera jamais supprimé, ce qui entraîne une fuite de ressources. Définir la route comme une fonction synchrone (def) permet à FastAPI de l'exécuter automatiquement dans un thread séparé, et l'utilisation d'un bloc try...finally robuste garantit la suppression du fichier temporaire.

Suggested change
@app.post("/v1/audio/transcriptions")
async def transcribe(
file: UploadFile = File(...),
model: str | None = Form(None),
language: str | None = Form(None),
response_format: str | None = Form(None),
):
suffix = Path(file.filename or "audio.wav").suffix or ".wav"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
tmp.write(await file.read())
tmp_path = tmp.name
try:
text = transcribe_path(tmp_path)
finally:
os.unlink(tmp_path)
return {"text": text}
@app.post("/v1/audio/transcriptions")
def transcribe(
file: UploadFile = File(...),
model: str | None = Form(None),
language: str | None = Form(None),
response_format: str | None = Form(None),
):
suffix = Path(file.filename or "audio.wav").suffix or ".wav"
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
tmp_path = tmp.name
try:
with tmp:
tmp.write(file.file.read())
text = transcribe_path(tmp_path)
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
return {"text": text}

Comment thread servers/cohere_server.py
Comment on lines +103 to +118
@app.post("/v1/audio/transcriptions")
async def transcribe(
file: UploadFile = File(...),
model: str | None = Form(None),
language: str | None = Form(None),
response_format: str | None = Form(None),
):
suffix = Path(file.filename or "audio.wav").suffix or ".wav"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
tmp.write(await file.read())
tmp_path = tmp.name
try:
text = transcribe_path(tmp_path, language or DEFAULT_LANGUAGE)
finally:
os.unlink(tmp_path)
return {"text": text}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

La fonction transcribe est définie comme async def, mais elle appelle transcribe_path qui effectue des calculs lourds et bloquants. Cela bloque la boucle d'événements de FastAPI. De plus, si une exception survient pendant await file.read(), le fichier temporaire créé avec delete=False ne sera pas supprimé. Définir la route comme synchrone (def) et utiliser un bloc try...finally robuste résout ces deux problèmes.

Suggested change
@app.post("/v1/audio/transcriptions")
async def transcribe(
file: UploadFile = File(...),
model: str | None = Form(None),
language: str | None = Form(None),
response_format: str | None = Form(None),
):
suffix = Path(file.filename or "audio.wav").suffix or ".wav"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
tmp.write(await file.read())
tmp_path = tmp.name
try:
text = transcribe_path(tmp_path, language or DEFAULT_LANGUAGE)
finally:
os.unlink(tmp_path)
return {"text": text}
@app.post("/v1/audio/transcriptions")
def transcribe(
file: UploadFile = File(...),
model: str | None = Form(None),
language: str | None = Form(None),
response_format: str | None = Form(None),
):
suffix = Path(file.filename or "audio.wav").suffix or ".wav"
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
tmp_path = tmp.name
try:
with tmp:
tmp.write(file.file.read())
text = transcribe_path(tmp_path, language or DEFAULT_LANGUAGE)
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
return {"text": text}

Comment thread servers/kyutai_server.py
inputs = processor(audio=chunk, sampling_rate=SR, return_tensors="pt")
inputs = inputs.to(DEVICE)
with torch.no_grad():
output_tokens = model.generate(**inputs)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Il est recommandé de déplacer explicitement les jetons générés (output_tokens) vers le CPU avant de les passer à processor.batch_decode. Certains décodeurs de Hugging Face peuvent lever des erreurs ou être inefficaces s'ils reçoivent des tenseurs situés sur un périphérique non-CPU (comme MPS).

Suggested change
output_tokens = model.generate(**inputs)
output_tokens = model.generate(**inputs).to("cpu")

Comment thread servers/cohere_server.py
audio_chunk_index = inputs.get("audio_chunk_index")
inputs = inputs.to(model.device, dtype=model.dtype)
with torch.no_grad():
outputs = model.generate(**inputs, max_new_tokens=MAX_NEW_TOKENS)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Il est recommandé de déplacer explicitement les tenseurs générés (outputs) vers le CPU avant de les passer à processor.decode pour éviter des erreurs de compatibilité de périphérique avec certains décodeurs de Hugging Face.

Suggested change
outputs = model.generate(**inputs, max_new_tokens=MAX_NEW_TOKENS)
outputs = model.generate(**inputs, max_new_tokens=MAX_NEW_TOKENS).to("cpu")

…load lock

- pyproject: transformers>=5.3 (la classe native CohereAsr requise par le fix
  n'existe pas en 4.53) ; renomme le projet en eval-transcript-servers (héberge
  désormais les 2 wrappers, plus seulement Kyutai).
- cohere_server: docstring corrigée (classe native, plus de trust_remote_code).
- les 2 serveurs: verrou (threading.Lock) sur le lazy-load pour éviter un
  double chargement concurrent (pic mémoire/OOM) ; commentaire sur les params
  OpenAI acceptés mais ignorés (model/response_format).
- README: transformers ≥ 5.3.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@benoitvx

Copy link
Copy Markdown
Contributor Author

🔎 Revue de code (Claude Code)

PR additive pure (+309/−0, 4 fichiers neufs, aucune ligne du package principal touchée) → risque d'intégration nul. Versionne les 2 wrappers serveurs locaux (Kyutai/Cohere) + le fix de chargement Cohere.

🔴 À corriger (correctness / repro)

  • pyproject.toml plancher transformers>=4.53 incompatible avec le fix : la classe native CohereAsrForConditionalGeneration n'existe que dans transformers ≥ 5.x. Sous 4.53, l'import from transformers import CohereAsrForConditionalGeneration plante.
  • Docstring cohere_server.py contredit le fix : disait encore « se charge via les classes Auto + trust_remote_code=True », précisément le chemin abandonné.

🟡 À considérer

  • pyproject name = "kyutai-server" + description Kyutai-only alors que le dossier héberge les deux serveurs.
  • get_model() sans verrou : deux requêtes concurrentes pendant le chargement initial chargeraient le modèle 2× (pic mémoire → OOM, ce qui a tué le serveur Cohere en run).
  • Params model / response_format acceptés puis ignorés (compat OpenAI) — à expliciter.

🟢 Points positifs

  • Fix bien diagnostiqué et commenté à l'endroit du code (float32, classe native, language au decode).
  • Bind 127.0.0.1, nettoyage des fichiers temp en finally.
  • Chunking Kyutai aligné silences avec garde min_seg (pas de boucle infinie, segments vides filtrés).

Tests & sécurité

  • Aucun test (le reste du repo en a). Un smoke-test de _silence_aligned_cuts (monotonie/bornes) serait peu coûteux.
  • Taille d'upload multipart non bornée → DoS théorique, non bloquant pour un serveur local de dev.

✅ Corrections appliquées (commit 3e34ca0)

🔴

  • transformers>=5.3.0 (commentaire + README alignés : « ≥ 5.3, validé sur 5.9 »).
  • Docstring cohere_server.py corrigée (classe native, plus de trust_remote_code).

🟡

  • Verrou threading.Lock sur le lazy-load des 2 serveurs (anti double-chargement / OOM).
  • pyproject renommé kyutai-servereval-transcript-servers + description élargie aux 2 wrappers.
  • Commentaire sur les params OpenAI acceptés mais ignorés (model/response_format).

Non retenu (non bloquant) : ajout de tests, borne de taille d'upload.

🤖 Generated with Claude Code

- HIGH: endpoints async def → def (FastAPI les exécute en threadpool, plus de
  blocage de l'event loop) + cleanup temp file robuste en try/finally (plus de
  fuite si la lecture du body échoue). Les 2 serveurs.
- MEDIUM: rapatrier les tokens générés sur CPU avant decode (compat décodeurs
  HF). Les 2 serveurs. Vérifié sur clip FR.
- CRITICAL (cast dtype Kyutai) NON appliqué : faux positif. Kyutai charge en
  float16 mais garde des biais conv en float32 ; forcer les inputs en float16
  casse l'encodeur ("Input type (c10::Half) and bias type (float)..."). Les
  inputs float32 d'origine sont corrects (confirmé par smoke-test). Commenté.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@benoitvx

Copy link
Copy Markdown
Contributor Author

Réponse aux findings Gemini (commit b2969a1)

🟠 HIGH — endpoints async bloquants + fuite temp file → corrigé (les 2 serveurs). Routes passées en def (FastAPI les exécute en threadpool, l'event loop n'est plus asphyxié) + try/finally robuste (le temp file est supprimé même si la lecture du body échoue).

🟡 MEDIUM — tokens vers CPU avant decode → appliqué (les 2 serveurs). Vérifié sur clip FR.

🔴 CRITICAL — cast dtype=model.dtype (kyutai_server.py) → NON appliqué (faux positif). Vérifié par smoke-test : ce cast casse l'encodeur Kyutai.

RuntimeError: Input type (c10::Half) and bias type (float) should be the same

Le modèle charge en float16 mais conserve des biais de conv en float32 ; les inputs float32 d'origine matchent ces couches (c'est pourquoi le bench tournait sans erreur, 8/8). Forcer les inputs en float16 introduit le mismatch. Le inputs.to(DEVICE) d'origine est donc correct et conservé, avec un commentaire explicatif. (À noter : Cohere, lui, charge en float32 et garde bien dtype=model.dtype — cohérent.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant