fix(provider): preserve custom headers and add custom-provider User-Agent setting (#635)

This commit is contained in:
Felix
2026-03-23 16:45:57 +08:00
committed by GitHub
Unverified
parent 7b1e79ed7b
commit 4d75dc1e5f
15 changed files with 144 additions and 19 deletions

View File

@@ -120,6 +120,7 @@ Skills ページでは OpenClaw の複数ソース(管理ディレクトリ、
### 🔐 セキュアなプロバイダー統合
複数のAIプロバイダーOpenAI、Anthropicなどに接続でき、資格情報はシステムのネイティブキーチェーンに安全に保存されます。OpenAI は API キーとブラウザ OAuthCodex サブスクリプション)の両方に対応しています。
OpenAI-compatible ゲートウェイを **Custom プロバイダー** で使う場合、**設定 → AI Providers → Provider 編集** でカスタム `User-Agent` を設定でき、互換性が必要なエンドポイントで有効です。
### 🌙 アダプティブテーマ
ライトモード、ダークモード、またはシステム同期テーマ。ClawXはあなたの好みに自動的に適応します。

View File

@@ -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.

View File

@@ -121,6 +121,7 @@ Skills 页面可展示来自多个 OpenClaw 来源的技能托管目录、wor
### 🔐 安全的供应商集成
连接多个 AI 供应商OpenAI、Anthropic 等凭证安全存储在系统原生密钥链中。OpenAI 同时支持 API Key 与浏览器 OAuthCodex 订阅)登录。
如果你通过 **自定义CustomProvider** 对接 OpenAI-compatible 网关,可以在 **设置 → AI Providers → 编辑 Provider** 中配置自定义 `User-Agent`,以提高兼容性。
### 🌙 自适应主题
支持浅色模式、深色模式或跟随系统主题。ClawX 自动适应你的偏好设置。

View File

@@ -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);

View File

@@ -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<string, string>)
: undefined),
model,
enabled: true,
isDefault: false,

View File

@@ -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,

View File

@@ -55,6 +55,7 @@ export interface ProviderConfig {
type: ProviderType;
baseUrl?: string;
apiProtocol?: ProviderProtocol;
headers?: Record<string, string>;
model?: string;
fallbackModels?: string[];
fallbackProviderIds?: string[];
@@ -118,6 +119,7 @@ export interface ProviderAccount {
authMode: ProviderAuthMode;
baseUrl?: string;
apiProtocol?: ProviderProtocol;
headers?: Record<string, string>;
model?: string;
fallbackModels?: string[];
fallbackAccountIds?: string[];

View File

@@ -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;

View File

@@ -34,6 +34,7 @@ export interface ProviderConfig {
type: ProviderType;
baseUrl?: string;
apiProtocol?: 'openai-completions' | 'openai-responses' | 'anthropic-messages';
headers?: Record<string, string>;
model?: string;
fallbackModels?: string[];
fallbackProviderIds?: string[];

View File

@@ -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, string>): string {
if (!headers) return '';
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === 'user-agent') {
return value;
}
}
return '';
}
function mergeHeadersWithUserAgent(
headers: Record<string, string> | undefined,
userAgent: string,
): Record<string, string> {
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<string, string>;
}
) => {
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<ProviderAccount['apiProtocol']>(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({
</div>
</div>
)}
{showUserAgentField && (
<div className="space-y-1.5 pt-2">
<Label className={currentLabelClasses}>{t('aiProviders.dialog.userAgent')}</Label>
<Input
value={userAgent}
onChange={(e) => setUserAgent(e.target.value)}
placeholder={t('aiProviders.dialog.userAgentPlaceholder')}
className={currentInputClasses}
/>
</div>
)}
</div>
)}
<div className="space-y-3">
@@ -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<string, string>;
}
) => Promise<void>;
onValidateKey: (
type: string,
@@ -856,6 +922,8 @@ function AddProviderDialog({
const [baseUrl, setBaseUrl] = useState('');
const [modelId, setModelId] = useState('');
const [apiProtocol, setApiProtocol] = useState<ProviderAccount['apiProtocol']>('openai-completions');
const [showAdvancedConfig, setShowAdvancedConfig] = useState(false);
const [userAgent, setUserAgent] = useState('');
const [arkMode, setArkMode] = useState<ArkMode>('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({
<div>
<p className="font-semibold text-[15px]">{typeInfo?.id === 'custom' ? t('aiProviders.custom') : typeInfo?.name}</p>
<button
onClick={() => {
setSelectedType(null);
setValidationError(null);
setBaseUrl('');
setModelId('');
setArkMode('apikey');
}}
className="text-[13px] text-blue-500 hover:text-blue-600 font-medium"
>
onClick={() => {
setSelectedType(null);
setValidationError(null);
setBaseUrl('');
setModelId('');
setUserAgent('');
setShowAdvancedConfig(false);
setArkMode('apikey');
}}
className="text-[13px] text-blue-500 hover:text-blue-600 font-medium"
>
{t('aiProviders.dialog.change')}
</button>
{effectiveDocsUrl && (
@@ -1409,6 +1483,30 @@ function AddProviderDialog({
</div>
</div>
)}
{showUserAgentInAddDialog && (
<div className="space-y-2.5">
<button
type="button"
onClick={() => setShowAdvancedConfig((value) => !value)}
className="flex items-center justify-between w-full text-[14px] font-bold text-foreground/80 hover:text-foreground transition-colors"
>
<span>{t('aiProviders.dialog.advancedConfig')}</span>
<ChevronDown className={cn("h-4 w-4 transition-transform", showAdvancedConfig && "rotate-180")} />
</button>
{showAdvancedConfig && (
<div className="space-y-2.5 pt-1">
<Label htmlFor="userAgent" className={labelClasses}>{t('aiProviders.dialog.userAgent')}</Label>
<Input
id="userAgent"
placeholder={t('aiProviders.dialog.userAgentPlaceholder')}
value={userAgent}
onChange={(e) => setUserAgent(e.target.value)}
className={inputClasses}
/>
</div>
)}
</div>
)}
{/* Device OAuth Trigger — only shown when in OAuth mode */}
{useOAuthFlow && (
<div className="space-y-4 pt-2">

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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,

View File

@@ -44,6 +44,7 @@ export interface ProviderConfig {
type: ProviderType;
baseUrl?: string;
apiProtocol?: 'openai-completions' | 'openai-responses' | 'anthropic-messages';
headers?: Record<string, string>;
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<string, string>;
model?: string;
fallbackModels?: string[];
fallbackAccountIds?: string[];