diff --git a/electron/api/routes/providers.ts b/electron/api/routes/providers.ts index e69c34af6..fb07dbaac 100644 --- a/electron/api/routes/providers.ts +++ b/electron/api/routes/providers.ts @@ -162,12 +162,13 @@ export async function handleProviderRoutes( if (url.pathname === '/api/providers/validate' && req.method === 'POST') { logLegacyProviderRoute('POST /api/providers/validate'); 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 providerType = provider?.type || body.providerId; const registryBaseUrl = getProviderConfig(providerType)?.baseUrl; 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) { sendJson(res, 500, { valid: false, error: String(error) }); } diff --git a/electron/services/providers/provider-runtime-sync.ts b/electron/services/providers/provider-runtime-sync.ts index 534ec94a3..7f8973635 100644 --- a/electron/services/providers/provider-runtime-sync.ts +++ b/electron/services/providers/provider-runtime-sync.ts @@ -216,7 +216,7 @@ async function syncProviderSecretToRuntime( async function resolveRuntimeSyncContext(config: ProviderConfig): Promise { const runtimeProviderKey = await resolveRuntimeProviderKey(config); 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) { return null; } @@ -245,7 +245,7 @@ async function syncCustomProviderAgentModel( runtimeProviderKey: string, apiKey: string | undefined, ): Promise { - if (config.type !== 'custom' && config.type !== 'ollama') { + if (config.type !== 'custom') { return; } @@ -257,7 +257,7 @@ async function syncCustomProviderAgentModel( const modelId = config.model; await updateAgentModelProvider(runtimeProviderKey, { baseUrl: config.baseUrl, - api: 'openai-completions', + api: config.apiProtocol || 'openai-completions', models: modelId ? [{ id: modelId, name: modelId }] : [], apiKey: resolvedKey, }); @@ -310,12 +310,12 @@ export async function syncUpdatedProviderToRuntime( const defaultProviderId = await getDefaultProvider(); if (defaultProviderId === config.id) { const modelOverride = config.model ? `${ock}/${config.model}` : undefined; - if (config.type !== 'custom' && config.type !== 'ollama') { + if (config.type !== 'custom') { await setOpenClawDefaultModel(ock, modelOverride, fallbackModels); } else { await setOpenClawDefaultModelWithOverride(ock, modelOverride, { baseUrl: config.baseUrl, - api: 'openai-completions', + api: config.apiProtocol || 'openai-completions', }, fallbackModels); } } @@ -379,10 +379,10 @@ export async function syncDefaultProviderToRuntime( ? (provider.model.startsWith(`${ock}/`) ? provider.model : `${ock}/${provider.model}`) : undefined; - if (provider.type === 'custom' || provider.type === 'ollama') { + if (provider.type === 'custom') { await setOpenClawDefaultModelWithOverride(ock, modelOverride, { baseUrl: provider.baseUrl, - api: 'openai-completions', + api: provider.apiProtocol || 'openai-completions', }, fallbackModels); } else { await setOpenClawDefaultModel(ock, modelOverride, fallbackModels); @@ -460,14 +460,14 @@ export async function syncDefaultProviderToRuntime( } if ( - (provider.type === 'custom' || provider.type === 'ollama') && + provider.type === 'custom' && providerKey && provider.baseUrl ) { const modelId = provider.model; await updateAgentModelProvider(ock, { baseUrl: provider.baseUrl, - api: 'openai-completions', + api: provider.apiProtocol || 'openai-completions', models: modelId ? [{ id: modelId, name: modelId }] : [], apiKey: providerKey, }); diff --git a/electron/services/providers/provider-store.ts b/electron/services/providers/provider-store.ts index 287f58c09..9a88110a0 100644 --- a/electron/services/providers/provider-store.ts +++ b/electron/services/providers/provider-store.ts @@ -27,9 +27,9 @@ export function providerConfigToAccount( label: config.name, authMode: inferAuthMode(config.type), baseUrl: config.baseUrl, - apiProtocol: config.type === 'custom' || config.type === 'ollama' + apiProtocol: config.apiProtocol || (config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' - : getProviderDefinition(config.type)?.providerConfig?.api, + : getProviderDefinition(config.type)?.providerConfig?.api), model: config.model, fallbackModels: config.fallbackModels, fallbackAccountIds: config.fallbackProviderIds, @@ -46,6 +46,7 @@ export function providerAccountToConfig(account: ProviderAccount): ProviderConfi name: account.label, type: account.vendorId, baseUrl: account.baseUrl, + apiProtocol: account.apiProtocol, model: account.model, fallbackModels: account.fallbackModels, fallbackProviderIds: account.fallbackAccountIds, diff --git a/electron/services/providers/provider-validation.ts b/electron/services/providers/provider-validation.ts index ce298ae28..46b7c2e99 100644 --- a/electron/services/providers/provider-validation.ts +++ b/electron/services/providers/provider-validation.ts @@ -170,6 +170,44 @@ async function performChatCompletionsProbe( } } +async function performAnthropicMessagesProbe( + providerLabel: string, + url: string, + headers: Record, +): 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( providerType: string, apiKey: string, @@ -185,13 +223,26 @@ async function validateAnthropicHeaderKey( apiKey: string, baseUrl?: 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 headers = { 'x-api-key': apiKey, '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( @@ -206,9 +257,18 @@ async function validateOpenRouterKey( export async function validateApiKeyWithProvider( providerType: string, apiKey: string, - options?: { baseUrl?: string }, + options?: { baseUrl?: string; apiProtocol?: 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') { return { valid: true }; } diff --git a/electron/shared/providers/types.ts b/electron/shared/providers/types.ts index 2cda0a972..e53fb1b23 100644 --- a/electron/shared/providers/types.ts +++ b/electron/shared/providers/types.ts @@ -54,6 +54,7 @@ export interface ProviderConfig { name: string; type: ProviderType; baseUrl?: string; + apiProtocol?: ProviderProtocol; model?: string; fallbackModels?: string[]; fallbackProviderIds?: string[]; @@ -131,25 +132,25 @@ export interface ProviderAccount { export type ProviderSecret = | { - type: 'api_key'; - accountId: string; - apiKey: string; - } + type: 'api_key'; + accountId: string; + apiKey: string; + } | { - type: 'oauth'; - accountId: string; - accessToken: string; - refreshToken: string; - expiresAt: number; - scopes?: string[]; - email?: string; - subject?: string; - } + type: 'oauth'; + accountId: string; + accessToken: string; + refreshToken: string; + expiresAt: number; + scopes?: string[]; + email?: string; + subject?: string; + } | { - type: 'local'; - accountId: string; - apiKey?: string; - }; + type: 'local'; + accountId: string; + apiKey?: string; + }; export interface ModelSummary { id: string; diff --git a/electron/utils/secure-storage.ts b/electron/utils/secure-storage.ts index 023d3276c..025b958ae 100644 --- a/electron/utils/secure-storage.ts +++ b/electron/utils/secure-storage.ts @@ -33,6 +33,7 @@ export interface ProviderConfig { name: string; type: ProviderType; baseUrl?: string; + apiProtocol?: 'openai-completions' | 'openai-responses' | 'anthropic-messages'; model?: string; fallbackModels?: string[]; fallbackProviderIds?: string[]; diff --git a/src/components/settings/ProvidersSettings.tsx b/src/components/settings/ProvidersSettings.tsx index 6c14828f0..40429b3d2 100644 --- a/src/components/settings/ProvidersSettings.tsx +++ b/src/components/settings/ProvidersSettings.tsx @@ -125,7 +125,7 @@ export function ProvidersSettings() { type: ProviderType, name: 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 id = buildProviderAccountId(type, null, vendors); @@ -137,7 +137,7 @@ export function ProvidersSettings() { label: name, authMode: options?.authMode || vendor?.defaultAuthMode || (type === 'ollama' ? 'local' : 'api_key'), baseUrl: options?.baseUrl, - apiProtocol: type === 'custom' || type === 'ollama' ? 'openai-completions' : undefined, + apiProtocol: options?.apiProtocol, model: options?.model, enabled: true, isDefault: false, @@ -220,6 +220,7 @@ export function ProvidersSettings() { const updates: Partial = {}; if (payload.updates) { 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.fallbackModels !== undefined) updates.fallbackModels = payload.updates.fallbackModels; if (payload.updates.fallbackProviderIds !== undefined) { @@ -267,7 +268,7 @@ interface ProviderCardProps { onSaveEdits: (payload: { newApiKey?: string; updates?: Partial }) => Promise; onValidateKey: ( key: string, - options?: { baseUrl?: string } + options?: { baseUrl?: string; apiProtocol?: string } ) => Promise<{ valid: boolean; error?: string }>; devModeUnlocked: boolean; } @@ -291,6 +292,7 @@ function ProviderCard({ const { account, vendor, status } = item; const [newKey, setNewKey] = useState(''); const [baseUrl, setBaseUrl] = useState(account.baseUrl || ''); + const [apiProtocol, setApiProtocol] = useState(account.apiProtocol || 'openai-completions'); const [modelId, setModelId] = useState(account.model || ''); const [fallbackModelsText, setFallbackModelsText] = useState( normalizeFallbackModels(account.fallbackModels).join('\n') @@ -312,6 +314,7 @@ function ProviderCard({ setNewKey(''); setShowKey(false); setBaseUrl(account.baseUrl || ''); + setApiProtocol(account.apiProtocol || 'openai-completions'); setModelId(account.model || ''); setFallbackModelsText(normalizeFallbackModels(account.fallbackModels).join('\n')); setFallbackProviderIds(normalizeFallbackProviderIds(account.fallbackAccountIds)); @@ -338,6 +341,7 @@ function ProviderCard({ setValidating(true); const result = await onValidateKey(newKey, { baseUrl: baseUrl.trim() || undefined, + apiProtocol: (account.vendorId === 'custom' || account.vendorId === 'ollama') ? apiProtocol : undefined, }); setValidating(false); if (!result.valid) { @@ -359,6 +363,9 @@ function ProviderCard({ if (typeInfo?.showBaseUrl && (baseUrl.trim() || undefined) !== (account.baseUrl || 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)) { updates.model = modelId.trim() || undefined; } @@ -505,13 +512,13 @@ function ProviderCard({ 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" /> )} {showModelIdField && ( -
+
)} + {account.vendorId === 'custom' && ( +
+ +
+ + +
+
+ )}
)}
@@ -666,12 +694,12 @@ interface AddProviderDialogProps { type: ProviderType, name: string, apiKey: string, - options?: { baseUrl?: string; model?: string; authMode?: ProviderAccount['authMode'] } + options?: { baseUrl?: string; model?: string; authMode?: ProviderAccount['authMode']; apiProtocol?: ProviderAccount['apiProtocol'] } ) => Promise; onValidateKey: ( type: string, apiKey: string, - options?: { baseUrl?: string } + options?: { baseUrl?: string; apiProtocol?: string } ) => Promise<{ valid: boolean; error?: string }>; devModeUnlocked: boolean; } @@ -690,6 +718,7 @@ function AddProviderDialog({ const [apiKey, setApiKey] = useState(''); const [baseUrl, setBaseUrl] = useState(''); const [modelId, setModelId] = useState(''); + const [apiProtocol, setApiProtocol] = useState('openai-completions'); const [showKey, setShowKey] = useState(false); const [saving, setSaving] = useState(false); const [validationError, setValidationError] = useState(null); @@ -865,6 +894,7 @@ function AddProviderDialog({ if (requiresKey && apiKey) { const result = await onValidateKey(selectedType, apiKey, { baseUrl: baseUrl.trim() || undefined, + apiProtocol: (selectedType === 'custom' || selectedType === 'ollama') ? apiProtocol : undefined, }); if (!result.valid) { setValidationError(result.error || t('aiProviders.toast.invalidKey')); @@ -886,6 +916,7 @@ function AddProviderDialog({ apiKey.trim(), { baseUrl: baseUrl.trim() || undefined, + apiProtocol: (selectedType === 'custom' || selectedType === 'ollama') ? apiProtocol : undefined, model: resolveProviderModelForSave(typeInfo, modelId, devModeUnlocked), authMode: useOAuthFlow ? (preferredOAuthMode || 'oauth_device') : selectedType === 'ollama' ? 'local' @@ -1056,7 +1087,7 @@ function AddProviderDialog({ 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" @@ -1079,6 +1110,27 @@ function AddProviderDialog({ />
)} + {selectedType === 'custom' && ( +
+ +
+ + +
+
+ )} {/* Device OAuth Trigger — only shown when in OAuth mode */} {useOAuthFlow && (
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index ce985c691..f3cb02f85 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -50,6 +50,7 @@ "replaceApiKeyHelp": "Leave this field empty if you want to keep the currently stored API key.", "baseUrl": "Base URL", "modelId": "Model ID", + "protocol": "Protocol", "fallbackModels": "Fallback Models", "fallbackProviders": "Fallback Providers", "fallbackModelIds": "Fallback Model IDs", @@ -73,6 +74,10 @@ "editKey": "Edit API key", "delete": "Delete provider" }, + "protocols": { + "openai": "OpenAI Compatible", + "anthropic": "Anthropic Compatible" + }, "toast": { "added": "Provider added successfully", "failedAdd": "Failed to add provider", @@ -235,4 +240,4 @@ "docs": "Website", "github": "GitHub" } -} +} \ No newline at end of file diff --git a/src/i18n/locales/zh/settings.json b/src/i18n/locales/zh/settings.json index 18755cb5f..df5138bb8 100644 --- a/src/i18n/locales/zh/settings.json +++ b/src/i18n/locales/zh/settings.json @@ -50,6 +50,7 @@ "replaceApiKeyHelp": "如果想保留当前已保存的 API key,这里留空即可。", "baseUrl": "基础 URL", "modelId": "模型 ID", + "protocol": "协议", "fallbackModels": "回退模型", "fallbackProviders": "跨 Provider 回退", "fallbackModelIds": "同 Provider 回退模型 ID", @@ -73,6 +74,10 @@ "editKey": "编辑 API 密钥", "delete": "删除提供商" }, + "protocols": { + "openai": "OpenAI 兼容", + "anthropic": "Anthropic 兼容" + }, "toast": { "added": "提供商添加成功", "failedAdd": "添加提供商失败", @@ -235,4 +240,4 @@ "docs": "官网", "github": "GitHub" } -} +} \ No newline at end of file diff --git a/src/lib/providers.ts b/src/lib/providers.ts index 36314d158..f38f75c74 100644 --- a/src/lib/providers.ts +++ b/src/lib/providers.ts @@ -43,6 +43,7 @@ export interface ProviderConfig { name: string; type: ProviderType; baseUrl?: string; + apiProtocol?: 'openai-completions' | 'openai-responses' | 'anthropic-messages'; model?: string; fallbackModels?: string[]; fallbackProviderIds?: string[]; diff --git a/test-anthropic-url.js b/test-anthropic-url.js new file mode 100644 index 000000000..2ac0c605a --- /dev/null +++ b/test-anthropic-url.js @@ -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); diff --git a/test-anthropic.js b/test-anthropic.js new file mode 100644 index 000000000..b1ee258c8 --- /dev/null +++ b/test-anthropic.js @@ -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);