From 596019505f13c3db8f74369da7979f6f658d8f98 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 10 Jun 2026 16:39:26 +0800 Subject: [PATCH] feat(provider-form): custom User-Agent presets dropdown in advanced settings Polish the provider-level User-Agent override UI on the Claude and Codex forms. - Add a shared CustomUserAgentField (label + input + preset dropdown + live validation) so both forms stay in sync. - Provide curated UA presets (Claude Code / Kilo Code families that pass coding-plan UA whitelists per #3671); the first is Claude Code's real `claude-cli/x (external, cli)` format. Whitelists gate on the name prefix, not the version, so static values stay valid across upgrades. - Expose presets via a dropdown to the right of the input (z-[200] so it renders above the dialog layers) instead of inline chips. - Move the field into the existing advanced/reasoning collapsibles. - userAgent.ts mirrors the backend byte rule (reject only control chars; non-ASCII is allowed) for a non-blocking inline hint. - i18n for all four locales (zh/en/ja/zh-TW). --- .../providers/forms/ClaudeFormFields.tsx | 38 +++----- .../providers/forms/CodexFormFields.tsx | 46 ++++----- .../providers/forms/CustomUserAgentField.tsx | 96 +++++++++++++++++++ src/config/userAgentPresets.ts | 20 ++++ src/i18n/locales/en.json | 4 + src/i18n/locales/ja.json | 4 + src/i18n/locales/zh-TW.json | 4 + src/i18n/locales/zh.json | 4 + src/lib/userAgent.ts | 16 ++++ tests/components/ClaudeFormFields.test.tsx | 2 + tests/lib/userAgent.test.ts | 34 +++++++ 11 files changed, 212 insertions(+), 56 deletions(-) create mode 100644 src/components/providers/forms/CustomUserAgentField.tsx create mode 100644 src/config/userAgentPresets.ts create mode 100644 src/lib/userAgent.ts create mode 100644 tests/lib/userAgent.test.ts diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index 7625c5347..fadd026ce 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -47,6 +47,7 @@ import { showFetchModelsError, type FetchedModel, } from "@/lib/api/model-fetch"; +import { CustomUserAgentField } from "./CustomUserAgentField"; import type { ProviderCategory, ClaudeApiFormat, @@ -204,7 +205,8 @@ export function ClaudeFormFields({ defaultSonnetModel || defaultOpusModel || apiFormat !== "anthropic" || - apiKeyField !== "ANTHROPIC_AUTH_TOKEN" + apiKeyField !== "ANTHROPIC_AUTH_TOKEN" || + customUserAgent ); const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue); @@ -257,7 +259,7 @@ export function ClaudeFormFields({ const modelsUrl = matchedPreset?.modelsUrl; setIsFetchingModels(true); - fetchModelsForConfig(baseUrl, apiKey, isFullUrl, modelsUrl) + fetchModelsForConfig(baseUrl, apiKey, isFullUrl, modelsUrl, customUserAgent) .then((models) => { setFetchedModels(models); showModelFetchResult(models.length); @@ -267,7 +269,7 @@ export function ClaudeFormFields({ showFetchModelsError(err, t); }) .finally(() => setIsFetchingModels(false)); - }, [baseUrl, apiKey, isFullUrl, showModelFetchResult, t]); + }, [baseUrl, apiKey, isFullUrl, customUserAgent, showModelFetchResult, t]); const handleFetchCopilotModels = useCallback(() => { if (!isCopilotAuthenticated) { @@ -671,30 +673,6 @@ export function ClaudeFormFields({ /> )} - {category !== "official" && ( -
- - {t("providerForm.customUserAgent", { - defaultValue: "自定义 User-Agent", - })} - - onCustomUserAgentChange(e.target.value)} - placeholder="Mozilla/5.0 ..." - autoComplete="off" - /> -

- {t("providerForm.customUserAgentHint", { - defaultValue: - "仅在开启本地路由/代理接管后生效,会替换转发到供应商 API 请求中的 User-Agent。", - })} -

-
- )} - {shouldShowModelSelector && ( @@ -962,6 +940,12 @@ export function ClaudeFormFields({ })}

+ +
)} diff --git a/src/components/providers/forms/CodexFormFields.tsx b/src/components/providers/forms/CodexFormFields.tsx index 680ec0878..ac1101d9b 100644 --- a/src/components/providers/forms/CodexFormFields.tsx +++ b/src/components/providers/forms/CodexFormFields.tsx @@ -25,6 +25,7 @@ import { showFetchModelsError, type FetchedModel, } from "@/lib/api/model-fetch"; +import { CustomUserAgentField } from "./CustomUserAgentField"; import type { CodexApiFormat, CodexCatalogModel, @@ -222,7 +223,13 @@ export function CodexFormFields({ return; } setIsFetchingModels(true); - fetchModelsForConfig(codexBaseUrl, codexApiKey, isFullUrl) + fetchModelsForConfig( + codexBaseUrl, + codexApiKey, + isFullUrl, + undefined, + customUserAgent, + ) .then((models) => { setFetchedModels(models); if (models.length === 0) { @@ -238,7 +245,7 @@ export function CodexFormFields({ showFetchModelsError(err, t); }) .finally(() => setIsFetchingModels(false)); - }, [codexBaseUrl, codexApiKey, isFullUrl, t]); + }, [codexBaseUrl, codexApiKey, isFullUrl, customUserAgent, t]); const handleAddCatalogRow = useCallback(() => { if (!onCatalogModelsChange) return; @@ -436,6 +443,14 @@ export function CodexFormFields({ })} /> + +
+ +
)} @@ -588,33 +603,6 @@ export function CodexFormFields({ onCustomEndpointsChange={onCustomEndpointsChange} /> )} - - {category !== "official" && ( -
- - onCustomUserAgentChange(e.target.value)} - placeholder="Mozilla/5.0 ..." - autoComplete="off" - /> -

- {t("providerForm.customUserAgentHint", { - defaultValue: - "仅在开启本地路由/代理接管后生效,会替换转发到供应商 API 请求中的 User-Agent。", - })} -

-
- )} ); } diff --git a/src/components/providers/forms/CustomUserAgentField.tsx b/src/components/providers/forms/CustomUserAgentField.tsx new file mode 100644 index 000000000..dc7151649 --- /dev/null +++ b/src/components/providers/forms/CustomUserAgentField.tsx @@ -0,0 +1,96 @@ +import { useTranslation } from "react-i18next"; +import { ChevronDown } from "lucide-react"; +import { FormLabel } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { isValidUserAgentHeader } from "@/lib/userAgent"; +import { USER_AGENT_PRESETS } from "@/config/userAgentPresets"; + +interface CustomUserAgentFieldProps { + /** 输入框的 id(用于 label htmlFor);两个表单需传入各自唯一值。 */ + id: string; + value: string; + onChange: (value: string) => void; +} + +/** + * 供应商级自定义 User-Agent 字段(Claude / Codex 表单共用)。 + * + * 含标签 + 输入框 + 右侧预设下拉菜单 + 实时合法性提示。校验口径与后端 + * `parse_custom_user_agent` 一致(见 `@/lib/userAgent`),非法时给非阻断红字提示 + * (运行时仍会静默忽略)。 + */ +export function CustomUserAgentField({ + id, + value, + onChange, +}: CustomUserAgentFieldProps) { + const { t } = useTranslation(); + const valid = isValidUserAgentHeader(value); + + return ( +
+ + {t("providerForm.customUserAgent", { + defaultValue: "自定义 User-Agent", + })} + +
+ onChange(e.target.value)} + placeholder="Mozilla/5.0 ..." + autoComplete="off" + className="flex-1" + /> + + + + + + {USER_AGENT_PRESETS.map((preset) => ( + onChange(preset)} + className="font-mono text-xs" + > + {preset} + + ))} + + +
+ {valid ? ( +

+ {t("providerForm.customUserAgentHint", { + defaultValue: + "仅在开启本地路由/代理接管后生效,会替换转发到供应商 API 请求中的 User-Agent。", + })} +

+ ) : ( +

+ {t("providerForm.customUserAgentInvalid", { + defaultValue: + "User-Agent 不能包含控制字符(如换行符),否则将被忽略。", + })} +

+ )} +
+ ); +} diff --git a/src/config/userAgentPresets.ts b/src/config/userAgentPresets.ts new file mode 100644 index 000000000..5e7f6f1f4 --- /dev/null +++ b/src/config/userAgentPresets.ts @@ -0,0 +1,20 @@ +/** + * 自定义 User-Agent 预设。 + * + * 取值来自 PR #3671 对 Kimi Coding Plan(api.kimi.com/coding)UA 白名单的 curl 实测: + * `claude-cli/*`、`claude-code/*`、`Kilo-Code/*` 可通过;`codex-cli`、`kimi-cli` 会被 403。 + * 白名单只校验 UA 名称前缀、不看版本号,因此用静态值即可,版本不会因 Claude Code 升级而失效。 + * + * 第一条是官方 Claude Code CLI 实际发送的完整格式(参见 `stream_check.rs` 里检测用的 + * `claude-cli/2.1.2 (external, cli)`),最贴近真实客户端、最稳过严格的 UA 校验;其余为简短变体。 + * + * 这些预设主要用于"非白名单 Coding Agent(Codex/Gemini/Hermes/OpenClaw 等)想接入受 UA + * 限制的上游"的场景——把转发请求伪装成已在白名单内的客户端。是否使用由用户显式选择。 + */ +export const USER_AGENT_PRESETS: readonly string[] = [ + "claude-cli/2.1.161 (external, cli)", + "claude-cli/2.1.161", + "claude-code/1.0.0", + "claude-code/0.1.0", + "Kilo-Code/1.0", +]; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f29569c34..f2ad7d2fb 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1043,6 +1043,10 @@ "anthropicSmallFastModel": "Fast Model", "apiFormat": "API Format", "apiFormatHint": "Select the input format for the provider's API", + "customUserAgent": "Custom User-Agent", + "customUserAgentHint": "Only takes effect when local routing/proxy takeover is enabled; replaces the User-Agent in requests forwarded to the provider API.", + "customUserAgentInvalid": "User-Agent must not contain control characters (e.g. line breaks); otherwise it will be ignored.", + "customUserAgentPresets": "Presets", "fullUrlLabel": "Full URL", "fullUrlEnabled": "Full URL Mode", "fullUrlDisabled": "Mark as Full URL", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 858067d78..eed2d090e 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1043,6 +1043,10 @@ "anthropicSmallFastModel": "高速モデル", "apiFormat": "API フォーマット", "apiFormatHint": "プロバイダー API の入力フォーマットを選択", + "customUserAgent": "カスタム User-Agent", + "customUserAgentHint": "ローカルルーティング/プロキシ引き継ぎが有効な場合にのみ適用され、プロバイダー API へ転送するリクエストの User-Agent を置き換えます。", + "customUserAgentInvalid": "User-Agent に制御文字(改行など)を含めることはできません。含まれている場合は無視されます。", + "customUserAgentPresets": "プリセット", "fullUrlLabel": "フル URL", "fullUrlEnabled": "フル URL モード", "fullUrlDisabled": "フル URL として設定", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 5dfe8fd15..6191fa536 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1014,6 +1014,10 @@ "anthropicSmallFastModel": "快速模型", "apiFormat": "API 格式", "apiFormatHint": "選擇供應商 API 的輸入格式", + "customUserAgent": "自訂 User-Agent", + "customUserAgentHint": "僅在開啟本地路由/代理接管後生效,會取代轉發至供應商 API 請求中的 User-Agent。", + "customUserAgentInvalid": "User-Agent 不能包含控制字元(如換行字元),否則將被忽略。", + "customUserAgentPresets": "預設", "fullUrlLabel": "完整 URL", "fullUrlEnabled": "完整 URL 模式", "fullUrlDisabled": "標記為完整 URL", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 59b6be3fd..6ffbea292 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1043,6 +1043,10 @@ "anthropicSmallFastModel": "快速模型", "apiFormat": "API 格式", "apiFormatHint": "选择供应商 API 的输入格式", + "customUserAgent": "自定义 User-Agent", + "customUserAgentHint": "仅在开启本地路由/代理接管后生效,会替换转发到供应商 API 请求中的 User-Agent。", + "customUserAgentInvalid": "User-Agent 不能包含控制字符(如换行符),否则将被忽略。", + "customUserAgentPresets": "预设", "fullUrlLabel": "完整 URL", "fullUrlEnabled": "完整 URL 模式", "fullUrlDisabled": "标记为完整 URL", diff --git a/src/lib/userAgent.ts b/src/lib/userAgent.ts new file mode 100644 index 000000000..5daa9049b --- /dev/null +++ b/src/lib/userAgent.ts @@ -0,0 +1,16 @@ +/** + * 自定义 User-Agent 合法性校验。 + * + * 与后端 `parse_custom_user_agent`(基于 `http::HeaderValue::from_str`)口径严格一致: + * HeaderValue 按**字节**判定合法性,规则为 `b >= 32 && b != 127 || b == '\t'`。也就是说: + * - 制表符(\t)、可见 ASCII(0x20–0x7E)、以及任意非 ASCII 字符(UTF-8 字节均 ≥ 0x80)都合法; + * - 仅控制字符非法:除 \t 外的 0x00–0x1F(含换行)与 0x7F(DEL)。 + * + * 空串(trim 后为空)视为"未设置",合法。 + */ +export function isValidUserAgentHeader(value: string): boolean { + const trimmed = value.trim(); + if (trimmed === "") return true; + // eslint-disable-next-line no-control-regex + return !/[\x00-\x08\x0a-\x1f\x7f]/.test(trimmed); +} diff --git a/tests/components/ClaudeFormFields.test.tsx b/tests/components/ClaudeFormFields.test.tsx index e18a15ca9..336339260 100644 --- a/tests/components/ClaudeFormFields.test.tsx +++ b/tests/components/ClaudeFormFields.test.tsx @@ -91,6 +91,8 @@ const renderCopilotForm = (overrides: Partial = {}) => { onApiKeyFieldChange: vi.fn(), isFullUrl: false, onFullUrlChange: vi.fn(), + customUserAgent: "", + onCustomUserAgentChange: vi.fn(), ...overrides, }; diff --git a/tests/lib/userAgent.test.ts b/tests/lib/userAgent.test.ts new file mode 100644 index 000000000..47a6865af --- /dev/null +++ b/tests/lib/userAgent.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import { isValidUserAgentHeader } from "@/lib/userAgent"; + +// 与后端 parse_custom_user_agent / http::HeaderValue::from_str 的字节级规则对齐: +// 合法 = b>=32 && b!=127 || b=='\t'。即仅控制字符(除 \t 外的 0x00–0x1F 与 0x7F)非法, +// 可见 ASCII 与非 ASCII 都合法。控制字符用 String.fromCharCode 构造,避免源码内嵌生字节。 +const NUL = String.fromCharCode(0); +const DEL = String.fromCharCode(0x7f); + +describe("isValidUserAgentHeader", () => { + it("treats empty / whitespace-only as valid (unset)", () => { + expect(isValidUserAgentHeader("")).toBe(true); + expect(isValidUserAgentHeader(" ")).toBe(true); + }); + + it("accepts visible ASCII (trimmed)", () => { + expect(isValidUserAgentHeader("claude-cli/2.1.161")).toBe(true); + expect(isValidUserAgentHeader(" claude-cli/2.1.161 ")).toBe(true); + }); + + it("accepts non-ASCII — matches backend HeaderValue byte rule", () => { + expect(isValidUserAgentHeader("claude-cli/1.0 中文")).toBe(true); + }); + + it("accepts internal tab", () => { + expect(isValidUserAgentHeader("claude\tcli")).toBe(true); + }); + + it("rejects control characters (newline / null / DEL)", () => { + expect(isValidUserAgentHeader("claude\ncli")).toBe(false); + expect(isValidUserAgentHeader(`claude${NUL}cli`)).toBe(false); + expect(isValidUserAgentHeader(`claude${DEL}cli`)).toBe(false); + }); +});