diff --git a/README.ja-JP.md b/README.ja-JP.md index 58d19c1..3483514 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 bbffc8b..2430535 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 534a2ac..be927b9 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 2ad2d55..6d6234e 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 2af677d..dff5775 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 9a88110..02f03cb 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 018c944..27291eb 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 4c28f65..af4b7f1 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 5f78342..d84d93d 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 1ec0dbc..14d8316 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 0c34536..252ab9f 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 015e693..a731362 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 522e98b..8065529 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 941bf1d..5895951 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 2372b37..379dda5 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[];