BareGit

Tavern-translate: fix intermittent translation failures

Read API responses as text before parsing so non-JSON bodies (proxy
error pages, empty bodies) produce a meaningful error instead of
"JSON.parse: unexpected character". Retry transient failures (429,
5xx, non-JSON) with exponential backoff. Make the model name
configurable in the options dialog instead of hardcoding gpt-3.5-turbo.
Author: MetroWind <chris.corsair@gmail.com>
Date: Sun May 31 16:14:12 2026 -0700
Commit: 66ca6976a161850f089a0196555dba65498f768c

Changes

diff --git a/tavern-translate/app.js b/tavern-translate/app.js
index 68b12e2..624a885 100644
--- a/tavern-translate/app.js
+++ b/tavern-translate/app.js
@@ -72,11 +72,14 @@ const setStorage = (key, val) => localStorage.setItem(STORAGE_PREFIX + key, val)
 const config = signal({
     api_url: getStorage('api_url') || 'https://api.openai.com/v1/chat/completions',
     api_key: getStorage('api_key') || '',
+    model: getStorage('model') || 'gpt-3.5-turbo',
     system_prompt: getStorage('system_prompt') || '作为一名资深翻译家,把用户提供的文本从英文翻译成中文,保持原文的格式和语气。注意 `{{user}}` 和 `{{char}}` 为特殊标记,不要翻译。',
     ignored_keys: getStorage('ignored_keys') || 'spec,spec_version,avatar,url,id,keys,secondary_keys,creator,version,chub,fav,talkativeness',
     concurrency: parseInt(getStorage('concurrency') || '3', 10)
 });
 
+const MAX_RETRIES = 3;
+
 const task_queue = new TaskQueue(config.value.concurrency);
 
 // --- Helper Functions ---
@@ -141,6 +144,81 @@ function flattenTree(node)
     return node;
 }
 
+// Try to pull a human-readable error message out of an OpenAI-style
+// error body. Returns null if the body is not JSON or has no message.
+function extractApiError(raw)
+{
+    try
+    {
+        const parsed = JSON.parse(raw);
+        if(typeof parsed.error === 'string') return parsed.error;
+        return parsed.error?.message || parsed.message || null;
+    }
+    catch(e)
+    {
+        return null;
+    }
+}
+
+// Perform a single translation request. Reads the response as text
+// first so that a non-JSON body (an HTML error page from a proxy, an
+// empty body, etc.) produces a meaningful error instead of a cryptic
+// "JSON.parse: unexpected character" failure.
+async function requestTranslation(text)
+{
+    const response = await fetch(config.value.api_url, {
+        method: 'POST',
+        headers: {
+            'Content-Type': 'application/json',
+            'Authorization': `Bearer ${config.value.api_key}`
+        },
+        body: JSON.stringify({
+            model: config.value.model,
+            messages: [
+                { role: 'system', content: config.value.system_prompt },
+                { role: 'user', content: text }
+            ],
+            temperature: 0.3
+        })
+    });
+
+    const raw = await response.text();
+
+    if(!response.ok)
+    {
+        const message = extractApiError(raw) ||
+            `HTTP ${response.status} ${response.statusText}`;
+        const err = new Error(message);
+        // Rate limiting and server errors are usually transient.
+        err.retriable = response.status === 429 || response.status >= 500;
+        throw err;
+    }
+
+    let data;
+    try
+    {
+        data = JSON.parse(raw);
+    }
+    catch(e)
+    {
+        // A 200 response with a non-JSON body. This is what produced the
+        // original "unexpected character at line 1 column 1" error, and
+        // it is typically a transient proxy/gateway hiccup.
+        const snippet = raw.trim().slice(0, 200) || '(empty body)';
+        const err = new Error(`API returned a non-JSON response: ${snippet}`);
+        err.retriable = true;
+        throw err;
+    }
+
+    const content = data.choices?.[0]?.message?.content;
+    if(content == null)
+    {
+        throw new Error(extractApiError(raw) ||
+            'API response did not contain a translation.');
+    }
+    return content;
+}
+
 async function translateNode(node, force = false)
 {
     if(node.status.value === 'translating') return;
@@ -153,30 +231,27 @@ async function translateNode(node, force = false)
     {
         const result = await task_queue.add(async () =>
         {
-            const response = await fetch(config.value.api_url, {
-                method: 'POST',
-                headers: {
-                    'Content-Type': 'application/json',
-                    'Authorization': `Bearer ${config.value.api_key}`
-                },
-                body: JSON.stringify({
-                    model: 'gpt-3.5-turbo',
-                    messages: [
-                        { role: 'system', content: config.value.system_prompt },
-                        { role: 'user', content: node.original_value }
-                    ],
-                    temperature: 0.3
-                })
-            });
-
-            if(!response.ok)
+            let last_error = null;
+            for(let attempt = 0; attempt <= MAX_RETRIES; attempt++)
             {
-                const error_data = await response.json();
-                throw new Error(error_data.error?.message || 'API Error');
-            }
+                if(attempt > 0)
+                {
+                    // Exponential backoff: 0.5s, 1s, 2s, ...
+                    const delay = 500 * Math.pow(2, attempt - 1);
+                    await new Promise(r => setTimeout(r, delay));
+                }
 
-            const data = await response.json();
-            return data.choices[0].message.content;
+                try
+                {
+                    return await requestTranslation(node.original_value);
+                }
+                catch(err)
+                {
+                    last_error = err;
+                    if(!err.retriable) throw err;
+                }
+            }
+            throw last_error || new Error('Translation failed.');
         });
 
         node.translated_value.value = result;
@@ -266,6 +341,10 @@ function OptionsDialog({ close })
                     <label>API Key</label>
                     <input type="password" value=${local_config.api_key} oninput=${(e) => set_local_config({ ...local_config, api_key: e.target.value })} />
                 </div>
+                <div class="form-group">
+                    <label>Model</label>
+                    <input type="text" value=${local_config.model} oninput=${(e) => set_local_config({ ...local_config, model: e.target.value })} />
+                </div>
                 <div class="form-group">
                     <label>Concurrency (Max active requests)</label>
                     <input type="number" min="1" max="20" value=${local_config.concurrency} oninput=${(e) => set_local_config({ ...local_config, concurrency: parseInt(e.target.value, 10) })} />