From 4d75dc1e5fbd23ef65ee48677827cdc225bd06b3 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:45:57 +0800 Subject: [PATCH] fix(provider): preserve custom headers and add custom-provider User-Agent setting (#635) --- README.ja-JP.md | 1 + README.md | 1 + README.zh-CN.md | 1 + .../providers/provider-runtime-sync.ts | 8 +- .../services/providers/provider-service.ts | 3 + electron/services/providers/provider-store.ts | 2 + electron/shared/providers/types.ts | 2 + electron/utils/openclaw-auth.ts | 10 +- electron/utils/secure-storage.ts | 1 + src/components/settings/ProvidersSettings.tsx | 122 ++++++++++++++++-- src/i18n/locales/en/settings.json | 3 + src/i18n/locales/ja/settings.json | 3 + src/i18n/locales/zh/settings.json | 3 + src/lib/provider-accounts.ts | 1 + src/lib/providers.ts | 2 + 15 files changed, 144 insertions(+), 19 deletions(-) diff --git a/README.ja-JP.md b/README.ja-JP.md index 58d19c173..3483514af 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -120,6 +120,7 @@ Skills ページでは OpenClaw の複数ソース(管理ディレクトリ、 ### 🔐 セキュアなプロバイダー統合 複数のAIプロバイダー(OpenAI、Anthropicなど)に接続でき、資格情報はシステムのネイティブキーチェーンに安全に保存されます。OpenAI は API キーとブラウザ OAuth(Codex サブスクリプション)の両方に対応しています。 +OpenAI-compatible ゲートウェイを **Custom プロバイダー** で使う場合、**設定 → AI Providers → Provider 編集** でカスタム `User-Agent` を設定でき、互換性が必要なエンドポイントで有効です。 ### 🌙 アダプティブテーマ ライトモード、ダークモード、またはシステム同期テーマ。ClawXはあなたの好みに自動的に適応します。 diff --git a/README.md b/README.md index bbffc8b05..2430535fd 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ Environment variables for bundled search skills: ### 🔐 Secure Provider Integration Connect to multiple AI providers (OpenAI, Anthropic, and more) with credentials stored securely in your system's native keychain. OpenAI supports both API key and browser OAuth (Codex subscription) sign-in. +For **Custom** providers used with OpenAI-compatible gateways, you can set a custom `User-Agent` in **Settings → AI Providers → Edit Provider** for compatibility-sensitive endpoints. ### 🌙 Adaptive Theming Light mode, dark mode, or system-synchronized themes. ClawX adapts to your preferences automatically. diff --git a/README.zh-CN.md b/README.zh-CN.md index 534a2ac58..be927b984 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -121,6 +121,7 @@ Skills 页面可展示来自多个 OpenClaw 来源的技能(托管目录、wor ### 🔐 安全的供应商集成 连接多个 AI 供应商(OpenAI、Anthropic 等),凭证安全存储在系统原生密钥链中。OpenAI 同时支持 API Key 与浏览器 OAuth(Codex 订阅)登录。 +如果你通过 **自定义(Custom)Provider** 对接 OpenAI-compatible 网关,可以在 **设置 → AI Providers → 编辑 Provider** 中配置自定义 `User-Agent`,以提高兼容性。 ### 🌙 自适应主题 支持浅色模式、深色模式或跟随系统主题。ClawX 自动适应你的偏好设置。 diff --git a/electron/services/providers/provider-runtime-sync.ts b/electron/services/providers/provider-runtime-sync.ts index 2ad2d5543..6d6234e71 100644 --- a/electron/services/providers/provider-runtime-sync.ts +++ b/electron/services/providers/provider-runtime-sync.ts @@ -294,7 +294,7 @@ async function syncRuntimeProviderConfig( baseUrl: normalizeProviderBaseUrl(config, config.baseUrl || context.meta?.baseUrl, context.api), api: context.api, apiKeyEnv: context.meta?.apiKeyEnv, - headers: context.meta?.headers, + headers: config.headers ?? context.meta?.headers, }); } @@ -374,7 +374,7 @@ export async function syncUpdatedProviderToRuntime( baseUrl: normalizeProviderBaseUrl(config, config.baseUrl || context.meta?.baseUrl, context.api), api: context.api, apiKeyEnv: context.meta?.apiKeyEnv, - headers: context.meta?.headers, + headers: config.headers ?? context.meta?.headers, }, fallbackModels); } else { await setOpenClawDefaultModel(ock, modelOverride, fallbackModels); @@ -383,6 +383,7 @@ export async function syncUpdatedProviderToRuntime( await setOpenClawDefaultModelWithOverride(ock, modelOverride, { baseUrl: normalizeProviderBaseUrl(config, config.baseUrl, config.apiProtocol || 'openai-completions'), api: config.apiProtocol || 'openai-completions', + headers: config.headers, }, fallbackModels); } } @@ -451,6 +452,7 @@ export async function syncDefaultProviderToRuntime( await setOpenClawDefaultModelWithOverride(ock, modelOverride, { baseUrl: normalizeProviderBaseUrl(provider, provider.baseUrl, provider.apiProtocol || 'openai-completions'), api: provider.apiProtocol || 'openai-completions', + headers: provider.headers, }, fallbackModels); } else if (shouldUseExplicitDefaultOverride(provider, ock)) { await setOpenClawDefaultModelWithOverride(ock, modelOverride, { @@ -461,7 +463,7 @@ export async function syncDefaultProviderToRuntime( ), api: provider.apiProtocol || getProviderConfig(provider.type)?.api, apiKeyEnv: getProviderConfig(provider.type)?.apiKeyEnv, - headers: getProviderConfig(provider.type)?.headers, + headers: provider.headers ?? getProviderConfig(provider.type)?.headers, }, fallbackModels); } else { await setOpenClawDefaultModel(ock, modelOverride, fallbackModels); diff --git a/electron/services/providers/provider-service.ts b/electron/services/providers/provider-service.ts index 2af677d17..dff5775c1 100644 --- a/electron/services/providers/provider-service.ts +++ b/electron/services/providers/provider-service.ts @@ -157,6 +157,9 @@ export class ProviderService { authMode: definition?.defaultAuthMode ?? 'api_key', baseUrl, apiProtocol: definition?.providerConfig?.api, + headers: (entry.headers && typeof entry.headers === 'object' + ? (entry.headers as Record) + : undefined), model, enabled: true, isDefault: false, diff --git a/electron/services/providers/provider-store.ts b/electron/services/providers/provider-store.ts index 9a88110a0..02f03cbbd 100644 --- a/electron/services/providers/provider-store.ts +++ b/electron/services/providers/provider-store.ts @@ -30,6 +30,7 @@ export function providerConfigToAccount( apiProtocol: config.apiProtocol || (config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' : getProviderDefinition(config.type)?.providerConfig?.api), + headers: config.headers, model: config.model, fallbackModels: config.fallbackModels, fallbackAccountIds: config.fallbackProviderIds, @@ -47,6 +48,7 @@ export function providerAccountToConfig(account: ProviderAccount): ProviderConfi type: account.vendorId, baseUrl: account.baseUrl, apiProtocol: account.apiProtocol, + headers: account.headers, model: account.model, fallbackModels: account.fallbackModels, fallbackProviderIds: account.fallbackAccountIds, diff --git a/electron/shared/providers/types.ts b/electron/shared/providers/types.ts index 018c944ae..27291eb32 100644 --- a/electron/shared/providers/types.ts +++ b/electron/shared/providers/types.ts @@ -55,6 +55,7 @@ export interface ProviderConfig { type: ProviderType; baseUrl?: string; apiProtocol?: ProviderProtocol; + headers?: Record; model?: string; fallbackModels?: string[]; fallbackProviderIds?: string[]; @@ -118,6 +119,7 @@ export interface ProviderAccount { authMode: ProviderAuthMode; baseUrl?: string; apiProtocol?: ProviderProtocol; + headers?: Record; model?: string; fallbackModels?: string[]; fallbackAccountIds?: string[]; diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index 4c28f651a..af4b7f1db 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -557,10 +557,12 @@ function upsertOpenClawProviderEntry( models: mergeProviderModels(registryModels, existingModels, runtimeModels), }; if (options.apiKeyEnv) nextProvider.apiKey = options.apiKeyEnv; - if (options.headers && Object.keys(options.headers).length > 0) { - nextProvider.headers = options.headers; - } else { - delete nextProvider.headers; + if (options.headers !== undefined) { + if (Object.keys(options.headers).length > 0) { + nextProvider.headers = options.headers; + } else { + delete nextProvider.headers; + } } if (options.authHeader !== undefined) { nextProvider.authHeader = options.authHeader; diff --git a/electron/utils/secure-storage.ts b/electron/utils/secure-storage.ts index 5f7834209..d84d93df2 100644 --- a/electron/utils/secure-storage.ts +++ b/electron/utils/secure-storage.ts @@ -34,6 +34,7 @@ export interface ProviderConfig { type: ProviderType; baseUrl?: string; apiProtocol?: 'openai-completions' | 'openai-responses' | 'anthropic-messages'; + headers?: Record; model?: string; fallbackModels?: string[]; fallbackProviderIds?: string[]; diff --git a/src/components/settings/ProvidersSettings.tsx b/src/components/settings/ProvidersSettings.tsx index 1ec0dbc0b..14d8316b9 100644 --- a/src/components/settings/ProvidersSettings.tsx +++ b/src/components/settings/ProvidersSettings.tsx @@ -86,6 +86,30 @@ function fallbackModelsEqual(a?: string[], b?: string[]): boolean { return left.length === right.length && left.every((model, index) => model === right[index]); } +function getUserAgentHeader(headers?: Record): string { + if (!headers) return ''; + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === 'user-agent') { + return value; + } + } + return ''; +} + +function mergeHeadersWithUserAgent( + headers: Record | undefined, + userAgent: string, +): Record { + const next = Object.fromEntries( + Object.entries(headers ?? {}).filter(([key]) => key.toLowerCase() !== 'user-agent'), + ); + const normalizedUserAgent = userAgent.trim(); + if (normalizedUserAgent) { + next['User-Agent'] = normalizedUserAgent; + } + return next; +} + function isArkCodePlanMode( vendorId: string, baseUrl: string | undefined, @@ -97,6 +121,14 @@ function isArkCodePlanMode( return (baseUrl || '').trim() === codePlanPresetBaseUrl && (modelId || '').trim() === codePlanPresetModelId; } +function shouldShowUserAgentField(account: ProviderAccount): boolean { + return account.vendorId === 'custom'; +} + +function shouldShowUserAgentFieldForNewProvider(providerType: ProviderType | null): boolean { + return providerType === 'custom'; +} + function getAuthModeLabel( authMode: ProviderAccount['authMode'], t: (key: string) => string @@ -150,7 +182,13 @@ export function ProvidersSettings() { type: ProviderType, name: string, apiKey: string, - options?: { baseUrl?: string; model?: string; authMode?: ProviderAccount['authMode']; apiProtocol?: ProviderAccount['apiProtocol'] } + options?: { + baseUrl?: string; + model?: string; + authMode?: ProviderAccount['authMode']; + apiProtocol?: ProviderAccount['apiProtocol']; + headers?: Record; + } ) => { const vendor = vendorMap.get(type); const id = buildProviderAccountId(type, null, vendors); @@ -163,6 +201,7 @@ export function ProvidersSettings() { authMode: options?.authMode || vendor?.defaultAuthMode || (type === 'ollama' ? 'local' : 'api_key'), baseUrl: options?.baseUrl, apiProtocol: options?.apiProtocol, + headers: options?.headers, model: options?.model, enabled: true, isDefault: false, @@ -246,6 +285,7 @@ export function ProvidersSettings() { 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.headers !== undefined) updates.headers = payload.updates.headers; 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) { @@ -318,6 +358,7 @@ function ProviderCard({ const [newKey, setNewKey] = useState(''); const [baseUrl, setBaseUrl] = useState(account.baseUrl || ''); const [apiProtocol, setApiProtocol] = useState(account.apiProtocol || 'openai-completions'); + const [userAgent, setUserAgent] = useState(getUserAgentHeader(account.headers)); const [modelId, setModelId] = useState(account.model || ''); const [fallbackModelsText, setFallbackModelsText] = useState( normalizeFallbackModels(account.fallbackModels).join('\n') @@ -344,6 +385,7 @@ function ProviderCard({ ? (typeInfo?.codePlanDocsUrl || providerDocsUrl) : providerDocsUrl; const canEditModelConfig = Boolean(typeInfo?.showBaseUrl || showModelIdField); + const showUserAgentField = shouldShowUserAgentField(account); useEffect(() => { if (isEditing) { @@ -351,6 +393,7 @@ function ProviderCard({ setShowKey(false); setBaseUrl(account.baseUrl || ''); setApiProtocol(account.apiProtocol || 'openai-completions'); + setUserAgent(getUserAgentHeader(account.headers)); setModelId(account.model || ''); setFallbackModelsText(normalizeFallbackModels(account.fallbackModels).join('\n')); setFallbackProviderIds(normalizeFallbackProviderIds(account.fallbackAccountIds)); @@ -364,7 +407,7 @@ function ProviderCard({ ) ? 'codeplan' : 'apikey' ); } - }, [isEditing, account.baseUrl, account.fallbackModels, account.fallbackAccountIds, account.model, account.apiProtocol, account.vendorId, typeInfo?.codePlanPresetBaseUrl, typeInfo?.codePlanPresetModelId]); + }, [isEditing, account.baseUrl, account.headers, account.fallbackModels, account.fallbackAccountIds, account.model, account.apiProtocol, account.vendorId, typeInfo?.codePlanPresetBaseUrl, typeInfo?.codePlanPresetModelId]); const fallbackOptions = allProviders.filter((candidate) => candidate.account.id !== account.id); @@ -414,6 +457,11 @@ function ProviderCard({ if (showModelIdField && (modelId.trim() || undefined) !== (account.model || undefined)) { updates.model = modelId.trim() || undefined; } + const existingUserAgent = getUserAgentHeader(account.headers).trim(); + const nextUserAgent = userAgent.trim(); + if (nextUserAgent !== existingUserAgent) { + updates.headers = mergeHeadersWithUserAgent(account.headers, nextUserAgent); + } if (!fallbackModelsEqual(normalizedFallbackModels, account.fallbackModels)) { updates.fallbackModels = normalizedFallbackModels; } @@ -670,6 +718,17 @@ function ProviderCard({ )} + {showUserAgentField && ( +
+ + setUserAgent(e.target.value)} + placeholder={t('aiProviders.dialog.userAgentPlaceholder')} + className={currentInputClasses} + /> +
+ )} )}
@@ -786,6 +845,7 @@ function ProviderCard({ || ( !newKey.trim() && (baseUrl.trim() || undefined) === (account.baseUrl || undefined) + && userAgent.trim() === getUserAgentHeader(account.headers).trim() && (modelId.trim() || undefined) === (account.model || undefined) && fallbackModelsEqual(normalizeFallbackModels(fallbackModelsText.split('\n')), account.fallbackModels) && fallbackProviderIdsEqual(fallbackProviderIds, account.fallbackAccountIds) @@ -831,7 +891,13 @@ interface AddProviderDialogProps { type: ProviderType, name: string, apiKey: string, - options?: { baseUrl?: string; model?: string; authMode?: ProviderAccount['authMode']; apiProtocol?: ProviderAccount['apiProtocol'] } + options?: { + baseUrl?: string; + model?: string; + authMode?: ProviderAccount['authMode']; + apiProtocol?: ProviderAccount['apiProtocol']; + headers?: Record; + } ) => Promise; onValidateKey: ( type: string, @@ -856,6 +922,8 @@ function AddProviderDialog({ const [baseUrl, setBaseUrl] = useState(''); const [modelId, setModelId] = useState(''); const [apiProtocol, setApiProtocol] = useState('openai-completions'); + const [showAdvancedConfig, setShowAdvancedConfig] = useState(false); + const [userAgent, setUserAgent] = useState(''); const [arkMode, setArkMode] = useState('apikey'); const [showKey, setShowKey] = useState(false); const [saving, setSaving] = useState(false); @@ -895,6 +963,7 @@ function AddProviderDialog({ const supportsApiKey = typeInfo?.supportsApiKey ?? false; const vendorMap = new Map(vendors.map((vendor) => [vendor.id, vendor])); const selectedVendor = selectedType ? vendorMap.get(selectedType) : undefined; + const showUserAgentInAddDialog = shouldShowUserAgentFieldForNewProvider(selectedType); const preferredOAuthMode = selectedVendor?.supportedAuthModes.includes('oauth_browser') ? 'oauth_browser' : (selectedVendor?.supportedAuthModes.includes('oauth_device') @@ -1120,6 +1189,7 @@ function AddProviderDialog({ { baseUrl: baseUrl.trim() || undefined, apiProtocol: (selectedType === 'custom' || selectedType === 'ollama') ? apiProtocol : undefined, + headers: userAgent.trim() ? { 'User-Agent': userAgent.trim() } : undefined, model: resolveProviderModelForSave(typeInfo, modelId, devModeUnlocked), authMode: useOAuthFlow ? (preferredOAuthMode || 'oauth_device') : selectedType === 'ollama' ? 'local' @@ -1163,6 +1233,8 @@ function AddProviderDialog({ setName(type.id === 'custom' ? t('aiProviders.custom') : type.name); setBaseUrl(type.defaultBaseUrl || ''); setModelId(type.defaultModelId || ''); + setUserAgent(''); + setShowAdvancedConfig(false); setArkMode('apikey'); }} className="p-4 rounded-2xl border border-black/5 dark:border-white/5 hover:bg-black/5 dark:hover:bg-white/5 transition-colors text-center group" @@ -1191,15 +1263,17 @@ function AddProviderDialog({

{typeInfo?.id === 'custom' ? t('aiProviders.custom') : typeInfo?.name}

{effectiveDocsUrl && ( @@ -1409,6 +1483,30 @@ function AddProviderDialog({
)} + {showUserAgentInAddDialog && ( +
+ + {showAdvancedConfig && ( +
+ + setUserAgent(e.target.value)} + className={inputClasses} + /> +
+ )} +
+ )} {/* 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 0c3453610..252ab9fdc 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -58,6 +58,9 @@ "codePlanPresetDesc": "Code Plan uses https://ark.cn-beijing.volces.com/api/coding/v3 and model ark-code-latest. Do not use /api/v3 for Code Plan traffic.", "codePlanDoc": "Code Plan docs", "protocol": "Protocol", + "advancedConfig": "Advanced configuration", + "userAgent": "User-Agent", + "userAgentPlaceholder": "ClawX/1.0", "fallbackModels": "Fallback Models", "fallbackProviders": "Fallback Providers", "fallbackModelIds": "Fallback Model IDs", diff --git a/src/i18n/locales/ja/settings.json b/src/i18n/locales/ja/settings.json index 015e693b7..a73136292 100644 --- a/src/i18n/locales/ja/settings.json +++ b/src/i18n/locales/ja/settings.json @@ -58,6 +58,9 @@ "codePlanPresetDesc": "Code Plan は https://ark.cn-beijing.volces.com/api/coding/v3 と model ark-code-latest を使います。Code Plan 通信に /api/v3 を使わないでください。", "codePlanDoc": "Code Plan ドキュメント", "protocol": "プロトコル", + "advancedConfig": "詳細設定", + "userAgent": "User-Agent", + "userAgentPlaceholder": "ClawX/1.0", "fallbackModels": "フォールバックモデル", "fallbackProviders": "別プロバイダーへのフォールバック", "fallbackModelIds": "同一プロバイダーのフォールバックモデル ID", diff --git a/src/i18n/locales/zh/settings.json b/src/i18n/locales/zh/settings.json index 522e98b24..80655295c 100644 --- a/src/i18n/locales/zh/settings.json +++ b/src/i18n/locales/zh/settings.json @@ -58,6 +58,9 @@ "codePlanPresetDesc": "Code Plan 使用 https://ark.cn-beijing.volces.com/api/coding/v3 与模型 ark-code-latest。请勿把 /api/v3 用于 Code Plan 流量。", "codePlanDoc": "Code Plan 文档", "protocol": "协议", + "advancedConfig": "高级配置", + "userAgent": "User-Agent", + "userAgentPlaceholder": "ClawX/1.0", "fallbackModels": "回退模型", "fallbackProviders": "跨 Provider 回退", "fallbackModelIds": "同 Provider 回退模型 ID", diff --git a/src/lib/provider-accounts.ts b/src/lib/provider-accounts.ts index 941bf1d1d..58959511c 100644 --- a/src/lib/provider-accounts.ts +++ b/src/lib/provider-accounts.ts @@ -81,6 +81,7 @@ export function legacyProviderToAccount(provider: ProviderWithKeyInfo): Provider label: provider.name, authMode: provider.type === 'ollama' ? 'local' : 'api_key', baseUrl: provider.baseUrl, + headers: provider.headers, model: provider.model, fallbackModels: provider.fallbackModels, fallbackAccountIds: provider.fallbackProviderIds, diff --git a/src/lib/providers.ts b/src/lib/providers.ts index 2372b373f..379dda555 100644 --- a/src/lib/providers.ts +++ b/src/lib/providers.ts @@ -44,6 +44,7 @@ export interface ProviderConfig { type: ProviderType; baseUrl?: string; apiProtocol?: 'openai-completions' | 'openai-responses' | 'anthropic-messages'; + headers?: Record; model?: string; fallbackModels?: string[]; fallbackProviderIds?: string[]; @@ -107,6 +108,7 @@ export interface ProviderAccount { authMode: ProviderAuthMode; baseUrl?: string; apiProtocol?: 'openai-completions' | 'openai-responses' | 'anthropic-messages'; + headers?: Record; model?: string; fallbackModels?: string[]; fallbackAccountIds?: string[];