mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
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:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Открыть",
|
||||
|
||||
@@ -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": "打开目录",
|
||||
|
||||
@@ -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": "開啟目錄",
|
||||
|
||||
Reference in New Issue
Block a user