From 880995af193721bdef4545e5312fb4cfb8253208 Mon Sep 17 00:00:00 2001 From: paisley <8197966+su8su@users.noreply.github.com> Date: Tue, 10 Mar 2026 19:22:33 +0800 Subject: [PATCH] fix minimax cn api key 401 error (#396) --- .../providers/provider-runtime-sync.ts | 36 ++++++++++++- .../services/providers/provider-validation.ts | 31 ++++++----- tests/unit/provider-validation.test.ts | 52 +++++++++++++++++++ 3 files changed, 104 insertions(+), 15 deletions(-) create mode 100644 tests/unit/provider-validation.test.ts diff --git a/electron/services/providers/provider-runtime-sync.ts b/electron/services/providers/provider-runtime-sync.ts index 7f8973635..87fe38ff8 100644 --- a/electron/services/providers/provider-runtime-sync.ts +++ b/electron/services/providers/provider-runtime-sync.ts @@ -24,6 +24,22 @@ type RuntimeProviderSyncContext = { api: string; }; +function normalizeProviderBaseUrl(config: ProviderConfig, baseUrl?: string): string | undefined { + if (!baseUrl) { + return undefined; + } + + if (config.type === 'minimax-portal' || config.type === 'minimax-portal-cn') { + return baseUrl.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic'; + } + + return baseUrl; +} + +function shouldUseExplicitDefaultOverride(config: ProviderConfig, runtimeProviderKey: string): boolean { + return Boolean(config.baseUrl || config.apiProtocol || runtimeProviderKey !== config.type); +} + export function getOpenClawProviderKey(type: string, providerId: string): string { if (type === 'custom' || type === 'ollama') { const suffix = providerId.replace(/-/g, '').slice(0, 8); @@ -233,7 +249,7 @@ async function syncRuntimeProviderConfig( context: RuntimeProviderSyncContext, ): Promise { await syncProviderConfigToOpenClaw(context.runtimeProviderKey, config.model, { - baseUrl: config.baseUrl || context.meta?.baseUrl, + baseUrl: normalizeProviderBaseUrl(config, config.baseUrl || context.meta?.baseUrl), api: context.api, apiKeyEnv: context.meta?.apiKeyEnv, headers: context.meta?.headers, @@ -311,7 +327,16 @@ export async function syncUpdatedProviderToRuntime( if (defaultProviderId === config.id) { const modelOverride = config.model ? `${ock}/${config.model}` : undefined; if (config.type !== 'custom') { - await setOpenClawDefaultModel(ock, modelOverride, fallbackModels); + if (shouldUseExplicitDefaultOverride(config, ock)) { + await setOpenClawDefaultModelWithOverride(ock, modelOverride, { + baseUrl: normalizeProviderBaseUrl(config, config.baseUrl || context.meta?.baseUrl), + api: context.api, + apiKeyEnv: context.meta?.apiKeyEnv, + headers: context.meta?.headers, + }, fallbackModels); + } else { + await setOpenClawDefaultModel(ock, modelOverride, fallbackModels); + } } else { await setOpenClawDefaultModelWithOverride(ock, modelOverride, { baseUrl: config.baseUrl, @@ -384,6 +409,13 @@ export async function syncDefaultProviderToRuntime( baseUrl: provider.baseUrl, api: provider.apiProtocol || 'openai-completions', }, fallbackModels); + } else if (shouldUseExplicitDefaultOverride(provider, ock)) { + await setOpenClawDefaultModelWithOverride(ock, modelOverride, { + baseUrl: normalizeProviderBaseUrl(provider, provider.baseUrl || getProviderConfig(provider.type)?.baseUrl), + api: provider.apiProtocol || getProviderConfig(provider.type)?.api, + apiKeyEnv: getProviderConfig(provider.type)?.apiKeyEnv, + headers: getProviderConfig(provider.type)?.headers, + }, fallbackModels); } else { await setOpenClawDefaultModel(ock, modelOverride, fallbackModels); } diff --git a/electron/services/providers/provider-validation.ts b/electron/services/providers/provider-validation.ts index 46b7c2e99..59efc5563 100644 --- a/electron/services/providers/provider-validation.ts +++ b/electron/services/providers/provider-validation.ts @@ -1,4 +1,5 @@ import { proxyAwareFetch } from '../../utils/proxy-fetch'; +import { getProviderConfig } from '../../utils/provider-registry'; type ValidationProfile = | 'openai-compatible' @@ -59,7 +60,18 @@ function logValidationRequest( ); } -function getValidationProfile(providerType: string): ValidationProfile { +function getValidationProfile( + providerType: string, + options?: { apiProtocol?: string } +): ValidationProfile { + const providerApi = options?.apiProtocol || getProviderConfig(providerType)?.api; + if (providerApi === 'anthropic-messages') { + return 'anthropic-header'; + } + if (providerApi === 'openai-completions' || providerApi === 'openai-responses') { + return 'openai-compatible'; + } + switch (providerType) { case 'anthropic': return 'anthropic-header'; @@ -259,15 +271,8 @@ export async function validateApiKeyWithProvider( apiKey: string, options?: { baseUrl?: string; apiProtocol?: string }, ): Promise<{ valid: boolean; error?: string }> { - let profile = getValidationProfile(providerType); - - if (providerType === 'custom' && options?.apiProtocol) { - if (options.apiProtocol === 'anthropic-messages') { - profile = 'anthropic-header'; - } else { - profile = 'openai-compatible'; - } - } + const profile = getValidationProfile(providerType, options); + const resolvedBaseUrl = options?.baseUrl || getProviderConfig(providerType)?.baseUrl; if (profile === 'none') { return { valid: true }; @@ -281,11 +286,11 @@ export async function validateApiKeyWithProvider( try { switch (profile) { case 'openai-compatible': - return await validateOpenAiCompatibleKey(providerType, trimmedKey, options?.baseUrl); + return await validateOpenAiCompatibleKey(providerType, trimmedKey, resolvedBaseUrl); case 'google-query-key': - return await validateGoogleQueryKey(providerType, trimmedKey, options?.baseUrl); + return await validateGoogleQueryKey(providerType, trimmedKey, resolvedBaseUrl); case 'anthropic-header': - return await validateAnthropicHeaderKey(providerType, trimmedKey, options?.baseUrl); + return await validateAnthropicHeaderKey(providerType, trimmedKey, resolvedBaseUrl); case 'openrouter': return await validateOpenRouterKey(providerType, trimmedKey); default: diff --git a/tests/unit/provider-validation.test.ts b/tests/unit/provider-validation.test.ts new file mode 100644 index 000000000..98ebf4ff1 --- /dev/null +++ b/tests/unit/provider-validation.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const proxyAwareFetch = vi.fn(); + +vi.mock('@electron/utils/proxy-fetch', () => ({ + proxyAwareFetch, +})); + +describe('validateApiKeyWithProvider', () => { + beforeEach(() => { + proxyAwareFetch.mockReset(); + proxyAwareFetch.mockResolvedValue( + new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + }); + + it('validates MiniMax CN keys with Anthropic headers', async () => { + const { validateApiKeyWithProvider } = await import('@electron/services/providers/provider-validation'); + + const result = await validateApiKeyWithProvider('minimax-portal-cn', 'sk-cn-test'); + + expect(result).toEqual({ valid: true }); + expect(proxyAwareFetch).toHaveBeenCalledWith( + 'https://api.minimaxi.com/anthropic/v1/models?limit=1', + expect.objectContaining({ + headers: expect.objectContaining({ + 'x-api-key': 'sk-cn-test', + 'anthropic-version': '2023-06-01', + }), + }) + ); + }); + + it('still validates OpenAI-compatible providers with bearer auth', async () => { + const { validateApiKeyWithProvider } = await import('@electron/services/providers/provider-validation'); + + const result = await validateApiKeyWithProvider('openai', 'sk-openai-test'); + + expect(result).toEqual({ valid: true }); + expect(proxyAwareFetch).toHaveBeenCalledWith( + 'https://api.openai.com/v1/models?limit=1', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer sk-openai-test', + }), + }) + ); + }); +});