Moteur OpenRouter (google/gemini-3.5-flash-lite), par defaut

Meme consigne, meme encodage, meme repli local que le moteur Gemini direct ;
seuls l'adresse, l'authentification (Bearer) et le format de requete
(input_audio, compatible OpenAI) changent. Cle : trousseau zonza-openrouter,
puis OPENROUTER_API_KEY. Un 200 portant un objet error est traite comme un
echec, pas comme une transcription vide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 08:35:11 +02:00
co-authored by Claude Opus 5.5
parent 9be2ea030b
commit 50b12795d3
6 changed files with 198 additions and 17 deletions
+4 -2
View File
@@ -76,14 +76,16 @@ def choisir_transcripteur(nom, modele, langue, amorce):
peripherique = "cuda" if nom == "faster-gpu" else "cpu"
type_calcul = "float16" if nom == "faster-gpu" else "int8"
return TranscripteurFasterWhisper(modele, langue, amorce, peripherique, type_calcul)
if nom == "gemini":
if nom in ("gemini", "openrouter"):
# Toujours enveloppe d'un repli : la dictee ne doit jamais etre perdue
# parce que le reseau ou l'API tombe (cf moteur/gemini.py).
from core import modele_pour_moteur
from moteur.gemini import TranscripteurAvecRepli, TranscripteurGemini
from moteur.openrouter import TranscripteurOpenRouter
classe = TranscripteurGemini if nom == "gemini" else TranscripteurOpenRouter
local = _moteur_local()
return TranscripteurAvecRepli(
TranscripteurGemini(modele, langue, amorce),
classe(modele, langue, amorce),
fabriquer_secours=lambda: choisir_transcripteur(
local, modele_pour_moteur(local), langue, amorce),
)
+17 -11
View File
@@ -58,8 +58,8 @@ def audio_en_wav(audio, sample_rate):
return tampon.getvalue()
def _lire_trousseau():
"""Clé rangée par `security add-generic-password -s zonza-gemini ...`.
def _lire_trousseau(service=SERVICE_TROUSSEAU):
"""Clé rangée par `security add-generic-password -s <service> ...`.
Le trousseau, et non `~/.zshrc` : Zonza est lancée par le Finder, qui ne
lit jamais la configuration du shell — une clé exportée dans `.zshrc`
@@ -69,7 +69,7 @@ def _lire_trousseau():
return None
try:
sortie = subprocess.run(
["security", "find-generic-password", "-s", SERVICE_TROUSSEAU, "-w"],
["security", "find-generic-password", "-s", service, "-w"],
capture_output=True, text=True, timeout=5,
)
except Exception:
@@ -79,19 +79,25 @@ def _lire_trousseau():
return sortie.stdout.strip() or None
def lire_cle(trousseau=None, environ=None):
"""La clé du trousseau, sinon GEMINI_API_KEY, sinon None."""
trousseau = trousseau or _lire_trousseau
def lire_cle(trousseau=None, environ=None, service=SERVICE_TROUSSEAU,
variable="GEMINI_API_KEY"):
"""La clé du trousseau, sinon la variable d'environnement, sinon None."""
trousseau = trousseau or (lambda: _lire_trousseau(service))
environ = os.environ if environ is None else environ
return trousseau() or environ.get("GEMINI_API_KEY") or None
return trousseau() or environ.get(variable) or None
def _envoyer(url, cle, corps, delai_s):
"""POST JSON. La clé part en en-tête, jamais dans l'URL (qui finit en journal)."""
"""La clé part en en-tête, jamais dans l'URL (qui finit en journal)."""
return poster_json(url, {"x-goog-api-key": cle}, corps, delai_s)
def poster_json(url, entetes, corps, delai_s):
"""POST JSON ; toute erreur HTTP ou réseau devient une ErreurGemini."""
requete = urllib.request.Request(
url,
data=json.dumps(corps).encode("utf-8"),
headers={"Content-Type": "application/json", "x-goog-api-key": cle},
headers={"Content-Type": "application/json", **entetes},
method="POST",
)
try:
@@ -179,12 +185,12 @@ class TranscripteurAvecRepli:
try:
self.principal.warmup()
except Exception as e:
print(f"[gemini] indisponible ({e}) — moteur local chargé en secours")
print(f"[api] indisponible ({e}) — moteur local chargé en secours")
self._secours_pret()
def transcrire(self, audio):
try:
return self.principal.transcrire(audio)
except Exception as e:
print(f"[gemini] échec ({e}) — transcription locale")
print(f"[api] échec ({e}) — transcription locale")
return self._secours_pret().transcrire(audio)
+79
View File
@@ -0,0 +1,79 @@
"""Transcription par OpenRouter — même famille Gemini, autre guichet.
Comme `moteur/gemini.py`, ce moteur N'EST PAS local : l'audio part chez
OpenRouter, qui le relaie à Google. L'encodage, la consigne, le repli local et
la lecture de la clé sont partagés avec le moteur Gemini direct ; seuls
l'adresse, l'authentification et le format de requête (compatible OpenAI,
partie `input_audio`) diffèrent.
"""
import base64
import json
from core import clean_transcript
from moteur.gemini import (
CONSIGNE, DELAI_RESEAU_S, ErreurGemini, audio_en_wav, lire_cle, poster_json,
)
URL_API = "https://openrouter.ai/api/v1/chat/completions"
SERVICE_TROUSSEAU = "zonza-openrouter"
def lire_cle_openrouter(trousseau=None, environ=None):
"""La clé du trousseau `zonza-openrouter`, sinon OPENROUTER_API_KEY."""
return lire_cle(trousseau, environ, service=SERVICE_TROUSSEAU,
variable="OPENROUTER_API_KEY")
def _envoyer(url, entetes, corps, delai_s):
return poster_json(url, entetes, corps, delai_s)
class TranscripteurOpenRouter:
"""Implémentation du contrat Transcripteur via OpenRouter."""
def __init__(self, modele, langue, amorce, sample_rate=16000,
lire_cle=lire_cle_openrouter, envoyer=_envoyer):
self.modele = modele
self.langue = langue
self.amorce = amorce or ""
self.sample_rate = sample_rate
self._lire_cle = lire_cle
self._envoyer = envoyer
def _cle(self):
cle = self._lire_cle()
if not cle:
raise ErreurGemini(
"aucune clé OpenRouter : lancer `security add-generic-password "
f"-s {SERVICE_TROUSSEAU} -a zonza -w` puis relancer Zonza"
)
return cle
def warmup(self):
"""Rien à charger : on vérifie seulement la clé, sans appel payant."""
self._cle()
def transcrire(self, audio):
wav = base64.b64encode(audio_en_wav(audio, self.sample_rate)).decode("ascii")
corps = {
"model": self.modele,
"messages": [
{"role": "system", "content": CONSIGNE.format(
langue=self.langue, amorce=self.amorce)},
{"role": "user", "content": [
{"type": "input_audio", "input_audio": {"data": wav, "format": "wav"}},
{"type": "text", "text": "Transcris cet enregistrement."},
]},
],
}
entetes = {"Authorization": f"Bearer {self._cle()}", "X-Title": "Zonza"}
reponse = self._envoyer(URL_API, entetes, corps, DELAI_RESEAU_S)
# OpenRouter peut rendre un 200 portant un objet `error` quand le
# fournisseur en aval tombe : ce n'est PAS une transcription vide.
if "error" in reponse:
erreur = reponse["error"]
raise ErreurGemini(f"OpenRouter : {erreur.get('message', erreur)}")
choix = reponse.get("choices") or []
if not choix:
raise ErreurGemini(f"réponse sans transcription : {json.dumps(reponse)[:300]}")
return clean_transcript(choix[0].get("message", {}).get("content") or "")