mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat(proxy): 添加本地代理请求覆盖功能,支持自定义请求头和请求体 (#4589)
* feat(proxy): 添加本地代理请求覆盖功能,支持自定义请求头和请求体 * fix(proxy): harden local request overrides validation * feat(proxy): 添加受保护的本地代理请求头名称验证功能 * fix(i18n): 更新本地代理请求覆盖的错误提示信息格式 --------- Co-authored-by: jason.mei <jason.mei@ucloud.cn>
This commit is contained in:
@@ -48,6 +48,7 @@ import {
|
||||
type FetchedModel,
|
||||
} from "@/lib/api/model-fetch";
|
||||
import { CustomUserAgentField } from "./CustomUserAgentField";
|
||||
import { LocalProxyRequestOverridesField } from "./LocalProxyRequestOverridesField";
|
||||
import type {
|
||||
ProviderCategory,
|
||||
ClaudeApiFormat,
|
||||
@@ -145,6 +146,10 @@ interface ClaudeFormFieldsProps {
|
||||
// Local proxy User-Agent override
|
||||
customUserAgent: string;
|
||||
onCustomUserAgentChange: (value: string) => void;
|
||||
localProxyHeadersOverride: string;
|
||||
onLocalProxyHeadersOverrideChange: (value: string) => void;
|
||||
localProxyBodyOverride: string;
|
||||
onLocalProxyBodyOverrideChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function ClaudeFormFields({
|
||||
@@ -201,8 +206,15 @@ export function ClaudeFormFields({
|
||||
onFullUrlChange,
|
||||
customUserAgent,
|
||||
onCustomUserAgentChange,
|
||||
localProxyHeadersOverride,
|
||||
onLocalProxyHeadersOverrideChange,
|
||||
localProxyBodyOverride,
|
||||
onLocalProxyBodyOverrideChange,
|
||||
}: ClaudeFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
const hasRequestOverrides = Boolean(
|
||||
localProxyHeadersOverride.trim() || localProxyBodyOverride.trim(),
|
||||
);
|
||||
const hasAnyAdvancedValue = !!(
|
||||
claudeModel ||
|
||||
defaultHaikuModel ||
|
||||
@@ -211,7 +223,8 @@ export function ClaudeFormFields({
|
||||
defaultFableModel ||
|
||||
apiFormat !== "anthropic" ||
|
||||
apiKeyField !== "ANTHROPIC_AUTH_TOKEN" ||
|
||||
customUserAgent
|
||||
customUserAgent ||
|
||||
hasRequestOverrides
|
||||
);
|
||||
const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue);
|
||||
|
||||
@@ -963,6 +976,15 @@ export function ClaudeFormFields({
|
||||
value={customUserAgent}
|
||||
onChange={onCustomUserAgentChange}
|
||||
/>
|
||||
|
||||
<div className="border-t border-border-default pt-3">
|
||||
<LocalProxyRequestOverridesField
|
||||
headersJson={localProxyHeadersOverride}
|
||||
bodyJson={localProxyBodyOverride}
|
||||
onHeadersJsonChange={onLocalProxyHeadersOverrideChange}
|
||||
onBodyJsonChange={onLocalProxyBodyOverrideChange}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type FetchedModel,
|
||||
} from "@/lib/api/model-fetch";
|
||||
import { CustomUserAgentField } from "./CustomUserAgentField";
|
||||
import { LocalProxyRequestOverridesField } from "./LocalProxyRequestOverridesField";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
CodexApiFormat,
|
||||
@@ -78,6 +79,10 @@ interface CodexFormFieldsProps {
|
||||
// Local proxy User-Agent override
|
||||
customUserAgent: string;
|
||||
onCustomUserAgentChange: (value: string) => void;
|
||||
localProxyHeadersOverride: string;
|
||||
onLocalProxyHeadersOverrideChange: (value: string) => void;
|
||||
localProxyBodyOverride: string;
|
||||
onLocalProxyBodyOverrideChange: (value: string) => void;
|
||||
}
|
||||
|
||||
type CodexCatalogRow = CodexCatalogModel & { rowId: string };
|
||||
@@ -136,6 +141,10 @@ export function CodexFormFields({
|
||||
speedTestEndpoints,
|
||||
customUserAgent,
|
||||
onCustomUserAgentChange,
|
||||
localProxyHeadersOverride,
|
||||
onLocalProxyHeadersOverrideChange,
|
||||
localProxyBodyOverride,
|
||||
onLocalProxyBodyOverrideChange,
|
||||
}: CodexFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -150,7 +159,11 @@ export function CodexFormFields({
|
||||
const supportsEffort = codexChatReasoning.supportsEffort === true;
|
||||
|
||||
// needsLocalRouting 非默认值说明预设/用户动过路由配置,需要让模型映射保持可见
|
||||
const hasAnyAdvancedValue = !!customUserAgent || needsLocalRouting;
|
||||
const hasRequestOverrides = Boolean(
|
||||
localProxyHeadersOverride.trim() || localProxyBodyOverride.trim(),
|
||||
);
|
||||
const hasAnyAdvancedValue =
|
||||
!!customUserAgent || hasRequestOverrides || needsLocalRouting;
|
||||
const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue);
|
||||
|
||||
// 预设/编辑加载填充高级值后自动展开(仅从折叠→展开,不会自动折叠)
|
||||
@@ -480,6 +493,7 @@ export function CodexFormFields({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-3",
|
||||
(shouldShowSpeedTest ||
|
||||
(needsLocalRouting && canEditReasoning)) &&
|
||||
"border-t border-border-default pt-3",
|
||||
@@ -490,6 +504,14 @@ export function CodexFormFields({
|
||||
value={customUserAgent}
|
||||
onChange={onCustomUserAgentChange}
|
||||
/>
|
||||
<div className="border-t border-border-default pt-3">
|
||||
<LocalProxyRequestOverridesField
|
||||
headersJson={localProxyHeadersOverride}
|
||||
bodyJson={localProxyBodyOverride}
|
||||
onHeadersJsonChange={onLocalProxyHeadersOverrideChange}
|
||||
onBodyJsonChange={onLocalProxyBodyOverrideChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模型映射 —— 仅在本地路由 + 可编辑时显示;上方恒有 UA 字段,分隔线无需条件 */}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
parseBodyOverrideJson,
|
||||
parseHeaderOverrideJson,
|
||||
} from "@/lib/requestOverrides";
|
||||
|
||||
interface LocalProxyRequestOverridesFieldProps {
|
||||
headersJson: string;
|
||||
bodyJson: string;
|
||||
onHeadersJsonChange: (value: string) => void;
|
||||
onBodyJsonChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function LocalProxyRequestOverridesField({
|
||||
headersJson,
|
||||
bodyJson,
|
||||
onHeadersJsonChange,
|
||||
onBodyJsonChange,
|
||||
}: LocalProxyRequestOverridesFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const headerError = parseHeaderOverrideJson(headersJson).error;
|
||||
const bodyError = parseBodyOverrideJson(bodyJson).error;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<FormLabel>
|
||||
{t("providerForm.localProxyRequestOverrides", {
|
||||
defaultValue: "本地代理请求覆盖",
|
||||
})}
|
||||
</FormLabel>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerForm.localProxyRequestOverridesHint", {
|
||||
defaultValue:
|
||||
"仅在本地路由/代理接管后生效,应用于协议转换后的上游请求。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<FormLabel className="text-xs text-muted-foreground">
|
||||
{t("providerForm.localProxyHeaderOverrides", {
|
||||
defaultValue: "Header 覆盖",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Textarea
|
||||
value={headersJson}
|
||||
onChange={(event) => onHeadersJsonChange(event.target.value)}
|
||||
placeholder={'{\n "X-Provider": "cc-switch"\n}'}
|
||||
className="min-h-[132px] resize-y font-mono text-xs"
|
||||
aria-invalid={Boolean(headerError)}
|
||||
/>
|
||||
{headerError && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t("providerForm.localProxyHeaderOverridesInvalidDetail", {
|
||||
error: headerError,
|
||||
defaultValue: "Header 覆盖格式错误:{{error}}",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel className="text-xs text-muted-foreground">
|
||||
{t("providerForm.localProxyBodyOverrides", {
|
||||
defaultValue: "Body 覆盖",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Textarea
|
||||
value={bodyJson}
|
||||
onChange={(event) => onBodyJsonChange(event.target.value)}
|
||||
placeholder={'{\n "temperature": 0.2\n}'}
|
||||
className="min-h-[132px] resize-y font-mono text-xs"
|
||||
aria-invalid={Boolean(bodyError)}
|
||||
/>
|
||||
{bodyError && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t("providerForm.localProxyBodyOverridesInvalidDetail", {
|
||||
error: bodyError,
|
||||
defaultValue: "Body 覆盖格式错误:{{error}}",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,10 @@ import { Button } from "@/components/ui/button";
|
||||
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
|
||||
import {
|
||||
buildLocalProxyRequestOverrides,
|
||||
formatRequestOverrideObject,
|
||||
} from "@/lib/requestOverrides";
|
||||
import { providersApi, settingsApi, type AppId } from "@/lib/api";
|
||||
import { useDarkMode } from "@/hooks/useDarkMode";
|
||||
import type {
|
||||
@@ -211,6 +215,10 @@ const normalizeCodexChatReasoningForSave = (
|
||||
};
|
||||
};
|
||||
|
||||
type LocalProxyRequestOverridesBuildResult = ReturnType<
|
||||
typeof buildLocalProxyRequestOverrides
|
||||
>;
|
||||
|
||||
export interface ProviderFormProps {
|
||||
appId: AppId;
|
||||
providerId?: string;
|
||||
@@ -358,6 +366,16 @@ function ProviderFormFull({
|
||||
});
|
||||
setCodexChatReasoning(initialData?.meta?.codexChatReasoning ?? {});
|
||||
setCustomUserAgent(initialData?.meta?.customUserAgent ?? "");
|
||||
setLocalProxyHeadersOverride(
|
||||
formatRequestOverrideObject(
|
||||
initialData?.meta?.localProxyRequestOverrides?.headers,
|
||||
),
|
||||
);
|
||||
setLocalProxyBodyOverride(
|
||||
formatRequestOverrideObject(
|
||||
initialData?.meta?.localProxyRequestOverrides?.body,
|
||||
),
|
||||
);
|
||||
}, [appId, initialData, supportsFullUrl]);
|
||||
|
||||
const defaultValues: ProviderFormData = useMemo(
|
||||
@@ -414,6 +432,10 @@ function ProviderFormFull({
|
||||
const [softIssues, setSoftIssues] = useState<string[] | null>(null);
|
||||
const [pendingFormValues, setPendingFormValues] =
|
||||
useState<ProviderFormData | null>(null);
|
||||
const [
|
||||
pendingLocalProxyRequestOverridesResult,
|
||||
setPendingLocalProxyRequestOverridesResult,
|
||||
] = useState<LocalProxyRequestOverridesBuildResult | null>(null);
|
||||
// 确认框走的提交路径绕过了 react-hook-form 的 isSubmitting,单独追踪
|
||||
const [isConfirmSubmitting, setIsConfirmSubmitting] = useState(false);
|
||||
|
||||
@@ -517,6 +539,18 @@ function ProviderFormFull({
|
||||
const [customUserAgent, setCustomUserAgent] = useState<string>(
|
||||
() => initialData?.meta?.customUserAgent ?? "",
|
||||
);
|
||||
const [localProxyHeadersOverride, setLocalProxyHeadersOverride] =
|
||||
useState<string>(() =>
|
||||
formatRequestOverrideObject(
|
||||
initialData?.meta?.localProxyRequestOverrides?.headers,
|
||||
),
|
||||
);
|
||||
const [localProxyBodyOverride, setLocalProxyBodyOverride] = useState<string>(
|
||||
() =>
|
||||
formatRequestOverrideObject(
|
||||
initialData?.meta?.localProxyRequestOverrides?.body,
|
||||
),
|
||||
);
|
||||
|
||||
const {
|
||||
codexAuth,
|
||||
@@ -931,7 +965,26 @@ function ProviderFormFull({
|
||||
|
||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||
|
||||
const shouldApplyLocalProxyRequestOverrides =
|
||||
(appId === "claude" || appId === "codex") && category !== "official";
|
||||
|
||||
const handleSubmit = async (values: ProviderFormData) => {
|
||||
const overridesResult = shouldApplyLocalProxyRequestOverrides
|
||||
? buildLocalProxyRequestOverrides(
|
||||
localProxyHeadersOverride,
|
||||
localProxyBodyOverride,
|
||||
)
|
||||
: {};
|
||||
if (overridesResult.error) {
|
||||
toast.error(
|
||||
t("providerForm.localProxyRequestOverridesInvalid", {
|
||||
defaultValue: `本地代理请求覆盖格式错误:${overridesResult.error}`,
|
||||
error: overridesResult.error,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 软性问题(业务约束,用户可选择仍要保存)
|
||||
const issues: string[] = [];
|
||||
|
||||
@@ -1169,13 +1222,27 @@ function ProviderFormFull({
|
||||
// 弹确认框让用户决定是否仍要保存
|
||||
setSoftIssues(issues);
|
||||
setPendingFormValues(values);
|
||||
setPendingLocalProxyRequestOverridesResult(overridesResult);
|
||||
return;
|
||||
}
|
||||
|
||||
await performSubmit(values);
|
||||
await performSubmit(values, overridesResult);
|
||||
};
|
||||
|
||||
const performSubmit = async (values: ProviderFormData) => {
|
||||
const performSubmit = async (
|
||||
values: ProviderFormData,
|
||||
overridesResult: LocalProxyRequestOverridesBuildResult,
|
||||
) => {
|
||||
if (overridesResult.error) {
|
||||
toast.error(
|
||||
t("providerForm.localProxyRequestOverridesInvalid", {
|
||||
defaultValue: `本地代理请求覆盖格式错误:${overridesResult.error}`,
|
||||
error: overridesResult.error,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// OAuth / 其它身份识别(与 handleSubmit 保持一致)
|
||||
const isCopilotProvider =
|
||||
templatePreset?.providerType === "github_copilot" ||
|
||||
@@ -1398,6 +1465,9 @@ function ProviderFormFull({
|
||||
(appId === "claude" || appId === "codex") && category !== "official"
|
||||
? customUserAgent.trim() || undefined
|
||||
: undefined,
|
||||
localProxyRequestOverrides: shouldApplyLocalProxyRequestOverrides
|
||||
? overridesResult.overrides
|
||||
: undefined,
|
||||
testConfig: testConfig.enabled ? testConfig : undefined,
|
||||
costMultiplier: pricingConfig.enabled
|
||||
? pricingConfig.costMultiplier
|
||||
@@ -2025,6 +2095,10 @@ function ProviderFormFull({
|
||||
onFullUrlChange={setLocalIsFullUrl}
|
||||
customUserAgent={customUserAgent}
|
||||
onCustomUserAgentChange={setCustomUserAgent}
|
||||
localProxyHeadersOverride={localProxyHeadersOverride}
|
||||
onLocalProxyHeadersOverrideChange={setLocalProxyHeadersOverride}
|
||||
localProxyBodyOverride={localProxyBodyOverride}
|
||||
onLocalProxyBodyOverrideChange={setLocalProxyBodyOverride}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2059,6 +2133,10 @@ function ProviderFormFull({
|
||||
speedTestEndpoints={speedTestEndpoints}
|
||||
customUserAgent={customUserAgent}
|
||||
onCustomUserAgentChange={setCustomUserAgent}
|
||||
localProxyHeadersOverride={localProxyHeadersOverride}
|
||||
onLocalProxyHeadersOverrideChange={setLocalProxyHeadersOverride}
|
||||
localProxyBodyOverride={localProxyBodyOverride}
|
||||
onLocalProxyBodyOverrideChange={setLocalProxyBodyOverride}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2383,15 +2461,19 @@ function ProviderFormFull({
|
||||
onConfirm={async () => {
|
||||
if (isConfirmSubmitting) return;
|
||||
const values = pendingFormValues;
|
||||
if (!values) {
|
||||
const overridesResult = pendingLocalProxyRequestOverridesResult;
|
||||
if (!values || !overridesResult) {
|
||||
setSoftIssues(null);
|
||||
setPendingFormValues(null);
|
||||
setPendingLocalProxyRequestOverridesResult(null);
|
||||
return;
|
||||
}
|
||||
setIsConfirmSubmitting(true);
|
||||
try {
|
||||
await performSubmit(values);
|
||||
await performSubmit(values, overridesResult);
|
||||
setSoftIssues(null);
|
||||
setPendingFormValues(null);
|
||||
setPendingLocalProxyRequestOverridesResult(null);
|
||||
} catch (error) {
|
||||
console.error("[ProviderForm] soft-confirm submit failed:", error);
|
||||
// 保留确认框和 pending values,让用户可以重试或取消
|
||||
@@ -2403,6 +2485,7 @@ function ProviderFormFull({
|
||||
if (isConfirmSubmitting) return;
|
||||
setSoftIssues(null);
|
||||
setPendingFormValues(null);
|
||||
setPendingLocalProxyRequestOverridesResult(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user