mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-06-16 13:34:04 +08:00
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).
This commit is contained in:
@@ -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" && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="claude-custom-user-agent">
|
||||
{t("providerForm.customUserAgent", {
|
||||
defaultValue: "自定义 User-Agent",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="claude-custom-user-agent"
|
||||
type="text"
|
||||
value={customUserAgent}
|
||||
onChange={(e) => onCustomUserAgentChange(e.target.value)}
|
||||
placeholder="Mozilla/5.0 ..."
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerForm.customUserAgentHint", {
|
||||
defaultValue:
|
||||
"仅在开启本地路由/代理接管后生效,会替换转发到供应商 API 请求中的 User-Agent。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShowModelSelector && (
|
||||
<Collapsible open={advancedExpanded} onOpenChange={setAdvancedExpanded}>
|
||||
<CollapsibleTrigger asChild>
|
||||
@@ -962,6 +940,12 @@ export function ClaudeFormFields({
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CustomUserAgentField
|
||||
id="claude-custom-user-agent"
|
||||
value={customUserAgent}
|
||||
onChange={onCustomUserAgentChange}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
@@ -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({
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border-default pt-3">
|
||||
<CustomUserAgentField
|
||||
id="codex-custom-user-agent"
|
||||
value={customUserAgent}
|
||||
onChange={onCustomUserAgentChange}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
@@ -588,33 +603,6 @@ export function CodexFormFields({
|
||||
onCustomEndpointsChange={onCustomEndpointsChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{category !== "official" && (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="codex-custom-user-agent"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
{t("providerForm.customUserAgent", {
|
||||
defaultValue: "自定义 User-Agent",
|
||||
})}
|
||||
</label>
|
||||
<Input
|
||||
id="codex-custom-user-agent"
|
||||
type="text"
|
||||
value={customUserAgent}
|
||||
onChange={(e) => onCustomUserAgentChange(e.target.value)}
|
||||
placeholder="Mozilla/5.0 ..."
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerForm.customUserAgentHint", {
|
||||
defaultValue:
|
||||
"仅在开启本地路由/代理接管后生效,会替换转发到供应商 API 请求中的 User-Agent。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor={id}>
|
||||
{t("providerForm.customUserAgent", {
|
||||
defaultValue: "自定义 User-Agent",
|
||||
})}
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="Mozilla/5.0 ..."
|
||||
autoComplete="off"
|
||||
className="flex-1"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" variant="outline" className="shrink-0 gap-1">
|
||||
{t("providerForm.customUserAgentPresets", {
|
||||
defaultValue: "预设",
|
||||
})}
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="max-h-64 overflow-y-auto z-[200]"
|
||||
>
|
||||
{USER_AGENT_PRESETS.map((preset) => (
|
||||
<DropdownMenuItem
|
||||
key={preset}
|
||||
onSelect={() => onChange(preset)}
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
{preset}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{valid ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerForm.customUserAgentHint", {
|
||||
defaultValue:
|
||||
"仅在开启本地路由/代理接管后生效,会替换转发到供应商 API 请求中的 User-Agent。",
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-destructive">
|
||||
{t("providerForm.customUserAgentInvalid", {
|
||||
defaultValue:
|
||||
"User-Agent 不能包含控制字符(如换行符),否则将被忽略。",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
];
|
||||
@@ -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",
|
||||
|
||||
@@ -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 として設定",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -91,6 +91,8 @@ const renderCopilotForm = (overrides: Partial<ClaudeFormFieldsProps> = {}) => {
|
||||
onApiKeyFieldChange: vi.fn(),
|
||||
isFullUrl: false,
|
||||
onFullUrlChange: vi.fn(),
|
||||
customUserAgent: "",
|
||||
onCustomUserAgentChange: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user