From a43460f28d1c4afe26055bc6c98dc82e80a5f6e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sun, 3 May 2026 17:30:59 +0800 Subject: [PATCH] fix: honor provider auth env strategy Provider presets now carry an explicit auth strategy so Anthropic-compatible services can choose the right Claude Code environment shape instead of relying on one global variable. The desktop editor exposes the same strategy, keeps settings.json previews in sync, and applies the strategy to connection tests as well as runtime env sync. Constraint: Third-party Anthropic-compatible providers do not agree on ANTHROPIC_API_KEY versus ANTHROPIC_AUTH_TOKEN. Rejected: Force all third-party providers onto one env var | breaks providers that require bearer auth, dual variables, or dummy local auth. Confidence: high Scope-risk: moderate Directive: Add new provider presets with explicit authStrategy; do not infer auth behavior from model names alone. Tested: bun test src/server/__tests__/providers.test.ts src/server/__tests__/providers-real.test.ts src/server/__tests__/provider-presets.test.ts src/server/__tests__/conversation-service.test.ts Tested: cd desktop && bun run lint Tested: ALLOW_CLI_CORE_CHANGE=1 bun run quality:pr Tested: bun run quality:gate --mode baseline --allow-live --provider-model minimax:main:minimax-main Tested: agent-browser provider edit flow on http://127.0.0.1:19811 Not-tested: Live successful calls for every third-party provider with real credentials. --- desktop/src/api/providers.ts | 2 +- desktop/src/i18n/locales/en.ts | 11 ++ desktop/src/i18n/locales/zh.ts | 11 ++ desktop/src/pages/Settings.tsx | 175 +++++++++++++++++- desktop/src/stores/providerStore.ts | 2 +- desktop/src/types/provider.ts | 11 ++ desktop/src/types/providerPreset.ts | 3 +- .../__tests__/conversation-service.test.ts | 2 + src/server/__tests__/provider-presets.test.ts | 9 + src/server/__tests__/providers-real.test.ts | 14 +- src/server/__tests__/providers.test.ts | 160 +++++++++++++++- src/server/api/providers.ts | 2 +- src/server/config/providerPresets.json | 11 +- src/server/config/providerPresets.ts | 3 +- src/server/services/providerService.ts | 89 ++++++++- src/server/types/provider.ts | 13 ++ 16 files changed, 488 insertions(+), 30 deletions(-) diff --git a/desktop/src/api/providers.ts b/desktop/src/api/providers.ts index e19ac1b7..0a248f95 100644 --- a/desktop/src/api/providers.ts +++ b/desktop/src/api/providers.ts @@ -61,7 +61,7 @@ export const providersApi = { return api.post<{ ok: true }>('/api/providers/official') }, - test(id: string, overrides?: { baseUrl?: string; modelId?: string; apiFormat?: string }) { + test(id: string, overrides?: { baseUrl?: string; modelId?: string; apiFormat?: string; authStrategy?: string }) { return api.post(`/api/providers/${id}/test`, overrides) }, diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 61ab89d3..af6eed67 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -218,6 +218,17 @@ export const en = { 'settings.providers.apiFormatOpenaiChat': 'OpenAI Chat Completions (proxy)', 'settings.providers.apiFormatOpenaiResponses': 'OpenAI Responses API (proxy)', 'settings.providers.proxyHint': 'Requests will be translated via the local proxy', + 'settings.providers.authStrategy': 'Auth Variable', + 'settings.providers.authStrategyApiKey': 'API Key (ANTHROPIC_API_KEY)', + 'settings.providers.authStrategyApiKeyDesc': 'Direct Anthropic API access using x-api-key.', + 'settings.providers.authStrategyAuthToken': 'Bearer Token (ANTHROPIC_AUTH_TOKEN)', + 'settings.providers.authStrategyAuthTokenDesc': 'Most third-party Anthropic-compatible services using Authorization Bearer.', + 'settings.providers.authStrategyAuthTokenEmptyApiKey': 'Bearer + Empty API_KEY', + 'settings.providers.authStrategyAuthTokenEmptyApiKeyDesc': 'OpenRouter/Ollama-style setups that must avoid falling back to an Anthropic API key.', + 'settings.providers.authStrategyDualSameToken': 'Write token to both variables', + 'settings.providers.authStrategyDualSameTokenDesc': 'Services such as Hugging Face Router that check both variables.', + 'settings.providers.authStrategyDualDummy': 'Write dummy to both variables', + 'settings.providers.authStrategyDualDummyDesc': 'Local vLLM-compatible services that only need placeholder auth values.', // Settings > Permissions 'settings.permissions.title': 'Permission Mode', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 42bf3744..b8104073 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -220,6 +220,17 @@ export const zh: Record = { 'settings.providers.apiFormatOpenaiChat': 'OpenAI Chat Completions (代理转换)', 'settings.providers.apiFormatOpenaiResponses': 'OpenAI Responses API (代理转换)', 'settings.providers.proxyHint': '请求将通过本地代理转换协议格式', + 'settings.providers.authStrategy': '认证变量', + 'settings.providers.authStrategyApiKey': 'API Key (ANTHROPIC_API_KEY)', + 'settings.providers.authStrategyApiKeyDesc': '直连 Anthropic 官方 API,发送 x-api-key。', + 'settings.providers.authStrategyAuthToken': 'Bearer Token (ANTHROPIC_AUTH_TOKEN)', + 'settings.providers.authStrategyAuthTokenDesc': '大多数第三方 Anthropic 兼容服务,发送 Authorization Bearer。', + 'settings.providers.authStrategyAuthTokenEmptyApiKey': 'Bearer + 清空 API_KEY', + 'settings.providers.authStrategyAuthTokenEmptyApiKeyDesc': 'OpenRouter/Ollama 等场景,避免回退到 Anthropic API key。', + 'settings.providers.authStrategyDualSameToken': '两个变量写同一个 Token', + 'settings.providers.authStrategyDualSameTokenDesc': 'Hugging Face Router 等同时检查两个变量的服务。', + 'settings.providers.authStrategyDualDummy': '两个变量写 dummy', + 'settings.providers.authStrategyDualDummyDesc': 'vLLM 等本地兼容服务,只需要占位认证值。', // Settings > Permissions 'settings.permissions.title': '权限模式', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 69ba2308..641ec6a7 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -9,7 +9,7 @@ import { Button } from '../components/shared/Button' import { Dropdown } from '../components/shared/Dropdown' import type { PermissionMode, EffortLevel, ThemeMode, WebSearchMode } from '../types/settings' import type { Locale } from '../i18n' -import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat } from '../types/provider' +import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat, ProviderAuthStrategy } from '../types/provider' import type { ProviderPreset } from '../types/providerPreset' import { AdapterSettings } from './AdapterSettings' import { useAgentStore } from '../stores/agentStore' @@ -349,6 +349,8 @@ const MODEL_CONTEXT_WINDOWS_ENV_KEY = 'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS' const MODEL_CONTEXT_WINDOW_MIN = 16000 const MODEL_CONTEXT_WINDOW_MAX = 10000000 const MODEL_SLOTS = ['main', 'haiku', 'sonnet', 'opus'] as const +const DEFAULT_PROVIDER_AUTH_STRATEGY: ProviderAuthStrategy = 'auth_token' +const AUTH_ENV_KEYS = new Set(['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN']) type ModelSlot = typeof MODEL_SLOTS[number] type ModelContextInputs = Record @@ -360,6 +362,58 @@ function getPresetAutoCompactWindow(preset: ProviderPreset): string { return preset.defaultEnv?.[AUTO_COMPACT_WINDOW_ENV_KEY] ?? '' } +function getPresetAuthStrategy(preset: ProviderPreset): ProviderAuthStrategy { + return preset.authStrategy ?? DEFAULT_PROVIDER_AUTH_STRATEGY +} + +function omitAuthEnv(env: Record | undefined): Record { + if (!env) return {} + return Object.fromEntries( + Object.entries(env).filter(([key]) => !AUTH_ENV_KEYS.has(key.toUpperCase())), + ) +} + +function getProviderAuthValue(apiKey: string, preset: ProviderPreset): string { + return apiKey || preset.defaultEnv?.ANTHROPIC_AUTH_TOKEN || preset.defaultEnv?.ANTHROPIC_API_KEY || (preset.needsApiKey ? '(your API key)' : '') +} + +function buildSettingsJsonAuthEnv( + apiFormat: ApiFormat, + authStrategy: ProviderAuthStrategy, + apiKey: string, + preset: ProviderPreset, +): Record { + if (apiFormat !== 'anthropic') { + return { ANTHROPIC_API_KEY: 'proxy-managed' } + } + + const value = getProviderAuthValue(apiKey, preset) + switch (authStrategy) { + case 'api_key': + return value ? { ANTHROPIC_API_KEY: value } : {} + case 'auth_token': + return value ? { ANTHROPIC_AUTH_TOKEN: value } : {} + case 'auth_token_empty_api_key': + return { + ANTHROPIC_API_KEY: '', + ...(value ? { ANTHROPIC_AUTH_TOKEN: value } : {}), + } + case 'dual_same_token': + return value ? { ANTHROPIC_API_KEY: value, ANTHROPIC_AUTH_TOKEN: value } : {} + case 'dual_dummy': + return { ANTHROPIC_API_KEY: 'dummy', ANTHROPIC_AUTH_TOKEN: 'dummy' } + } +} + +function inferAuthStrategyFromEnv(env: Record): ProviderAuthStrategy | null { + if (env.ANTHROPIC_API_KEY === 'dummy' && env.ANTHROPIC_AUTH_TOKEN === 'dummy') return 'dual_dummy' + if (env.ANTHROPIC_API_KEY === '' && env.ANTHROPIC_AUTH_TOKEN) return 'auth_token_empty_api_key' + if (env.ANTHROPIC_API_KEY && env.ANTHROPIC_AUTH_TOKEN && env.ANTHROPIC_API_KEY === env.ANTHROPIC_AUTH_TOKEN) return 'dual_same_token' + if (env.ANTHROPIC_AUTH_TOKEN) return 'auth_token' + if (env.ANTHROPIC_API_KEY) return 'api_key' + return null +} + function parseAutoCompactWindowInput(value: string): number | undefined { const trimmed = value.trim() if (!trimmed) return undefined @@ -485,12 +539,38 @@ function updateSettingsJsonModels(raw: string, models: ModelMapping): string { } } +function updateSettingsJsonProviderConnection( + raw: string, + apiFormat: ApiFormat, + authStrategy: ProviderAuthStrategy, + apiKey: string, + preset: ProviderPreset, + baseUrl: string, +): string { + try { + const parsed = JSON.parse(raw || '{}') as { env?: Record } + const existingEnv = parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env) + ? parsed.env + : {} + const env = { ...existingEnv } + delete env.ANTHROPIC_API_KEY + delete env.ANTHROPIC_AUTH_TOKEN + env.ANTHROPIC_BASE_URL = apiFormat !== 'anthropic' ? 'http://127.0.0.1:3456/proxy' : baseUrl + Object.assign(env, buildSettingsJsonAuthEnv(apiFormat, authStrategy, apiKey, preset)) + parsed.env = env + return JSON.stringify(parsed, null, 2) + } catch { + return raw + } +} + function buildFallbackPreset(provider?: SavedProvider): ProviderPreset { return { id: provider?.presetId ?? 'custom', name: provider?.name ?? 'Custom', baseUrl: provider?.baseUrl ?? '', apiFormat: provider?.apiFormat ?? 'anthropic', + authStrategy: provider?.authStrategy, defaultModels: provider?.models ?? { main: '', haiku: '', sonnet: '', opus: '' }, modelContextWindows: provider?.modelContextWindows, defaultEnv: provider?.autoCompactWindow !== undefined @@ -537,6 +617,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF const [name, setName] = useState(provider?.name ?? initialPreset.name) const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? initialPreset.baseUrl) const [apiFormat, setApiFormat] = useState(provider?.apiFormat ?? initialPreset.apiFormat ?? 'anthropic') + const [authStrategy, setAuthStrategy] = useState(provider?.authStrategy ?? getPresetAuthStrategy(initialPreset)) const [apiKey, setApiKey] = useState(provider?.apiKey ?? '') const [showApiKey, setShowApiKey] = useState(false) const [notes, setNotes] = useState(provider?.notes ?? '') @@ -576,15 +657,13 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF skipWebFetchPreflight: settings.skipWebFetchPreflight ?? true, env: { ...cleanedEnv, - ...(selectedPreset.defaultEnv ?? {}), + ...omitAuthEnv(selectedPreset.defaultEnv), ...(autoCompactWindowEnv ? { [AUTO_COMPACT_WINDOW_ENV_KEY]: autoCompactWindowEnv } : {}), ...(Object.keys(modelContextWindows).length > 0 ? { [MODEL_CONTEXT_WINDOWS_ENV_KEY]: JSON.stringify(modelContextWindows) } : {}), ANTHROPIC_BASE_URL: needsProxy ? 'http://127.0.0.1:3456/proxy' : baseUrl, - ANTHROPIC_AUTH_TOKEN: needsProxy - ? 'proxy-managed' - : (apiKey || selectedPreset.defaultEnv?.ANTHROPIC_AUTH_TOKEN || (selectedPreset.needsApiKey ? '(your API key)' : '')), + ...buildSettingsJsonAuthEnv(apiFormat, authStrategy, apiKey, selectedPreset), ANTHROPIC_MODEL: models.main, ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku, ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet, @@ -604,6 +683,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF setName(preset.name) setBaseUrl(preset.baseUrl) setApiFormat(preset.apiFormat ?? 'anthropic') + setAuthStrategy(getPresetAuthStrategy(preset)) setModels({ ...preset.defaultModels }) setModelContextInputs(getModelContextInputs(preset.defaultModels, preset)) setAutoCompactWindow(getPresetAutoCompactWindow(preset)) @@ -639,6 +719,39 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF }, ] const selectedApiFormatLabel = apiFormatItems.find((item) => item.value === apiFormat)?.label ?? t('settings.providers.apiFormatAnthropic') + const authStrategyItems = [ + { + value: 'auth_token' as const, + label: t('settings.providers.authStrategyAuthToken'), + description: t('settings.providers.authStrategyAuthTokenDesc'), + icon: key, + }, + { + value: 'auth_token_empty_api_key' as const, + label: t('settings.providers.authStrategyAuthTokenEmptyApiKey'), + description: t('settings.providers.authStrategyAuthTokenEmptyApiKeyDesc'), + icon: key_off, + }, + { + value: 'api_key' as const, + label: t('settings.providers.authStrategyApiKey'), + description: t('settings.providers.authStrategyApiKeyDesc'), + icon: vpn_key, + }, + { + value: 'dual_same_token' as const, + label: t('settings.providers.authStrategyDualSameToken'), + description: t('settings.providers.authStrategyDualSameTokenDesc'), + icon: sync_alt, + }, + { + value: 'dual_dummy' as const, + label: t('settings.providers.authStrategyDualDummy'), + description: t('settings.providers.authStrategyDualDummyDesc'), + icon: construction, + }, + ] satisfies Array<{ value: ProviderAuthStrategy; label: string; description: string; icon: ReactNode }> + const selectedAuthStrategyLabel = authStrategyItems.find((item) => item.value === authStrategy)?.label ?? t('settings.providers.authStrategyAuthToken') const configuredContextWindows = buildModelContextWindows(models, modelContextInputs) const configuredContextSummary = Object.entries(configuredContextWindows) .filter(([model], index, entries) => entries.findIndex(([candidate]) => candidate === model) === index) @@ -657,6 +770,22 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF setAutoCompactWindow(value) setSettingsJson((current) => updateSettingsJsonAutoCompactWindow(current, value)) } + const handleBaseUrlChange = (value: string) => { + setBaseUrl(value) + setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, authStrategy, apiKey, selectedPreset, value)) + } + const handleApiKeyChange = (value: string) => { + setApiKey(value) + setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, authStrategy, value, selectedPreset, baseUrl)) + } + const handleApiFormatChange = (value: ApiFormat) => { + setApiFormat(value) + setSettingsJson((current) => updateSettingsJsonProviderConnection(current, value, authStrategy, apiKey, selectedPreset, baseUrl)) + } + const handleAuthStrategyChange = (value: ProviderAuthStrategy) => { + setAuthStrategy(value) + setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, value, apiKey, selectedPreset, baseUrl)) + } const handleModelChange = (slot: ModelSlot, value: string) => { const nextModels = { ...models, [slot]: value } const nextInputs = { @@ -715,6 +844,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF presetId: selectedPreset.id, name: name.trim(), apiKey: apiKey.trim(), + authStrategy, baseUrl: baseUrl.trim(), apiFormat, models, @@ -726,6 +856,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF const input: UpdateProviderInput = { name: name.trim(), baseUrl: baseUrl.trim(), + authStrategy, apiFormat, models, autoCompactWindow: parsedAutoCompactWindow ?? null, @@ -757,6 +888,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF baseUrl: baseUrl.trim(), modelId: models.main.trim(), apiFormat, + authStrategy, }) } else { if (requiresApiKey && !apiKey.trim()) return @@ -764,6 +896,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF baseUrl: baseUrl.trim(), apiKey: apiKey.trim() || selectedPreset.defaultEnv?.ANTHROPIC_AUTH_TOKEN || 'local', modelId: models.main.trim(), + authStrategy, apiFormat, }) } @@ -812,7 +945,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF setNotes(e.target.value)} placeholder={t('settings.providers.notesPlaceholder')} /> - setBaseUrl(e.target.value)} placeholder={t('settings.providers.baseUrlPlaceholder')} /> + handleBaseUrlChange(e.target.value)} placeholder={t('settings.providers.baseUrlPlaceholder')} /> {/* API Format */} {(isCustom || mode === 'edit') ? ( @@ -821,7 +954,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF items={apiFormatItems} value={apiFormat} - onChange={setApiFormat} + onChange={handleApiFormatChange} width="100%" className="block w-full" trigger={ @@ -847,6 +980,28 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF ) : null} + {apiFormat === 'anthropic' && ( +
+ + + items={authStrategyItems} + value={authStrategy} + onChange={handleAuthStrategyChange} + width="100%" + className="block w-full" + trigger={ + + } + /> +
+ )} +