feat(providers): add OpenAI/Claude connectivity test in edit sheet

- Introduce useConnectivityTest hook that drives per-key and bulk tests
  via apiCallApi against the chat-completions / messages endpoints, with
  signature-based status reset
- OpenAI form: each API key entry header now shows a status icon plus a
  per-key Test button, and the entries section gains a Test all button
- Claude form: enable the Test model field and add a Test button with
  inline status; falls back to the persisted apiKey when the edit form
  field is left blank
- Add ConnectivityStatusIcon, related styles (spin keyframes, buttons,
  error banner), and i18n keys (en/zh-CN/zh-TW/ru) for the test labels
  and validation messages
This commit is contained in:
LTbinglingfeng
2026-05-25 01:02:47 +08:00
Unverified
parent 47ba6ab91c
commit bf299cffdf
8 changed files with 754 additions and 84 deletions
+1 -1
View File
@@ -76,7 +76,7 @@ export const PROVIDER_DESCRIPTORS: Record<ProviderBrand, ProviderDescriptor> = {
supportsHeaders: true,
supportsExcludedModels: true,
supportsPriority: true,
supportsTestModel: false,
supportsTestModel: true,
supportsWebsockets: false,
supportsCloak: true,
supportsApiKeyEntries: false,
@@ -1,6 +1,12 @@
import { useId, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { IconPlus, IconX } from '@/components/ui/icons';
import {
IconAlertTriangle,
IconCheckCircle2,
IconLoader2,
IconPlus,
IconX,
} from '@/components/ui/icons';
import { Collapsible } from '@/components/ui/Collapsible';
import { hasDisableAllModelsRule } from '@/components/providers/utils';
import type {
@@ -16,6 +22,11 @@ import type {
ProviderEntryFormInput,
ProviderResource,
} from '../../types';
import {
useConnectivityTest,
type ConnectivityErrorMessages,
type ConnectivityState,
} from './useConnectivityTest';
import styles from './sharedForm.module.scss';
export interface BaseProviderFormHandle {
@@ -70,7 +81,8 @@ function buildInitialForm(
brand === 'claude'
? { mode: '', strictMode: false, sensitiveWordsText: '' }
: undefined,
testModel: brand === 'openaiCompatibility' ? '' : undefined,
testModel:
brand === 'openaiCompatibility' || brand === 'claude' ? '' : undefined,
apiKeyEntries:
brand === 'openaiCompatibility' ? [emptyApiKeyEntry()] : undefined,
};
@@ -144,9 +156,35 @@ function buildInitialForm(
(cfg as ProviderKeyConfig).cloak?.sensitiveWords?.join('\n') ?? '',
}
: undefined,
testModel: brand === 'claude' ? '' : undefined,
};
}
function ConnectivityStatusIcon({ state }: { state: ConnectivityState }) {
if (state === 'loading') {
return (
<span className={`${styles.statusIcon} ${styles.statusIconLoading}`}>
<IconLoader2 size={14} />
</span>
);
}
if (state === 'success') {
return (
<span className={`${styles.statusIcon} ${styles.statusIconSuccess}`}>
<IconCheckCircle2 size={14} />
</span>
);
}
if (state === 'error') {
return (
<span className={`${styles.statusIcon} ${styles.statusIconError}`}>
<IconAlertTriangle size={14} />
</span>
);
}
return null;
}
export function BaseProviderForm({
brand,
resource,
@@ -163,6 +201,38 @@ export function BaseProviderForm({
);
const [error, setError] = useState<string | null>(null);
const fallbackApiKey = useMemo(() => {
if (brand !== 'claude' || mode !== 'edit' || !resource) return '';
return (resource.raw as ProviderKeyConfig | undefined)?.apiKey ?? '';
}, [brand, mode, resource]);
const connectivityMessages = useMemo<ConnectivityErrorMessages>(
() => ({
baseUrlRequired: t('providersPage.connectivity.baseUrlRequired'),
endpointInvalid: t('providersPage.connectivity.endpointInvalid'),
apiKeyRequired: t('providersPage.connectivity.apiKeyRequired'),
modelRequired: t('providersPage.connectivity.modelRequired'),
timeout: (seconds: number) =>
t('providersPage.connectivity.timeout', { seconds }),
requestFailed: t('providersPage.connectivity.requestFailed'),
}),
[t]
);
const connectivity = useConnectivityTest(
{
brand,
baseUrl: form.baseUrl,
testModel: form.testModel,
models: form.models,
formHeaders: form.headers,
apiKeyEntries: form.apiKeyEntries,
apiKey: form.apiKey,
fallbackApiKey,
},
connectivityMessages
);
const updateField = <K extends keyof ProviderEntryFormInput>(
key: K,
value: ProviderEntryFormInput[K]
@@ -359,6 +429,12 @@ export function BaseProviderForm({
<div className={styles.field}>
<label className={styles.label} htmlFor={`${fid}-testModel`}>
{t('providersPage.form.testModel')}
{brand === 'claude' ? (
<span className={styles.labelHint}>
{' '}
· {t('providersPage.form.testModelClaudeHint')}
</span>
) : null}
</label>
<input
id={`${fid}-testModel`}
@@ -367,6 +443,34 @@ export function BaseProviderForm({
onChange={(e) => updateField('testModel', e.target.value)}
disabled={mutating}
/>
{brand === 'claude' ? (
<div className={styles.connectivityRow}>
<button
type="button"
className={styles.connectivityBtn}
disabled={mutating || connectivity.isTestingAny}
onClick={() => void connectivity.runClaude()}
>
{connectivity.claudeStatus.state === 'loading' ? (
<span className={`${styles.statusIcon} ${styles.statusIconLoading}`}>
<IconLoader2 size={14} />
</span>
) : null}
<span>{t('providersPage.connectivity.test')}</span>
</button>
<ConnectivityStatusIcon state={connectivity.claudeStatus.state} />
{connectivity.claudeStatus.state === 'success' ? (
<span className={styles.connectivityHintSuccess}>
{t('providersPage.connectivity.success')}
</span>
) : null}
</div>
) : null}
{brand === 'claude' && connectivity.claudeStatus.state === 'error' ? (
<div className={styles.connectivityError}>
{connectivity.claudeStatus.message}
</div>
) : null}
</div>
) : null}
@@ -410,91 +514,133 @@ export function BaseProviderForm({
defaultOpen
>
<div className={styles.entriesList}>
{apiKeyEntries.map((entry, idx) => (
<div key={idx} className={styles.entryCard}>
<div className={styles.entryCardHeader}>
<span>
{t('providersPage.form.apiKeyEntry', { index: idx + 1 })}
<div className={styles.entriesToolbar}>
<button
type="button"
className={styles.connectivityBtn}
disabled={mutating || connectivity.isTestingAny}
onClick={() => void connectivity.runOpenAIAllKeys()}
>
{connectivity.isTestingAny ? (
<span className={`${styles.statusIcon} ${styles.statusIconLoading}`}>
<IconLoader2 size={14} />
</span>
<button
type="button"
className={styles.removeBtn}
disabled={mutating || apiKeyEntries.length <= 1}
onClick={() =>
updateField(
'apiKeyEntries',
apiKeyEntries.filter((_, i) => i !== idx)
)
}
>
<IconX size={12} />
</button>
</div>
<div className={styles.field}>
<label className={styles.label}>
{t('providersPage.form.apiKey')}
</label>
<input
className={styles.input}
type="password"
value={entry.apiKey}
onChange={(e) =>
updateField(
'apiKeyEntries',
apiKeyEntries.map((it, i) =>
i === idx ? { ...it, apiKey: e.target.value } : it
)
)
}
disabled={mutating}
placeholder={t('providersPage.form.apiKeyCreatePlaceholder')}
/>
</div>
<div className={styles.field}>
<label className={styles.label}>
{t('providersPage.form.proxyUrl')}
</label>
<input
className={styles.input}
value={entry.proxyUrl}
onChange={(e) =>
updateField(
'apiKeyEntries',
apiKeyEntries.map((it, i) =>
i === idx ? { ...it, proxyUrl: e.target.value } : it
)
)
}
disabled={mutating}
placeholder="http://127.0.0.1:7890"
/>
</div>
<div className={styles.field}>
<label className={styles.label}>
{t('providersPage.form.headers')}
<span className={styles.labelHint}>
{' '}
· {t('providersPage.form.headersHint')}
) : null}
<span>{t('providersPage.connectivity.testAll')}</span>
</button>
</div>
{apiKeyEntries.map((entry, idx) => {
const status = connectivity.openaiStatuses[idx] ?? {
state: 'idle' as ConnectivityState,
message: '',
};
return (
<div key={idx} className={styles.entryCard}>
<div className={styles.entryCardHeader}>
<span>
{t('providersPage.form.apiKeyEntry', { index: idx + 1 })}
</span>
</label>
<textarea
className={styles.textarea}
value={entry.headersText}
rows={3}
onChange={(e) =>
updateField(
'apiKeyEntries',
apiKeyEntries.map((it, i) =>
i === idx ? { ...it, headersText: e.target.value } : it
<div className={styles.entryCardHeaderRight}>
<ConnectivityStatusIcon state={status.state} />
<button
type="button"
className={styles.connectivityBtnGhost}
disabled={mutating || status.state === 'loading'}
onClick={() => void connectivity.runOpenAIKey(idx)}
>
{status.state === 'loading' ? (
<span className={`${styles.statusIcon} ${styles.statusIconLoading}`}>
<IconLoader2 size={14} />
</span>
) : null}
<span>{t('providersPage.connectivity.test')}</span>
</button>
<button
type="button"
className={styles.removeBtn}
disabled={mutating || apiKeyEntries.length <= 1}
onClick={() =>
updateField(
'apiKeyEntries',
apiKeyEntries.filter((_, i) => i !== idx)
)
}
>
<IconX size={12} />
</button>
</div>
</div>
<div className={styles.field}>
<label className={styles.label}>
{t('providersPage.form.apiKey')}
</label>
<input
className={styles.input}
type="password"
value={entry.apiKey}
onChange={(e) =>
updateField(
'apiKeyEntries',
apiKeyEntries.map((it, i) =>
i === idx ? { ...it, apiKey: e.target.value } : it
)
)
)
}
disabled={mutating}
placeholder="X-Custom-Header: value"
/>
}
disabled={mutating}
placeholder={t('providersPage.form.apiKeyCreatePlaceholder')}
/>
</div>
<div className={styles.field}>
<label className={styles.label}>
{t('providersPage.form.proxyUrl')}
</label>
<input
className={styles.input}
value={entry.proxyUrl}
onChange={(e) =>
updateField(
'apiKeyEntries',
apiKeyEntries.map((it, i) =>
i === idx ? { ...it, proxyUrl: e.target.value } : it
)
)
}
disabled={mutating}
placeholder="http://127.0.0.1:7890"
/>
</div>
<div className={styles.field}>
<label className={styles.label}>
{t('providersPage.form.headers')}
<span className={styles.labelHint}>
{' '}
· {t('providersPage.form.headersHint')}
</span>
</label>
<textarea
className={styles.textarea}
value={entry.headersText}
rows={3}
onChange={(e) =>
updateField(
'apiKeyEntries',
apiKeyEntries.map((it, i) =>
i === idx ? { ...it, headersText: e.target.value } : it
)
)
}
disabled={mutating}
placeholder="X-Custom-Header: value"
/>
</div>
{status.state === 'error' ? (
<div className={styles.connectivityError}>
{status.message}
</div>
) : null}
</div>
</div>
))}
);
})}
<button
type="button"
className={styles.addBtn}
@@ -207,6 +207,125 @@
color: var(--muted-foreground);
}
.entryCardHeaderRight {
display: flex;
align-items: center;
gap: 6px;
}
.entriesToolbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-bottom: 2px;
}
.connectivityRow {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
}
.connectivityBtn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 12px;
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background-color $transition-fast, border-color $transition-fast;
&:hover:not(:disabled) {
border-color: var(--primary-color);
color: var(--primary-color);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.connectivityBtnGhost {
display: inline-flex;
align-items: center;
gap: 6px;
height: 24px;
padding: 0 8px;
border-radius: var(--radius-md);
border: 1px solid transparent;
background: transparent;
color: var(--text-secondary);
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: background-color $transition-fast, color $transition-fast;
&:hover:not(:disabled) {
background: var(--bg-tertiary);
color: var(--text-primary);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.connectivityError {
border: 1px solid var(--destructive-30);
background: var(--destructive-10);
color: var(--destructive-color);
padding: 8px 10px;
border-radius: var(--radius-md);
font-size: 11px;
line-height: 1.4;
word-break: break-word;
}
.connectivityHintSuccess {
font-size: 11px;
color: var(--primary-color);
}
.statusIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
}
.statusIconLoading {
color: var(--text-secondary);
animation: spin 0.8s linear infinite;
}
.statusIconSuccess {
color: var(--primary-color);
}
.statusIconError {
color: var(--destructive-color);
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.entriesList {
display: flex;
flex-direction: column;
@@ -0,0 +1,357 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
import {
buildClaudeMessagesEndpoint,
buildOpenAIChatCompletionsEndpoint,
} from '@/components/providers/utils';
import { buildHeaderObject, hasHeader } from '@/utils/headers';
import type {
ApiKeyEntryInput,
ModelEntryInput,
ProviderBrand,
} from '../../types';
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_ANTHROPIC_VERSION = '2023-06-01';
export type ConnectivityState = 'idle' | 'loading' | 'success' | 'error';
export interface ConnectivityStatus {
state: ConnectivityState;
message: string;
}
const IDLE: ConnectivityStatus = { state: 'idle', message: '' };
const errorMessage = (err: unknown): string => {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
return '';
};
const pickModel = (
testModel: string | undefined,
models: ModelEntryInput[]
): string => {
const trimmed = (testModel ?? '').trim();
if (trimmed) return trimmed;
for (const m of models) {
const name = (m.name ?? '').trim();
if (name) return name;
}
return '';
};
const parseHeadersText = (text: string): Record<string, string> => {
const out: Record<string, string> = {};
String(text ?? '')
.split(/\n+/)
.map((line) => line.trim())
.filter(Boolean)
.forEach((line) => {
const sep = line.indexOf(':');
if (sep <= 0) return;
const key = line.slice(0, sep).trim();
const value = line.slice(sep + 1).trim();
if (!key) return;
out[key] = value;
});
return out;
};
const resolveBearerToken = (headers: Record<string, string>): string => {
const auth = Object.entries(headers).find(
([k]) => k.toLowerCase() === 'authorization'
)?.[1];
if (!auth) return '';
const match = String(auth).match(/^Bearer\s+(.+)$/i);
return match ? match[1].trim() : '';
};
export interface UseConnectivityTestArgs {
brand: ProviderBrand;
baseUrl: string;
testModel?: string;
models: ModelEntryInput[];
formHeaders: Array<{ key: string; value: string }>;
apiKeyEntries?: ApiKeyEntryInput[];
apiKey?: string;
fallbackApiKey?: string;
}
export interface ConnectivityErrorMessages {
baseUrlRequired: string;
endpointInvalid: string;
apiKeyRequired: string;
modelRequired: string;
timeout: (seconds: number) => string;
requestFailed: string;
}
export interface UseConnectivityTestResult {
openaiStatuses: ConnectivityStatus[];
claudeStatus: ConnectivityStatus;
isTestingAny: boolean;
runOpenAIKey: (idx: number) => Promise<boolean>;
runOpenAIAllKeys: () => Promise<void>;
runClaude: () => Promise<void>;
}
export function useConnectivityTest(
args: UseConnectivityTestArgs,
messages: ConnectivityErrorMessages
): UseConnectivityTestResult {
const {
brand,
baseUrl,
testModel,
models,
formHeaders,
apiKeyEntries,
apiKey,
fallbackApiKey,
} = args;
const entriesCount = apiKeyEntries?.length ?? 0;
const [openaiStatuses, setOpenaiStatuses] = useState<ConnectivityStatus[]>(
() => Array.from({ length: entriesCount }, () => IDLE)
);
const [claudeStatus, setClaudeStatus] = useState<ConnectivityStatus>(IDLE);
const [inFlight, setInFlight] = useState(0);
useEffect(() => {
setOpenaiStatuses((prev) => {
if (prev.length === entriesCount) return prev;
const next = prev.slice(0, entriesCount);
while (next.length < entriesCount) next.push(IDLE);
return next;
});
}, [entriesCount]);
const signature = useMemo(() => {
const h = formHeaders.map((it) => `${it.key}:${it.value}`).join('|');
const m = models.map((it) => `${it.name}:${it.alias ?? ''}`).join('|');
return `${baseUrl}||${(testModel ?? '').trim()}||${h}||${m}`;
}, [baseUrl, testModel, formHeaders, models]);
const lastSignatureRef = useRef(signature);
useEffect(() => {
if (lastSignatureRef.current === signature) return;
lastSignatureRef.current = signature;
setOpenaiStatuses((prev) => prev.map(() => IDLE));
setClaudeStatus(IDLE);
}, [signature]);
const updateOpenaiStatus = useCallback(
(idx: number, value: ConnectivityStatus) => {
setOpenaiStatuses((prev) => {
const next = [...prev];
next[idx] = value;
return next;
});
},
[]
);
const runOpenAIKey = useCallback(
async (idx: number): Promise<boolean> => {
if (brand !== 'openaiCompatibility') return false;
const trimmedBase = baseUrl.trim();
if (!trimmedBase) {
updateOpenaiStatus(idx, {
state: 'error',
message: messages.baseUrlRequired,
});
return false;
}
const endpoint = buildOpenAIChatCompletionsEndpoint(trimmedBase);
if (!endpoint) {
updateOpenaiStatus(idx, {
state: 'error',
message: messages.endpointInvalid,
});
return false;
}
const entry = apiKeyEntries?.[idx];
const entryKey = (entry?.apiKey ?? '').trim();
if (!entryKey) {
updateOpenaiStatus(idx, {
state: 'error',
message: messages.apiKeyRequired,
});
return false;
}
const model = pickModel(testModel, models);
if (!model) {
updateOpenaiStatus(idx, {
state: 'error',
message: messages.modelRequired,
});
return false;
}
const headerObj: Record<string, string> = {
'Content-Type': 'application/json',
...buildHeaderObject(formHeaders),
...parseHeadersText(entry?.headersText ?? ''),
};
if (!hasHeader(headerObj, 'authorization')) {
headerObj.Authorization = `Bearer ${entryKey}`;
}
updateOpenaiStatus(idx, { state: 'loading', message: '' });
setInFlight((n) => n + 1);
try {
const result = await apiCallApi.request(
{
method: 'POST',
url: endpoint,
header: headerObj,
data: JSON.stringify({
model,
messages: [{ role: 'user', content: 'Hi' }],
stream: false,
max_tokens: 5,
}),
},
{ timeout: DEFAULT_TIMEOUT_MS }
);
if (result.statusCode < 200 || result.statusCode >= 300) {
throw new Error(getApiCallErrorMessage(result));
}
updateOpenaiStatus(idx, { state: 'success', message: '' });
return true;
} catch (err) {
const raw = errorMessage(err);
const isTimeout =
(typeof err === 'object' &&
err !== null &&
'code' in err &&
String((err as { code?: string }).code) === 'ECONNABORTED') ||
raw.toLowerCase().includes('timeout');
updateOpenaiStatus(idx, {
state: 'error',
message: isTimeout
? messages.timeout(DEFAULT_TIMEOUT_MS / 1000)
: raw || messages.requestFailed,
});
return false;
} finally {
setInFlight((n) => n - 1);
}
},
[
apiKeyEntries,
baseUrl,
brand,
formHeaders,
messages,
models,
testModel,
updateOpenaiStatus,
]
);
const runOpenAIAllKeys = useCallback(async (): Promise<void> => {
if (brand !== 'openaiCompatibility') return;
const entries = apiKeyEntries ?? [];
if (!entries.length) return;
await Promise.all(entries.map((_, idx) => runOpenAIKey(idx)));
}, [apiKeyEntries, brand, runOpenAIKey]);
const runClaude = useCallback(async (): Promise<void> => {
if (brand !== 'claude') return;
const endpoint = buildClaudeMessagesEndpoint(baseUrl ?? '');
if (!endpoint) {
setClaudeStatus({ state: 'error', message: messages.endpointInvalid });
return;
}
const model = pickModel(testModel, models);
if (!model) {
setClaudeStatus({ state: 'error', message: messages.modelRequired });
return;
}
const customHeaders = buildHeaderObject(formHeaders);
const explicitKey = (apiKey ?? '').trim();
const persistedKey = (fallbackApiKey ?? '').trim();
const headerKey = resolveBearerToken(customHeaders);
const hasApiKeyHeader = hasHeader(customHeaders, 'x-api-key');
const resolvedKey = explicitKey || persistedKey || headerKey;
if (!resolvedKey && !hasApiKeyHeader) {
setClaudeStatus({ state: 'error', message: messages.apiKeyRequired });
return;
}
const headerObj: Record<string, string> = {
'Content-Type': 'application/json',
...customHeaders,
};
if (!hasHeader(headerObj, 'anthropic-version')) {
headerObj['anthropic-version'] = DEFAULT_ANTHROPIC_VERSION;
}
if (!hasApiKeyHeader && resolvedKey) {
headerObj['x-api-key'] = resolvedKey;
}
setClaudeStatus({ state: 'loading', message: '' });
setInFlight((n) => n + 1);
try {
const result = await apiCallApi.request(
{
method: 'POST',
url: endpoint,
header: headerObj,
data: JSON.stringify({
model,
max_tokens: 8,
messages: [{ role: 'user', content: 'Hi' }],
}),
},
{ timeout: DEFAULT_TIMEOUT_MS }
);
if (result.statusCode < 200 || result.statusCode >= 300) {
throw new Error(getApiCallErrorMessage(result));
}
setClaudeStatus({ state: 'success', message: '' });
} catch (err) {
const raw = errorMessage(err);
const isTimeout =
(typeof err === 'object' &&
err !== null &&
'code' in err &&
String((err as { code?: string }).code) === 'ECONNABORTED') ||
raw.toLowerCase().includes('timeout');
setClaudeStatus({
state: 'error',
message: isTimeout
? messages.timeout(DEFAULT_TIMEOUT_MS / 1000)
: raw || messages.requestFailed,
});
} finally {
setInFlight((n) => n - 1);
}
}, [
apiKey,
baseUrl,
brand,
fallbackApiKey,
formHeaders,
messages,
models,
testModel,
]);
return {
openaiStatuses,
claudeStatus,
isTestingAny: inFlight > 0,
runOpenAIKey,
runOpenAIAllKeys,
runClaude,
};
}
+12
View File
@@ -1549,6 +1549,7 @@
"disabledHint": "Disabled entries won't be used by the gateway",
"websockets": "Enable WebSockets",
"testModel": "Test model",
"testModelClaudeHint": "Used for connectivity testing only; not saved",
"modelsSection": "Custom models",
"addModel": "Add model",
"headersSection": "Request headers",
@@ -1599,6 +1600,17 @@
"disabled": "Disabled",
"toggleFailed": "Failed to update status"
},
"connectivity": {
"test": "Test",
"testAll": "Test all",
"success": "Reachable",
"baseUrlRequired": "Base URL is required",
"endpointInvalid": "Endpoint URL is invalid",
"apiKeyRequired": "API key is required",
"modelRequired": "Test model is required",
"timeout": "Timed out after {{seconds}}s",
"requestFailed": "Request failed"
},
"modelCatalog": {
"summaryTitle": "Model catalog",
"openAction": "Open catalog",
+12
View File
@@ -1546,6 +1546,7 @@
"disabledHint": "Отключённые записи не используются шлюзом",
"websockets": "Включить WebSockets",
"testModel": "Тестовая модель",
"testModelClaudeHint": "Используется только для проверки соединения; не сохраняется",
"modelsSection": "Пользовательские модели",
"addModel": "Добавить модель",
"headersSection": "Заголовки запроса",
@@ -1596,6 +1597,17 @@
"disabled": "Отключено",
"toggleFailed": "Не удалось обновить статус"
},
"connectivity": {
"test": "Проверить",
"testAll": "Проверить все",
"success": "Доступно",
"baseUrlRequired": "Base URL обязателен",
"endpointInvalid": "Некорректный адрес",
"apiKeyRequired": "API-ключ обязателен",
"modelRequired": "Нужна тестовая модель",
"timeout": "Таймаут после {{seconds}} с",
"requestFailed": "Запрос не выполнен"
},
"modelCatalog": {
"summaryTitle": "Каталог моделей",
"openAction": "Открыть",
+12
View File
@@ -1549,6 +1549,7 @@
"disabledHint": "停用后不会被网关使用",
"websockets": "启用 WebSockets",
"testModel": "测试模型",
"testModelClaudeHint": "仅用于连通性测试,不会被保存",
"modelsSection": "自定义模型",
"addModel": "添加模型",
"headersSection": "请求头",
@@ -1599,6 +1600,17 @@
"disabled": "已停用",
"toggleFailed": "更新状态失败"
},
"connectivity": {
"test": "测试",
"testAll": "全部测试",
"success": "连通",
"baseUrlRequired": "服务地址必填",
"endpointInvalid": "服务地址不合法",
"apiKeyRequired": "API 密钥必填",
"modelRequired": "测试模型必填",
"timeout": "请求超过 {{seconds}} 秒",
"requestFailed": "请求失败"
},
"modelCatalog": {
"summaryTitle": "模型目录",
"openAction": "打开目录",
+12
View File
@@ -1575,6 +1575,7 @@
"disabledHint": "停用後不會被閘道使用",
"websockets": "啟用 WebSockets",
"testModel": "測試模型",
"testModelClaudeHint": "僅用於連通性測試,不會被儲存",
"modelsSection": "自訂模型",
"addModel": "新增模型",
"headersSection": "請求標頭",
@@ -1625,6 +1626,17 @@
"disabled": "已停用",
"toggleFailed": "更新狀態失敗"
},
"connectivity": {
"test": "測試",
"testAll": "全部測試",
"success": "連通",
"baseUrlRequired": "服務位址必填",
"endpointInvalid": "服務位址不合法",
"apiKeyRequired": "API 金鑰必填",
"modelRequired": "測試模型必填",
"timeout": "請求超過 {{seconds}} 秒",
"requestFailed": "請求失敗"
},
"modelCatalog": {
"summaryTitle": "模型目錄",
"openAction": "開啟目錄",