From c0c8701cc3949931f250baf15e4e0cc575d268dc Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:19:05 +0800 Subject: [PATCH] fix(providers): complete custom openai-responses support (#436) --- electron/main/ipc-handlers.ts | 18 ++- .../providers/provider-runtime-sync.ts | 43 ++++-- .../services/providers/provider-validation.ts | 129 +++++++++++++++--- src/components/settings/ProvidersSettings.tsx | 57 +++++--- src/i18n/locales/en/settings.json | 4 +- src/i18n/locales/en/setup.json | 8 +- src/i18n/locales/ja/settings.json | 9 +- src/i18n/locales/ja/setup.json | 8 +- src/i18n/locales/zh/settings.json | 4 +- src/i18n/locales/zh/setup.json | 8 +- src/pages/Setup/index.tsx | 80 ++++++++++- src/stores/providers.ts | 4 +- tests/unit/provider-validation.test.ts | 109 ++++++++++++++- 13 files changed, 414 insertions(+), 67 deletions(-) diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 97fbce4a5..a7a102a42 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -299,8 +299,8 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { } if (request.action === 'validateKey') { const payload = request.payload as - | { providerId?: string; apiKey?: string; options?: { baseUrl?: string } } - | [string, string, { baseUrl?: string }?] + | { providerId?: string; apiKey?: string; options?: { baseUrl?: string; apiProtocol?: string } } + | [string, string, { baseUrl?: string; apiProtocol?: string }?] | undefined; const providerId = Array.isArray(payload) ? payload[0] : payload?.providerId; const apiKey = Array.isArray(payload) ? payload[1] : payload?.apiKey; @@ -313,7 +313,11 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { const providerType = provider?.type || providerId; const registryBaseUrl = getProviderConfig(providerType)?.baseUrl; const resolvedBaseUrl = options?.baseUrl || provider?.baseUrl || registryBaseUrl; - data = await validateApiKeyWithProvider(providerType, apiKey, { baseUrl: resolvedBaseUrl }); + const resolvedProtocol = options?.apiProtocol || provider?.apiProtocol; + data = await validateApiKeyWithProvider(providerType, apiKey, { + baseUrl: resolvedBaseUrl, + apiProtocol: resolvedProtocol, + }); break; } if (request.action === 'save') { @@ -2062,7 +2066,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { _, providerId: string, apiKey: string, - options?: { baseUrl?: string } + options?: { baseUrl?: string; apiProtocol?: string } ) => { logLegacyProviderChannel('provider:validateKey'); try { @@ -2076,9 +2080,13 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { // Prefer caller-supplied baseUrl (live form value) over persisted config. // This ensures Setup/Settings validation reflects unsaved edits immediately. const resolvedBaseUrl = options?.baseUrl || provider?.baseUrl || registryBaseUrl; + const resolvedProtocol = options?.apiProtocol || provider?.apiProtocol; console.log(`[clawx-validate] validating provider type: ${providerType}`); - return await validateApiKeyWithProvider(providerType, apiKey, { baseUrl: resolvedBaseUrl }); + return await validateApiKeyWithProvider(providerType, apiKey, { + baseUrl: resolvedBaseUrl, + apiProtocol: resolvedProtocol, + }); } catch (error) { console.error('Validation error:', error); return { valid: false, error: String(error) }; diff --git a/electron/services/providers/provider-runtime-sync.ts b/electron/services/providers/provider-runtime-sync.ts index 4d5b7dff1..45b3b4150 100644 --- a/electron/services/providers/provider-runtime-sync.ts +++ b/electron/services/providers/provider-runtime-sync.ts @@ -26,16 +26,35 @@ type RuntimeProviderSyncContext = { api: string; }; -function normalizeProviderBaseUrl(config: ProviderConfig, baseUrl?: string): string | undefined { +function normalizeProviderBaseUrl( + config: ProviderConfig, + baseUrl?: string, + apiProtocol?: string, +): string | undefined { if (!baseUrl) { return undefined; } + const normalized = baseUrl.trim().replace(/\/+$/, ''); + if (config.type === 'minimax-portal' || config.type === 'minimax-portal-cn') { - return baseUrl.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic'; + return normalized.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic'; } - return baseUrl; + if (config.type === 'custom' || config.type === 'ollama') { + const protocol = apiProtocol || config.apiProtocol || 'openai-completions'; + if (protocol === 'openai-responses') { + return normalized.replace(/\/responses?$/i, ''); + } + if (protocol === 'openai-completions') { + return normalized.replace(/\/chat\/completions$/i, ''); + } + if (protocol === 'anthropic-messages') { + return normalized.replace(/\/v1\/messages$/i, '').replace(/\/messages$/i, ''); + } + } + + return normalized; } function shouldUseExplicitDefaultOverride(config: ProviderConfig, runtimeProviderKey: string): boolean { @@ -266,7 +285,7 @@ async function syncRuntimeProviderConfig( context: RuntimeProviderSyncContext, ): Promise { await syncProviderConfigToOpenClaw(context.runtimeProviderKey, config.model, { - baseUrl: normalizeProviderBaseUrl(config, config.baseUrl || context.meta?.baseUrl), + baseUrl: normalizeProviderBaseUrl(config, config.baseUrl || context.meta?.baseUrl, context.api), api: context.api, apiKeyEnv: context.meta?.apiKeyEnv, headers: context.meta?.headers, @@ -289,7 +308,7 @@ async function syncCustomProviderAgentModel( const modelId = config.model; await updateAgentModelProvider(runtimeProviderKey, { - baseUrl: config.baseUrl, + baseUrl: normalizeProviderBaseUrl(config, config.baseUrl, config.apiProtocol || 'openai-completions'), api: config.apiProtocol || 'openai-completions', models: modelId ? [{ id: modelId, name: modelId }] : [], apiKey: resolvedKey, @@ -346,7 +365,7 @@ export async function syncUpdatedProviderToRuntime( if (config.type !== 'custom') { if (shouldUseExplicitDefaultOverride(config, ock)) { await setOpenClawDefaultModelWithOverride(ock, modelOverride, { - baseUrl: normalizeProviderBaseUrl(config, config.baseUrl || context.meta?.baseUrl), + baseUrl: normalizeProviderBaseUrl(config, config.baseUrl || context.meta?.baseUrl, context.api), api: context.api, apiKeyEnv: context.meta?.apiKeyEnv, headers: context.meta?.headers, @@ -356,7 +375,7 @@ export async function syncUpdatedProviderToRuntime( } } else { await setOpenClawDefaultModelWithOverride(ock, modelOverride, { - baseUrl: config.baseUrl, + baseUrl: normalizeProviderBaseUrl(config, config.baseUrl, config.apiProtocol || 'openai-completions'), api: config.apiProtocol || 'openai-completions', }, fallbackModels); } @@ -423,12 +442,16 @@ export async function syncDefaultProviderToRuntime( if (provider.type === 'custom') { await setOpenClawDefaultModelWithOverride(ock, modelOverride, { - baseUrl: provider.baseUrl, + baseUrl: normalizeProviderBaseUrl(provider, provider.baseUrl, provider.apiProtocol || 'openai-completions'), api: provider.apiProtocol || 'openai-completions', }, fallbackModels); } else if (shouldUseExplicitDefaultOverride(provider, ock)) { await setOpenClawDefaultModelWithOverride(ock, modelOverride, { - baseUrl: normalizeProviderBaseUrl(provider, provider.baseUrl || getProviderConfig(provider.type)?.baseUrl), + baseUrl: normalizeProviderBaseUrl( + provider, + provider.baseUrl || getProviderConfig(provider.type)?.baseUrl, + provider.apiProtocol || getProviderConfig(provider.type)?.api, + ), api: provider.apiProtocol || getProviderConfig(provider.type)?.api, apiKeyEnv: getProviderConfig(provider.type)?.apiKeyEnv, headers: getProviderConfig(provider.type)?.headers, @@ -518,7 +541,7 @@ export async function syncDefaultProviderToRuntime( ) { const modelId = provider.model; await updateAgentModelProvider(ock, { - baseUrl: provider.baseUrl, + baseUrl: normalizeProviderBaseUrl(provider, provider.baseUrl, provider.apiProtocol || 'openai-completions'), api: provider.apiProtocol || 'openai-completions', models: modelId ? [{ id: modelId, name: modelId }] : [], apiKey: providerKey, diff --git a/electron/services/providers/provider-validation.ts b/electron/services/providers/provider-validation.ts index 59efc5563..b18057cd4 100644 --- a/electron/services/providers/provider-validation.ts +++ b/electron/services/providers/provider-validation.ts @@ -2,12 +2,15 @@ import { proxyAwareFetch } from '../../utils/proxy-fetch'; import { getProviderConfig } from '../../utils/provider-registry'; type ValidationProfile = - | 'openai-compatible' + | 'openai-completions' + | 'openai-responses' | 'google-query-key' | 'anthropic-header' | 'openrouter' | 'none'; +type ValidationResult = { valid: boolean; error?: string; status?: number }; + function logValidationStatus(provider: string, status: number): void { console.log(`[clawx-validate] ${provider} HTTP ${status}`); } @@ -49,6 +52,28 @@ function buildOpenAiModelsUrl(baseUrl: string): string { return `${normalizeBaseUrl(baseUrl)}/models?limit=1`; } +function resolveOpenAiProbeUrls( + baseUrl: string, + apiProtocol: 'openai-completions' | 'openai-responses', +): { modelsUrl: string; probeUrl: string } { + const normalizedBase = normalizeBaseUrl(baseUrl); + const endpointSuffixPattern = /(\/responses?|\/chat\/completions)$/; + const rootBase = normalizedBase.replace(endpointSuffixPattern, ''); + const modelsUrl = buildOpenAiModelsUrl(rootBase); + + if (apiProtocol === 'openai-responses') { + const probeUrl = /(\/responses?)$/.test(normalizedBase) + ? normalizedBase + : `${rootBase}/responses`; + return { modelsUrl, probeUrl }; + } + + const probeUrl = /\/chat\/completions$/.test(normalizedBase) + ? normalizedBase + : `${rootBase}/chat/completions`; + return { modelsUrl, probeUrl }; +} + function logValidationRequest( provider: string, method: string, @@ -68,8 +93,11 @@ function getValidationProfile( if (providerApi === 'anthropic-messages') { return 'anthropic-header'; } - if (providerApi === 'openai-completions' || providerApi === 'openai-responses') { - return 'openai-compatible'; + if (providerApi === 'openai-responses') { + return 'openai-responses'; + } + if (providerApi === 'openai-completions') { + return 'openai-completions'; } switch (providerType) { @@ -82,7 +110,7 @@ function getValidationProfile( case 'ollama': return 'none'; default: - return 'openai-compatible'; + return 'openai-completions'; } } @@ -90,13 +118,14 @@ async function performProviderValidationRequest( providerLabel: string, url: string, headers: Record, -): Promise<{ valid: boolean; error?: string }> { +): Promise { try { logValidationRequest(providerLabel, 'GET', url, headers); const response = await proxyAwareFetch(url, { headers }); logValidationStatus(providerLabel, response.status); const data = await response.json().catch(() => ({})); - return classifyAuthResponse(response.status, data); + const result = classifyAuthResponse(response.status, data); + return { ...result, status: response.status }; } catch (error) { return { valid: false, @@ -121,34 +150,73 @@ function classifyAuthResponse( async function validateOpenAiCompatibleKey( providerType: string, apiKey: string, + apiProtocol: 'openai-completions' | 'openai-responses', baseUrl?: string, -): Promise<{ valid: boolean; error?: string }> { +): Promise { const trimmedBaseUrl = baseUrl?.trim(); if (!trimmedBaseUrl) { return { valid: false, error: `Base URL is required for provider "${providerType}" validation` }; } const headers = { Authorization: `Bearer ${apiKey}` }; - const modelsUrl = buildOpenAiModelsUrl(trimmedBaseUrl); + const { modelsUrl, probeUrl } = resolveOpenAiProbeUrls(trimmedBaseUrl, apiProtocol); const modelsResult = await performProviderValidationRequest(providerType, modelsUrl, headers); - if (modelsResult.error?.includes('API error: 404')) { + if (modelsResult.status === 404) { console.log( - `[clawx-validate] ${providerType} /models returned 404, falling back to /chat/completions probe`, + `[clawx-validate] ${providerType} /models returned 404, falling back to ${apiProtocol} probe`, ); - const base = normalizeBaseUrl(trimmedBaseUrl); - const chatUrl = `${base}/chat/completions`; - return await performChatCompletionsProbe(providerType, chatUrl, headers); + if (apiProtocol === 'openai-responses') { + return await performResponsesProbe(providerType, probeUrl, headers); + } + return await performChatCompletionsProbe(providerType, probeUrl, headers); } return modelsResult; } +async function performResponsesProbe( + providerLabel: string, + url: string, + headers: Record, +): Promise { + 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', + input: 'hi', + }), + }); + 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 performChatCompletionsProbe( providerLabel: string, url: string, headers: Record, -): Promise<{ valid: boolean; error?: string }> { +): Promise { try { logValidationRequest(providerLabel, 'POST', url, headers); const response = await proxyAwareFetch(url, { @@ -186,7 +254,7 @@ async function performAnthropicMessagesProbe( providerLabel: string, url: string, headers: Record, -): Promise<{ valid: boolean; error?: string }> { +): Promise { try { logValidationRequest(providerLabel, 'POST', url, headers); const response = await proxyAwareFetch(url, { @@ -224,7 +292,7 @@ async function validateGoogleQueryKey( providerType: string, apiKey: string, baseUrl?: string, -): Promise<{ valid: boolean; error?: string }> { +): Promise { const base = normalizeBaseUrl(baseUrl || 'https://generativelanguage.googleapis.com/v1beta'); const url = `${base}/models?pageSize=1&key=${encodeURIComponent(apiKey)}`; return await performProviderValidationRequest(providerType, url, {}); @@ -234,7 +302,7 @@ async function validateAnthropicHeaderKey( providerType: string, apiKey: string, baseUrl?: string, -): Promise<{ valid: boolean; error?: string }> { +): Promise { const rawBase = normalizeBaseUrl(baseUrl || 'https://api.anthropic.com/v1'); const base = rawBase.endsWith('/v1') ? rawBase : `${rawBase}/v1`; const url = `${base}/models?limit=1`; @@ -246,7 +314,12 @@ async function validateAnthropicHeaderKey( 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')) { + if ( + modelsResult.status === 404 || + modelsResult.status === 400 || + 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`, ); @@ -260,7 +333,7 @@ async function validateAnthropicHeaderKey( async function validateOpenRouterKey( providerType: string, apiKey: string, -): Promise<{ valid: boolean; error?: string }> { +): Promise { const url = 'https://openrouter.ai/api/v1/auth/key'; const headers = { Authorization: `Bearer ${apiKey}` }; return await performProviderValidationRequest(providerType, url, headers); @@ -270,7 +343,7 @@ export async function validateApiKeyWithProvider( providerType: string, apiKey: string, options?: { baseUrl?: string; apiProtocol?: string }, -): Promise<{ valid: boolean; error?: string }> { +): Promise { const profile = getValidationProfile(providerType, options); const resolvedBaseUrl = options?.baseUrl || getProviderConfig(providerType)?.baseUrl; @@ -285,8 +358,20 @@ export async function validateApiKeyWithProvider( try { switch (profile) { - case 'openai-compatible': - return await validateOpenAiCompatibleKey(providerType, trimmedKey, resolvedBaseUrl); + case 'openai-completions': + return await validateOpenAiCompatibleKey( + providerType, + trimmedKey, + 'openai-completions', + resolvedBaseUrl, + ); + case 'openai-responses': + return await validateOpenAiCompatibleKey( + providerType, + trimmedKey, + 'openai-responses', + resolvedBaseUrl, + ); case 'google-query-key': return await validateGoogleQueryKey(providerType, trimmedKey, resolvedBaseUrl); case 'anthropic-header': diff --git a/src/components/settings/ProvidersSettings.tsx b/src/components/settings/ProvidersSettings.tsx index 1719d590e..602eb1066 100644 --- a/src/components/settings/ProvidersSettings.tsx +++ b/src/components/settings/ProvidersSettings.tsx @@ -59,6 +59,15 @@ function normalizeFallbackProviderIds(ids?: string[]): string[] { return Array.from(new Set((ids ?? []).filter(Boolean))); } +function getProtocolBaseUrlPlaceholder( + apiProtocol: ProviderAccount['apiProtocol'], +): string { + if (apiProtocol === 'anthropic-messages') { + return 'https://api.example.com/anthropic'; + } + return 'https://api.example.com/v1'; +} + function fallbackProviderIdsEqual(a?: string[], b?: string[]): boolean { const left = normalizeFallbackProviderIds(a).sort(); const right = normalizeFallbackProviderIds(b).sort(); @@ -271,7 +280,7 @@ interface ProviderCardProps { onSaveEdits: (payload: { newApiKey?: string; updates?: Partial }) => Promise; onValidateKey: ( key: string, - options?: { baseUrl?: string; apiProtocol?: string } + options?: { baseUrl?: string; apiProtocol?: ProviderAccount['apiProtocol'] } ) => Promise<{ valid: boolean; error?: string }>; devModeUnlocked: boolean; } @@ -537,7 +546,7 @@ function ProviderCard({ setBaseUrl(e.target.value)} - placeholder={apiProtocol === 'anthropic-messages' ? "https://api.example.com/anthropic" : "https://api.example.com/v1"} + placeholder={getProtocolBaseUrlPlaceholder(apiProtocol)} className={currentInputClasses} /> @@ -562,7 +571,14 @@ function ProviderCard({ 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-card 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')} + {t('aiProviders.protocols.openaiCompletions', 'OpenAI Completions')} + + - + + diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index d376cd63b..4244de91f 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -79,6 +79,8 @@ }, "protocols": { "openai": "OpenAI Compatible", + "openaiCompletions": "OpenAI Completions", + "openaiResponses": "OpenAI Responses", "anthropic": "Anthropic Compatible" }, "toast": { @@ -245,4 +247,4 @@ "docs": "Website", "github": "GitHub" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/en/setup.json b/src/i18n/locales/en/setup.json index c280e7cb9..d42a003c4 100644 --- a/src/i18n/locales/en/setup.json +++ b/src/i18n/locales/en/setup.json @@ -67,6 +67,12 @@ "baseUrl": "Base URL", "modelId": "Model ID", "modelIdDesc": "The model identifier from your provider (e.g. deepseek-ai/DeepSeek-V3)", + "protocol": "Protocol", + "protocols": { + "openaiCompletions": "OpenAI Completions", + "openaiResponses": "OpenAI Responses", + "anthropic": "Anthropic Compatible" + }, "apiKey": "API Key", "save": "Save", "validateSave": "Validate & Save", @@ -138,4 +144,4 @@ "description": "Shell command execution" } } -} \ No newline at end of file +} diff --git a/src/i18n/locales/ja/settings.json b/src/i18n/locales/ja/settings.json index e2a5cdeff..46d2b9607 100644 --- a/src/i18n/locales/ja/settings.json +++ b/src/i18n/locales/ja/settings.json @@ -52,6 +52,7 @@ "replaceApiKeyHelp": "現在保存されている API キーをそのまま使う場合は、この欄を空のままにしてください。", "baseUrl": "ベース URL", "modelId": "モデル ID", + "protocol": "プロトコル", "fallbackModels": "フォールバックモデル", "fallbackProviders": "別プロバイダーへのフォールバック", "fallbackModelIds": "同一プロバイダーのフォールバックモデル ID", @@ -76,6 +77,12 @@ "editKey": "API キーを編集", "delete": "プロバイダーを削除" }, + "protocols": { + "openai": "OpenAI 互換", + "openaiCompletions": "OpenAI Completions", + "openaiResponses": "OpenAI Responses", + "anthropic": "Anthropic 互換" + }, "toast": { "added": "プロバイダーが正常に追加されました", "failedAdd": "プロバイダーの追加に失敗しました", @@ -237,4 +244,4 @@ "docs": "公式サイト", "github": "GitHub" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/ja/setup.json b/src/i18n/locales/ja/setup.json index 636410092..2a597b44c 100644 --- a/src/i18n/locales/ja/setup.json +++ b/src/i18n/locales/ja/setup.json @@ -67,6 +67,12 @@ "baseUrl": "ベース URL", "modelId": "モデル ID", "modelIdDesc": "プロバイダーのモデル識別子(例:deepseek-ai/DeepSeek-V3)", + "protocol": "プロトコル", + "protocols": { + "openaiCompletions": "OpenAI Completions", + "openaiResponses": "OpenAI Responses", + "anthropic": "Anthropic 互換" + }, "apiKey": "API キー", "save": "保存", "validateSave": "検証して保存", @@ -138,4 +144,4 @@ "description": "シェルコマンドの実行" } } -} \ No newline at end of file +} diff --git a/src/i18n/locales/zh/settings.json b/src/i18n/locales/zh/settings.json index 6648e5a89..ce40901db 100644 --- a/src/i18n/locales/zh/settings.json +++ b/src/i18n/locales/zh/settings.json @@ -79,6 +79,8 @@ }, "protocols": { "openai": "OpenAI 兼容", + "openaiCompletions": "OpenAI Completions", + "openaiResponses": "OpenAI Responses", "anthropic": "Anthropic 兼容" }, "toast": { @@ -245,4 +247,4 @@ "docs": "官网", "github": "GitHub" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/zh/setup.json b/src/i18n/locales/zh/setup.json index 2afa252bd..039f39fad 100644 --- a/src/i18n/locales/zh/setup.json +++ b/src/i18n/locales/zh/setup.json @@ -67,6 +67,12 @@ "baseUrl": "基础 URL", "modelId": "模型 ID", "modelIdDesc": "提供商的模型标识符(例如 deepseek-ai/DeepSeek-V3)", + "protocol": "协议", + "protocols": { + "openaiCompletions": "OpenAI Completions", + "openaiResponses": "OpenAI Responses", + "anthropic": "Anthropic 兼容" + }, "apiKey": "API 密钥", "save": "保存", "validateSave": "验证并保存", @@ -138,4 +144,4 @@ "description": "Shell 命令执行" } } -} \ No newline at end of file +} diff --git a/src/pages/Setup/index.tsx b/src/pages/Setup/index.tsx index b504356ee..03439fba3 100644 --- a/src/pages/Setup/index.tsx +++ b/src/pages/Setup/index.tsx @@ -114,6 +114,15 @@ import clawxIcon from '@/assets/logo.svg'; // Use the shared provider registry for setup providers const providers = SETUP_PROVIDERS; +function getProtocolBaseUrlPlaceholder( + apiProtocol: ProviderAccount['apiProtocol'], +): string { + if (apiProtocol === 'anthropic-messages') { + return 'https://api.example.com/anthropic'; + } + return 'https://api.example.com/v1'; +} + // NOTE: Channel types moved to Settings > Channels page // NOTE: Skill bundles moved to Settings > Skills page - auto-install essential skills during setup @@ -712,6 +721,7 @@ function ProviderContent({ const [selectedAccountId, setSelectedAccountId] = useState(null); const [baseUrl, setBaseUrl] = useState(''); const [modelId, setModelId] = useState(''); + const [apiProtocol, setApiProtocol] = useState('openai-completions'); const [providerMenuOpen, setProviderMenuOpen] = useState(false); const providerMenuRef = useRef(null); @@ -905,6 +915,7 @@ function ProviderContent({ let cancelled = false; (async () => { if (!selectedProvider) return; + setApiProtocol('openai-completions'); try { const snapshot = await fetchProviderSnapshot(); const statusMap = new Map(snapshot.statuses.map((status) => [status.id, status])); @@ -917,7 +928,7 @@ function ProviderContent({ const accountIdForLoad = preferredAccount?.id || selectedProvider; setSelectedAccountId(preferredAccount?.id || null); - const savedProvider = await hostApiFetch<{ baseUrl?: string; model?: string } | null>( + const savedProvider = await hostApiFetch<{ baseUrl?: string; model?: string; apiProtocol?: ProviderAccount['apiProtocol'] } | null>( `/api/providers/${encodeURIComponent(accountIdForLoad)}`, ); const storedKey = (await hostApiFetch<{ apiKey: string | null }>( @@ -929,6 +940,7 @@ function ProviderContent({ const info = providers.find((p) => p.id === selectedProvider); setBaseUrl(savedProvider?.baseUrl || info?.defaultBaseUrl || ''); setModelId(savedProvider?.model || info?.defaultModelId || ''); + setApiProtocol(savedProvider?.apiProtocol || 'openai-completions'); } } catch (error) { if (!cancelled) { @@ -1002,7 +1014,12 @@ function ProviderContent({ 'provider:validateKey', selectedAccountId || selectedProvider, apiKey, - { baseUrl: baseUrl.trim() || undefined } + { + baseUrl: baseUrl.trim() || undefined, + apiProtocol: (selectedProvider === 'custom' || selectedProvider === 'ollama') + ? apiProtocol + : undefined, + } ) as { valid: boolean; error?: string }; setKeyValid(result.valid); @@ -1039,6 +1056,9 @@ function ProviderContent({ ? 'local' : 'api_key', baseUrl: baseUrl.trim() || undefined, + apiProtocol: (selectedProvider === 'custom' || selectedProvider === 'ollama') + ? apiProtocol + : undefined, model: effectiveModelId, enabled: true, isDefault: false, @@ -1056,6 +1076,7 @@ function ProviderContent({ label: accountPayload.label, authMode: accountPayload.authMode, baseUrl: accountPayload.baseUrl, + apiProtocol: accountPayload.apiProtocol, model: accountPayload.model, enabled: accountPayload.enabled, }, @@ -1212,7 +1233,7 @@ function ProviderContent({ { setBaseUrl(e.target.value); @@ -1246,6 +1267,59 @@ function ProviderContent({ )} + {selectedProvider === 'custom' && ( +
+ +
+ + + +
+
+ )} + {/* Auth mode toggle for providers supporting both */} {isOAuth && supportsApiKey && (
diff --git a/src/stores/providers.ts b/src/stores/providers.ts index 7d495a655..3239c3d19 100644 --- a/src/stores/providers.ts +++ b/src/stores/providers.ts @@ -38,7 +38,7 @@ interface ProviderState { validateAccountApiKey: ( accountId: string, apiKey: string, - options?: { baseUrl?: string } + options?: { baseUrl?: string; apiProtocol?: ProviderAccount['apiProtocol'] } ) => Promise<{ valid: boolean; error?: string }>; getAccountApiKey: (accountId: string) => Promise; @@ -62,7 +62,7 @@ interface ProviderState { validateApiKey: ( providerId: string, apiKey: string, - options?: { baseUrl?: string } + options?: { baseUrl?: string; apiProtocol?: ProviderAccount['apiProtocol'] } ) => Promise<{ valid: boolean; error?: string }>; getApiKey: (providerId: string) => Promise; } diff --git a/tests/unit/provider-validation.test.ts b/tests/unit/provider-validation.test.ts index 98ebf4ff1..cce9c86ac 100644 --- a/tests/unit/provider-validation.test.ts +++ b/tests/unit/provider-validation.test.ts @@ -22,7 +22,7 @@ describe('validateApiKeyWithProvider', () => { const result = await validateApiKeyWithProvider('minimax-portal-cn', 'sk-cn-test'); - expect(result).toEqual({ valid: true }); + expect(result).toMatchObject({ valid: true }); expect(proxyAwareFetch).toHaveBeenCalledWith( 'https://api.minimaxi.com/anthropic/v1/models?limit=1', expect.objectContaining({ @@ -39,7 +39,7 @@ describe('validateApiKeyWithProvider', () => { const result = await validateApiKeyWithProvider('openai', 'sk-openai-test'); - expect(result).toEqual({ valid: true }); + expect(result).toMatchObject({ valid: true }); expect(proxyAwareFetch).toHaveBeenCalledWith( 'https://api.openai.com/v1/models?limit=1', expect.objectContaining({ @@ -49,4 +49,109 @@ describe('validateApiKeyWithProvider', () => { }) ); }); + + it('falls back to /responses for openai-responses when /models is unavailable', async () => { + proxyAwareFetch + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: 'Not Found' } }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: 'Unknown model' } }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + ); + + const { validateApiKeyWithProvider } = await import('@electron/services/providers/provider-validation'); + const result = await validateApiKeyWithProvider('custom', 'sk-response-test', { + baseUrl: 'https://responses.example.com/v1', + apiProtocol: 'openai-responses', + }); + + expect(result).toMatchObject({ valid: true }); + expect(proxyAwareFetch).toHaveBeenNthCalledWith( + 1, + 'https://responses.example.com/v1/models?limit=1', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer sk-response-test', + }), + }) + ); + expect(proxyAwareFetch).toHaveBeenNthCalledWith( + 2, + 'https://responses.example.com/v1/responses', + expect.objectContaining({ + method: 'POST', + }) + ); + }); + + it('falls back to /chat/completions for openai-completions when /models is unavailable', async () => { + proxyAwareFetch + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: 'Not Found' } }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: 'Unknown model' } }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + ); + + const { validateApiKeyWithProvider } = await import('@electron/services/providers/provider-validation'); + const result = await validateApiKeyWithProvider('custom', 'sk-chat-test', { + baseUrl: 'https://chat.example.com/v1', + apiProtocol: 'openai-completions', + }); + + expect(result).toMatchObject({ valid: true }); + expect(proxyAwareFetch).toHaveBeenNthCalledWith( + 2, + 'https://chat.example.com/v1/chat/completions', + expect.objectContaining({ + method: 'POST', + }) + ); + }); + + it('does not duplicate endpoint suffix when baseUrl already points to /responses', async () => { + proxyAwareFetch + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: 'Not Found' } }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: 'Unknown model' } }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + ); + + const { validateApiKeyWithProvider } = await import('@electron/services/providers/provider-validation'); + const result = await validateApiKeyWithProvider('custom', 'sk-endpoint-test', { + baseUrl: 'https://openrouter.ai/api/v1/responses', + apiProtocol: 'openai-responses', + }); + + expect(result).toMatchObject({ valid: true }); + expect(proxyAwareFetch).toHaveBeenNthCalledWith( + 1, + 'https://openrouter.ai/api/v1/models?limit=1', + expect.anything(), + ); + expect(proxyAwareFetch).toHaveBeenNthCalledWith( + 2, + 'https://openrouter.ai/api/v1/responses', + expect.anything(), + ); + }); });