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

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