← Retour au blog

Une clé API, plusieurs locataires : Comment nous isolons les traductions de DeepL à travers les clients

Rasepi utilise une seule clé API DeepL pour tous les locataires. Voici comment nous gérons les glossaires par client, les règles de style, les traductions mises en cache et l'isolation au niveau des blocs sans que rien ne fuie.

Sous le capot
Une clé API, plusieurs locataires : Comment nous isolons les traductions de DeepL à travers les clients

Une question revient chaque fois que j'explique l'architecture de traduction de Rasepi à un autre développeur : "Attendez, tous vos locataires partagent une clé API DeepL ? Comment faites-vous pour que leurs glossaires et leurs règles de style ne s'infiltrent pas les uns dans les autres ?"

C'est une question légitime. Et la réponse implique plus de travail de conception que vous ne l'imaginez.

Nous avons abordé le [pipeline de traduction complet] (/fr/blog/inside-the-translation-engine-glossaries-style-rules-and-smart-retranslation/) dans un article précédent, le hachage au niveau des blocs, l'orchestrateur, l'ensemble du flux allant de l'enregistrement du document à la sortie traduite. Ce billet se penche sur le problème spécifique de la multi-location. Comment prendre une API tierce qui n'a pas de concept de locataires et construire une isolation de locataire au-dessus d'elle.

Le problème : DeepL ne connaît pas vos clients

L'API de DeepL s'authentifie à l'aide d'une seule clé d'API. Tout ce qui est créé sous cette clé, glossaires, listes de règles de style, historique des traductions, appartient au même compte. Il n'y a pas de notion de "ce glossaire appartient au locataire A" du côté de DeepL.

Lorsque vous appelez GET /v2/glossaries, vous obtenez tous les glossaires de tous les locataires. Lorsque vous créez une liste de règles de style, elle vit dans le même espace de noms que les règles de style de tous les autres locataires. L'API est plate.

Pour un produit auto-hébergé où chaque client exécute sa propre instance avec sa propre clé DeepL, c'est parfait. Pour un SaaS multi-locataires où nous gérons l'infrastructure ? Vous avez besoin d'une couche d'isolation.

La base de données est la source de vérité

Notre principale décision de conception : la base de données possède tout le contenu du glossaire et la configuration des règles de style. DeepL est une cible d'exécution, rien de plus.

Chaque entité TenantGlossary et TenantStyleRuleList implémente ITenantScoped, ce qui signifie que les filtres de requête globaux d'EF Core étendent automatiquement toutes les lectures au locataire actuel. Une requête pour des glossaires dans le contexte de requête du locataire A ne renverra jamais les entrées du locataire B. Il s'agit du même modèle d'isolation que nous utilisons partout dans Rasepi, appliqué au niveau de l'ORM.

Voici ce qui est intéressant. Lorsqu'un locataire modifie un terme du glossaire, nous n'appelons pas immédiatement DeepL. Nous mettons à jour la ligne de la base de données et définissons IsDirty = true. C'est tout. Le glossaire DeepL est créé (ou recréé) paresseusement, juste avant que la prochaine traduction n'en ait besoin.

public async Task<string?> GetOrSyncDeepLGlossaryIdAsync(
    string sourceLanguage, string targetLanguage)
{
    var glossary = await _db.TenantGlossaries
        .Include(g => g.Entries)
        .FirstOrDefaultAsync(g =>
            g.SourceLanguage == sourceLanguage &&
            g.TargetLanguage == targetLanguage);

    if (glossary?.Entries.Count == 0) return null;

    if (!glossary.IsDirty && glossary.DeepLGlossaryId is not null)
        return glossary.DeepLGlossaryId;

    // Dirty: delete old, create new
    if (glossary.DeepLGlossaryId is not null)
        await _deepL.DeleteGlossaryAsync(glossary.DeepLGlossaryId);

    var entries = glossary.Entries
        .ToDictionary(e => e.SourceTerm, e => e.TargetTerm);

    var created = await _deepL.CreateGlossaryAsync(
        $"rasepi-{glossary.Id}",
        glossary.SourceLanguage,
        glossary.TargetLanguage,
        entries);

    glossary.DeepLGlossaryId = created.GlossaryId;
    glossary.IsDirty = false;
    glossary.LastSyncedAt = DateTime.UtcNow;
    await _db.SaveChangesAsync();

    return glossary.DeepLGlossaryId;
}

Le filtre de requête sur TenantGlossaries assure l'isolation. Le drapeau IsDirty assure la synchronisation paresseuse. Et la convention de nommage (rasepi-{glossary.Id}) n'est utilisée que pour le débogage dans le tableau de bord DeepL, elle n'a pas d'utilité fonctionnelle.

Pourquoi paresseux ? Parce que les glossaires DeepL v2 sont immuables. Vous ne pouvez pas les modifier. Toute modification doit être supprimée et recréée. Si une équipe importe un CSV contenant 200 termes et corrige ensuite une faute de frappe dans une entrée, nous ne voulons pas supprimer et recréer le glossaire DeepL deux fois. Il suffit de définir IsDirty les deux fois et la recréation unique se produit lors de l'exécution de la traduction suivante.

Règles de style : même modèle, API différente

Les règles de style de DeepL sont plus récentes (API v3) et mutables, ce qui est plus agréable. Vous pouvez mettre à jour les règles configurées en place avec PUT /v3/style_rules/{style_id}/configured_rules, et les instructions personnalisées peuvent être ajoutées ou supprimées individuellement.

Nous utilisons toujours le même modèle IsDirty. Un TenantStyleRuleList a un DeepLStyleId qui correspond à l'identifiant d'exécution de DeepL, plus un ConfiguredRulesJson pour les règles de formatage et une collection d'entrées TenantCustomInstruction pour les directives de traduction en texte libre.

La véritable puissance réside dans ces instructions personnalisées. Chacune d'entre elles est une directive en langage clair, d'une longueur maximale de 300 caractères, qui détermine la manière dont DeepL traduit. Exemples réels de nos locataires :

  • Pour un cabinet d'avocats allemand : "Utilisez toujours la forme "Sie", jamais "du"".
  • Traduire "deployment" par "Bereitstellung", jamais par "Deployment", pour des termes dépendant du contexte qui dépassent les simples correspondances du glossaire.
  • Utiliser l'orthographe de l'anglais britannique (couleur, organisation, licence) pour une entreprise britannique qui traduit entre les différentes variantes de l'anglais.
  • "Placez les symboles monétaires après le montant numérique" pour les conventions européennes

Chaque locataire peut avoir des instructions complètement différentes par langue cible, toutes derrière la même clé API. L'isolation vient du fait que chaque appel de traduction n'inclut que les glossary_id et style_id appartenant au locataire demandeur. Les ressources DeepL des autres locataires ne sont jamais référencées.

L'appel de traduction : tout se compose

Lorsque l'orchestrateur traduit un bloc, il assemble tous les paramètres spécifiques au locataire en une seule demande :

var glossaryId = await _glossaryService
    .GetOrSyncDeepLGlossaryIdAsync(sourceLang, targetLang);
var styleId = await _styleRuleService
    .GetOrSyncStyleIdAsync(targetLang);
var formality = langConfig.Formality ?? "default";

var options = new TranslationOptions
{
    GlossaryId = glossaryId,
    StyleId = styleId,
    Formality = formality,
    Context = documentContext,
    ModelType = styleId != null ? "quality_optimized" : null
};

Chaque paramètre ici est spécifique à un locataire. Le glossaryId a été résolu par une requête filtrée par le locataire. Le styleId a été résolu de la même manière. Le formality provient du TenantLanguageConfig, qui a également été filtré par le locataire. Même le context (paragraphes environnants envoyés pour améliorer la qualité de la traduction, non facturés) provient du document du locataire.

Une chose que je tiens à souligner : lorsque style_id est défini, DeepL utilise automatiquement leur modèle quality_optimized. Vous ne pouvez pas combiner des règles de style avec latency_optimized. Il s'agit d'une contrainte de DeepL, mais honnêtement, elle est raisonnable. Si vous investissez dans des règles de style personnalisées, vous voulez probablement obtenir la meilleure qualité possible.

Mise en cache au niveau du bloc : la base de données comme mémoire de traduction

Nous n'appelons pas DeepL pour les blocs qui n'ont pas changé. Le mécanisme de mise en cache est la table TranslationBlock elle-même.

Chaque EntryBlock source a un ContentHash, un SHA256 de son contenu sémantique (avec des attributs de métadonnées comme blockId et deleted dépouillés). Chaque TranslationBlock stocke le SourceContentHash qui était en vigueur lorsque la traduction a été effectuée. Lorsque le bloc source change, son hachage change. L'orchestrateur compare les hachages et ne met en file d'attente que les blocs qui ne correspondent pas.

L'arbre de décision pour chaque bloc ressemble à ceci :

  1. Les hashs correspondent, la traduction existe = ignorer (mis en cache, à jour)
  2. Le code a changé, la traduction est automatique, la traduction n'est pas verrouillée = retraduire automatiquement.
  3. Hash modifié, édité par l'homme ou verrouillé = marquer comme périmé, ne pas écraser

Ce troisième cas est crucial. Si votre traducteur allemand a affiné manuellement un paragraphe, nous ne le supprimons pas simplement parce que la source anglaise a changé. Nous le marquons comme périmé pour qu'il sache qu'il doit être révisé, mais le texte traduit reste intact.

Résultat pratique : la modification d'un paragraphe dans un document de 30 paragraphes déclenche exactement un appel à l'API DeepL (enfin, un lot qui comprend un bloc). Les 29 autres paragraphes, toutes langues confondues, sont déjà mis en cache et ne coûtent rien.

Pourquoi ne pas utiliser une clé distincte par locataire ?

J'y ai sérieusement réfléchi. Donner à chaque locataire sa propre clé d'API DeepL, éliminer complètement le problème d'isolation.

Trois raisons pour lesquelles nous ne l'avons pas fait :

  1. **Chaque locataire aurait besoin de son propre abonnement à DeepL ou d'un moyen de fournir des sous-comptes. DeepL n'offre pas de gestion de clés multi-locataires de manière native.
  2. **Le partage de l'infrastructure signifie le partage des limites tarifaires et des remises sur les volumes. Notre utilisation globale nous permet d'obtenir de meilleurs prix.
  3. **Simplicité d'exploitation : une seule clé à changer, un seul quota à surveiller, une seule intégration à maintenir.

En contrepartie, nous avons besoin de la couche d'isolation que j'ai décrite. Mais étant donné que nous avons déjà des requêtes EF Core adaptées aux locataires pour tout le reste du système, l'ajouter aux glossaires et aux règles de style était simple. Le modèle existait déjà.

Ce qui vous protège réellement

Pour résumer les garanties d'isolation :

  • Les entrées du glossaire sont stockées dans TenantGlossary (implémente ITenantScoped), filtrées par les filtres de requête globaux d'EF Core. DeepL Les ID de glossaire sont des références opaques qui ne sont résolues que dans le contexte du locataire.
  • Les règles de style et les instructions personnalisées suivent le même modèle à travers TenantStyleRuleList.
  • Le contenu traduit** se trouve dans TranslationBlock, délimité par sa chaîne mère EntryHub, qui est également délimitée par le locataire.
  • La garde SaveChanges met en place TenantId automatiquement sur les nouvelles entités et la lance sur les écritures inter-locataires.
  • Pas de IgnoreQueryFilters()** dans le code de production. Jamais.

Le principe de conception est simple : DeepL voit les identifiants de ressources. Rasepi voit des entités à l'échelle du locataire. La correspondance entre les deux ne traverse jamais les frontières des locataires car la requête qui résout la correspondance est physiquement incapable de renvoyer les données d'un autre locataire.

Si vous construisez un SaaS multi-locataires qui s'intègre à des API tierces sans prise en charge native des locataires, ce modèle fonctionne bien. Traitez l'API externe comme un moteur d'exécution sans état, conservez toute la configuration dans votre propre base de données à l'échelle du locataire, synchronisez paresseusement et ne faites jamais confiance aux listes de ressources externes pour l'isolation.

Gardez votre documentation à jour. Automatiquement.

Rasepi impose des dates de révision, suit la qualité du contenu et publie en plus de 40 langues.

Commencer gratuitement →