mistral_translator 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 242809c8a55ea79c9c436a2fc7b17d7a6deda5372090eaaa43b9a0863e1ba552
4
- data.tar.gz: b6f86bc08b253f5ea4097e9fa1f3e901e41886067155c2fc595202694ce18acb
3
+ metadata.gz: 22b02e4c378ffdd992bbb525c57a03fd15c4c82e2ee916c43a3753b09398c8e0
4
+ data.tar.gz: 1bdf19928ac211fbbc7e7fe064f600d53639c3b0aa57063b14b74fe3b95384c0
5
5
  SHA512:
6
- metadata.gz: 6183a2226ee0985e5229c2cb203fed6cfb71ced4c2a0cdb28fba16115f5b6fdf28b860baba50db8f3461e64792267558f3413481e715c54f2f1b5bf0747d3647
7
- data.tar.gz: a71be86ea71b15e50af048cbba672e5fdecd41e6d3cd1e1313d534d11d2af7747ac593cf36791ad805f9b6db4ff6d6d88853a8b166a046cc45747a66e879bf0c
6
+ metadata.gz: 67bb96d4142aa345f2feb2f1cf35ef30e131d529ad22338e9bd31c0c237d165a446b74e8ed75aa7715d903e486657250fb823e4add5994edade18d763fa50e2f
7
+ data.tar.gz: 39fcfd92f88aa10bb1ca930a8c9539a9d3e12ce7a8b4d1564d2cd0336ac5f349d9903334abec4a7f0d8fe8876a90d82738d8e6bc2a024bc91df5303c8f096588
data/CHANGELOG.md CHANGED
@@ -1,4 +1,78 @@
1
- ## [Unreleased]
1
+ ## [0.5.0] - 2026-10-29
2
+
3
+ ### Added
4
+
5
+ - `config.read_timeout`: how long to wait for the API's answer, in seconds. Defaults to 60, the value the gem used until now; anything but a positive number raises `ConfigurationError` when it is set.
6
+ - `config.open_timeout`: how long to wait for the connection to open (TCP, then TLS), in seconds. Defaults to 60, validated the same way.
7
+
8
+ ### Changed
9
+
10
+ No change to translation results:
11
+
12
+ - Translations and summaries share one retry policy, with the same delays and counts as before. Their retry log lines now share one format, such as `translation retry 1/3 for fr->en in 2s: …`.
13
+ - The `Summarizer` no longer prints to stdout when `MISTRAL_TRANSLATOR_TEST_OUTPUT` is set.
14
+ - A response is parsed once instead of twice.
15
+
16
+ ### Deprecated
17
+
18
+ These options still work, print a warning, and will be removed in 1.0:
19
+
20
+ - `config.ssl_timeout`, the former name of `open_timeout`, which it still sets. It is now validated like `open_timeout`, so `nil` (no limit) is rejected.
21
+ - `config.default_max_tokens` and `config.default_temperature`, which the gem never read.
22
+
23
+ ### Removed
24
+
25
+ Undocumented code that the gem never called:
26
+
27
+ - the `HelpersExtensions` copies of `translate_with_progress`, `translate_multi_style` and `find_locale_suggestions`, which `Helpers` redefines;
28
+ - `TranslatorHelpers::RetryHandler`, `TranslatorHelpers::LoggingHelper` and `InputValidator#validate_batch_inputs!`;
29
+ - `PromptBuilder.bulk_translation_prompt`, `PromptBuilder.tiered_summary_prompt`, `PromptHelpers::FormatInstructions`, `ResponseParser.parse_bulk_translation_response` and `ResponseParser::JsonParserState`.
30
+
31
+ ### Security
32
+
33
+ - When the scan for a JSON object inside text reached its limit, the error kept the `JSON::ParserError` as its `cause`, and that error quotes the response.
34
+
35
+ ## [0.4.0] - 2026-10-28
36
+
37
+ ### Changed
38
+
39
+ These changes alter behaviour that callers may rely on:
40
+
41
+ - Unusable responses now raise instead of being returned. A response without JSON, with a target that is not text, or with a blank target raises `InvalidResponseError` or `EmptyTranslationError` after the usual retries. Previously the raw response text could be stored as the translation or the summary.
42
+ - `summarize_and_translate` no longer falls back to an untranslated summary in the source language; it retries like the other summaries, then raises.
43
+ - `translate_auto` raises `InvalidResponseError` when language detection is unreadable or names an unsupported language. It used to assume English, which returned the source text untranslated when the target was English.
44
+ - `PromptBuilder` methods return a `MistralTranslator::Prompt` (`instructions` and `text`) instead of a String. `Prompt#to_s` gives the previous single-text form.
45
+ - Prompts are sent as two messages: instructions in the system message, the text alone in the user message, between delimiters, with a rule telling the model to treat it as data.
46
+ - `Client#complete` takes `json: false`. The gem's own calls pass `json: true`, which sends `response_format: json_object`.
47
+ - 182 languages instead of 13: every ISO 639-1 code named by CLDR. `SUPPORTED_LANGUAGES` keeps the native names, unchanged for the original 13, and `supported_languages` returns a much longer string. Quality depends on the model and is weaker for rare languages.
48
+ - In `translate_batch` and the batch path of `translate_to_multiple`, an unreadable response fails its own item (`nil`) instead of the whole batch.
49
+ - `translate_auto`, `translate_to_multiple` and the summaries enforce the same 50,000 character limit as `translate`.
50
+
51
+ ### Added
52
+
53
+ - `config.proxy`: an `http://` proxy URL or URI, or `:env` to read `HTTPS_PROXY`, then `HTTP_PROXY`, honouring `NO_PROXY`. Disabled by default.
54
+ - `preserve_html:` in `translate_batch` and `translate_auto`.
55
+ - `context:` and `style:` in every summary method.
56
+ - English and French language names are accepted in place of codes (`"German"`, `"allemand"`), and locale suggestions are based on language names.
57
+ - `bin/generate_languages` regenerates `lib/mistral_translator/data/languages.json` from CLDR (macOS).
58
+
59
+ ### Fixed
60
+
61
+ - `Helpers.smart_summarize` raised `ArgumentError` on every call.
62
+ - A translated text mentioning "rate limit" or "quota exceeded" was taken for a rate limit, retried, then failed. Only the 429 status counts now.
63
+ - Context and glossary were formatted three different ways; an empty glossary printed `GLOSSAIRE : {}` in quality check prompts, and the `has_context` / `has_glossary` metadata were never set.
64
+ - `translate_batch` silently ignored misspelled options.
65
+ - Locale suggestions returned unrelated codes once every ISO 639-1 language was supported.
66
+
67
+ ### Security
68
+
69
+ - User text and API responses no longer reach logs or error messages: the verbose request log records sizes only, error messages carry no response content, errors no longer keep the original Ruby error as `cause`, and the error callback receives the sanitized error. The response stays available through `InvalidResponseError#raw_response`.
70
+ - Delimiters found in the text are neutralised so it cannot close its block, and `Prompt#inspect` shows lengths only.
71
+ - `https://` proxies are rejected: net-http-persistent talks to the proxy in clear, so their credentials would travel unencrypted.
72
+
73
+ ### Removed
74
+
75
+ - `TranslatorHelpers::PromptHandler` and the test-only `make_request` fallback in `perform_client_request`.
2
76
 
3
77
  ## [0.3.2] - 2026-10-28
4
78
 
data/README.md CHANGED
@@ -30,14 +30,16 @@ MistralTranslator.translate("Bonjour le monde", from: "fr", to: "en")
30
30
  - **Native Rails integration** (Mobility, Globalize, I18n attributes)
31
31
  - **Batch processing** for high-volume translations
32
32
  - **Multi-level summarization** with translation
33
- - **Robust error handling** with automatic retry and fallback
33
+ - **Robust error handling** with automatic retries; unusable responses raise instead of being returned
34
34
  - **Complete monitoring** with metrics and callbacks
35
35
  - **Thread-safe & concurrent** with connection pooling ([docs](https://peyochanchan.github.io/mistral_translator/#/advanced-usage/concurrent-async))
36
36
  - **Built-in rate limiting** and security
37
37
 
38
38
  ## Supported Languages
39
39
 
40
- `fr` `en` `es` `pt` `de` `it` `nl` `ru` `mg` `ja` `ko` `zh` `ar`
40
+ All 182 ISO 639-1 languages named by CLDR (`fr`, `en`, `es`, `de`, `ja`, `br`, `wo`, `mg`…). `MistralTranslator.supported_locales` lists them, and English or French names are accepted in place of codes (`"German"`, `"allemand"`). The list and the names come from the CLDR data of macOS 27.2, through `bin/generate_languages` (2026-10-28).
41
+
42
+ Translation quality depends on the Mistral model: excellent for widely used languages, weaker for rare ones. Check the output before relying on a rare language.
41
43
 
42
44
  ## Installation
43
45
 
@@ -162,6 +164,11 @@ MistralTranslator.configure do |config|
162
164
  config.model = "mistral-small-latest" # AI model to use (an alias that follows the current Mistral Small)
163
165
  config.retry_delays = [1, 2, 4, 8, 16] # Retry delays in seconds
164
166
  config.enable_metrics = true # Enable performance metrics
167
+ config.proxy = :env # HTTP proxy: nil (default), "http://host:port", or :env
168
+ # to read HTTPS_PROXY / HTTP_PROXY and NO_PROXY
169
+ config.open_timeout = 60 # Seconds to open the connection (default 60)
170
+ config.read_timeout = 60 # Seconds to wait for the API's answer (default 60);
171
+ # raise it for long texts or summaries
165
172
 
166
173
  # Callbacks for monitoring
167
174
  config.on_translation_complete = ->(from, to, orig_len, trans_len, duration) {
@@ -174,6 +181,8 @@ MistralTranslator.configure do |config|
174
181
  end
175
182
  ```
176
183
 
184
+ Configure the gem before the first call. The module-level methods (`MistralTranslator.translate`, `summarize`…) share one client, which reads `api_key`, `model`, `api_url`, `retry_delays` and the connection settings (`read_timeout`, `proxy`, SSL) on the first call. To change them afterwards, call `MistralTranslator.reset_configuration!` and configure again, or create a `MistralTranslator::Translator.new` (or `Summarizer.new`), which builds its own client from the current configuration.
185
+
177
186
  ## Error Handling
178
187
 
179
188
  ```ruby
@@ -188,6 +197,9 @@ rescue MistralTranslator::AuthenticationError
188
197
  rescue MistralTranslator::UnsupportedLanguageError => e
189
198
  # Language not supported
190
199
  Rails.logger.error "Language '#{e.language}' not supported"
200
+ rescue MistralTranslator::InvalidResponseError, MistralTranslator::EmptyTranslationError
201
+ # The model kept returning an unusable answer after the retries: nothing is returned
202
+ Rails.logger.error "Translation unavailable"
191
203
  rescue MistralTranslator::Error => e
192
204
  # General error handling
193
205
  Rails.logger.error "Translation failed: #{e.message}"
@@ -13,13 +13,53 @@ MistralTranslator.configure do |config|
13
13
  config.api_key = ENV['MISTRAL_API_KEY'] # Obligatoire
14
14
  config.model = "mistral-small-latest" # Défaut
15
15
  config.api_url = "https://api.mistral.ai" # URL API
16
- config.default_max_tokens = 4000 # Limite tokens
17
- config.default_temperature = 0.3 # Créativité (0-1)
18
16
  config.retry_delays = [2, 4, 8, 16, 32] # Délais retry (secondes)
19
17
  config.enable_metrics = false # Suivi des stats
18
+ config.proxy = nil # Proxy HTTP (désactivé par défaut)
19
+ config.open_timeout = 60 # Ouverture de la connexion (secondes)
20
+ config.read_timeout = 60 # Attente maximale de la réponse (secondes)
20
21
  end
21
22
  ```
22
23
 
24
+ Configurez la gem avant le premier appel. Les méthodes du module (`MistralTranslator.translate`, `summarize`…) partagent un client, qui lit `api_key`, `model`, `api_url`, `retry_delays` et les réglages de connexion (`read_timeout`, `proxy`, SSL) lors du premier appel. Pour les changer ensuite, appelez `MistralTranslator.reset_configuration!` puis configurez à nouveau, ou créez un `MistralTranslator::Translator.new` (ou `Summarizer.new`), qui construit son propre client avec la configuration du moment.
25
+
26
+ ## Délais
27
+
28
+ Deux délais, en secondes, 60 par défaut :
29
+
30
+ - `open_timeout` : ouverture de la connexion à l'API (TCP puis TLS) ;
31
+ - `read_timeout` : attente de la réponse. Un long texte ou un résumé détaillé peut demander plus au modèle ; augmentez-le dans ce cas.
32
+
33
+ ```ruby
34
+ config.read_timeout = 180
35
+ ```
36
+
37
+ - Seul un nombre strictement positif est accepté (entier ou décimal) ; toute autre valeur lève `ConfigurationError` dès l'affectation.
38
+ - Délai de réponse dépassé : la requête lève `ApiError` (« Request timeout »).
39
+
40
+ ## Options obsolètes
41
+
42
+ Elles fonctionnent encore, avec un avertissement, et seront retirées en 1.0 :
43
+
44
+ - `ssl_timeout` : ancien nom d'`open_timeout`, qu'il règle toujours ;
45
+ - `default_max_tokens` et `default_temperature` : jamais lues par la gem, elles n'ont aucun effet. Pour régler ces paramètres, passez `max_tokens:` et `temperature:` à `Client#chat` ou `Client#complete`.
46
+
47
+ ## Proxy
48
+
49
+ Désactivé par défaut. Trois valeurs possibles :
50
+
51
+ ```ruby
52
+ config.proxy = nil # Aucun proxy (défaut)
53
+ config.proxy = "http://user:password@proxy:3128" # Ce proxy (String ou URI)
54
+ config.proxy = :env # HTTPS_PROXY, puis HTTP_PROXY ; NO_PROXY respecté
55
+ ```
56
+
57
+ - Seuls les proxys `http://` sont acceptés. La bibliothèque HTTP utilisée parle toujours en clair au proxy : avec `https://`, les identifiants du proxy circuleraient sans chiffrement. Le flux vers l'API Mistral reste chiffré de bout en bout (tunnel `CONNECT` puis TLS).
58
+ - Avec `:env`, `NO_PROXY` suit les règles de curl : `*`, un domaine et ses sous-domaines (`mistral.ai`, `.mistral.ai`, `*.mistral.ai`), éventuellement avec le port 443.
59
+ - Une valeur invalide lève `ConfigurationError` dès l'affectation ; le message ne reprend jamais l'URL, pour ne pas exposer les identifiants.
60
+ - `api_url` reste verrouillée sur `https://api.mistral.ai`.
61
+ - Définissez le proxy avant la première traduction : le client et sa connexion sont créés une fois puis réutilisés.
62
+
23
63
  ## Callbacks
24
64
 
25
65
  ```ruby
@@ -63,9 +103,9 @@ config.setup_rails_logging # Active les logs Rails standard
63
103
  MistralTranslator.health_check
64
104
  # => { status: :ok, message: "API connection successful" }
65
105
 
66
- # Langues supportées
106
+ # Langues supportées : les 182 codes ISO 639-1
67
107
  MistralTranslator.supported_locales
68
- # => ["fr", "en", "es", "pt", "de", "it", "nl", "ru", "mg", "ja", "ko", "zh", "ar"]
108
+ # => ["aa", "ab", "ae", "af", ..., "zh", "zu"]
69
109
  ```
70
110
 
71
111
  ---
@@ -210,8 +210,10 @@ end
210
210
 
211
211
  **Attributs:**
212
212
 
213
- - `message` (String) - Description de l'erreur
214
- - `raw_response` (String, optionnel) - Réponse brute reçue
213
+ - `message` (String) - Description de l'erreur. Elle ne contient jamais la réponse de l'API, pour qu'aucun texte traduit ne finisse dans les journaux.
214
+ - `raw_response` (String, optionnel) - Réponse brute reçue, accessible au code appelant uniquement
215
+
216
+ L'erreur Ruby d'origine (`JSON::ParserError`, `NoMethodError`…) n'est pas conservée dans `cause` : son message cite la réponse.
215
217
 
216
218
  **Causes courantes:**
217
219
 
@@ -321,13 +323,13 @@ end
321
323
 
322
324
  ### `MistralTranslator::SecurityError`
323
325
 
324
- **Description:** Violation de sécurité détectée.
326
+ **Statut:** définie pour usage futur ; la gem ne la lève actuellement nulle part. Inutile de la rattraper.
325
327
 
326
- **Causes courantes:**
328
+ Les contrôles de sécurité existants lèvent d'autres erreurs :
327
329
 
328
- - Texte dépassant la taille limite (50,000 caractères)
329
- - Contenu potentiellement malveillant détecté
330
- - Tentative d'injection de prompt
330
+ - Texte de plus de 50 000 caractères : `ArgumentError` (traductions, détection automatique et résumés)
331
+ - Proxy invalide : `ConfigurationError`
332
+ - Instructions glissées dans le texte à traduire : rien n'est levé. Le texte est envoyé à part des consignes (message `user`), entre délimiteurs, avec une règle demandant au modèle de le traiter comme une donnée.
331
333
 
332
334
  **Exemple:**
333
335
 
@@ -344,44 +346,33 @@ end
344
346
  **Solutions:**
345
347
 
346
348
  - Diviser les textes longs en plusieurs parties
347
- - Valider le contenu avant traduction
348
349
  - Utiliser `translate_batch` pour les lots importants
349
350
 
350
351
  ---
351
352
 
352
353
  ### `MistralTranslator::RateLimitExceededError`
353
354
 
354
- **Description:** Limite de taux personnalisée dépassée.
355
+ **Statut:** définie pour usage futur ; la gem ne la lève actuellement nulle part.
355
356
 
356
357
  **Attributs:**
357
358
 
358
359
  - `wait_time` (Integer, optionnel) - Temps d'attente suggéré
359
360
  - `retry_after` (Integer, optionnel) - Timestamp de retry
360
361
 
361
- **Différence avec `RateLimitError`:**
362
+ **Comportement réel des limites de débit:**
362
363
 
363
- - `RateLimitError` : Limite API Mistral (HTTP 429)
364
- - `RateLimitExceededError` : Limite locale de la gem
365
-
366
- **Exemple:**
364
+ - Limite locale (`Security::BasicRateLimiter`) : la requête **attend** qu'un créneau se libère, sans lever d'erreur.
365
+ - Limite de l'API Mistral (HTTP 429) : le client relance avec les délais de `config.retry_delays`, puis lève `RateLimitError`.
367
366
 
368
367
  ```ruby
369
- # Configuration du rate limiter local
370
368
  rate_limiter = MistralTranslator::Security::BasicRateLimiter.new(
371
369
  max_requests: 10,
372
370
  window_seconds: 60
373
371
  )
374
-
375
372
  client = MistralTranslator::Client.new(rate_limiter: rate_limiter)
376
373
 
377
- begin
378
- # Trop de requêtes locales
379
- 15.times { client.complete("Hello") }
380
- rescue MistralTranslator::RateLimitExceededError => e
381
- puts "Local rate limit exceeded"
382
- sleep(e.wait_time) if e.wait_time
383
- retry
384
- end
374
+ # Au-delà de 10 requêtes par minute, les appels attendent leur tour
375
+ 15.times { client.complete("Hello") }
385
376
  ```
386
377
 
387
378
  ---
@@ -62,8 +62,13 @@ Traduit un texte avec détection automatique de la langue source.
62
62
  - `to` (String|Symbol) - Code de langue cible
63
63
  - `context` (String, optionnel) - Contexte pour la traduction
64
64
  - `glossary` (Hash, optionnel) - Dictionnaire de termes
65
+ - `preserve_html` (Boolean, optionnel) - Préserve les balises HTML (défaut: false)
65
66
 
66
- **Retour:** String - Le texte traduit
67
+ **Retour:** String - Le texte traduit (le texte d'origine si la langue détectée est déjà la langue cible)
68
+
69
+ **Exceptions:**
70
+
71
+ - `InvalidResponseError` - Détection illisible ou langue non supportée : aucune langue n'est supposée
67
72
 
68
73
  **Exemple:**
69
74
 
@@ -118,8 +123,9 @@ Traduit plusieurs textes en une fois pour optimiser les performances.
118
123
  - `to` (String|Symbol) - Code de langue cible
119
124
  - `context` (String, optionnel) - Contexte commun pour tous les textes
120
125
  - `glossary` (Hash, optionnel) - Dictionnaire de termes
126
+ - `preserve_html` (Boolean, optionnel) - Préserve les balises HTML (défaut: false)
121
127
 
122
- **Retour:** Hash - Hash avec les index comme clés et traductions comme valeurs
128
+ **Retour:** Hash - Hash avec les index comme clés et traductions comme valeurs. Un élément dont la requête ou la réponse échoue vaut `nil`, sans faire échouer le lot.
123
129
 
124
130
  **Exemple:**
125
131
 
@@ -292,7 +298,7 @@ Remet la configuration aux valeurs par défaut.
292
298
 
293
299
  Retourne la liste des codes de langue supportés.
294
300
 
295
- **Retour:** Array<String> - Liste des codes (ex: ["fr", "en", "es"])
301
+ **Retour:** Array<String> - Liste des 182 codes ISO 639-1 (ex: ["aa", "ab", ..., "zu"])
296
302
 
297
303
  ### `supported_languages`
298
304
 
@@ -399,7 +405,7 @@ Client HTTP pour l'API Mistral.
399
405
 
400
406
  **Méthodes d'instance:**
401
407
 
402
- - `complete(prompt, max_tokens: nil, temperature: nil, context: {})` - Complétion
408
+ - `complete(prompt, max_tokens: nil, temperature: nil, context: {}, json: false)` - Complétion ; `json: true` demande un objet JSON à l'API (`response_format: json_object`), le prompt devant décrire le JSON attendu
403
409
  - `chat(prompt, max_tokens: nil, temperature: nil, context: {})` - Chat
404
410
  - `translate_batch(requests, batch_size: 5)` - Traduction par lots
405
411
 
@@ -39,15 +39,23 @@ MistralTranslator.translate_auto("Guten Tag", to: "fr")
39
39
  ## 🌍 Langues Supportées
40
40
 
41
41
  ```ruby
42
- # Vérifier les langues disponibles
42
+ # Les 182 langues ISO 639-1 sont disponibles
43
43
  MistralTranslator::LocaleHelper.supported_locales
44
- # => ["fr", "en", "es", "pt", "de", "it", "nl", "ru", "mg", "ja", "ko", "zh", "ar"]
44
+ # => ["aa", "ab", "ae", "af", ..., "zh", "zu"]
45
+
46
+ # Les noms anglais et français sont acceptés à la place du code
47
+ MistralTranslator::LocaleHelper.language_to_locale("allemand")
48
+ # => "de"
45
49
 
46
50
  # Vérifier une langue spécifique
47
51
  MistralTranslator::LocaleHelper.locale_supported?("ja")
48
52
  # => true
49
53
  ```
50
54
 
55
+ La liste et les noms viennent des données CLDR de macOS 27.2, via `bin/generate_languages` (2026-10-28).
56
+
57
+ La qualité de traduction dépend du modèle Mistral : excellente pour les langues les plus parlées, plus faible pour les langues rares. Vérifiez le résultat avant de vous appuyer sur une langue rare.
58
+
51
59
  ## 🚨 Gestion d'Erreurs Simple
52
60
 
53
61
  ```ruby
data/docs/installation.md CHANGED
@@ -79,8 +79,6 @@ MistralTranslator.configure do |config|
79
79
  config.model = "mistral-small-latest" # Modèle par défaut
80
80
  config.retry_delays = [2, 4, 8, 16] # Délais retry
81
81
  config.enable_metrics = true # Suivi des stats
82
- config.default_max_tokens = 4000 # Limite tokens
83
- config.default_temperature = 0.3 # Créativité
84
82
  end
85
83
  ```
86
84
 
@@ -272,7 +272,6 @@ MistralTranslator.configure do |config|
272
272
 
273
273
  # Optimisation Rails
274
274
  config.enable_metrics = true
275
- config.default_max_tokens = 4000
276
275
 
277
276
  # Cache intelligent des traductions
278
277
  config.on_translation_complete = ->(from, to, orig_len, trans_len, duration) {
@@ -22,7 +22,9 @@ module MistralTranslator
22
22
  @rate_limiter = rate_limiter || Security::BasicRateLimiter.new
23
23
  end
24
24
 
25
- def complete(prompt, max_tokens: nil, temperature: nil, context: {})
25
+ # json: true demande un objet JSON à l'API (response_format json_object) ;
26
+ # le prompt doit alors décrire le JSON attendu.
27
+ def complete(prompt, max_tokens: nil, temperature: nil, context: {}, json: false)
26
28
  # Vérifier le rate limit avant de faire la requête
27
29
  @rate_limiter.wait_and_record!
28
30
 
@@ -30,14 +32,15 @@ module MistralTranslator
30
32
  start_time = Time.now
31
33
  trigger_translation_start_callback(ctx, prompt)
32
34
 
33
- response = make_request_with_retry(prompt, max_tokens, temperature, ctx)
35
+ request_options = { max_tokens: max_tokens, temperature: temperature, json: json }
36
+ response = make_request_with_retry(prompt, request_options, ctx)
34
37
  content = extract_content_from_response(response)
35
38
 
36
39
  trigger_translation_complete_callback(ctx, prompt, content, start_time)
37
40
  content
38
41
  rescue JSON::ParserError => e
39
42
  handle_json_parse_error(e, ctx)
40
- rescue NoMethodError => e
43
+ rescue NoMethodError, TypeError => e
41
44
  handle_response_structure_error(e, ctx)
42
45
  end
43
46
 
@@ -49,14 +52,14 @@ module MistralTranslator
49
52
  start_time = Time.now
50
53
  trigger_translation_start_callback(ctx, prompt)
51
54
 
52
- response = make_request_with_retry(prompt, max_tokens, temperature, ctx)
55
+ response = make_request_with_retry(prompt, { max_tokens: max_tokens, temperature: temperature }, ctx)
53
56
  content = extract_chat_content_from_response(response)
54
57
 
55
58
  trigger_translation_complete_callback(ctx, prompt, content, start_time)
56
59
  content
57
60
  rescue JSON::ParserError => e
58
61
  handle_json_parse_error(e, ctx)
59
- rescue NoMethodError => e
62
+ rescue NoMethodError, TypeError => e
60
63
  handle_response_structure_error(e, ctx)
61
64
  end
62
65
 
@@ -122,29 +125,25 @@ module MistralTranslator
122
125
  )
123
126
  end
124
127
 
125
- def handle_json_parse_error(error, context)
126
- MistralTranslator.configuration.trigger_translation_error(
127
- context[:from_locale],
128
- context[:to_locale],
129
- error,
130
- context[:attempt] || 0
131
- )
132
- message = if context[:operation] == :chat
133
- "JSON parse error: #{error.message}"
134
- else
135
- "Invalid JSON in API response: #{error.message}"
136
- end
137
- raise InvalidResponseError, message
128
+ # Le message de JSON::ParserError, NoMethodError ou TypeError cite la réponse de l'API :
129
+ # ni lui ni l'erreur d'origine (cause) ne sont repris, et le callback reçoit l'erreur nettoyée.
130
+ def handle_json_parse_error(_error, context)
131
+ raise_invalid_response("Invalid JSON in API response", context)
138
132
  end
139
133
 
140
134
  def handle_response_structure_error(error, context)
135
+ raise_invalid_response("Invalid response structure (#{error.class})", context)
136
+ end
137
+
138
+ def raise_invalid_response(message, context)
139
+ error = InvalidResponseError.new(message)
141
140
  MistralTranslator.configuration.trigger_translation_error(
142
141
  context[:from_locale],
143
142
  context[:to_locale],
144
143
  error,
145
144
  context[:attempt] || 0
146
145
  )
147
- raise InvalidResponseError, "Invalid response structure: #{error.message}"
146
+ raise error, cause: nil
148
147
  end
149
148
  end
150
149
  end
@@ -6,10 +6,10 @@ module MistralTranslator
6
6
  module ClientHelpers
7
7
  # Helper pour la gestion des requêtes HTTP
8
8
  module RequestHandler
9
- def make_request(prompt, max_tokens, temperature)
9
+ def make_request(prompt, max_tokens, temperature, json: false)
10
10
  uri = URI("#{@base_uri}/v1/chat/completions")
11
11
 
12
- request_body = build_request_body(prompt, max_tokens, temperature)
12
+ request_body = build_request_body(prompt, max_tokens, temperature, json: json)
13
13
 
14
14
  request = Net::HTTP::Post.new(uri.path, headers)
15
15
  request.body = request_body.to_json
@@ -22,18 +22,22 @@ module MistralTranslator
22
22
  raise ApiError, "Request timeout: #{e.message}"
23
23
  rescue Net::HTTPError => e
24
24
  raise ApiError, "HTTP error: #{e.message}"
25
+ rescue Net::HTTP::Persistent::Error => e
26
+ # Proxy ou API injoignable ; le message d'origine peut citer l'adresse du proxy
27
+ raise ApiError, "Connection failed (#{e.class})", cause: nil
25
28
  end
26
29
 
27
30
  def http_pool
28
31
  @http_pool ||= begin
29
32
  pool = Net::HTTP::Persistent.new(name: "mistral_translator")
30
- pool.read_timeout = 60
33
+ pool.read_timeout = MistralTranslator.configuration.read_timeout
31
34
  pool.idle_timeout = 30
32
- pool.open_timeout = MistralTranslator.configuration.ssl_timeout
35
+ pool.open_timeout = MistralTranslator.configuration.open_timeout
33
36
  pool.max_requests = 100
34
37
 
35
38
  # SSL/TLS configuration
36
39
  configure_ssl(pool)
40
+ configure_proxy(pool)
37
41
 
38
42
  pool
39
43
  end
@@ -56,18 +60,32 @@ module MistralTranslator
56
60
  pool.ca_path = config.ssl_ca_path if config.ssl_ca_path
57
61
  end
58
62
 
59
- def build_request_body(prompt, max_tokens, temperature)
63
+ def configure_proxy(pool)
64
+ proxy_uri = Proxy.resolve(MistralTranslator.configuration.proxy, host: URI(@base_uri).host)
65
+ pool.proxy = proxy_uri if proxy_uri
66
+ end
67
+
68
+ def build_request_body(prompt, max_tokens, temperature, json: false)
60
69
  body = {
61
70
  model: @model,
62
- messages: [{ role: "user", content: prompt }]
71
+ messages: request_messages(prompt)
63
72
  }
64
73
 
65
74
  body[:max_tokens] = max_tokens if max_tokens
66
75
  body[:temperature] = temperature if temperature
76
+ # L'API garantit alors un objet JSON syntaxiquement valide
77
+ body[:response_format] = { type: "json_object" } if json
67
78
 
68
79
  body
69
80
  end
70
81
 
82
+ # Un Prompt sépare consignes et texte ; une String part en un seul message.
83
+ def request_messages(prompt)
84
+ return prompt.messages if prompt.respond_to?(:messages)
85
+
86
+ [{ role: "user", content: prompt }]
87
+ end
88
+
71
89
  def headers
72
90
  {
73
91
  "Authorization" => "Bearer #{@api_key}",
@@ -79,15 +97,17 @@ module MistralTranslator
79
97
 
80
98
  # Helper pour la gestion des erreurs et retry
81
99
  module ErrorHandler
82
- def make_request_with_retry(prompt, max_tokens, temperature, context, attempt = 0)
100
+ # request_options : max_tokens, temperature, json
101
+ def make_request_with_retry(prompt, request_options, context, attempt = 0)
83
102
  context[:attempt] = attempt
84
- response = make_request(prompt, max_tokens, temperature)
103
+ response = make_request(prompt, request_options[:max_tokens], request_options[:temperature],
104
+ json: request_options.fetch(:json, false))
85
105
 
86
106
  # Vérifier les erreurs dans la réponse
87
107
  check_response_for_errors(response)
88
108
 
89
109
  if rate_limit_exceeded?(response)
90
- handle_rate_limit(prompt, max_tokens, temperature, context, attempt)
110
+ handle_rate_limit(prompt, request_options, context, attempt)
91
111
  else
92
112
  response
93
113
  end
@@ -107,18 +127,13 @@ module MistralTranslator
107
127
  end
108
128
  end
109
129
 
130
+ # Seul le statut compte : le corps d'une réponse 200 contient le texte traduit,
131
+ # qui peut très bien parler de « rate limit ».
110
132
  def rate_limit_exceeded?(response)
111
- return true if response.code.to_i == 429
112
-
113
- return false unless response.code.to_i == 200
114
-
115
- body_content = response.body.to_s
116
- return false if body_content.length > 1000
117
-
118
- body_content.match?(/rate.?limit|quota.?exceeded/i)
133
+ response.code.to_i == 429
119
134
  end
120
135
 
121
- def handle_rate_limit(prompt, max_tokens, temperature, context, attempt)
136
+ def handle_rate_limit(prompt, request_options, context, attempt)
122
137
  unless attempt < @retry_delays.length
123
138
  raise RateLimitError, "API rate limit exceeded after #{@retry_delays.length} retries"
124
139
  end
@@ -135,7 +150,7 @@ module MistralTranslator
135
150
 
136
151
  log_rate_limit_retry(wait_time, attempt)
137
152
  sleep(wait_time)
138
- make_request_with_retry(prompt, max_tokens, temperature, context, attempt + 1)
153
+ make_request_with_retry(prompt, request_options, context, attempt + 1)
139
154
  end
140
155
  end
141
156
 
@@ -153,7 +168,7 @@ module MistralTranslator
153
168
  attempt: 0
154
169
  }
155
170
 
156
- result = complete(request[:prompt], context: context)
171
+ result = complete(request[:prompt], context: context, json: true)
157
172
  results << {
158
173
  success: true,
159
174
  result: result,
@@ -175,10 +190,13 @@ module MistralTranslator
175
190
 
176
191
  # Helper pour le logging
177
192
  module LoggingHelper
193
+ # Jamais le contenu des messages : seulement le modèle et les tailles
178
194
  def log_request_response(request_body, response)
179
- # Log seulement si mode verbose activé
180
- Logger.debug_if_verbose("Request sent to API: #{request_body.to_json}", sensitive: true)
181
- Logger.debug_if_verbose("Response received: #{response.code}", sensitive: false)
195
+ sizes = request_body[:messages].map { |message| "#{message[:role]}=#{message[:content].to_s.length}" }
196
+ Logger.debug_if_verbose(
197
+ "Request sent to API: model=#{request_body[:model]} #{sizes.join(" ")}, response #{response.code}",
198
+ sensitive: false
199
+ )
182
200
  end
183
201
 
184
202
  def log_rate_limit_retry(wait_time, attempt)