From da9315815d97abd718f497273ab751ab33314379 Mon Sep 17 00:00:00 2001 From: Palash Debnath Date: Thu, 2 Jul 2026 17:03:13 +0530 Subject: [PATCH] =?UTF-8?q?feat(settings):=20LLM=20provider=20testing=20pa?= =?UTF-8?q?ss=20=E2=80=94=20latency=20+=20classified=20errors,=20model=20d?= =?UTF-8?q?iscovery,=20full=20i18n,=20router=20tests=20(#887)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: mergetest Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 11 ++ backend/api/routers/settings.py | 97 ++++++++++- .../components/settings/LLMProvidersPanel.jsx | 153 +++++++++++++---- .../settings/LLMProvidersPanel.test.jsx | 131 ++++++++++++++ frontend/src/i18n/locales/ar.json | 33 ++++ frontend/src/i18n/locales/de.json | 33 ++++ frontend/src/i18n/locales/en.json | 34 +++- frontend/src/i18n/locales/es.json | 33 ++++ frontend/src/i18n/locales/fr.json | 33 ++++ frontend/src/i18n/locales/hi.json | 33 ++++ frontend/src/i18n/locales/id.json | 33 ++++ frontend/src/i18n/locales/it.json | 33 ++++ frontend/src/i18n/locales/ja.json | 33 ++++ frontend/src/i18n/locales/ko.json | 33 ++++ frontend/src/i18n/locales/nl.json | 33 ++++ frontend/src/i18n/locales/pl.json | 33 ++++ frontend/src/i18n/locales/pt.json | 33 ++++ frontend/src/i18n/locales/ru.json | 33 ++++ frontend/src/i18n/locales/sv.json | 33 ++++ frontend/src/i18n/locales/th.json | 33 ++++ frontend/src/i18n/locales/tr.json | 33 ++++ frontend/src/i18n/locales/uk.json | 33 ++++ frontend/src/i18n/locales/vi.json | 33 ++++ frontend/src/i18n/locales/zh-CN.json | 33 ++++ frontend/src/i18n/locales/zh-TW.json | 33 ++++ tests/fixtures/api_routes.txt | 1 + tests/test_llm_providers_router.py | 162 ++++++++++++++++++ 27 files changed, 1204 insertions(+), 45 deletions(-) create mode 100644 frontend/src/components/settings/LLMProvidersPanel.test.jsx create mode 100644 tests/test_llm_providers_router.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 03b40571..719531d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently. ## [Unreleased] +### Added + +- **LLM Providers: one-click connection testing with real diagnostics.** The + Test button in Settings → LLM Providers now measures round-trip latency and + turns failures into plain-language guidance — bad key (401/403), wrong + model or URL (404), rate-limited (429), or unreachable server — instead of + a raw exception dump. A new "Fetch models" button lists every model your + key can access so you pick from real names instead of guessing. The whole + panel is now translated into all 21 languages, provider error messages + never echo your API key, and the settings API gained full test coverage. + ### Changed - **The app now always opens maximized (not fullscreen).** Window size and diff --git a/backend/api/routers/settings.py b/backend/api/routers/settings.py index ecdf74fc..4772f9e2 100644 --- a/backend/api/routers/settings.py +++ b/backend/api/routers/settings.py @@ -293,13 +293,58 @@ def set_active_llm_provider(body: _LLMActiveBody): return list_llm_providers() +def _scrub_llm_detail(e: Exception, api_key: str | None) -> str: + """Scrubbed, UI-safe failure text. scrub_text() covers env secrets and + home paths — but a STORE-persisted key isn't in the env, and some + providers echo the key in error bodies, so redact the exact resolved key + explicitly before the generic pass.""" + from core.scrub import scrub_text + detail = f"{type(e).__name__}: {e}" + if api_key and api_key != "local" and len(api_key) >= 8: + detail = detail.replace(api_key, "•••") + return scrub_text(detail) + + +def _classify_llm_error(e: Exception) -> str: + """Map a provider-call failure to an actionable kind the UI can localize. + + Kinds: auth (bad/missing key), not_found (model or endpoint path), + rate_limit, network (DNS/conn/timeout), error (everything else). + Status codes win when the OpenAI SDK provides one; exception-family + names catch the non-HTTP failures (DNS, refused, TLS, timeout). + """ + status = getattr(e, "status_code", None) + if status in (401, 403): + return "auth" + if status == 404: + return "not_found" + if status == 429: + return "rate_limit" + name = type(e).__name__ + if name in ("APIConnectionError", "APITimeoutError", "ConnectError", + "ConnectTimeout", "TimeoutError"): + return "network" + if name == "AuthenticationError": + return "auth" + if name == "NotFoundError": + return "not_found" + if name == "RateLimitError": + return "rate_limit" + return "error" + + @router.post("/llm-providers/{provider_id}/test") def test_llm_provider(provider_id: str): """One cheap round-trip against a provider to prove the key/URL work. Temporarily activates the provider for the probe by resolving its config - directly (does not change the persisted active selection). + directly (does not change the persisted active selection). Returns + latency_ms plus, on failure, a classified ``kind`` (config / auth / + not_found / rate_limit / network / error) so the UI shows an actionable, + localizable message instead of a raw exception string. """ + import time as _time + from services import llm_providers p = llm_providers.get_provider(provider_id) if p is None: @@ -307,9 +352,10 @@ def test_llm_provider(provider_id: str): base_url = llm_providers.resolve_base_url(p) api_key = llm_providers.resolve_api_key(p) if not base_url: - return {"ok": False, "detail": "No Base URL set for this provider."} + return {"ok": False, "kind": "config", "detail": "No Base URL set for this provider."} if not api_key: - return {"ok": False, "detail": "No API key configured for this provider."} + return {"ok": False, "kind": "config", "detail": "No API key configured for this provider."} + t0 = _time.monotonic() try: from openai import OpenAI client = OpenAI(api_key=api_key, base_url=base_url) @@ -319,10 +365,49 @@ def test_llm_provider(provider_id: str): timeout=20, ) reply = (res.choices[0].message.content or "").strip() - return {"ok": True, "model": llm_providers.resolve_model(p), "reply": reply[:80]} + return { + "ok": True, + "model": llm_providers.resolve_model(p), + "reply": reply[:80], + "latency_ms": int((_time.monotonic() - t0) * 1000), + } except Exception as e: # noqa: BLE001 — surface a clean, scrubbed error to the UI - from core.scrub import scrub_text - return {"ok": False, "detail": scrub_text(f"{type(e).__name__}: {e}")} + return { + "ok": False, + "kind": _classify_llm_error(e), + "detail": _scrub_llm_detail(e, api_key), + "latency_ms": int((_time.monotonic() - t0) * 1000), + } + + +@router.get("/llm-providers/{provider_id}/models") +def list_llm_provider_models(provider_id: str): + """List model ids the provider's key can access (OpenAI-compat /models). + + Powers the model-picker datalist in Settings → LLM Providers so users + don't have to guess model names. Read-only; failures return the same + classified shape as /test; capped so a huge catalog can't bloat the UI. + """ + from services import llm_providers + p = llm_providers.get_provider(provider_id) + if p is None: + raise HTTPException(status_code=404, detail=f"unknown provider {provider_id!r}") + base_url = llm_providers.resolve_base_url(p) + api_key = llm_providers.resolve_api_key(p) + if not base_url or not api_key: + return {"ok": False, "kind": "config", "models": []} + try: + from openai import OpenAI + client = OpenAI(api_key=api_key, base_url=base_url) + ids = sorted(m.id for m in client.models.list(timeout=10)) + return {"ok": True, "models": ids[:200]} + except Exception as e: # noqa: BLE001 + return { + "ok": False, + "kind": _classify_llm_error(e), + "detail": _scrub_llm_detail(e, api_key), + "models": [], + } # ── License acceptance (Phase 3 Plan 03-01 / TTS-05) ────────────────────── diff --git a/frontend/src/components/settings/LLMProvidersPanel.jsx b/frontend/src/components/settings/LLMProvidersPanel.jsx index 7aff8d4e..d0ef7abb 100644 --- a/frontend/src/components/settings/LLMProvidersPanel.jsx +++ b/frontend/src/components/settings/LLMProvidersPanel.jsx @@ -1,5 +1,5 @@ /** - * Settings → System → LLM Providers (v0.3.8). + * Settings → System → LLM Providers (v0.3.8; test/UX/i18n pass for v0.3.9). * * One place to configure the high-quality LLM that powers Cinematic and * Autofit translation (fitting each line to its segment's time budget). Every @@ -13,15 +13,22 @@ * notes,base_url,model,has_key,key_from_env,configured}]} * PUT /api/settings/llm-providers/{id} {api_key?,base_url?,model?,account_id?,make_active?} * POST /api/settings/llm-providers/active {provider} - * POST /api/settings/llm-providers/{id}/test → {ok, model?, reply?, detail?} + * POST /api/settings/llm-providers/{id}/test + * → {ok, model?, reply?, latency_ms?, kind?, detail?} (kind: config|auth| + * not_found|rate_limit|network|error → localized message below) + * GET /api/settings/llm-providers/{id}/models → {ok, models[], kind?} */ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Brain, ExternalLink } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { apiJson, apiFetch, apiPost } from '../../api/client'; import { SettingsSection, SettingRow, SettingsInput } from './primitives'; import { Button, Badge, Select } from '../../ui'; +const MODELS_DATALIST_ID = 'llm-provider-models-list'; + export default function LLMProvidersPanel() { + const { t } = useTranslation(); const [providers, setProviders] = useState([]); const [active, setActive] = useState(null); const [editing, setEditing] = useState(''); @@ -29,6 +36,8 @@ export default function LLMProvidersPanel() { const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); const [test, setTest] = useState(null); + const [models, setModels] = useState(null); // null = not fetched; [] = fetched, none + const [loadingModels, setLoadingModels] = useState(false); const [error, setError] = useState(null); const current = useMemo( @@ -36,6 +45,21 @@ export default function LLMProvidersPanel() { [providers, editing], ); + // Failure kinds from /test and /models → localized, actionable messages. + const kindMessage = useCallback( + (res) => { + const byKind = { + config: t('settings.llmp_err_config'), + auth: t('settings.llmp_err_auth'), + not_found: t('settings.llmp_err_not_found'), + rate_limit: t('settings.llmp_err_rate_limit'), + network: t('settings.llmp_err_network'), + }; + return byKind[res?.kind] || res?.detail || t('settings.llmp_err_error'); + }, + [t], + ); + const populate = useCallback((list, id) => { const p = list.find((x) => x.id === id); if (!p) return; @@ -43,6 +67,7 @@ export default function LLMProvidersPanel() { // sane default; api_key is never echoed (only the has_key flag comes back). setFields({ base_url: p.base_url || '', model: p.model || '', api_key: '', account_id: '' }); setTest(null); + setModels(null); }, []); const refresh = useCallback( @@ -62,10 +87,10 @@ export default function LLMProvidersPanel() { populate(data.providers || [], pick); return data; } catch (e) { - setError(e?.message || 'Failed to load providers'); + setError(e?.message || t('settings.llmp_load_failed')); } }, - [populate], + [populate, t], ); useEffect(() => { @@ -96,7 +121,7 @@ export default function LLMProvidersPanel() { }); await refresh(current.id); } catch (e) { - setError(e?.message || 'Failed to save'); + setError(e?.message || t('settings.llmp_save_failed')); } finally { setSaving(false); } @@ -113,18 +138,40 @@ export default function LLMProvidersPanel() { const res = await apiPost(`/api/settings/llm-providers/${current.id}/test`); setTest(res); } catch (e) { - setTest({ ok: false, detail: e?.message || 'Test failed' }); + setTest({ ok: false, detail: e?.message || t('settings.llmp_err_error') }); } finally { setTesting(false); } }; + const fetchModels = async () => { + if (!current) return; + setLoadingModels(true); + setError(null); + try { + // Save non-key fields first so the probe uses the just-typed base URL. + await save(false); + const res = await apiJson(`/api/settings/llm-providers/${current.id}/models`); + if (res.ok) { + setModels(res.models || []); + } else { + setModels([]); + setTest({ ok: false, kind: res.kind, detail: res.detail }); + } + } catch (e) { + setModels([]); + setTest({ ok: false, detail: e?.message || t('settings.llmp_err_error') }); + } finally { + setLoadingModels(false); + } + }; + if (!providers.length) { return ( {error && (
@@ -140,12 +187,12 @@ export default function LLMProvidersPanel() { return ( ( ))} @@ -168,7 +215,7 @@ export default function LLMProvidersPanel() { <> {(current.notes || current.signup_url) && ( {current.notes && {current.notes}} @@ -179,7 +226,7 @@ export default function LLMProvidersPanel() { rel="noreferrer" className="text-[12px] inline-flex items-center gap-[4px] opacity-80 hover:opacity-100" > - Get an API key + {t('settings.llmp_get_key')} )}
@@ -189,14 +236,14 @@ export default function LLMProvidersPanel() { {current.needs_account && ( setFields((f) => ({ ...f, account_id: e.target.value }))} - placeholder="Cloudflare account id" + placeholder={t('settings.llmp_account_placeholder')} data-testid="llm-account-id" /> } @@ -205,7 +252,7 @@ export default function LLMProvidersPanel() { {!current.local && ( setFields((f) => ({ ...f, api_key: e.target.value }))} placeholder={ current.key_from_env - ? 'set via environment (.env) — overrides this field' + ? t('settings.llmp_key_env') : current.has_key - ? 'stored — type to replace' - : 'paste your API key' + ? t('settings.llmp_key_stored') + : t('settings.llmp_key_paste') } disabled={current.key_from_env} data-testid="llm-provider-key" @@ -227,7 +274,7 @@ export default function LLMProvidersPanel() { )} setFields((f) => ({ ...f, model: e.target.value }))} - placeholder="model name" - data-testid="llm-provider-model" - /> +
+ setFields((f) => ({ ...f, model: e.target.value }))} + placeholder={t('settings.llmp_model_placeholder')} + list={models?.length ? MODELS_DATALIST_ID : undefined} + data-testid="llm-provider-model" + /> + + {models?.length ? ( + + {models.map((m) => ( + + ) : null} +
} /> @@ -260,7 +332,7 @@ export default function LLMProvidersPanel() { )} {isActive && ( - active + {t('settings.llmp_active_badge')} )} {test && ( - {test.ok ? `ok — ${test.model || ''}` : test.detail || 'failed'} + {test.ok + ? t('settings.llmp_test_ok', { + model: test.model || '', + ms: test.latency_ms ?? '—', + }) + : kindMessage(test)} )} diff --git a/frontend/src/components/settings/LLMProvidersPanel.test.jsx b/frontend/src/components/settings/LLMProvidersPanel.test.jsx new file mode 100644 index 00000000..a56b4d22 --- /dev/null +++ b/frontend/src/components/settings/LLMProvidersPanel.test.jsx @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; + +import LLMProvidersPanel from './LLMProvidersPanel'; + +const PROVIDERS = { + active: 'groq', + providers: [ + { + id: 'groq', + display_name: 'Groq', + local: false, + needs_account: false, + signup_url: 'https://console.groq.com', + notes: 'fast inference', + base_url: 'https://api.groq.com/openai/v1', + model: 'llama-3.3-70b', + has_key: true, + key_from_env: false, + configured: true, + }, + { + id: 'ollama', + display_name: 'Ollama', + local: true, + needs_account: false, + signup_url: null, + notes: null, + base_url: 'http://localhost:11434/v1', + model: 'llama3', + has_key: false, + key_from_env: false, + configured: false, + }, + ], +}; + +function mockFetchSequence(...responses) { + const fn = vi.fn(); + for (const r of responses) { + fn.mockResolvedValueOnce({ + ok: (r.status ?? 200) >= 200 && (r.status ?? 200) < 300, + status: r.status ?? 200, + json: async () => r.body, + text: async () => JSON.stringify(r.body), + }); + } + return fn; +} + +describe('LLMProvidersPanel', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('loads providers and preselects the active one', async () => { + global.fetch = mockFetchSequence({ body: PROVIDERS }); + render(); + const select = await screen.findByTestId('llm-provider-select'); + await waitFor(() => expect(select.value).toBe('groq')); + expect(screen.getByTestId('llm-provider-base-url').value).toBe( + 'https://api.groq.com/openai/v1', + ); + }); + + it('successful test shows model + latency badge', async () => { + global.fetch = mockFetchSequence( + { body: PROVIDERS }, // mount GET + { body: {} }, // save PUT + { body: PROVIDERS }, // refresh GET + { body: { ok: true, model: 'llama-3.3-70b', reply: 'ok', latency_ms: 412 } }, // test POST + ); + render(); + fireEvent.click(await screen.findByTestId('llm-provider-test')); + await waitFor(() => expect(screen.getByText(/llama-3\.3-70b · 412 ms/)).toBeInTheDocument()); + }); + + it('auth failure renders the actionable localized message, not the raw detail', async () => { + global.fetch = mockFetchSequence( + { body: PROVIDERS }, + { body: {} }, + { body: PROVIDERS }, + { + body: { + ok: false, + kind: 'auth', + detail: 'AuthenticationError: Incorrect API key', + latency_ms: 130, + }, + }, + ); + render(); + fireEvent.click(await screen.findByTestId('llm-provider-test')); + await waitFor(() => expect(screen.getByText(/Key rejected \(401\/403\)/)).toBeInTheDocument()); + expect(screen.queryByText(/AuthenticationError/)).toBeNull(); + }); + + it('network failure explains reachability (local server hint)', async () => { + global.fetch = mockFetchSequence( + { body: PROVIDERS }, + { body: {} }, + { body: PROVIDERS }, + { body: { ok: false, kind: 'network', detail: 'APIConnectionError: refused' } }, + ); + render(); + fireEvent.click(await screen.findByTestId('llm-provider-test')); + await waitFor(() => expect(screen.getByText(/Can't reach the provider/)).toBeInTheDocument()); + }); + + it('fetch models fills the datalist for the model input', async () => { + global.fetch = mockFetchSequence( + { body: PROVIDERS }, + { body: {} }, // save PUT (models saves non-key fields first) + { body: PROVIDERS }, // refresh GET + { body: { ok: true, models: ['llama-3.1-8b', 'llama-3.3-70b'] } }, // models GET + ); + render(); + fireEvent.click(await screen.findByTestId('llm-provider-models')); + await waitFor(() => expect(screen.getByTestId('llm-provider-model')).toHaveAttribute('list')); + expect(document.querySelectorAll('datalist option')).toHaveLength(2); + }); + + it('local provider hides the API key row', async () => { + global.fetch = mockFetchSequence({ body: PROVIDERS }); + render(); + const select = await screen.findByTestId('llm-provider-select'); + fireEvent.change(select, { target: { value: 'ollama' } }); + await waitFor(() => expect(screen.queryByTestId('llm-provider-key')).toBeNull()); + }); +}); diff --git a/frontend/src/i18n/locales/ar.json b/frontend/src/i18n/locales/ar.json index f2b156d7..d4f8d485 100644 --- a/frontend/src/i18n/locales/ar.json +++ b/frontend/src/i18n/locales/ar.json @@ -26,6 +26,39 @@ "engines": "المحركات", "capture": "الاختصارات", "credentials": "بيانات الاعتماد", + "llm_providers": "مقدمو خدمات LLM", + "llmp_desc": "يشغّل ترجمة Cinematic وAutofit — يعيد نموذج اللغة (LLM) صياغة كل سطر ليناسب الوقت المتاح لمقطعه حتى يبقى توقيت الفيديو متوافقًا. تُخزَّن المفاتيح مشفّرة؛ ويعمل مقدمو الخدمة المحليون (Ollama/LM Studio) دون اتصال بالإنترنت تمامًا.", + "llmp_provider": "مقدم الخدمة", + "llmp_provider_hint": "اختر مقدم خدمة لإعداده. يُستخدم النشط لترجمة Cinematic/Autofit. لا يحتاج المقدمون المحليون إلى مفتاح، لكن يجب أن يكون خادمهم قيد التشغيل.", + "llmp_local_tag": "محلي", + "llmp_about": "حول", + "llmp_get_key": "الحصول على مفتاح API", + "llmp_account_id": "معرّف الحساب", + "llmp_account_placeholder": "معرّف حساب Cloudflare", + "llmp_api_key": "مفتاح API", + "llmp_key_env": "مضبوط عبر البيئة (.env) — يتجاوز هذا الحقل", + "llmp_key_stored": "محفوظ — اكتب للاستبدال", + "llmp_key_paste": "الصق مفتاح API الخاص بك", + "llmp_base_url": "عنوان URL الأساسي", + "llmp_model": "النموذج", + "llmp_model_placeholder": "اسم النموذج", + "llmp_fetch_models": "جلب النماذج", + "llmp_models_loaded": "عدد النماذج المتاحة من مقدم الخدمة هذا: {{count}}", + "llmp_status": "الحالة", + "llmp_save": "حفظ", + "llmp_save_active": "حفظ واستخدام للترجمة", + "llmp_save_keep": "حفظ مع إبقاء النشط", + "llmp_test": "اختبار", + "llmp_active_badge": "نشط", + "llmp_test_ok": "تم — {{model}} · {{ms}} ms", + "llmp_err_config": "مفتاح API أو عنوان URL الأساسي مفقود — أدخلهما ثم احفظ.", + "llmp_err_auth": "رُفض المفتاح (401/403) — تحقق من مفتاح API الخاص بك.", + "llmp_err_not_found": "غير موجود (404) — تحقق من اسم النموذج ومسار عنوان URL الأساسي.", + "llmp_err_rate_limit": "تم تجاوز حد الطلبات (429) — المفتاح يعمل؛ حاول مجددًا بعد قليل.", + "llmp_err_network": "تعذّر الوصول إلى مقدم الخدمة — تحقق من عنوان URL الأساسي، أو الشبكة، أو من أن الخادم المحلي قيد التشغيل.", + "llmp_err_error": "فشل الاختبار", + "llmp_load_failed": "فشل تحميل مقدمي الخدمة", + "llmp_save_failed": "فشل الحفظ", "updates": "التحديثات", "logs": "السجلات", "about": "حول", diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 633b24a2..f39e28f5 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -26,6 +26,39 @@ "engines": "Engines", "capture": "Tastatur", "credentials": "Anmeldedaten", + "llm_providers": "LLM-Anbieter", + "llmp_desc": "Treibt die Cinematic- und Autofit-Übersetzung an — das LLM formuliert jede Zeile so um, dass sie ins Zeitbudget ihres Segments passt und das Video-Timing erhalten bleibt. Schlüssel werden verschlüsselt gespeichert; lokale Anbieter (Ollama/LM Studio) bleiben vollständig offline.", + "llmp_provider": "Anbieter", + "llmp_provider_hint": "Anbieter zum Konfigurieren auswählen. Der aktive wird für die Cinematic/Autofit-Übersetzung verwendet. Lokale Anbieter benötigen keinen Schlüssel, ihr Server muss jedoch laufen.", + "llmp_local_tag": "lokal", + "llmp_about": "Über", + "llmp_get_key": "API-Schlüssel anfordern", + "llmp_account_id": "Konto-ID", + "llmp_account_placeholder": "Cloudflare-Konto-ID", + "llmp_api_key": "API-Schlüssel", + "llmp_key_env": "über Umgebung (.env) gesetzt — überschreibt dieses Feld", + "llmp_key_stored": "gespeichert — zum Ersetzen eintippen", + "llmp_key_paste": "API-Schlüssel einfügen", + "llmp_base_url": "Basis-URL", + "llmp_model": "Modell", + "llmp_model_placeholder": "Modellname", + "llmp_fetch_models": "Modelle abrufen", + "llmp_models_loaded": "{{count}} Modelle bei diesem Anbieter verfügbar", + "llmp_status": "Status", + "llmp_save": "Speichern", + "llmp_save_active": "Speichern & für Übersetzung verwenden", + "llmp_save_keep": "Speichern & aktiven Anbieter behalten", + "llmp_test": "Testen", + "llmp_active_badge": "aktiv", + "llmp_test_ok": "OK — {{model}} · {{ms}} ms", + "llmp_err_config": "API-Schlüssel oder Basis-URL fehlt — ausfüllen und speichern.", + "llmp_err_auth": "Schlüssel abgelehnt (401/403) — API-Schlüssel prüfen.", + "llmp_err_not_found": "Nicht gefunden (404) — Modellname und Pfad der Basis-URL prüfen.", + "llmp_err_rate_limit": "Rate-Limit erreicht (429) — der Schlüssel funktioniert; gleich erneut versuchen.", + "llmp_err_network": "Anbieter nicht erreichbar — Basis-URL und Netzwerk prüfen bzw. sicherstellen, dass der lokale Server läuft.", + "llmp_err_error": "Test fehlgeschlagen", + "llmp_load_failed": "Anbieter konnten nicht geladen werden", + "llmp_save_failed": "Speichern fehlgeschlagen", "updates": "Updates", "logs": "Protokolle", "about": "Über", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index c0acae03..e802af96 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -321,6 +321,38 @@ "appearance": "Appearance", "credentials": "Credentials", "llm_providers": "LLM Providers", + "llmp_desc": "Powers Cinematic & Autofit translation — the LLM rewrites each line to fit its segment's time budget so the video timing holds. Keys are stored encrypted; local providers (Ollama/LM Studio) stay fully offline.", + "llmp_provider": "Provider", + "llmp_provider_hint": "Pick a provider to configure. The active one is used for Cinematic/Autofit translation. Local providers need no key but require their server to be running.", + "llmp_local_tag": "local", + "llmp_about": "About", + "llmp_get_key": "Get an API key", + "llmp_account_id": "Account ID", + "llmp_account_placeholder": "Cloudflare account id", + "llmp_api_key": "API key", + "llmp_key_env": "set via environment (.env) — overrides this field", + "llmp_key_stored": "stored — type to replace", + "llmp_key_paste": "paste your API key", + "llmp_base_url": "Base URL", + "llmp_model": "Model", + "llmp_model_placeholder": "model name", + "llmp_fetch_models": "Fetch models", + "llmp_models_loaded": "{{count}} models available from this provider", + "llmp_status": "Status", + "llmp_save": "Save", + "llmp_save_active": "Save & use for translation", + "llmp_save_keep": "Save & keep active", + "llmp_test": "Test", + "llmp_active_badge": "active", + "llmp_test_ok": "ok — {{model}} · {{ms}} ms", + "llmp_err_config": "Missing API key or Base URL — fill them in and save.", + "llmp_err_auth": "Key rejected (401/403) — check your API key.", + "llmp_err_not_found": "Not found (404) — check the model name and Base URL path.", + "llmp_err_rate_limit": "Rate-limited (429) — the key works; try again in a moment.", + "llmp_err_network": "Can't reach the provider — check the Base URL, your network, or that the local server is running.", + "llmp_err_error": "Test failed", + "llmp_load_failed": "Failed to load providers", + "llmp_save_failed": "Failed to save", "updates": "Updates", "proxy": "Proxy", "proxy_desc": "HTTP/SOCKS5 proxy for downloads (yt-dlp, HuggingFace). Supports http://, https://, socks5://. Restart required if changed after backend start.", @@ -2092,4 +2124,4 @@ "loaded_raw": "Recording loaded (raw — denoising unavailable)", "too_short": "Recording too short" } -} +} \ No newline at end of file diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index b35a1474..7aad3d76 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -26,6 +26,39 @@ "engines": "Motores", "capture": "Captura", "credentials": "Credenciales", + "llm_providers": "Proveedores LLM", + "llmp_desc": "Impulsa la traducción Cinematic y Autofit: el LLM reescribe cada línea para ajustarse al tiempo disponible de su segmento y mantener la sincronización del vídeo. Las claves se guardan cifradas; los proveedores locales (Ollama/LM Studio) funcionan totalmente sin conexión.", + "llmp_provider": "Proveedor", + "llmp_provider_hint": "Elige un proveedor para configurarlo. El activo se usa para la traducción Cinematic/Autofit. Los proveedores locales no necesitan clave, pero su servidor debe estar en ejecución.", + "llmp_local_tag": "local", + "llmp_about": "Acerca de", + "llmp_get_key": "Obtener una clave API", + "llmp_account_id": "ID de cuenta", + "llmp_account_placeholder": "ID de cuenta de Cloudflare", + "llmp_api_key": "Clave API", + "llmp_key_env": "definida por entorno (.env) — anula este campo", + "llmp_key_stored": "guardada — escribe para reemplazarla", + "llmp_key_paste": "pega tu clave API", + "llmp_base_url": "URL base", + "llmp_model": "Modelo", + "llmp_model_placeholder": "nombre del modelo", + "llmp_fetch_models": "Obtener modelos", + "llmp_models_loaded": "{{count}} modelos disponibles de este proveedor", + "llmp_status": "Estado", + "llmp_save": "Guardar", + "llmp_save_active": "Guardar y usar para traducción", + "llmp_save_keep": "Guardar y mantener el activo", + "llmp_test": "Probar", + "llmp_active_badge": "activo", + "llmp_test_ok": "ok — {{model}} · {{ms}} ms", + "llmp_err_config": "Falta la clave API o la URL base — rellénalas y guarda.", + "llmp_err_auth": "Clave rechazada (401/403) — comprueba tu clave API.", + "llmp_err_not_found": "No encontrado (404) — comprueba el nombre del modelo y la ruta de la URL base.", + "llmp_err_rate_limit": "Límite de peticiones (429) — la clave funciona; inténtalo de nuevo en un momento.", + "llmp_err_network": "No se puede conectar con el proveedor — comprueba la URL base, tu red o que el servidor local esté en ejecución.", + "llmp_err_error": "Error en la prueba", + "llmp_load_failed": "Error al cargar los proveedores", + "llmp_save_failed": "Error al guardar", "updates": "Actualizaciones", "logs": "Registros", "about": "Acerca de", diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 188632ff..f388d84a 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -26,6 +26,39 @@ "engines": "Moteurs", "capture": "Saisie", "credentials": "Identifiants", + "llm_providers": "Fournisseurs LLM", + "llmp_desc": "Alimente la traduction Cinematic et Autofit — le LLM reformule chaque ligne pour respecter le budget temps de son segment afin de préserver le timing de la vidéo. Les clés sont stockées chiffrées ; les fournisseurs locaux (Ollama/LM Studio) restent entièrement hors ligne.", + "llmp_provider": "Fournisseur", + "llmp_provider_hint": "Choisissez un fournisseur à configurer. Le fournisseur actif est utilisé pour la traduction Cinematic/Autofit. Les fournisseurs locaux ne nécessitent aucune clé, mais leur serveur doit être en cours d'exécution.", + "llmp_local_tag": "local", + "llmp_about": "À propos", + "llmp_get_key": "Obtenir une clé API", + "llmp_account_id": "ID de compte", + "llmp_account_placeholder": "ID de compte Cloudflare", + "llmp_api_key": "Clé API", + "llmp_key_env": "définie via l'environnement (.env) — remplace ce champ", + "llmp_key_stored": "enregistrée — saisissez pour remplacer", + "llmp_key_paste": "collez votre clé API", + "llmp_base_url": "URL de base", + "llmp_model": "Modèle", + "llmp_model_placeholder": "nom du modèle", + "llmp_fetch_models": "Récupérer les modèles", + "llmp_models_loaded": "{{count}} modèles disponibles chez ce fournisseur", + "llmp_status": "Statut", + "llmp_save": "Enregistrer", + "llmp_save_active": "Enregistrer et utiliser pour la traduction", + "llmp_save_keep": "Enregistrer et conserver l'actif", + "llmp_test": "Tester", + "llmp_active_badge": "actif", + "llmp_test_ok": "ok — {{model}} · {{ms}} ms", + "llmp_err_config": "Clé API ou URL de base manquante — renseignez-les puis enregistrez.", + "llmp_err_auth": "Clé rejetée (401/403) — vérifiez votre clé API.", + "llmp_err_not_found": "Introuvable (404) — vérifiez le nom du modèle et le chemin de l'URL de base.", + "llmp_err_rate_limit": "Limite de débit atteinte (429) — la clé fonctionne ; réessayez dans un instant.", + "llmp_err_network": "Impossible de joindre le fournisseur — vérifiez l'URL de base, votre réseau, ou que le serveur local est en cours d'exécution.", + "llmp_err_error": "Échec du test", + "llmp_load_failed": "Échec du chargement des fournisseurs", + "llmp_save_failed": "Échec de l'enregistrement", "updates": "Mises à jour", "logs": "Journaux", "about": "À propos", diff --git a/frontend/src/i18n/locales/hi.json b/frontend/src/i18n/locales/hi.json index 977afe8d..08e92235 100644 --- a/frontend/src/i18n/locales/hi.json +++ b/frontend/src/i18n/locales/hi.json @@ -26,6 +26,39 @@ "engines": "इंजन", "capture": "कैप्चर", "credentials": "क्रेडेंशियल", + "llm_providers": "LLM प्रदाता", + "llmp_desc": "Cinematic और Autofit अनुवाद को संचालित करता है — LLM हर पंक्ति को इस तरह फिर से लिखता है कि वह अपने खंड के समय-बजट में फ़िट हो और वीडियो की टाइमिंग बनी रहे। कुंजियाँ एन्क्रिप्ट करके संग्रहीत की जाती हैं; स्थानीय प्रदाता (Ollama/LM Studio) पूरी तरह ऑफ़लाइन रहते हैं।", + "llmp_provider": "प्रदाता", + "llmp_provider_hint": "कॉन्फ़िगर करने के लिए एक प्रदाता चुनें। सक्रिय प्रदाता का उपयोग Cinematic/Autofit अनुवाद के लिए होता है। स्थानीय प्रदाताओं को कुंजी की ज़रूरत नहीं, लेकिन उनका सर्वर चालू होना चाहिए।", + "llmp_local_tag": "स्थानीय", + "llmp_about": "विवरण", + "llmp_get_key": "API कुंजी प्राप्त करें", + "llmp_account_id": "खाता ID", + "llmp_account_placeholder": "Cloudflare खाता ID", + "llmp_api_key": "API कुंजी", + "llmp_key_env": "एनवायरनमेंट (.env) से सेट है — यह इस फ़ील्ड को ओवरराइड करता है", + "llmp_key_stored": "संग्रहीत — बदलने के लिए टाइप करें", + "llmp_key_paste": "अपनी API कुंजी पेस्ट करें", + "llmp_base_url": "बेस URL", + "llmp_model": "मॉडल", + "llmp_model_placeholder": "मॉडल का नाम", + "llmp_fetch_models": "मॉडल प्राप्त करें", + "llmp_models_loaded": "इस प्रदाता से {{count}} मॉडल उपलब्ध हैं", + "llmp_status": "स्थिति", + "llmp_save": "सहेजें", + "llmp_save_active": "सहेजें और अनुवाद के लिए उपयोग करें", + "llmp_save_keep": "सहेजें और मौजूदा सक्रिय रखें", + "llmp_test": "परीक्षण करें", + "llmp_active_badge": "सक्रिय", + "llmp_test_ok": "ठीक — {{model}} · {{ms}} ms", + "llmp_err_config": "API कुंजी या बेस URL मौजूद नहीं — उन्हें भरें और सहेजें।", + "llmp_err_auth": "कुंजी अस्वीकृत (401/403) — अपनी API कुंजी जाँचें।", + "llmp_err_not_found": "नहीं मिला (404) — मॉडल का नाम और बेस URL पथ जाँचें।", + "llmp_err_rate_limit": "रेट-लिमिट (429) — कुंजी काम करती है; थोड़ी देर में पुनः प्रयास करें।", + "llmp_err_network": "प्रदाता तक नहीं पहुँचा जा सका — बेस URL, अपना नेटवर्क, या स्थानीय सर्वर चालू है या नहीं, जाँचें।", + "llmp_err_error": "परीक्षण विफल", + "llmp_load_failed": "प्रदाता लोड करने में विफल", + "llmp_save_failed": "सहेजना विफल", "updates": "अपडेट", "logs": "लॉग", "about": "विवरण", diff --git a/frontend/src/i18n/locales/id.json b/frontend/src/i18n/locales/id.json index 7cdc870f..51d900f5 100644 --- a/frontend/src/i18n/locales/id.json +++ b/frontend/src/i18n/locales/id.json @@ -26,6 +26,39 @@ "engines": "Mesin", "capture": "Pintasan", "credentials": "Kredensial", + "llm_providers": "Penyedia LLM", + "llmp_desc": "Menggerakkan terjemahan Cinematic & Autofit — LLM menulis ulang setiap baris agar sesuai dengan jatah waktu segmennya sehingga pengaturan waktu video tetap terjaga. Kunci disimpan terenkripsi; penyedia lokal (Ollama/LM Studio) sepenuhnya offline.", + "llmp_provider": "Penyedia", + "llmp_provider_hint": "Pilih penyedia untuk dikonfigurasi. Penyedia aktif digunakan untuk terjemahan Cinematic/Autofit. Penyedia lokal tidak memerlukan kunci, tetapi servernya harus berjalan.", + "llmp_local_tag": "lokal", + "llmp_about": "Tentang", + "llmp_get_key": "Dapatkan kunci API", + "llmp_account_id": "ID akun", + "llmp_account_placeholder": "ID akun Cloudflare", + "llmp_api_key": "Kunci API", + "llmp_key_env": "disetel lewat environment (.env) — menggantikan kolom ini", + "llmp_key_stored": "tersimpan — ketik untuk mengganti", + "llmp_key_paste": "tempel kunci API Anda", + "llmp_base_url": "URL dasar", + "llmp_model": "Model", + "llmp_model_placeholder": "nama model", + "llmp_fetch_models": "Ambil model", + "llmp_models_loaded": "{{count}} model tersedia dari penyedia ini", + "llmp_status": "Status", + "llmp_save": "Simpan", + "llmp_save_active": "Simpan & gunakan untuk terjemahan", + "llmp_save_keep": "Simpan & pertahankan yang aktif", + "llmp_test": "Uji", + "llmp_active_badge": "aktif", + "llmp_test_ok": "ok — {{model}} · {{ms}} ms", + "llmp_err_config": "Kunci API atau URL dasar belum diisi — isi lalu simpan.", + "llmp_err_auth": "Kunci ditolak (401/403) — periksa kunci API Anda.", + "llmp_err_not_found": "Tidak ditemukan (404) — periksa nama model dan jalur URL dasar.", + "llmp_err_rate_limit": "Terkena batas laju (429) — kunci berfungsi; coba lagi sebentar lagi.", + "llmp_err_network": "Tidak dapat menghubungi penyedia — periksa URL dasar, jaringan Anda, atau pastikan server lokal sedang berjalan.", + "llmp_err_error": "Pengujian gagal", + "llmp_load_failed": "Gagal memuat penyedia", + "llmp_save_failed": "Gagal menyimpan", "updates": "Pembaruan", "logs": "Log", "about": "Tentang", diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json index 00e4bb46..4592e7ba 100644 --- a/frontend/src/i18n/locales/it.json +++ b/frontend/src/i18n/locales/it.json @@ -26,6 +26,39 @@ "engines": "Motori", "capture": "Scorciatoie", "credentials": "Credenziali", + "llm_providers": "Provider LLM", + "llmp_desc": "Alimenta la traduzione Cinematic e Autofit: il LLM riscrive ogni riga per rientrare nel budget di tempo del suo segmento, preservando il timing del video. Le chiavi sono salvate cifrate; i provider locali (Ollama/LM Studio) restano completamente offline.", + "llmp_provider": "Provider", + "llmp_provider_hint": "Scegli un provider da configurare. Quello attivo viene usato per la traduzione Cinematic/Autofit. I provider locali non richiedono chiavi, ma il loro server deve essere in esecuzione.", + "llmp_local_tag": "locale", + "llmp_about": "Informazioni", + "llmp_get_key": "Ottieni una chiave API", + "llmp_account_id": "ID account", + "llmp_account_placeholder": "ID account Cloudflare", + "llmp_api_key": "Chiave API", + "llmp_key_env": "impostata via ambiente (.env) — ha precedenza su questo campo", + "llmp_key_stored": "salvata — digita per sostituirla", + "llmp_key_paste": "incolla la tua chiave API", + "llmp_base_url": "URL di base", + "llmp_model": "Modello", + "llmp_model_placeholder": "nome del modello", + "llmp_fetch_models": "Recupera modelli", + "llmp_models_loaded": "{{count}} modelli disponibili da questo provider", + "llmp_status": "Stato", + "llmp_save": "Salva", + "llmp_save_active": "Salva e usa per la traduzione", + "llmp_save_keep": "Salva e mantieni l'attivo", + "llmp_test": "Testa", + "llmp_active_badge": "attivo", + "llmp_test_ok": "ok — {{model}} · {{ms}} ms", + "llmp_err_config": "Chiave API o URL di base mancante — compilali e salva.", + "llmp_err_auth": "Chiave rifiutata (401/403) — controlla la tua chiave API.", + "llmp_err_not_found": "Non trovato (404) — controlla il nome del modello e il percorso dell'URL di base.", + "llmp_err_rate_limit": "Limite di richieste (429) — la chiave funziona; riprova tra un momento.", + "llmp_err_network": "Impossibile raggiungere il provider — controlla l'URL di base, la rete o che il server locale sia in esecuzione.", + "llmp_err_error": "Test non riuscito", + "llmp_load_failed": "Impossibile caricare i provider", + "llmp_save_failed": "Salvataggio non riuscito", "updates": "Aggiornamenti", "logs": "Registri", "about": "Informazioni", diff --git a/frontend/src/i18n/locales/ja.json b/frontend/src/i18n/locales/ja.json index 8c827dc9..4b1152e4 100644 --- a/frontend/src/i18n/locales/ja.json +++ b/frontend/src/i18n/locales/ja.json @@ -26,6 +26,39 @@ "engines": "エンジン", "capture": "キャプチャ", "credentials": "資格情報", + "llm_providers": "LLM プロバイダー", + "llmp_desc": "Cinematic・Autofit 翻訳を支える機能です。LLM が各行をセグメントの時間枠に収まるように書き換え、動画のタイミングを保ちます。キーは暗号化して保存され、ローカルプロバイダー(Ollama/LM Studio)は完全にオフラインで動作します。", + "llmp_provider": "プロバイダー", + "llmp_provider_hint": "設定するプロバイダーを選択してください。アクティブなプロバイダーが Cinematic/Autofit 翻訳に使用されます。ローカルプロバイダーはキー不要ですが、サーバーが起動している必要があります。", + "llmp_local_tag": "ローカル", + "llmp_about": "情報", + "llmp_get_key": "API キーを取得", + "llmp_account_id": "アカウント ID", + "llmp_account_placeholder": "Cloudflare アカウント ID", + "llmp_api_key": "API キー", + "llmp_key_env": "環境変数 (.env) で設定済み — この欄より優先されます", + "llmp_key_stored": "保存済み — 入力すると置き換えます", + "llmp_key_paste": "API キーを貼り付け", + "llmp_base_url": "ベース URL", + "llmp_model": "モデル", + "llmp_model_placeholder": "モデル名", + "llmp_fetch_models": "モデルを取得", + "llmp_models_loaded": "このプロバイダーで {{count}} 個のモデルが利用可能です", + "llmp_status": "ステータス", + "llmp_save": "保存", + "llmp_save_active": "保存して翻訳に使用", + "llmp_save_keep": "保存(アクティブは変更しない)", + "llmp_test": "テスト", + "llmp_active_badge": "アクティブ", + "llmp_test_ok": "OK — {{model}} · {{ms}} ms", + "llmp_err_config": "API キーまたはベース URL が未入力です — 入力して保存してください。", + "llmp_err_auth": "キーが拒否されました (401/403) — API キーを確認してください。", + "llmp_err_not_found": "見つかりません (404) — モデル名とベース URL のパスを確認してください。", + "llmp_err_rate_limit": "レート制限中 (429) — キーは有効です。しばらくしてから再試行してください。", + "llmp_err_network": "プロバイダーに接続できません — ベース URL、ネットワーク、またはローカルサーバーが起動しているか確認してください。", + "llmp_err_error": "テストに失敗しました", + "llmp_load_failed": "プロバイダーの読み込みに失敗しました", + "llmp_save_failed": "保存に失敗しました", "updates": "アップデート", "logs": "ログ", "about": "情報", diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json index 82cbd5ce..54a0298c 100644 --- a/frontend/src/i18n/locales/ko.json +++ b/frontend/src/i18n/locales/ko.json @@ -26,6 +26,39 @@ "engines": "엔진", "capture": "입력", "credentials": "자격 증명", + "llm_providers": "LLM 제공업체", + "llmp_desc": "Cinematic 및 Autofit 번역을 구동합니다. LLM이 각 줄을 해당 구간의 시간 안에 맞게 다시 써서 영상 타이밍을 유지합니다. 키는 암호화되어 저장되며, 로컬 제공업체(Ollama/LM Studio)는 완전히 오프라인으로 작동합니다.", + "llmp_provider": "제공업체", + "llmp_provider_hint": "구성할 제공업체를 선택하세요. 활성 제공업체가 Cinematic/Autofit 번역에 사용됩니다. 로컬 제공업체는 키가 필요 없지만 서버가 실행 중이어야 합니다.", + "llmp_local_tag": "로컬", + "llmp_about": "정보", + "llmp_get_key": "API 키 발급받기", + "llmp_account_id": "계정 ID", + "llmp_account_placeholder": "Cloudflare 계정 ID", + "llmp_api_key": "API 키", + "llmp_key_env": "환경 변수(.env)로 설정됨 — 이 필드보다 우선합니다", + "llmp_key_stored": "저장됨 — 입력하면 교체됩니다", + "llmp_key_paste": "API 키를 붙여넣으세요", + "llmp_base_url": "기본 URL", + "llmp_model": "모델", + "llmp_model_placeholder": "모델 이름", + "llmp_fetch_models": "모델 가져오기", + "llmp_models_loaded": "이 제공업체에서 {{count}}개의 모델을 사용할 수 있습니다", + "llmp_status": "상태", + "llmp_save": "저장", + "llmp_save_active": "저장 후 번역에 사용", + "llmp_save_keep": "저장(활성 제공업체 유지)", + "llmp_test": "테스트", + "llmp_active_badge": "활성", + "llmp_test_ok": "정상 — {{model}} · {{ms}} ms", + "llmp_err_config": "API 키 또는 기본 URL이 없습니다 — 입력한 후 저장하세요.", + "llmp_err_auth": "키가 거부되었습니다(401/403) — API 키를 확인하세요.", + "llmp_err_not_found": "찾을 수 없습니다(404) — 모델 이름과 기본 URL 경로를 확인하세요.", + "llmp_err_rate_limit": "요청 한도 초과(429) — 키는 정상입니다. 잠시 후 다시 시도하세요.", + "llmp_err_network": "제공업체에 연결할 수 없습니다 — 기본 URL, 네트워크 또는 로컬 서버 실행 여부를 확인하세요.", + "llmp_err_error": "테스트 실패", + "llmp_load_failed": "제공업체를 불러오지 못했습니다", + "llmp_save_failed": "저장하지 못했습니다", "updates": "업데이트", "logs": "로그", "about": "정보", diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index 6d6b4a8a..73340354 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -26,6 +26,39 @@ "engines": "Motoren", "capture": "Sneltoetsen", "credentials": "Inloggegevens", + "llm_providers": "LLM-providers", + "llmp_desc": "Drijft de Cinematic- en Autofit-vertaling aan — de LLM herschrijft elke regel zodat die binnen het tijdsbudget van zijn segment past en de videotiming behouden blijft. Sleutels worden versleuteld opgeslagen; lokale providers (Ollama/LM Studio) blijven volledig offline.", + "llmp_provider": "Provider", + "llmp_provider_hint": "Kies een provider om te configureren. De actieve wordt gebruikt voor Cinematic/Autofit-vertaling. Lokale providers hebben geen sleutel nodig, maar hun server moet wel draaien.", + "llmp_local_tag": "lokaal", + "llmp_about": "Over", + "llmp_get_key": "API-sleutel aanvragen", + "llmp_account_id": "Account-ID", + "llmp_account_placeholder": "Cloudflare-account-ID", + "llmp_api_key": "API-sleutel", + "llmp_key_env": "ingesteld via omgeving (.env) — overschrijft dit veld", + "llmp_key_stored": "opgeslagen — typ om te vervangen", + "llmp_key_paste": "plak je API-sleutel", + "llmp_base_url": "Basis-URL", + "llmp_model": "Model", + "llmp_model_placeholder": "modelnaam", + "llmp_fetch_models": "Modellen ophalen", + "llmp_models_loaded": "{{count}} modellen beschikbaar bij deze provider", + "llmp_status": "Status", + "llmp_save": "Opslaan", + "llmp_save_active": "Opslaan en gebruiken voor vertaling", + "llmp_save_keep": "Opslaan en actieve provider behouden", + "llmp_test": "Testen", + "llmp_active_badge": "actief", + "llmp_test_ok": "ok — {{model}} · {{ms}} ms", + "llmp_err_config": "API-sleutel of basis-URL ontbreekt — vul ze in en sla op.", + "llmp_err_auth": "Sleutel geweigerd (401/403) — controleer je API-sleutel.", + "llmp_err_not_found": "Niet gevonden (404) — controleer de modelnaam en het pad van de basis-URL.", + "llmp_err_rate_limit": "Limiet bereikt (429) — de sleutel werkt; probeer het zo opnieuw.", + "llmp_err_network": "Kan de provider niet bereiken — controleer de basis-URL, je netwerk, of dat de lokale server draait.", + "llmp_err_error": "Test mislukt", + "llmp_load_failed": "Kan providers niet laden", + "llmp_save_failed": "Opslaan mislukt", "updates": "Updates", "logs": "Logboeken", "about": "Over", diff --git a/frontend/src/i18n/locales/pl.json b/frontend/src/i18n/locales/pl.json index 5640b200..2f8ab471 100644 --- a/frontend/src/i18n/locales/pl.json +++ b/frontend/src/i18n/locales/pl.json @@ -26,6 +26,39 @@ "engines": "Silniki", "capture": "Skróty klawiszowe", "credentials": "Dane uwierzytelniające", + "llm_providers": "Dostawcy LLM", + "llmp_desc": "Zasila tłumaczenie Cinematic i Autofit — LLM przeredagowuje każdą linię tak, aby zmieściła się w budżecie czasowym swojego segmentu i synchronizacja wideo została zachowana. Klucze są przechowywane w postaci zaszyfrowanej; lokalni dostawcy (Ollama/LM Studio) działają całkowicie offline.", + "llmp_provider": "Dostawca", + "llmp_provider_hint": "Wybierz dostawcę do skonfigurowania. Aktywny jest używany do tłumaczenia Cinematic/Autofit. Lokalni dostawcy nie wymagają klucza, ale ich serwer musi być uruchomiony.", + "llmp_local_tag": "lokalny", + "llmp_about": "Informacje", + "llmp_get_key": "Uzyskaj klucz API", + "llmp_account_id": "ID konta", + "llmp_account_placeholder": "ID konta Cloudflare", + "llmp_api_key": "Klucz API", + "llmp_key_env": "ustawiony przez środowisko (.env) — nadpisuje to pole", + "llmp_key_stored": "zapisany — wpisz, aby zastąpić", + "llmp_key_paste": "wklej swój klucz API", + "llmp_base_url": "Bazowy adres URL", + "llmp_model": "Model", + "llmp_model_placeholder": "nazwa modelu", + "llmp_fetch_models": "Pobierz modele", + "llmp_models_loaded": "Dostępne modele u tego dostawcy: {{count}}", + "llmp_status": "Status", + "llmp_save": "Zapisz", + "llmp_save_active": "Zapisz i używaj do tłumaczenia", + "llmp_save_keep": "Zapisz i zachowaj aktywnego", + "llmp_test": "Testuj", + "llmp_active_badge": "aktywny", + "llmp_test_ok": "ok — {{model}} · {{ms}} ms", + "llmp_err_config": "Brak klucza API lub bazowego adresu URL — uzupełnij je i zapisz.", + "llmp_err_auth": "Klucz odrzucony (401/403) — sprawdź swój klucz API.", + "llmp_err_not_found": "Nie znaleziono (404) — sprawdź nazwę modelu i ścieżkę bazowego adresu URL.", + "llmp_err_rate_limit": "Limit zapytań (429) — klucz działa; spróbuj ponownie za chwilę.", + "llmp_err_network": "Nie można połączyć się z dostawcą — sprawdź bazowy adres URL, sieć lub czy lokalny serwer jest uruchomiony.", + "llmp_err_error": "Test nie powiódł się", + "llmp_load_failed": "Nie udało się załadować dostawców", + "llmp_save_failed": "Nie udało się zapisać", "updates": "Aktualizacje", "logs": "Logi", "about": "O programie", diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json index db2abaef..d09b2bec 100644 --- a/frontend/src/i18n/locales/pt.json +++ b/frontend/src/i18n/locales/pt.json @@ -26,6 +26,39 @@ "engines": "Motores", "capture": "Captura", "credentials": "Credenciais", + "llm_providers": "Provedores de LLM", + "llmp_desc": "Alimenta a tradução Cinematic e Autofit — o LLM reescreve cada linha para caber no tempo do seu segmento, mantendo a sincronização do vídeo. As chaves são armazenadas criptografadas; provedores locais (Ollama/LM Studio) permanecem totalmente offline.", + "llmp_provider": "Provedor", + "llmp_provider_hint": "Escolha um provedor para configurar. O ativo é usado na tradução Cinematic/Autofit. Provedores locais não precisam de chave, mas o servidor deles precisa estar em execução.", + "llmp_local_tag": "local", + "llmp_about": "Sobre", + "llmp_get_key": "Obter uma chave de API", + "llmp_account_id": "ID da conta", + "llmp_account_placeholder": "ID da conta Cloudflare", + "llmp_api_key": "Chave de API", + "llmp_key_env": "definida via ambiente (.env) — substitui este campo", + "llmp_key_stored": "armazenada — digite para substituir", + "llmp_key_paste": "cole sua chave de API", + "llmp_base_url": "URL base", + "llmp_model": "Modelo", + "llmp_model_placeholder": "nome do modelo", + "llmp_fetch_models": "Buscar modelos", + "llmp_models_loaded": "{{count}} modelos disponíveis neste provedor", + "llmp_status": "Status", + "llmp_save": "Salvar", + "llmp_save_active": "Salvar e usar na tradução", + "llmp_save_keep": "Salvar e manter o ativo", + "llmp_test": "Testar", + "llmp_active_badge": "ativo", + "llmp_test_ok": "ok — {{model}} · {{ms}} ms", + "llmp_err_config": "Falta a chave de API ou a URL base — preencha e salve.", + "llmp_err_auth": "Chave rejeitada (401/403) — verifique sua chave de API.", + "llmp_err_not_found": "Não encontrado (404) — verifique o nome do modelo e o caminho da URL base.", + "llmp_err_rate_limit": "Limite de requisições (429) — a chave funciona; tente novamente em instantes.", + "llmp_err_network": "Não foi possível acessar o provedor — verifique a URL base, sua rede ou se o servidor local está em execução.", + "llmp_err_error": "Falha no teste", + "llmp_load_failed": "Falha ao carregar provedores", + "llmp_save_failed": "Falha ao salvar", "updates": "Atualizações", "logs": "Registros", "about": "Sobre", diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index 2174b9f8..43b459df 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -26,6 +26,39 @@ "engines": "Движки", "capture": "Захват", "credentials": "Ключи", + "llm_providers": "Поставщики LLM", + "llmp_desc": "Обеспечивает перевод Cinematic и Autofit — LLM переписывает каждую строку так, чтобы она укладывалась в отведённое время своего сегмента и синхронизация видео сохранялась. Ключи хранятся в зашифрованном виде; локальные поставщики (Ollama/LM Studio) работают полностью офлайн.", + "llmp_provider": "Поставщик", + "llmp_provider_hint": "Выберите поставщика для настройки. Активный используется для перевода Cinematic/Autofit. Локальным поставщикам ключ не нужен, но их сервер должен быть запущен.", + "llmp_local_tag": "локальный", + "llmp_about": "Сведения", + "llmp_get_key": "Получить ключ API", + "llmp_account_id": "ID аккаунта", + "llmp_account_placeholder": "ID аккаунта Cloudflare", + "llmp_api_key": "Ключ API", + "llmp_key_env": "задан через окружение (.env) — имеет приоритет над этим полем", + "llmp_key_stored": "сохранён — введите, чтобы заменить", + "llmp_key_paste": "вставьте ваш ключ API", + "llmp_base_url": "Базовый URL", + "llmp_model": "Модель", + "llmp_model_placeholder": "название модели", + "llmp_fetch_models": "Получить список моделей", + "llmp_models_loaded": "Доступно моделей у этого поставщика: {{count}}", + "llmp_status": "Статус", + "llmp_save": "Сохранить", + "llmp_save_active": "Сохранить и использовать для перевода", + "llmp_save_keep": "Сохранить, не меняя активного", + "llmp_test": "Проверить", + "llmp_active_badge": "активен", + "llmp_test_ok": "ок — {{model}} · {{ms}} мс", + "llmp_err_config": "Не указан ключ API или базовый URL — заполните их и сохраните.", + "llmp_err_auth": "Ключ отклонён (401/403) — проверьте ключ API.", + "llmp_err_not_found": "Не найдено (404) — проверьте название модели и путь базового URL.", + "llmp_err_rate_limit": "Превышен лимит запросов (429) — ключ работает; повторите попытку чуть позже.", + "llmp_err_network": "Не удаётся связаться с поставщиком — проверьте базовый URL, сеть или что локальный сервер запущен.", + "llmp_err_error": "Проверка не удалась", + "llmp_load_failed": "Не удалось загрузить поставщиков", + "llmp_save_failed": "Не удалось сохранить", "updates": "Обновления", "logs": "Логи", "about": "О программе", diff --git a/frontend/src/i18n/locales/sv.json b/frontend/src/i18n/locales/sv.json index ecf7f83f..8a110103 100644 --- a/frontend/src/i18n/locales/sv.json +++ b/frontend/src/i18n/locales/sv.json @@ -26,6 +26,39 @@ "engines": "Motorer", "capture": "Genvägar", "credentials": "Autentisering", + "llm_providers": "LLM-leverantörer", + "llmp_desc": "Driver Cinematic- och Autofit-översättning — LLM:en skriver om varje rad så att den ryms inom segmentets tidsbudget och videons timing hålls. Nycklar lagras krypterat; lokala leverantörer (Ollama/LM Studio) förblir helt offline.", + "llmp_provider": "Leverantör", + "llmp_provider_hint": "Välj en leverantör att konfigurera. Den aktiva används för Cinematic/Autofit-översättning. Lokala leverantörer behöver ingen nyckel, men deras server måste vara igång.", + "llmp_local_tag": "lokal", + "llmp_about": "Om", + "llmp_get_key": "Skaffa en API-nyckel", + "llmp_account_id": "Konto-ID", + "llmp_account_placeholder": "Cloudflare-konto-ID", + "llmp_api_key": "API-nyckel", + "llmp_key_env": "satt via miljön (.env) — åsidosätter det här fältet", + "llmp_key_stored": "lagrad — skriv för att ersätta", + "llmp_key_paste": "klistra in din API-nyckel", + "llmp_base_url": "Bas-URL", + "llmp_model": "Modell", + "llmp_model_placeholder": "modellnamn", + "llmp_fetch_models": "Hämta modeller", + "llmp_models_loaded": "{{count}} modeller tillgängliga från denna leverantör", + "llmp_status": "Status", + "llmp_save": "Spara", + "llmp_save_active": "Spara och använd för översättning", + "llmp_save_keep": "Spara och behåll den aktiva", + "llmp_test": "Testa", + "llmp_active_badge": "aktiv", + "llmp_test_ok": "ok — {{model}} · {{ms}} ms", + "llmp_err_config": "API-nyckel eller bas-URL saknas — fyll i dem och spara.", + "llmp_err_auth": "Nyckeln avvisades (401/403) — kontrollera din API-nyckel.", + "llmp_err_not_found": "Hittades inte (404) — kontrollera modellnamnet och bas-URL:ens sökväg.", + "llmp_err_rate_limit": "Hastighetsbegränsad (429) — nyckeln fungerar; försök igen om en stund.", + "llmp_err_network": "Kan inte nå leverantören — kontrollera bas-URL:en, ditt nätverk eller att den lokala servern är igång.", + "llmp_err_error": "Testet misslyckades", + "llmp_load_failed": "Det gick inte att ladda leverantörer", + "llmp_save_failed": "Det gick inte att spara", "updates": "Uppdateringar", "logs": "Loggar", "about": "Om", diff --git a/frontend/src/i18n/locales/th.json b/frontend/src/i18n/locales/th.json index 7336952f..32be9932 100644 --- a/frontend/src/i18n/locales/th.json +++ b/frontend/src/i18n/locales/th.json @@ -26,6 +26,39 @@ "engines": "เอนจิน", "capture": "แป้นพิมพ์", "credentials": "สิทธิ์การใช้งาน", + "llm_providers": "ผู้ให้บริการ LLM", + "llmp_desc": "ขับเคลื่อนการแปลแบบ Cinematic และ Autofit — LLM จะเขียนแต่ละบรรทัดใหม่ให้พอดีกับเวลาของช่วงนั้น เพื่อคงจังหวะเวลาของวิดีโอไว้ คีย์ถูกเก็บแบบเข้ารหัส ผู้ให้บริการภายในเครื่อง (Ollama/LM Studio) ทำงานแบบออฟไลน์ทั้งหมด", + "llmp_provider": "ผู้ให้บริการ", + "llmp_provider_hint": "เลือกผู้ให้บริการที่จะตั้งค่า ผู้ให้บริการที่ใช้งานอยู่จะถูกใช้สำหรับการแปล Cinematic/Autofit ผู้ให้บริการภายในเครื่องไม่ต้องใช้คีย์ แต่เซิร์ฟเวอร์ต้องกำลังทำงานอยู่", + "llmp_local_tag": "ภายในเครื่อง", + "llmp_about": "เกี่ยวกับ", + "llmp_get_key": "รับคีย์ API", + "llmp_account_id": "ID บัญชี", + "llmp_account_placeholder": "ID บัญชี Cloudflare", + "llmp_api_key": "คีย์ API", + "llmp_key_env": "ตั้งค่าผ่านสภาพแวดล้อม (.env) — จะแทนที่ช่องนี้", + "llmp_key_stored": "บันทึกแล้ว — พิมพ์เพื่อแทนที่", + "llmp_key_paste": "วางคีย์ API ของคุณ", + "llmp_base_url": "Base URL", + "llmp_model": "โมเดล", + "llmp_model_placeholder": "ชื่อโมเดล", + "llmp_fetch_models": "ดึงรายชื่อโมเดล", + "llmp_models_loaded": "มีโมเดลให้ใช้ {{count}} รายการจากผู้ให้บริการนี้", + "llmp_status": "สถานะ", + "llmp_save": "บันทึก", + "llmp_save_active": "บันทึกและใช้สำหรับการแปล", + "llmp_save_keep": "บันทึกโดยคงตัวที่ใช้งานอยู่", + "llmp_test": "ทดสอบ", + "llmp_active_badge": "ใช้งานอยู่", + "llmp_test_ok": "โอเค — {{model}} · {{ms}} ms", + "llmp_err_config": "ไม่มีคีย์ API หรือ Base URL — กรอกข้อมูลแล้วบันทึก", + "llmp_err_auth": "คีย์ถูกปฏิเสธ (401/403) — ตรวจสอบคีย์ API ของคุณ", + "llmp_err_not_found": "ไม่พบ (404) — ตรวจสอบชื่อโมเดลและพาธของ Base URL", + "llmp_err_rate_limit": "ถูกจำกัดอัตรา (429) — คีย์ใช้งานได้ ลองอีกครั้งในอีกสักครู่", + "llmp_err_network": "ติดต่อผู้ให้บริการไม่ได้ — ตรวจสอบ Base URL เครือข่ายของคุณ หรือว่าเซิร์ฟเวอร์ภายในเครื่องกำลังทำงานอยู่", + "llmp_err_error": "การทดสอบล้มเหลว", + "llmp_load_failed": "ไม่สามารถโหลดผู้ให้บริการได้", + "llmp_save_failed": "บันทึกล้มเหลว", "updates": "อัปเดต", "logs": "บันทึกระบบ", "about": "เกี่ยวกับ", diff --git a/frontend/src/i18n/locales/tr.json b/frontend/src/i18n/locales/tr.json index 67a03bc4..81d82c46 100644 --- a/frontend/src/i18n/locales/tr.json +++ b/frontend/src/i18n/locales/tr.json @@ -26,6 +26,39 @@ "engines": "Motorlar", "capture": "Kısayollar", "credentials": "Kimlik Bilgileri", + "llm_providers": "LLM sağlayıcıları", + "llmp_desc": "Cinematic ve Autofit çevirisini çalıştırır — LLM her satırı, bölümünün süre bütçesine sığacak şekilde yeniden yazar; böylece videonun zamanlaması korunur. Anahtarlar şifrelenmiş olarak saklanır; yerel sağlayıcılar (Ollama/LM Studio) tamamen çevrimdışı kalır.", + "llmp_provider": "Sağlayıcı", + "llmp_provider_hint": "Yapılandırmak için bir sağlayıcı seçin. Etkin olan, Cinematic/Autofit çevirisi için kullanılır. Yerel sağlayıcılar anahtar gerektirmez ama sunucularının çalışıyor olması gerekir.", + "llmp_local_tag": "yerel", + "llmp_about": "Hakkında", + "llmp_get_key": "API anahtarı alın", + "llmp_account_id": "Hesap kimliği", + "llmp_account_placeholder": "Cloudflare hesap kimliği", + "llmp_api_key": "API anahtarı", + "llmp_key_env": "ortam değişkeniyle (.env) ayarlandı — bu alanı geçersiz kılar", + "llmp_key_stored": "kayıtlı — değiştirmek için yazın", + "llmp_key_paste": "API anahtarınızı yapıştırın", + "llmp_base_url": "Temel URL", + "llmp_model": "Model", + "llmp_model_placeholder": "model adı", + "llmp_fetch_models": "Modelleri getir", + "llmp_models_loaded": "Bu sağlayıcıda {{count}} model mevcut", + "llmp_status": "Durum", + "llmp_save": "Kaydet", + "llmp_save_active": "Kaydet ve çeviri için kullan", + "llmp_save_keep": "Kaydet, etkin olanı koru", + "llmp_test": "Test et", + "llmp_active_badge": "etkin", + "llmp_test_ok": "tamam — {{model}} · {{ms}} ms", + "llmp_err_config": "API anahtarı veya Temel URL eksik — doldurup kaydedin.", + "llmp_err_auth": "Anahtar reddedildi (401/403) — API anahtarınızı kontrol edin.", + "llmp_err_not_found": "Bulunamadı (404) — model adını ve Temel URL yolunu kontrol edin.", + "llmp_err_rate_limit": "Hız sınırına takıldı (429) — anahtar çalışıyor; birazdan yeniden deneyin.", + "llmp_err_network": "Sağlayıcıya ulaşılamıyor — Temel URL'yi, ağınızı veya yerel sunucunun çalışıp çalışmadığını kontrol edin.", + "llmp_err_error": "Test başarısız oldu", + "llmp_load_failed": "Sağlayıcılar yüklenemedi", + "llmp_save_failed": "Kaydedilemedi", "updates": "Güncellemeler", "logs": "Günlükler", "about": "Hakkında", diff --git a/frontend/src/i18n/locales/uk.json b/frontend/src/i18n/locales/uk.json index dc36ef94..1329d48d 100644 --- a/frontend/src/i18n/locales/uk.json +++ b/frontend/src/i18n/locales/uk.json @@ -26,6 +26,39 @@ "engines": "Движки", "capture": "Введення", "credentials": "Ключі", + "llm_providers": "Постачальники LLM", + "llmp_desc": "Забезпечує переклад Cinematic та Autofit — LLM переписує кожен рядок так, щоб він вкладався в часовий бюджет свого сегмента й хронометраж відео зберігався. Ключі зберігаються в зашифрованому вигляді; локальні постачальники (Ollama/LM Studio) працюють повністю офлайн.", + "llmp_provider": "Постачальник", + "llmp_provider_hint": "Виберіть постачальника для налаштування. Активний використовується для перекладу Cinematic/Autofit. Локальним постачальникам ключ не потрібен, але їхній сервер має бути запущений.", + "llmp_local_tag": "локальний", + "llmp_about": "Відомості", + "llmp_get_key": "Отримати ключ API", + "llmp_account_id": "ID облікового запису", + "llmp_account_placeholder": "ID облікового запису Cloudflare", + "llmp_api_key": "Ключ API", + "llmp_key_env": "задано через середовище (.env) — має пріоритет над цим полем", + "llmp_key_stored": "збережено — введіть, щоб замінити", + "llmp_key_paste": "вставте свій ключ API", + "llmp_base_url": "Базовий URL", + "llmp_model": "Модель", + "llmp_model_placeholder": "назва моделі", + "llmp_fetch_models": "Отримати список моделей", + "llmp_models_loaded": "Доступно моделей у цього постачальника: {{count}}", + "llmp_status": "Статус", + "llmp_save": "Зберегти", + "llmp_save_active": "Зберегти й використовувати для перекладу", + "llmp_save_keep": "Зберегти, не змінюючи активного", + "llmp_test": "Перевірити", + "llmp_active_badge": "активний", + "llmp_test_ok": "ок — {{model}} · {{ms}} мс", + "llmp_err_config": "Не вказано ключ API або базовий URL — заповніть їх і збережіть.", + "llmp_err_auth": "Ключ відхилено (401/403) — перевірте свій ключ API.", + "llmp_err_not_found": "Не знайдено (404) — перевірте назву моделі та шлях базового URL.", + "llmp_err_rate_limit": "Перевищено ліміт запитів (429) — ключ працює; спробуйте ще раз за мить.", + "llmp_err_network": "Не вдається з'єднатися з постачальником — перевірте базовий URL, мережу або чи запущено локальний сервер.", + "llmp_err_error": "Перевірка не вдалася", + "llmp_load_failed": "Не вдалося завантажити постачальників", + "llmp_save_failed": "Не вдалося зберегти", "updates": "Оновлення", "logs": "Логи", "about": "Про програму", diff --git a/frontend/src/i18n/locales/vi.json b/frontend/src/i18n/locales/vi.json index f75988b1..04996c06 100644 --- a/frontend/src/i18n/locales/vi.json +++ b/frontend/src/i18n/locales/vi.json @@ -26,6 +26,39 @@ "engines": "Công cụ", "capture": "Phím tắt", "credentials": "Xác thực", + "llm_providers": "Nhà cung cấp LLM", + "llmp_desc": "Cung cấp năng lực cho bản dịch Cinematic và Autofit — LLM viết lại từng dòng cho vừa quỹ thời gian của phân đoạn để giữ đúng nhịp thời gian của video. Khóa được lưu ở dạng mã hóa; nhà cung cấp cục bộ (Ollama/LM Studio) hoạt động hoàn toàn ngoại tuyến.", + "llmp_provider": "Nhà cung cấp", + "llmp_provider_hint": "Chọn nhà cung cấp để cấu hình. Nhà cung cấp đang hoạt động sẽ được dùng cho bản dịch Cinematic/Autofit. Nhà cung cấp cục bộ không cần khóa nhưng máy chủ của chúng phải đang chạy.", + "llmp_local_tag": "cục bộ", + "llmp_about": "Giới thiệu", + "llmp_get_key": "Lấy khóa API", + "llmp_account_id": "ID tài khoản", + "llmp_account_placeholder": "ID tài khoản Cloudflare", + "llmp_api_key": "Khóa API", + "llmp_key_env": "đặt qua biến môi trường (.env) — ghi đè trường này", + "llmp_key_stored": "đã lưu — gõ để thay thế", + "llmp_key_paste": "dán khóa API của bạn", + "llmp_base_url": "URL cơ sở", + "llmp_model": "Mô hình", + "llmp_model_placeholder": "tên mô hình", + "llmp_fetch_models": "Lấy danh sách mô hình", + "llmp_models_loaded": "{{count}} mô hình khả dụng từ nhà cung cấp này", + "llmp_status": "Trạng thái", + "llmp_save": "Lưu", + "llmp_save_active": "Lưu và dùng cho bản dịch", + "llmp_save_keep": "Lưu và giữ nhà cung cấp đang hoạt động", + "llmp_test": "Kiểm tra", + "llmp_active_badge": "đang hoạt động", + "llmp_test_ok": "ok — {{model}} · {{ms}} ms", + "llmp_err_config": "Thiếu khóa API hoặc URL cơ sở — hãy điền và lưu.", + "llmp_err_auth": "Khóa bị từ chối (401/403) — kiểm tra khóa API của bạn.", + "llmp_err_not_found": "Không tìm thấy (404) — kiểm tra tên mô hình và đường dẫn của URL cơ sở.", + "llmp_err_rate_limit": "Bị giới hạn tốc độ (429) — khóa vẫn hoạt động; hãy thử lại sau giây lát.", + "llmp_err_network": "Không thể kết nối tới nhà cung cấp — kiểm tra URL cơ sở, mạng của bạn, hoặc máy chủ cục bộ có đang chạy không.", + "llmp_err_error": "Kiểm tra thất bại", + "llmp_load_failed": "Không tải được danh sách nhà cung cấp", + "llmp_save_failed": "Lưu không thành công", "updates": "Cập nhật", "logs": "Nhật ký", "about": "Giới thiệu", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 5caad428..84caa831 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -251,6 +251,39 @@ "sharing": "分享", "appearance": "外观", "credentials": "凭证", + "llm_providers": "LLM 提供商", + "llmp_desc": "为 Cinematic 与 Autofit 翻译提供支持:LLM 会改写每一行,使其符合所在片段的时间预算,从而保持视频时序。密钥加密存储;本地提供商(Ollama/LM Studio)完全离线运行。", + "llmp_provider": "提供商", + "llmp_provider_hint": "选择要配置的提供商。当前启用的提供商将用于 Cinematic/Autofit 翻译。本地提供商无需密钥,但其服务器必须处于运行状态。", + "llmp_local_tag": "本地", + "llmp_about": "关于", + "llmp_get_key": "获取 API 密钥", + "llmp_account_id": "账户 ID", + "llmp_account_placeholder": "Cloudflare 账户 ID", + "llmp_api_key": "API 密钥", + "llmp_key_env": "已通过环境变量 (.env) 设置,将覆盖此字段", + "llmp_key_stored": "已保存,输入即可替换", + "llmp_key_paste": "粘贴你的 API 密钥", + "llmp_base_url": "基础 URL", + "llmp_model": "模型", + "llmp_model_placeholder": "模型名称", + "llmp_fetch_models": "获取模型", + "llmp_models_loaded": "该提供商有 {{count}} 个可用模型", + "llmp_status": "状态", + "llmp_save": "保存", + "llmp_save_active": "保存并用于翻译", + "llmp_save_keep": "保存但保持当前启用项", + "llmp_test": "测试", + "llmp_active_badge": "已启用", + "llmp_test_ok": "正常:{{model}} · {{ms}} ms", + "llmp_err_config": "缺少 API 密钥或基础 URL,请填写后保存。", + "llmp_err_auth": "密钥被拒绝(401/403),请检查你的 API 密钥。", + "llmp_err_not_found": "未找到(404),请检查模型名称和基础 URL 路径。", + "llmp_err_rate_limit": "已被限流(429),密钥有效,请稍后重试。", + "llmp_err_network": "无法连接到提供商,请检查基础 URL、网络,或确认本地服务器正在运行。", + "llmp_err_error": "测试失败", + "llmp_load_failed": "无法加载提供商", + "llmp_save_failed": "保存失败", "updates": "更新", "proxy": "代理", "proxy_desc": "HTTP/SOCKS5 下载代理(yt-dlp、HuggingFace)。支持 http://、https://、socks5://。后端启动后更改需重启。", diff --git a/frontend/src/i18n/locales/zh-TW.json b/frontend/src/i18n/locales/zh-TW.json index 78aab1bc..09b8e236 100644 --- a/frontend/src/i18n/locales/zh-TW.json +++ b/frontend/src/i18n/locales/zh-TW.json @@ -26,6 +26,39 @@ "engines": "配音引擎", "capture": "鍵盤設定", "credentials": "金鑰憑證", + "llm_providers": "LLM 提供者", + "llmp_desc": "為 Cinematic 與 Autofit 翻譯提供支援:LLM 會改寫每一行,使其符合所在片段的時間預算,以維持影片時序。金鑰以加密方式儲存;本機提供者(Ollama/LM Studio)完全離線運作。", + "llmp_provider": "提供者", + "llmp_provider_hint": "選擇要設定的提供者。目前啟用的提供者將用於 Cinematic/Autofit 翻譯。本機提供者無需金鑰,但其伺服器必須處於執行狀態。", + "llmp_local_tag": "本機", + "llmp_about": "關於", + "llmp_get_key": "取得 API 金鑰", + "llmp_account_id": "帳戶 ID", + "llmp_account_placeholder": "Cloudflare 帳戶 ID", + "llmp_api_key": "API 金鑰", + "llmp_key_env": "已透過環境變數 (.env) 設定,將覆寫此欄位", + "llmp_key_stored": "已儲存,輸入即可取代", + "llmp_key_paste": "貼上你的 API 金鑰", + "llmp_base_url": "基礎 URL", + "llmp_model": "模型", + "llmp_model_placeholder": "模型名稱", + "llmp_fetch_models": "取得模型", + "llmp_models_loaded": "此提供者有 {{count}} 個可用模型", + "llmp_status": "狀態", + "llmp_save": "儲存", + "llmp_save_active": "儲存並用於翻譯", + "llmp_save_keep": "儲存但維持目前啟用項", + "llmp_test": "測試", + "llmp_active_badge": "已啟用", + "llmp_test_ok": "正常:{{model}} · {{ms}} ms", + "llmp_err_config": "缺少 API 金鑰或基礎 URL,請填寫後儲存。", + "llmp_err_auth": "金鑰遭拒(401/403),請檢查你的 API 金鑰。", + "llmp_err_not_found": "找不到(404),請檢查模型名稱與基礎 URL 路徑。", + "llmp_err_rate_limit": "已達速率限制(429),金鑰有效,請稍後再試。", + "llmp_err_network": "無法連線至提供者,請檢查基礎 URL、網路,或確認本機伺服器正在執行。", + "llmp_err_error": "測試失敗", + "llmp_load_failed": "無法載入提供者", + "llmp_save_failed": "儲存失敗", "updates": "更新", "logs": "系統日誌", "about": "關於軟體", diff --git a/tests/fixtures/api_routes.txt b/tests/fixtures/api_routes.txt index 896bb03b..6c41fcb3 100644 --- a/tests/fixtures/api_routes.txt +++ b/tests/fixtures/api_routes.txt @@ -24,6 +24,7 @@ GET /api/settings/hf-token/state GET /api/settings/license/{engine_id} GET /api/settings/llm-endpoint GET /api/settings/llm-providers +GET /api/settings/llm-providers/{provider_id}/models GET /api/settings/perf/torch-compile-disabled GET /api/settings/storage/models-dir GET /archetypes diff --git a/tests/test_llm_providers_router.py b/tests/test_llm_providers_router.py new file mode 100644 index 00000000..a3710166 --- /dev/null +++ b/tests/test_llm_providers_router.py @@ -0,0 +1,162 @@ +"""Router surface for /api/settings/llm-providers (v0.3.9 testing pass). + +`tests/test_llm_providers.py` covers the registry service; these cover the +router handlers the UI calls — the /test probe's error classification +(kind: config/auth/not_found/rate_limit/network/error + latency_ms) and the +/models discovery endpoint, with the OpenAI client faked at the SDK boundary +(no network) and settings_store backed by in-memory dicts (house convention, +same as test_llm_providers.py — direct handler calls, no TestClient, so the +loopback auth guard isn't in play). +""" +from __future__ import annotations + +import os +import sys +import types + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "backend")) + +os.environ.setdefault("OMNIVOICE_MODEL", "test") +os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1") + +_HAS_OPENAI = __import__("importlib").util.find_spec("openai") is not None +pytestmark = pytest.mark.skipif(not _HAS_OPENAI, reason="openai package not installed") + + +@pytest.fixture +def settings_mod(monkeypatch): + """Router module with settings_store in-memory (no SQLite, no prefs I/O).""" + from services import settings_store as ss + + text: dict[str, str] = {} + secrets: dict[str, str] = {} + monkeypatch.setattr(ss, "get_text", lambda k, default=None: text.get(k, default)) + monkeypatch.setattr(ss, "set_text", lambda k, v: text.__setitem__(k, v)) + monkeypatch.setattr(ss, "get_secret", lambda n: secrets.get(n)) + monkeypatch.setattr(ss, "set_secret", lambda n, v: secrets.__setitem__(n, v) if v else secrets.pop(n, None)) + monkeypatch.setattr(ss, "list_secret_names", lambda: list(secrets)) + for var in ("LLM_DEFAULT_PROVIDER", "TRANSLATE_BASE_URL", "TRANSLATE_API_KEY", + "TRANSLATE_MODEL", "OPENAI_API_KEY", "GROQ_API_KEY", "GROQ_MODEL"): + monkeypatch.delenv(var, raising=False) + import importlib + return importlib.import_module("api.routers.settings") + + +def _fake_openai(monkeypatch, *, reply="ok", models=None, raise_exc=None): + """Fake `openai.OpenAI` with canned chat/models behavior.""" + class _Msg: + def __init__(self, content): + self.message = types.SimpleNamespace(content=content) + + class _FakeClient: + def __init__(self, api_key=None, base_url=None): + self.chat = types.SimpleNamespace( + completions=types.SimpleNamespace(create=self._create)) + self.models = types.SimpleNamespace(list=self._models) + + def _create(self, **kw): + if raise_exc is not None: + raise raise_exc + return types.SimpleNamespace(choices=[_Msg(reply)]) + + def _models(self, **kw): + if raise_exc is not None: + raise raise_exc + return [types.SimpleNamespace(id=m) for m in (models or [])] + + import openai + monkeypatch.setattr(openai, "OpenAI", _FakeClient) + + +def _configure_groq(settings_mod, key="gsk-test-123"): + settings_mod.save_llm_provider( + "groq", settings_mod._LLMProviderBody(api_key=key, make_active=True)) + + +# ── list / save ───────────────────────────────────────────────────────────── + +def test_list_never_leaks_keys(settings_mod): + _configure_groq(settings_mod) + body = settings_mod.list_llm_providers() + assert body["active"] == "groq" + groq = next(p for p in body["providers"] if p["id"] == "groq") + assert groq["has_key"] is True and groq["configured"] is True + assert "gsk-test-123" not in str(body) # the key never round-trips + + +def test_unknown_provider_404s(settings_mod): + from fastapi import HTTPException + with pytest.raises(HTTPException): + settings_mod.test_llm_provider("nope") + with pytest.raises(HTTPException): + settings_mod.list_llm_provider_models("nope") + + +# ── /test probe ───────────────────────────────────────────────────────────── + +def test_probe_ok_includes_latency(settings_mod, monkeypatch): + _configure_groq(settings_mod) + _fake_openai(monkeypatch, reply="ok") + body = settings_mod.test_llm_provider("groq") + assert body["ok"] is True and body["reply"] == "ok" + assert isinstance(body["latency_ms"], int) and body["latency_ms"] >= 0 + + +def test_probe_unconfigured_is_kind_config(settings_mod): + # openai: no key stored, env cleared → config guidance, no network attempt + body = settings_mod.test_llm_provider("openai") + assert body["ok"] is False and body["kind"] == "config" + + +@pytest.mark.parametrize("exc_name,status,expected_kind", [ + ("AuthenticationError", 401, "auth"), + ("NotFoundError", 404, "not_found"), + ("RateLimitError", 429, "rate_limit"), + ("APIConnectionError", None, "network"), + ("ValueError", None, "error"), +]) +def test_probe_classifies_failures(settings_mod, monkeypatch, exc_name, status, expected_kind): + _configure_groq(settings_mod) + exc = type(exc_name, (Exception,), {})() + if status is not None: + exc.status_code = status + _fake_openai(monkeypatch, raise_exc=exc) + body = settings_mod.test_llm_provider("groq") + assert body["ok"] is False + assert body["kind"] == expected_kind + assert "latency_ms" in body + + +def test_probe_failure_detail_is_scrubbed(settings_mod, monkeypatch): + _configure_groq(settings_mod) + _fake_openai(monkeypatch, raise_exc=RuntimeError( + "boom key=gsk-test-123 at /Users/someone/secret")) + body = settings_mod.test_llm_provider("groq") + assert body["ok"] is False + assert "gsk-test-123" not in body["detail"] + + +# ── /models discovery ─────────────────────────────────────────────────────── + +def test_models_lists_sorted_ids(settings_mod, monkeypatch): + _configure_groq(settings_mod) + _fake_openai(monkeypatch, models=["zeta", "alpha", "mid"]) + body = settings_mod.list_llm_provider_models("groq") + assert body["ok"] is True + assert body["models"] == ["alpha", "mid", "zeta"] + + +def test_models_unconfigured_is_kind_config(settings_mod): + body = settings_mod.list_llm_provider_models("openai") + assert body == {"ok": False, "kind": "config", "models": []} + + +def test_models_failure_is_classified(settings_mod, monkeypatch): + _configure_groq(settings_mod) + exc = type("AuthenticationError", (Exception,), {})() + exc.status_code = 401 + _fake_openai(monkeypatch, raise_exc=exc) + body = settings_mod.list_llm_provider_models("groq") + assert body["ok"] is False and body["kind"] == "auth" and body["models"] == []