feat: support dual protocols (OpenAI/Anthropic) for custom providers (#389)
This commit is contained in:
committed by
GitHub
Unverified
parent
80e89ddc5c
commit
99681777a0
@@ -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) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "添加提供商失败",
|
||||||
|
|||||||
@@ -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
8
test-anthropic-url.js
Normal 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
8
test-anthropic.js
Normal 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);
|
||||||
Reference in New Issue
Block a user