feat(settings): LLM provider testing pass — latency + classified errors, model discovery, full i18n, router tests (#887)
Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
mergetest
Claude Fable 5
parent
bb492086c9
commit
da9315815d
@@ -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
|
||||
|
||||
@@ -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) ──────────────────────
|
||||
|
||||
@@ -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 (
|
||||
<SettingsSection
|
||||
icon={Brain}
|
||||
title="LLM Providers"
|
||||
description="Configure a high-quality LLM for Cinematic & Autofit translation."
|
||||
title={t('settings.llm_providers')}
|
||||
description={t('settings.llmp_desc')}
|
||||
>
|
||||
{error && (
|
||||
<div className="perfpanel__error" role="alert">
|
||||
@@ -140,12 +187,12 @@ export default function LLMProvidersPanel() {
|
||||
return (
|
||||
<SettingsSection
|
||||
icon={Brain}
|
||||
title="LLM Providers"
|
||||
description="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."
|
||||
title={t('settings.llm_providers')}
|
||||
description={t('settings.llmp_desc')}
|
||||
>
|
||||
<SettingRow
|
||||
title="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."
|
||||
title={t('settings.llmp_provider')}
|
||||
hint={t('settings.llmp_provider_hint')}
|
||||
control={
|
||||
<Select
|
||||
value={editing}
|
||||
@@ -155,9 +202,9 @@ export default function LLMProvidersPanel() {
|
||||
{providers.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.display_name}
|
||||
{p.local ? ' · local' : ''}
|
||||
{p.local ? ` · ${t('settings.llmp_local_tag')}` : ''}
|
||||
{p.configured ? ' ✓' : ''}
|
||||
{active === p.id ? ' (active)' : ''}
|
||||
{active === p.id ? ` (${t('settings.llmp_active_badge')})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
@@ -168,7 +215,7 @@ export default function LLMProvidersPanel() {
|
||||
<>
|
||||
{(current.notes || current.signup_url) && (
|
||||
<SettingRow
|
||||
title="About"
|
||||
title={t('settings.llmp_about')}
|
||||
control={
|
||||
<div className="flex flex-col gap-[4px] min-w-0">
|
||||
{current.notes && <span className="text-[12px] opacity-70">{current.notes}</span>}
|
||||
@@ -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 <ExternalLink size={12} />
|
||||
{t('settings.llmp_get_key')} <ExternalLink size={12} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
@@ -189,14 +236,14 @@ export default function LLMProvidersPanel() {
|
||||
|
||||
{current.needs_account && (
|
||||
<SettingRow
|
||||
title="Account ID"
|
||||
title={t('settings.llmp_account_id')}
|
||||
control={
|
||||
<SettingsInput
|
||||
mono
|
||||
type="text"
|
||||
value={fields.account_id}
|
||||
onChange={(e) => 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 && (
|
||||
<SettingRow
|
||||
title="API key"
|
||||
title={t('settings.llmp_api_key')}
|
||||
control={
|
||||
<SettingsInput
|
||||
mono
|
||||
@@ -214,10 +261,10 @@ export default function LLMProvidersPanel() {
|
||||
onChange={(e) => 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() {
|
||||
)}
|
||||
|
||||
<SettingRow
|
||||
title="Base URL"
|
||||
title={t('settings.llmp_base_url')}
|
||||
control={
|
||||
<SettingsInput
|
||||
mono
|
||||
@@ -240,16 +287,41 @@ export default function LLMProvidersPanel() {
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
title="Model"
|
||||
title={t('settings.llmp_model')}
|
||||
hint={
|
||||
models?.length
|
||||
? t('settings.llmp_models_loaded', { count: models.length })
|
||||
: undefined
|
||||
}
|
||||
control={
|
||||
<SettingsInput
|
||||
mono
|
||||
type="text"
|
||||
value={fields.model}
|
||||
onChange={(e) => setFields((f) => ({ ...f, model: e.target.value }))}
|
||||
placeholder="model name"
|
||||
data-testid="llm-provider-model"
|
||||
/>
|
||||
<div className="flex items-center gap-[8px] min-w-0">
|
||||
<SettingsInput
|
||||
mono
|
||||
type="text"
|
||||
value={fields.model}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={fetchModels}
|
||||
loading={loadingModels}
|
||||
disabled={saving || testing || loadingModels}
|
||||
data-testid="llm-provider-models"
|
||||
>
|
||||
{t('settings.llmp_fetch_models')}
|
||||
</Button>
|
||||
{models?.length ? (
|
||||
<datalist id={MODELS_DATALIST_ID}>
|
||||
{models.map((m) => (
|
||||
<option key={m} value={m} />
|
||||
))}
|
||||
</datalist>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -260,7 +332,7 @@ export default function LLMProvidersPanel() {
|
||||
)}
|
||||
|
||||
<SettingRow
|
||||
title="Status"
|
||||
title={t('settings.llmp_status')}
|
||||
control={
|
||||
<div className="flex flex-wrap items-center gap-[8px]">
|
||||
<Button
|
||||
@@ -271,7 +343,7 @@ export default function LLMProvidersPanel() {
|
||||
disabled={saving || testing}
|
||||
data-testid="llm-provider-save"
|
||||
>
|
||||
Save
|
||||
{t('settings.llmp_save')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -281,7 +353,7 @@ export default function LLMProvidersPanel() {
|
||||
disabled={saving || testing}
|
||||
data-testid="llm-provider-activate"
|
||||
>
|
||||
{isActive ? 'Save & keep active' : 'Save & use for translation'}
|
||||
{isActive ? t('settings.llmp_save_keep') : t('settings.llmp_save_active')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
@@ -291,16 +363,21 @@ export default function LLMProvidersPanel() {
|
||||
disabled={saving || testing}
|
||||
data-testid="llm-provider-test"
|
||||
>
|
||||
Test
|
||||
{t('settings.llmp_test')}
|
||||
</Button>
|
||||
{isActive && (
|
||||
<Badge tone="success" dot role="status">
|
||||
active
|
||||
{t('settings.llmp_active_badge')}
|
||||
</Badge>
|
||||
)}
|
||||
{test && (
|
||||
<Badge tone={test.ok ? 'success' : 'warn'} role="status">
|
||||
{test.ok ? `ok — ${test.model || ''}` : test.detail || 'failed'}
|
||||
{test.ok
|
||||
? t('settings.llmp_test_ok', {
|
||||
model: test.model || '',
|
||||
ms: test.latency_ms ?? '—',
|
||||
})
|
||||
: kindMessage(test)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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(<LLMProvidersPanel />);
|
||||
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(<LLMProvidersPanel />);
|
||||
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(<LLMProvidersPanel />);
|
||||
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(<LLMProvidersPanel />);
|
||||
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(<LLMProvidersPanel />);
|
||||
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(<LLMProvidersPanel />);
|
||||
const select = await screen.findByTestId('llm-provider-select');
|
||||
fireEvent.change(select, { target: { value: 'ollama' } });
|
||||
await waitFor(() => expect(screen.queryByTestId('llm-provider-key')).toBeNull());
|
||||
});
|
||||
});
|
||||
@@ -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": "حول",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "विवरण",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "情報",
|
||||
|
||||
@@ -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": "정보",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "О программе",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "เกี่ยวกับ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Про програму",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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://。后端启动后更改需重启。",
|
||||
|
||||
@@ -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": "關於軟體",
|
||||
|
||||
Vendored
+1
@@ -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
|
||||
|
||||
@@ -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"] == []
|
||||
Reference in New Issue
Block a user