feat: support dual protocols (OpenAI/Anthropic) for custom providers (#389)

This commit is contained in:
paisley
2026-03-10 17:35:51 +08:00
committed by GitHub
Unverified
parent 80e89ddc5c
commit 99681777a0
12 changed files with 187 additions and 44 deletions

View File

@@ -162,12 +162,13 @@ export async function handleProviderRoutes(
if (url.pathname === '/api/providers/validate' && req.method === 'POST') { if (url.pathname === '/api/providers/validate' && req.method === 'POST') {
logLegacyProviderRoute('POST /api/providers/validate'); logLegacyProviderRoute('POST /api/providers/validate');
try { try {
const body = await parseJsonBody<{ providerId: string; apiKey: string; options?: { baseUrl?: string } }>(req); const body = await parseJsonBody<{ providerId: string; apiKey: string; options?: { baseUrl?: string; apiProtocol?: string } }>(req);
const provider = await providerService.getLegacyProvider(body.providerId); const provider = await providerService.getLegacyProvider(body.providerId);
const providerType = provider?.type || body.providerId; const providerType = provider?.type || body.providerId;
const registryBaseUrl = getProviderConfig(providerType)?.baseUrl; const registryBaseUrl = getProviderConfig(providerType)?.baseUrl;
const resolvedBaseUrl = body.options?.baseUrl || provider?.baseUrl || registryBaseUrl; const resolvedBaseUrl = body.options?.baseUrl || provider?.baseUrl || registryBaseUrl;
sendJson(res, 200, await validateApiKeyWithProvider(providerType, body.apiKey, { baseUrl: resolvedBaseUrl })); const resolvedProtocol = body.options?.apiProtocol || provider?.apiProtocol;
sendJson(res, 200, await validateApiKeyWithProvider(providerType, body.apiKey, { baseUrl: resolvedBaseUrl, apiProtocol: resolvedProtocol as any }));
} catch (error) { } catch (error) {
sendJson(res, 500, { valid: false, error: String(error) }); sendJson(res, 500, { valid: false, error: String(error) });
} }

View File

@@ -216,7 +216,7 @@ async function syncProviderSecretToRuntime(
async function resolveRuntimeSyncContext(config: ProviderConfig): Promise<RuntimeProviderSyncContext | null> { async function resolveRuntimeSyncContext(config: ProviderConfig): Promise<RuntimeProviderSyncContext | null> {
const runtimeProviderKey = await resolveRuntimeProviderKey(config); const runtimeProviderKey = await resolveRuntimeProviderKey(config);
const meta = getProviderConfig(config.type); const meta = getProviderConfig(config.type);
const api = config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' : meta?.api; const api = config.apiProtocol || (config.type === 'custom' ? 'openai-completions' : meta?.api);
if (!api) { if (!api) {
return null; return null;
} }
@@ -245,7 +245,7 @@ async function syncCustomProviderAgentModel(
runtimeProviderKey: string, runtimeProviderKey: string,
apiKey: string | undefined, apiKey: string | undefined,
): Promise<void> { ): Promise<void> {
if (config.type !== 'custom' && config.type !== 'ollama') { if (config.type !== 'custom') {
return; return;
} }
@@ -257,7 +257,7 @@ async function syncCustomProviderAgentModel(
const modelId = config.model; const modelId = config.model;
await updateAgentModelProvider(runtimeProviderKey, { await updateAgentModelProvider(runtimeProviderKey, {
baseUrl: config.baseUrl, baseUrl: config.baseUrl,
api: 'openai-completions', api: config.apiProtocol || 'openai-completions',
models: modelId ? [{ id: modelId, name: modelId }] : [], models: modelId ? [{ id: modelId, name: modelId }] : [],
apiKey: resolvedKey, apiKey: resolvedKey,
}); });
@@ -310,12 +310,12 @@ export async function syncUpdatedProviderToRuntime(
const defaultProviderId = await getDefaultProvider(); const defaultProviderId = await getDefaultProvider();
if (defaultProviderId === config.id) { if (defaultProviderId === config.id) {
const modelOverride = config.model ? `${ock}/${config.model}` : undefined; const modelOverride = config.model ? `${ock}/${config.model}` : undefined;
if (config.type !== 'custom' && config.type !== 'ollama') { if (config.type !== 'custom') {
await setOpenClawDefaultModel(ock, modelOverride, fallbackModels); await setOpenClawDefaultModel(ock, modelOverride, fallbackModels);
} else { } else {
await setOpenClawDefaultModelWithOverride(ock, modelOverride, { await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
baseUrl: config.baseUrl, baseUrl: config.baseUrl,
api: 'openai-completions', api: config.apiProtocol || 'openai-completions',
}, fallbackModels); }, fallbackModels);
} }
} }
@@ -379,10 +379,10 @@ export async function syncDefaultProviderToRuntime(
? (provider.model.startsWith(`${ock}/`) ? provider.model : `${ock}/${provider.model}`) ? (provider.model.startsWith(`${ock}/`) ? provider.model : `${ock}/${provider.model}`)
: undefined; : undefined;
if (provider.type === 'custom' || provider.type === 'ollama') { if (provider.type === 'custom') {
await setOpenClawDefaultModelWithOverride(ock, modelOverride, { await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
baseUrl: provider.baseUrl, baseUrl: provider.baseUrl,
api: 'openai-completions', api: provider.apiProtocol || 'openai-completions',
}, fallbackModels); }, fallbackModels);
} else { } else {
await setOpenClawDefaultModel(ock, modelOverride, fallbackModels); await setOpenClawDefaultModel(ock, modelOverride, fallbackModels);
@@ -460,14 +460,14 @@ export async function syncDefaultProviderToRuntime(
} }
if ( if (
(provider.type === 'custom' || provider.type === 'ollama') && provider.type === 'custom' &&
providerKey && providerKey &&
provider.baseUrl provider.baseUrl
) { ) {
const modelId = provider.model; const modelId = provider.model;
await updateAgentModelProvider(ock, { await updateAgentModelProvider(ock, {
baseUrl: provider.baseUrl, baseUrl: provider.baseUrl,
api: 'openai-completions', api: provider.apiProtocol || 'openai-completions',
models: modelId ? [{ id: modelId, name: modelId }] : [], models: modelId ? [{ id: modelId, name: modelId }] : [],
apiKey: providerKey, apiKey: providerKey,
}); });

View File

@@ -27,9 +27,9 @@ export function providerConfigToAccount(
label: config.name, label: config.name,
authMode: inferAuthMode(config.type), authMode: inferAuthMode(config.type),
baseUrl: config.baseUrl, baseUrl: config.baseUrl,
apiProtocol: config.type === 'custom' || config.type === 'ollama' apiProtocol: config.apiProtocol || (config.type === 'custom' || config.type === 'ollama'
? 'openai-completions' ? 'openai-completions'
: getProviderDefinition(config.type)?.providerConfig?.api, : getProviderDefinition(config.type)?.providerConfig?.api),
model: config.model, model: config.model,
fallbackModels: config.fallbackModels, fallbackModels: config.fallbackModels,
fallbackAccountIds: config.fallbackProviderIds, fallbackAccountIds: config.fallbackProviderIds,
@@ -46,6 +46,7 @@ export function providerAccountToConfig(account: ProviderAccount): ProviderConfi
name: account.label, name: account.label,
type: account.vendorId, type: account.vendorId,
baseUrl: account.baseUrl, baseUrl: account.baseUrl,
apiProtocol: account.apiProtocol,
model: account.model, model: account.model,
fallbackModels: account.fallbackModels, fallbackModels: account.fallbackModels,
fallbackProviderIds: account.fallbackAccountIds, fallbackProviderIds: account.fallbackAccountIds,

View File

@@ -170,6 +170,44 @@ async function performChatCompletionsProbe(
} }
} }
async function performAnthropicMessagesProbe(
providerLabel: string,
url: string,
headers: Record<string, string>,
): Promise<{ valid: boolean; error?: string }> {
try {
logValidationRequest(providerLabel, 'POST', url, headers);
const response = await proxyAwareFetch(url, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'validation-probe',
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 1,
}),
});
logValidationStatus(providerLabel, response.status);
const data = await response.json().catch(() => ({}));
if (response.status === 401 || response.status === 403) {
return { valid: false, error: 'Invalid API key' };
}
if (
(response.status >= 200 && response.status < 300) ||
response.status === 400 ||
response.status === 429
) {
return { valid: true };
}
return classifyAuthResponse(response.status, data);
} catch (error) {
return {
valid: false,
error: `Connection error: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
async function validateGoogleQueryKey( async function validateGoogleQueryKey(
providerType: string, providerType: string,
apiKey: string, apiKey: string,
@@ -185,13 +223,26 @@ async function validateAnthropicHeaderKey(
apiKey: string, apiKey: string,
baseUrl?: string, baseUrl?: string,
): Promise<{ valid: boolean; error?: string }> { ): Promise<{ valid: boolean; error?: string }> {
const base = normalizeBaseUrl(baseUrl || 'https://api.anthropic.com/v1'); const rawBase = normalizeBaseUrl(baseUrl || 'https://api.anthropic.com/v1');
const base = rawBase.endsWith('/v1') ? rawBase : `${rawBase}/v1`;
const url = `${base}/models?limit=1`; const url = `${base}/models?limit=1`;
const headers = { const headers = {
'x-api-key': apiKey, 'x-api-key': apiKey,
'anthropic-version': '2023-06-01', 'anthropic-version': '2023-06-01',
}; };
return await performProviderValidationRequest(providerType, url, headers);
const modelsResult = await performProviderValidationRequest(providerType, url, headers);
// If the endpoint doesn't implement /models (like Minimax Anthropic compatibility), fallback to a /messages probe.
if (modelsResult.error?.includes('API error: 404') || modelsResult.error?.includes('API error: 400')) {
console.log(
`[clawx-validate] ${providerType} /models returned error, falling back to /messages probe`,
);
const messagesUrl = `${base}/messages`;
return await performAnthropicMessagesProbe(providerType, messagesUrl, headers);
}
return modelsResult;
} }
async function validateOpenRouterKey( async function validateOpenRouterKey(
@@ -206,9 +257,18 @@ async function validateOpenRouterKey(
export async function validateApiKeyWithProvider( export async function validateApiKeyWithProvider(
providerType: string, providerType: string,
apiKey: string, apiKey: string,
options?: { baseUrl?: string }, options?: { baseUrl?: string; apiProtocol?: string },
): Promise<{ valid: boolean; error?: string }> { ): Promise<{ valid: boolean; error?: string }> {
const profile = getValidationProfile(providerType); let profile = getValidationProfile(providerType);
if (providerType === 'custom' && options?.apiProtocol) {
if (options.apiProtocol === 'anthropic-messages') {
profile = 'anthropic-header';
} else {
profile = 'openai-compatible';
}
}
if (profile === 'none') { if (profile === 'none') {
return { valid: true }; return { valid: true };
} }

View File

@@ -54,6 +54,7 @@ export interface ProviderConfig {
name: string; name: string;
type: ProviderType; type: ProviderType;
baseUrl?: string; baseUrl?: string;
apiProtocol?: ProviderProtocol;
model?: string; model?: string;
fallbackModels?: string[]; fallbackModels?: string[];
fallbackProviderIds?: string[]; fallbackProviderIds?: string[];
@@ -131,25 +132,25 @@ export interface ProviderAccount {
export type ProviderSecret = export type ProviderSecret =
| { | {
type: 'api_key'; type: 'api_key';
accountId: string; accountId: string;
apiKey: string; apiKey: string;
} }
| { | {
type: 'oauth'; type: 'oauth';
accountId: string; accountId: string;
accessToken: string; accessToken: string;
refreshToken: string; refreshToken: string;
expiresAt: number; expiresAt: number;
scopes?: string[]; scopes?: string[];
email?: string; email?: string;
subject?: string; subject?: string;
} }
| { | {
type: 'local'; type: 'local';
accountId: string; accountId: string;
apiKey?: string; apiKey?: string;
}; };
export interface ModelSummary { export interface ModelSummary {
id: string; id: string;

View File

@@ -33,6 +33,7 @@ export interface ProviderConfig {
name: string; name: string;
type: ProviderType; type: ProviderType;
baseUrl?: string; baseUrl?: string;
apiProtocol?: 'openai-completions' | 'openai-responses' | 'anthropic-messages';
model?: string; model?: string;
fallbackModels?: string[]; fallbackModels?: string[];
fallbackProviderIds?: string[]; fallbackProviderIds?: string[];

View File

@@ -125,7 +125,7 @@ export function ProvidersSettings() {
type: ProviderType, type: ProviderType,
name: string, name: string,
apiKey: string, apiKey: string,
options?: { baseUrl?: string; model?: string; authMode?: ProviderAccount['authMode'] } options?: { baseUrl?: string; model?: string; authMode?: ProviderAccount['authMode']; apiProtocol?: ProviderAccount['apiProtocol'] }
) => { ) => {
const vendor = vendorMap.get(type); const vendor = vendorMap.get(type);
const id = buildProviderAccountId(type, null, vendors); const id = buildProviderAccountId(type, null, vendors);
@@ -137,7 +137,7 @@ export function ProvidersSettings() {
label: name, label: name,
authMode: options?.authMode || vendor?.defaultAuthMode || (type === 'ollama' ? 'local' : 'api_key'), authMode: options?.authMode || vendor?.defaultAuthMode || (type === 'ollama' ? 'local' : 'api_key'),
baseUrl: options?.baseUrl, baseUrl: options?.baseUrl,
apiProtocol: type === 'custom' || type === 'ollama' ? 'openai-completions' : undefined, apiProtocol: options?.apiProtocol,
model: options?.model, model: options?.model,
enabled: true, enabled: true,
isDefault: false, isDefault: false,
@@ -220,6 +220,7 @@ export function ProvidersSettings() {
const updates: Partial<ProviderAccount> = {}; const updates: Partial<ProviderAccount> = {};
if (payload.updates) { if (payload.updates) {
if (payload.updates.baseUrl !== undefined) updates.baseUrl = payload.updates.baseUrl; if (payload.updates.baseUrl !== undefined) updates.baseUrl = payload.updates.baseUrl;
if (payload.updates.apiProtocol !== undefined) updates.apiProtocol = payload.updates.apiProtocol;
if (payload.updates.model !== undefined) updates.model = payload.updates.model; if (payload.updates.model !== undefined) updates.model = payload.updates.model;
if (payload.updates.fallbackModels !== undefined) updates.fallbackModels = payload.updates.fallbackModels; if (payload.updates.fallbackModels !== undefined) updates.fallbackModels = payload.updates.fallbackModels;
if (payload.updates.fallbackProviderIds !== undefined) { if (payload.updates.fallbackProviderIds !== undefined) {
@@ -267,7 +268,7 @@ interface ProviderCardProps {
onSaveEdits: (payload: { newApiKey?: string; updates?: Partial<ProviderConfig> }) => Promise<void>; onSaveEdits: (payload: { newApiKey?: string; updates?: Partial<ProviderConfig> }) => Promise<void>;
onValidateKey: ( onValidateKey: (
key: string, key: string,
options?: { baseUrl?: string } options?: { baseUrl?: string; apiProtocol?: string }
) => Promise<{ valid: boolean; error?: string }>; ) => Promise<{ valid: boolean; error?: string }>;
devModeUnlocked: boolean; devModeUnlocked: boolean;
} }
@@ -291,6 +292,7 @@ function ProviderCard({
const { account, vendor, status } = item; const { account, vendor, status } = item;
const [newKey, setNewKey] = useState(''); const [newKey, setNewKey] = useState('');
const [baseUrl, setBaseUrl] = useState(account.baseUrl || ''); const [baseUrl, setBaseUrl] = useState(account.baseUrl || '');
const [apiProtocol, setApiProtocol] = useState<ProviderAccount['apiProtocol']>(account.apiProtocol || 'openai-completions');
const [modelId, setModelId] = useState(account.model || ''); const [modelId, setModelId] = useState(account.model || '');
const [fallbackModelsText, setFallbackModelsText] = useState( const [fallbackModelsText, setFallbackModelsText] = useState(
normalizeFallbackModels(account.fallbackModels).join('\n') normalizeFallbackModels(account.fallbackModels).join('\n')
@@ -312,6 +314,7 @@ function ProviderCard({
setNewKey(''); setNewKey('');
setShowKey(false); setShowKey(false);
setBaseUrl(account.baseUrl || ''); setBaseUrl(account.baseUrl || '');
setApiProtocol(account.apiProtocol || 'openai-completions');
setModelId(account.model || ''); setModelId(account.model || '');
setFallbackModelsText(normalizeFallbackModels(account.fallbackModels).join('\n')); setFallbackModelsText(normalizeFallbackModels(account.fallbackModels).join('\n'));
setFallbackProviderIds(normalizeFallbackProviderIds(account.fallbackAccountIds)); setFallbackProviderIds(normalizeFallbackProviderIds(account.fallbackAccountIds));
@@ -338,6 +341,7 @@ function ProviderCard({
setValidating(true); setValidating(true);
const result = await onValidateKey(newKey, { const result = await onValidateKey(newKey, {
baseUrl: baseUrl.trim() || undefined, baseUrl: baseUrl.trim() || undefined,
apiProtocol: (account.vendorId === 'custom' || account.vendorId === 'ollama') ? apiProtocol : undefined,
}); });
setValidating(false); setValidating(false);
if (!result.valid) { if (!result.valid) {
@@ -359,6 +363,9 @@ function ProviderCard({
if (typeInfo?.showBaseUrl && (baseUrl.trim() || undefined) !== (account.baseUrl || undefined)) { if (typeInfo?.showBaseUrl && (baseUrl.trim() || undefined) !== (account.baseUrl || undefined)) {
updates.baseUrl = baseUrl.trim() || undefined; updates.baseUrl = baseUrl.trim() || undefined;
} }
if ((account.vendorId === 'custom' || account.vendorId === 'ollama') && apiProtocol !== account.apiProtocol) {
updates.apiProtocol = apiProtocol;
}
if (showModelIdField && (modelId.trim() || undefined) !== (account.model || undefined)) { if (showModelIdField && (modelId.trim() || undefined) !== (account.model || undefined)) {
updates.model = modelId.trim() || undefined; updates.model = modelId.trim() || undefined;
} }
@@ -505,13 +512,13 @@ function ProviderCard({
<Input <Input
value={baseUrl} value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)} onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://api.example.com/v1" placeholder={apiProtocol === 'anthropic-messages' ? "https://api.example.com/anthropic" : "https://api.example.com/v1"}
className="h-[40px] rounded-xl font-mono text-[13px] bg-white dark:bg-[#1a1a19] border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 shadow-sm" className="h-[40px] rounded-xl font-mono text-[13px] bg-white dark:bg-[#1a1a19] border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 shadow-sm"
/> />
</div> </div>
)} )}
{showModelIdField && ( {showModelIdField && (
<div className="space-y-1.5"> <div className="space-y-1.5 pt-2">
<Label className="text-[13px] text-muted-foreground">{t('aiProviders.dialog.modelId')}</Label> <Label className="text-[13px] text-muted-foreground">{t('aiProviders.dialog.modelId')}</Label>
<Input <Input
value={modelId} value={modelId}
@@ -521,6 +528,27 @@ function ProviderCard({
/> />
</div> </div>
)} )}
{account.vendorId === 'custom' && (
<div className="space-y-1.5 pt-2">
<Label className="text-[13px] text-muted-foreground">{t('aiProviders.dialog.protocol', 'Protocol')}</Label>
<div className="flex gap-2 text-[13px]">
<button
type="button"
onClick={() => setApiProtocol('openai-completions')}
className={cn("flex-1 py-1.5 px-3 rounded-lg border transition-colors", apiProtocol === 'openai-completions' ? "bg-white dark:bg-[#1a1a19] border-black/20 dark:border-white/20 shadow-sm font-medium" : "border-transparent bg-black/5 dark:bg-white/5 text-muted-foreground hover:bg-black/10 dark:hover:bg-white/10")}
>
{t('aiProviders.protocols.openai', 'OpenAI')}
</button>
<button
type="button"
onClick={() => setApiProtocol('anthropic-messages')}
className={cn("flex-1 py-1.5 px-3 rounded-lg border transition-colors", apiProtocol === 'anthropic-messages' ? "bg-white dark:bg-[#1a1a19] border-black/20 dark:border-white/20 shadow-sm font-medium" : "border-transparent bg-black/5 dark:bg-white/5 text-muted-foreground hover:bg-black/10 dark:hover:bg-white/10")}
>
{t('aiProviders.protocols.anthropic', 'Anthropic')}
</button>
</div>
</div>
)}
</div> </div>
)} )}
<div className="space-y-3 rounded-xl bg-[#eeece3] dark:bg-[#151514] border border-black/5 dark:border-white/5 p-4"> <div className="space-y-3 rounded-xl bg-[#eeece3] dark:bg-[#151514] border border-black/5 dark:border-white/5 p-4">
@@ -666,12 +694,12 @@ interface AddProviderDialogProps {
type: ProviderType, type: ProviderType,
name: string, name: string,
apiKey: string, apiKey: string,
options?: { baseUrl?: string; model?: string; authMode?: ProviderAccount['authMode'] } options?: { baseUrl?: string; model?: string; authMode?: ProviderAccount['authMode']; apiProtocol?: ProviderAccount['apiProtocol'] }
) => Promise<void>; ) => Promise<void>;
onValidateKey: ( onValidateKey: (
type: string, type: string,
apiKey: string, apiKey: string,
options?: { baseUrl?: string } options?: { baseUrl?: string; apiProtocol?: string }
) => Promise<{ valid: boolean; error?: string }>; ) => Promise<{ valid: boolean; error?: string }>;
devModeUnlocked: boolean; devModeUnlocked: boolean;
} }
@@ -690,6 +718,7 @@ function AddProviderDialog({
const [apiKey, setApiKey] = useState(''); const [apiKey, setApiKey] = useState('');
const [baseUrl, setBaseUrl] = useState(''); const [baseUrl, setBaseUrl] = useState('');
const [modelId, setModelId] = useState(''); const [modelId, setModelId] = useState('');
const [apiProtocol, setApiProtocol] = useState<ProviderAccount['apiProtocol']>('openai-completions');
const [showKey, setShowKey] = useState(false); const [showKey, setShowKey] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null); const [validationError, setValidationError] = useState<string | null>(null);
@@ -865,6 +894,7 @@ function AddProviderDialog({
if (requiresKey && apiKey) { if (requiresKey && apiKey) {
const result = await onValidateKey(selectedType, apiKey, { const result = await onValidateKey(selectedType, apiKey, {
baseUrl: baseUrl.trim() || undefined, baseUrl: baseUrl.trim() || undefined,
apiProtocol: (selectedType === 'custom' || selectedType === 'ollama') ? apiProtocol : undefined,
}); });
if (!result.valid) { if (!result.valid) {
setValidationError(result.error || t('aiProviders.toast.invalidKey')); setValidationError(result.error || t('aiProviders.toast.invalidKey'));
@@ -886,6 +916,7 @@ function AddProviderDialog({
apiKey.trim(), apiKey.trim(),
{ {
baseUrl: baseUrl.trim() || undefined, baseUrl: baseUrl.trim() || undefined,
apiProtocol: (selectedType === 'custom' || selectedType === 'ollama') ? apiProtocol : undefined,
model: resolveProviderModelForSave(typeInfo, modelId, devModeUnlocked), model: resolveProviderModelForSave(typeInfo, modelId, devModeUnlocked),
authMode: useOAuthFlow ? (preferredOAuthMode || 'oauth_device') : selectedType === 'ollama' authMode: useOAuthFlow ? (preferredOAuthMode || 'oauth_device') : selectedType === 'ollama'
? 'local' ? 'local'
@@ -1056,7 +1087,7 @@ function AddProviderDialog({
<Label htmlFor="baseUrl" className="text-[14px] font-bold text-foreground/80">{t('aiProviders.dialog.baseUrl')}</Label> <Label htmlFor="baseUrl" className="text-[14px] font-bold text-foreground/80">{t('aiProviders.dialog.baseUrl')}</Label>
<Input <Input
id="baseUrl" id="baseUrl"
placeholder="https://api.example.com/v1" placeholder={apiProtocol === 'anthropic-messages' ? "https://api.example.com/anthropic" : "https://api.example.com/v1"}
value={baseUrl} value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)} onChange={(e) => setBaseUrl(e.target.value)}
className="h-[44px] rounded-xl font-mono text-[13px] bg-white dark:bg-[#1a1a19] border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 shadow-sm" className="h-[44px] rounded-xl font-mono text-[13px] bg-white dark:bg-[#1a1a19] border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 shadow-sm"
@@ -1079,6 +1110,27 @@ function AddProviderDialog({
/> />
</div> </div>
)} )}
{selectedType === 'custom' && (
<div className="space-y-2">
<Label className="text-[14px] font-bold text-foreground/80">{t('aiProviders.dialog.protocol', 'Protocol')}</Label>
<div className="flex gap-2 text-[13px]">
<button
type="button"
onClick={() => setApiProtocol('openai-completions')}
className={cn("flex-1 py-1.5 px-3 rounded-lg border transition-colors", apiProtocol === 'openai-completions' ? "bg-white dark:bg-[#1a1a19] border-black/20 dark:border-white/20 shadow-sm font-medium" : "border-transparent bg-black/5 dark:bg-white/5 text-muted-foreground hover:bg-black/10 dark:hover:bg-white/10")}
>
{t('aiProviders.protocols.openai', 'OpenAI')}
</button>
<button
type="button"
onClick={() => setApiProtocol('anthropic-messages')}
className={cn("flex-1 py-1.5 px-3 rounded-lg border transition-colors", apiProtocol === 'anthropic-messages' ? "bg-white dark:bg-[#1a1a19] border-black/20 dark:border-white/20 shadow-sm font-medium" : "border-transparent bg-black/5 dark:bg-white/5 text-muted-foreground hover:bg-black/10 dark:hover:bg-white/10")}
>
{t('aiProviders.protocols.anthropic', 'Anthropic')}
</button>
</div>
</div>
)}
{/* Device OAuth Trigger — only shown when in OAuth mode */} {/* Device OAuth Trigger — only shown when in OAuth mode */}
{useOAuthFlow && ( {useOAuthFlow && (
<div className="space-y-4 pt-2"> <div className="space-y-4 pt-2">

View File

@@ -50,6 +50,7 @@
"replaceApiKeyHelp": "Leave this field empty if you want to keep the currently stored API key.", "replaceApiKeyHelp": "Leave this field empty if you want to keep the currently stored API key.",
"baseUrl": "Base URL", "baseUrl": "Base URL",
"modelId": "Model ID", "modelId": "Model ID",
"protocol": "Protocol",
"fallbackModels": "Fallback Models", "fallbackModels": "Fallback Models",
"fallbackProviders": "Fallback Providers", "fallbackProviders": "Fallback Providers",
"fallbackModelIds": "Fallback Model IDs", "fallbackModelIds": "Fallback Model IDs",
@@ -73,6 +74,10 @@
"editKey": "Edit API key", "editKey": "Edit API key",
"delete": "Delete provider" "delete": "Delete provider"
}, },
"protocols": {
"openai": "OpenAI Compatible",
"anthropic": "Anthropic Compatible"
},
"toast": { "toast": {
"added": "Provider added successfully", "added": "Provider added successfully",
"failedAdd": "Failed to add provider", "failedAdd": "Failed to add provider",
@@ -235,4 +240,4 @@
"docs": "Website", "docs": "Website",
"github": "GitHub" "github": "GitHub"
} }
} }

View File

@@ -50,6 +50,7 @@
"replaceApiKeyHelp": "如果想保留当前已保存的 API key这里留空即可。", "replaceApiKeyHelp": "如果想保留当前已保存的 API key这里留空即可。",
"baseUrl": "基础 URL", "baseUrl": "基础 URL",
"modelId": "模型 ID", "modelId": "模型 ID",
"protocol": "协议",
"fallbackModels": "回退模型", "fallbackModels": "回退模型",
"fallbackProviders": "跨 Provider 回退", "fallbackProviders": "跨 Provider 回退",
"fallbackModelIds": "同 Provider 回退模型 ID", "fallbackModelIds": "同 Provider 回退模型 ID",
@@ -73,6 +74,10 @@
"editKey": "编辑 API 密钥", "editKey": "编辑 API 密钥",
"delete": "删除提供商" "delete": "删除提供商"
}, },
"protocols": {
"openai": "OpenAI 兼容",
"anthropic": "Anthropic 兼容"
},
"toast": { "toast": {
"added": "提供商添加成功", "added": "提供商添加成功",
"failedAdd": "添加提供商失败", "failedAdd": "添加提供商失败",
@@ -235,4 +240,4 @@
"docs": "官网", "docs": "官网",
"github": "GitHub" "github": "GitHub"
} }
} }

View File

@@ -43,6 +43,7 @@ export interface ProviderConfig {
name: string; name: string;
type: ProviderType; type: ProviderType;
baseUrl?: string; baseUrl?: string;
apiProtocol?: 'openai-completions' | 'openai-responses' | 'anthropic-messages';
model?: string; model?: string;
fallbackModels?: string[]; fallbackModels?: string[];
fallbackProviderIds?: string[]; fallbackProviderIds?: string[];

8
test-anthropic-url.js Normal file
View File

@@ -0,0 +1,8 @@
const { Anthropic } = require('@anthropic-ai/sdk');
const client = new Anthropic({ apiKey: 'test', baseURL: 'https://api.minimaxi.com/anthropic' });
const req = client.buildRequest({ method: 'post', path: '/messages', body: {} });
console.log('Build Request URL:', req.url);
const client2 = new Anthropic({ apiKey: 'test', baseURL: 'https://api.minimaxi.com/anthropic/v1' });
const req2 = client2.buildRequest({ method: 'post', path: '/messages', body: {} });
console.log('Build Request URL 2:', req2.url);

8
test-anthropic.js Normal file
View File

@@ -0,0 +1,8 @@
const { Anthropic } = require('@anthropic-ai/sdk');
const client = new Anthropic({ apiKey: 'test', baseURL: 'https://api.minimaxi.com/anthropic' });
const req = client.buildRequest({ method: 'post', path: '/messages', body: {} });
console.log('Build Request URL 1:', req.url);
const client2 = new Anthropic({ apiKey: 'test', baseURL: 'https://api.minimaxi.com/anthropic/v1' });
const req2 = client2.buildRequest({ method: 'post', path: '/messages', body: {} });
console.log('Build Request URL 2:', req2.url);