Files
CC-Switch/src/components/providers/forms/ProviderForm.tsx
T
Jason 15e712e779 feat(codex): support native Responses direct-connect with generated model catalog
Codex providers can now run in two modes per provider:

- Proxy-Chat (route takeover): apiFormat=openai_chat, existing Responses<->Chat conversion. Unchanged.

- Native-Responses (direct): apiFormat=openai_responses, no proxy. cc-switch generates ~/.codex/cc-switch-model-catalog.json so Codex shows the custom models and tools work without the freeform apply_patch (type=custom) tool that native gateways like MiMo reject; editing falls back to shell_command.

Catalog generation is keyed on apiFormat (CodexCatalogToolProfile), decoupled from the takeover toggle, so native providers persist a catalog without enabling local route mapping.

Codex's catalog parser requires base_instructions on every entry; the native template carries a neutral default and per-vendor official text overrides it (MiMo, MiniMax). Synthesized catalogs for Qwen/Doubao/LongCat use the neutral default.

Existing native providers must be re-saved once to regenerate a valid catalog (no DB migration).
2026-06-29 23:30:58 +08:00

2554 lines
87 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState, useCallback } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
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 {
ProviderCategory,
ProviderMeta,
ProviderTestConfig,
ClaudeApiFormat,
CodexApiFormat,
CodexCatalogModel,
CodexChatReasoning,
ClaudeApiKeyField,
} from "@/types";
import {
providerPresets,
type ProviderPreset,
} from "@/config/claudeProviderPresets";
import {
codexProviderPresets,
type CodexProviderPreset,
} from "@/config/codexProviderPresets";
import {
geminiProviderPresets,
type GeminiProviderPreset,
} from "@/config/geminiProviderPresets";
import {
opencodeProviderPresets,
type OpenCodeProviderPreset,
} from "@/config/opencodeProviderPresets";
import {
openclawProviderPresets,
rebaseOpenClawSuggestedDefaults,
type OpenClawProviderPreset,
type OpenClawSuggestedDefaults,
} from "@/config/openclawProviderPresets";
import {
hermesProviderPresets,
type HermesProviderPreset,
} from "@/config/hermesProviderPresets";
import { OpenCodeFormFields } from "./OpenCodeFormFields";
import { OpenClawFormFields } from "./OpenClawFormFields";
import { HermesFormFields } from "./HermesFormFields";
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
import {
applyTemplateValues,
hasApiKeyField,
} from "@/utils/providerConfigUtils";
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
import {
extractCodexWireApi,
setCodexWireApi,
setCodexModelName as setCodexModelNameInConfig,
} from "@/utils/providerConfigUtils";
import { isNonNegativeDecimalString } from "@/types/usage";
import { getCodexCustomTemplate } from "@/config/codexTemplates";
import CodexConfigEditor from "./CodexConfigEditor";
import { CommonConfigEditor } from "./CommonConfigEditor";
import GeminiConfigEditor from "./GeminiConfigEditor";
import JsonEditor from "@/components/JsonEditor";
import { Label } from "@/components/ui/label";
import { ProviderPresetSelector } from "./ProviderPresetSelector";
import { BasicFormFields } from "./BasicFormFields";
import { ClaudeFormFields } from "./ClaudeFormFields";
import { ClaudeDesktopProviderForm } from "./ClaudeDesktopProviderForm";
import { CodexFormFields } from "./CodexFormFields";
import { GeminiFormFields } from "./GeminiFormFields";
import { OmoFormFields } from "./OmoFormFields";
import { parseOmoOtherFieldsObject } from "@/types/omo";
import {
ProviderAdvancedConfig,
type PricingModelSourceOption,
} from "./ProviderAdvancedConfig";
import {
useProviderCategory,
useApiKeyState,
useBaseUrlState,
useModelState,
useCodexConfigState,
useApiKeyLink,
useTemplateValues,
useCommonConfigSnippet,
useCodexCommonConfig,
useSpeedTestEndpoints,
useCodexTomlValidation,
useGeminiConfigState,
useGeminiCommonConfig,
useOmoModelSource,
useOpencodeFormState,
useOmoDraftState,
useOpenclawFormState,
useHermesFormState,
useCopilotAuth,
useCodexOauth,
} from "./hooks";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { useSettingsQuery } from "@/lib/query";
import {
CLAUDE_DEFAULT_CONFIG,
CODEX_DEFAULT_CONFIG,
GEMINI_DEFAULT_CONFIG,
OPENCODE_DEFAULT_CONFIG,
OPENCLAW_DEFAULT_CONFIG,
normalizePricingSource,
} from "./helpers/opencodeFormUtils";
import { HERMES_DEFAULT_CONFIG } from "./hooks/useHermesFormState";
import { resolveManagedAccountId } from "@/lib/authBinding";
import { useOpenClawLiveProviderIds } from "@/hooks/useOpenClaw";
import { useHermesLiveProviderIds } from "@/hooks/useHermes";
type PresetEntry = {
id: string;
preset:
| ProviderPreset
| CodexProviderPreset
| GeminiProviderPreset
| OpenCodeProviderPreset
| OpenClawProviderPreset
| HermesProviderPreset;
};
const codexApiFormatFromWireApi = (
wireApi: string | undefined,
): CodexApiFormat | undefined => {
switch (wireApi?.trim().toLowerCase()) {
case "chat":
case "chat_completions":
case "chat-completions":
case "openai_chat":
case "openai-chat":
return "openai_chat";
case "responses":
case "openai_responses":
case "openai-responses":
return "openai_responses";
default:
return undefined;
}
};
// 从已保存的 settingsConfig 推断 Codex 模型映射条目数(用于决定本地路由初始开关)。
const codexCatalogCountFromSettings = (settingsConfig: unknown): number => {
if (settingsConfig && typeof settingsConfig === "object") {
const models = (settingsConfig as { modelCatalog?: { models?: unknown } })
.modelCatalog?.models;
return Array.isArray(models) ? models.length : 0;
}
return 0;
};
export const normalizeCodexCatalogModelsForSave = (
models: CodexCatalogModel[],
): CodexCatalogModel[] => {
const seen = new Set<string>();
const normalized: CodexCatalogModel[] = [];
for (const item of models) {
const model = item.model.trim();
if (!model || seen.has(model)) continue;
seen.add(model);
const displayName = item.displayName?.trim();
const rawContextWindow = String(item.contextWindow ?? "").replace(
/[^\d]/g,
"",
);
const contextWindow = rawContextWindow
? Number.parseInt(rawContextWindow, 10)
: undefined;
const inputModalities = item.inputModalities?.filter(
(m) => typeof m === "string" && m.trim(),
);
const baseInstructions = item.baseInstructions?.trim();
normalized.push({
model,
...(displayName ? { displayName } : {}),
...(contextWindow && contextWindow > 0 ? { contextWindow } : {}),
// Native Responses profile overrides (ignored by the chat/proxy profile).
...(typeof item.supportsParallelToolCalls === "boolean"
? { supportsParallelToolCalls: item.supportsParallelToolCalls }
: {}),
...(inputModalities && inputModalities.length > 0
? { inputModalities }
: {}),
...(baseInstructions ? { baseInstructions } : {}),
});
}
return normalized;
};
const normalizeCodexChatReasoningForSave = (
value?: CodexChatReasoning,
): CodexChatReasoning | undefined => {
const supportsEffort = value?.supportsEffort === true;
const supportsThinking = value?.supportsThinking === true || supportsEffort;
const hasExplicitConfig = value && Object.keys(value).length > 0;
if (!supportsThinking && !supportsEffort) {
return hasExplicitConfig
? {
supportsThinking: false,
supportsEffort: false,
thinkingParam: "none",
effortParam: "none",
outputFormat: value?.outputFormat ?? "auto",
}
: undefined;
}
return {
supportsThinking,
supportsEffort,
thinkingParam: supportsThinking
? (value?.thinkingParam ?? "thinking")
: "none",
effortParam: supportsEffort
? (value?.effortParam ?? "reasoning_effort")
: "none",
effortValueMode: supportsEffort
? (value?.effortValueMode ?? "passthrough")
: undefined,
outputFormat: value?.outputFormat ?? "auto",
};
};
type LocalProxyRequestOverridesBuildResult = ReturnType<
typeof buildLocalProxyRequestOverrides
>;
export interface ProviderFormProps {
appId: AppId;
providerId?: string;
submitLabel: string;
onSubmit: (values: ProviderFormValues) => Promise<void> | void;
onCancel: () => void;
onUniversalPresetSelect?: (preset: UniversalProviderPreset) => void;
onManageUniversalProviders?: () => void;
onSubmittingChange?: (isSubmitting: boolean) => void;
initialData?: {
name?: string;
websiteUrl?: string;
notes?: string;
settingsConfig?: Record<string, unknown>;
category?: ProviderCategory;
meta?: ProviderMeta;
icon?: string;
iconColor?: string;
};
showButtons?: boolean;
isProxyTakeover?: boolean;
}
export function ProviderForm(props: ProviderFormProps) {
if (props.appId === "claude-desktop") {
return <ClaudeDesktopProviderForm {...props} />;
}
return <ProviderFormFull {...props} />;
}
function ProviderFormFull({
appId,
providerId,
submitLabel,
onSubmit,
onCancel,
onUniversalPresetSelect,
onManageUniversalProviders,
onSubmittingChange,
initialData,
showButtons = true,
isProxyTakeover = false,
}: ProviderFormProps) {
if (appId === "claude-desktop") {
throw new Error("ProviderFormFull should not receive claude-desktop");
}
const { t } = useTranslation();
const isEditMode = Boolean(initialData);
const queryClient = useQueryClient();
const { data: settingsData } = useSettingsQuery();
const showCommonConfigNotice =
settingsData != null && settingsData.commonConfigConfirmed !== true;
const isDarkMode = useDarkMode();
const handleCommonConfigConfirm = async () => {
try {
if (settingsData) {
const { webdavSync: _, ...rest } = settingsData;
await settingsApi.save({ ...rest, commonConfigConfirmed: true });
await queryClient.invalidateQueries({ queryKey: ["settings"] });
}
} catch (error) {
console.error("Failed to save commonConfigConfirmed:", error);
}
};
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(
initialData ? null : "custom",
);
const [activePreset, setActivePreset] = useState<{
id: string;
category?: ProviderCategory;
isPartner?: boolean;
partnerPromotionKey?: string;
suggestedDefaults?: OpenClawSuggestedDefaults;
} | null>(null);
const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false);
const [isCodexEndpointModalOpen, setIsCodexEndpointModalOpen] =
useState(false);
const [draftCustomEndpoints, setDraftCustomEndpoints] = useState<string[]>(
() => {
if (initialData) return [];
return [];
},
);
const [endpointAutoSelect, setEndpointAutoSelect] = useState<boolean>(
() => initialData?.meta?.endpointAutoSelect ?? true,
);
const supportsFullUrl = appId === "claude" || appId === "codex";
const [localIsFullUrl, setLocalIsFullUrl] = useState<boolean>(() => {
if (!supportsFullUrl) return false;
return initialData?.meta?.isFullUrl ?? false;
});
const [testConfig, setTestConfig] = useState<ProviderTestConfig>(
() => initialData?.meta?.testConfig ?? { enabled: false },
);
const [pricingConfig, setPricingConfig] = useState<{
enabled: boolean;
costMultiplier?: string;
pricingModelSource: PricingModelSourceOption;
}>(() => ({
enabled:
initialData?.meta?.costMultiplier !== undefined ||
initialData?.meta?.pricingModelSource !== undefined,
costMultiplier: initialData?.meta?.costMultiplier,
pricingModelSource: normalizePricingSource(
initialData?.meta?.pricingModelSource,
),
}));
const { category } = useProviderCategory({
appId,
selectedPresetId,
isEditMode,
initialCategory: initialData?.category,
});
const isOmoCategory = appId === "opencode" && category === "omo";
const isOmoSlimCategory = appId === "opencode" && category === "omo-slim";
const isAnyOmoCategory = isOmoCategory || isOmoSlimCategory;
useEffect(() => {
setSelectedPresetId(initialData ? null : "custom");
setActivePreset(null);
if (!initialData) {
setDraftCustomEndpoints([]);
}
setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true);
setLocalIsFullUrl(
supportsFullUrl ? (initialData?.meta?.isFullUrl ?? false) : false,
);
setTestConfig(initialData?.meta?.testConfig ?? { enabled: false });
setPricingConfig({
enabled:
initialData?.meta?.costMultiplier !== undefined ||
initialData?.meta?.pricingModelSource !== undefined,
costMultiplier: initialData?.meta?.costMultiplier,
pricingModelSource: normalizePricingSource(
initialData?.meta?.pricingModelSource,
),
});
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(
() => ({
name: initialData?.name ?? "",
websiteUrl: initialData?.websiteUrl ?? "",
notes: initialData?.notes ?? "",
settingsConfig: initialData?.settingsConfig
? JSON.stringify(initialData.settingsConfig, null, 2)
: appId === "codex"
? CODEX_DEFAULT_CONFIG
: appId === "gemini"
? GEMINI_DEFAULT_CONFIG
: appId === "opencode"
? OPENCODE_DEFAULT_CONFIG
: appId === "openclaw"
? OPENCLAW_DEFAULT_CONFIG
: appId === "hermes"
? HERMES_DEFAULT_CONFIG
: CLAUDE_DEFAULT_CONFIG,
icon: initialData?.icon ?? "",
iconColor: initialData?.iconColor ?? "",
}),
[initialData, appId],
);
const form = useForm<ProviderFormData>({
resolver: zodResolver(providerSchema),
defaultValues,
mode: "onSubmit",
});
const { isSubmitting } = form.formState;
const handleSettingsConfigChange = useCallback(
(config: string) => {
form.setValue("settingsConfig", config);
},
[form],
);
const [localApiKeyField, setLocalApiKeyField] = useState<ClaudeApiKeyField>(
() => {
if (appId !== "claude") return "ANTHROPIC_AUTH_TOKEN";
if (initialData?.meta?.apiKeyField) return initialData.meta.apiKeyField;
// Infer from existing config env
const env = (initialData?.settingsConfig as Record<string, unknown>)
?.env as Record<string, unknown> | undefined;
if (env?.ANTHROPIC_API_KEY !== undefined) return "ANTHROPIC_API_KEY";
return "ANTHROPIC_AUTH_TOKEN";
},
);
// 软校验:收集"业务约束"类问题(空值/缺项),由用户决定是否仍要保存
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);
useEffect(() => {
onSubmittingChange?.(isSubmitting || isConfirmSubmitting);
}, [isSubmitting, isConfirmSubmitting, onSubmittingChange]);
const {
apiKey,
handleApiKeyChange,
showApiKey: shouldShowApiKey,
} = useApiKeyState({
initialConfig: form.getValues("settingsConfig"),
onConfigChange: handleSettingsConfigChange,
selectedPresetId,
category,
appType: appId,
apiKeyField: appId === "claude" ? localApiKeyField : undefined,
});
const { baseUrl, handleClaudeBaseUrlChange } = useBaseUrlState({
appType: appId,
category,
settingsConfig: form.getValues("settingsConfig"),
codexConfig: "",
onSettingsConfigChange: handleSettingsConfigChange,
onCodexConfigChange: () => {},
});
const {
claudeModel,
defaultHaikuModel,
defaultHaikuModelName,
defaultSonnetModel,
defaultSonnetModelName,
defaultOpusModel,
defaultOpusModelName,
defaultFableModel,
defaultFableModelName,
handleModelChange,
} = useModelState({
settingsConfig: form.getValues("settingsConfig"),
onConfigChange: handleSettingsConfigChange,
});
const [localApiFormat, setLocalApiFormat] = useState<ClaudeApiFormat>(() => {
if (appId !== "claude") return "anthropic";
return initialData?.meta?.apiFormat ?? "anthropic";
});
const handleApiFormatChange = useCallback((format: ClaudeApiFormat) => {
setLocalApiFormat(format);
}, []);
const handleApiKeyFieldChange = useCallback(
(field: ClaudeApiKeyField) => {
const prev = localApiKeyField;
setLocalApiKeyField(field);
// Swap the env key name in settingsConfig
try {
const raw = form.getValues("settingsConfig");
const config = JSON.parse(raw || "{}");
if (config?.env && prev in config.env) {
const value = config.env[prev];
delete config.env[prev];
config.env[field] = value;
const updated = JSON.stringify(config, null, 2);
form.setValue("settingsConfig", updated);
handleSettingsConfigChange(updated);
}
} catch {
// ignore parse errors during editing
}
},
[localApiKeyField, form, handleSettingsConfigChange],
);
// Copilot OAuth 认证状态(仅 Claude 应用需要)
const { isAuthenticated: isCopilotAuthenticated } = useCopilotAuth();
// Codex OAuth 认证状态(ChatGPT Plus/Pro 反代)
const { isAuthenticated: isCodexOauthAuthenticated } = useCodexOauth();
// 选中的 GitHub 账号 ID(多账号支持)
const [selectedGitHubAccountId, setSelectedGitHubAccountId] = useState<
string | null
>(() => resolveManagedAccountId(initialData?.meta, "github_copilot"));
// 选中的 ChatGPT 账号 IDCodex OAuth 多账号支持)
const [selectedCodexAccountId, setSelectedCodexAccountId] = useState<
string | null
>(() => resolveManagedAccountId(initialData?.meta, "codex_oauth"));
const [codexFastMode, setCodexFastMode] = useState<boolean>(
() => initialData?.meta?.codexFastMode ?? false,
);
const [codexChatReasoning, setCodexChatReasoning] =
useState<CodexChatReasoning>(
() => initialData?.meta?.codexChatReasoning ?? {},
);
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,
codexConfig,
codexApiKey,
codexBaseUrl,
codexCatalogModels,
codexAuthError,
setCodexAuth,
setCodexConfig,
setCodexCatalogModels,
handleCodexApiKeyChange,
handleCodexBaseUrlChange,
handleCodexConfigChange: originalHandleCodexConfigChange,
resetCodexConfig,
} = useCodexConfigState({ initialData });
const initialCodexApiFormat: CodexApiFormat =
initialData?.meta?.apiFormat === "openai_chat"
? "openai_chat"
: initialData?.meta?.apiFormat === "openai_responses"
? "openai_responses"
: (codexApiFormatFromWireApi(
extractCodexWireApi(
typeof initialData?.settingsConfig?.config === "string"
? initialData.settingsConfig.config
: "",
),
) ?? "openai_responses");
const [localCodexApiFormat, setLocalCodexApiFormat] =
useState<CodexApiFormat>(initialCodexApiFormat);
// 本地路由(接管)开关 —— 纯模型映射门控,与上游格式完全独立。
// 没有独立持久化字段,初值仅按「是否已配置模型映射」推断(有 catalog 即视为
// 接管已开)。只在 useState 初始化与预设重置点设置,跟 localCodexApiFormat
// 对称,避免漂移。
const [codexTakeoverEnabled, setCodexTakeoverEnabled] = useState<boolean>(
() =>
// Native (openai_responses) providers run DIRECT (no proxy takeover) yet
// still carry a modelCatalog so cc-switch can generate model-catalogs.json.
// So a catalog must NOT auto-enable takeover for native providers.
initialCodexApiFormat !== "openai_responses" &&
codexCatalogCountFromSettings(initialData?.settingsConfig) > 0,
);
const { configError: codexConfigError, debouncedValidate } =
useCodexTomlValidation();
const handleCodexConfigChange = useCallback(
(value: string) => {
originalHandleCodexConfigChange(value);
debouncedValidate(value);
},
[originalHandleCodexConfigChange, debouncedValidate],
);
const handleCodexApiFormatChange = useCallback(
(format: CodexApiFormat) => {
setLocalCodexApiFormat(format);
// wire_api is always "responses" for Codex; format controls proxy-layer conversion
setCodexConfig((prev) => {
const updated = setCodexWireApi(prev, "responses");
debouncedValidate(updated);
return updated;
});
},
[setCodexConfig, debouncedValidate],
);
useEffect(() => {
if (appId === "codex" && !initialData && selectedPresetId === "custom") {
const template = getCodexCustomTemplate();
resetCodexConfig(template.auth, template.config);
setCodexChatReasoning({});
setCodexTakeoverEnabled(false);
}
}, [appId, initialData, selectedPresetId, resetCodexConfig]);
useEffect(() => {
form.reset(defaultValues);
}, [defaultValues, form]);
const presetCategoryLabels: Record<string, string> = useMemo(
() => ({
official: t("providerForm.categoryOfficial", {
defaultValue: "官方",
}),
cn_official: t("providerForm.categoryCnOfficial", {
defaultValue: "国内官方",
}),
aggregator: t("providerForm.categoryAggregation", {
defaultValue: "聚合服务",
}),
third_party: t("providerForm.categoryThirdParty", {
defaultValue: "第三方",
}),
omo: "OMO",
}),
[t],
);
const presetEntries = useMemo(() => {
if (appId === "codex") {
return codexProviderPresets.map<PresetEntry>((preset, index) => ({
id: `codex-${index}`,
preset,
}));
} else if (appId === "gemini") {
return geminiProviderPresets.map<PresetEntry>((preset, index) => ({
id: `gemini-${index}`,
preset,
}));
} else if (appId === "opencode") {
return opencodeProviderPresets.map<PresetEntry>((preset, index) => ({
id: `opencode-${index}`,
preset,
}));
} else if (appId === "openclaw") {
return openclawProviderPresets.map<PresetEntry>((preset, index) => ({
id: `openclaw-${index}`,
preset,
}));
} else if (appId === "hermes") {
return hermesProviderPresets.map<PresetEntry>((preset, index) => ({
id: `hermes-${index}`,
preset,
}));
}
return providerPresets
.filter((p) => !p.hidden)
.map<PresetEntry>((preset, index) => ({
id: `claude-${index}`,
preset,
}));
}, [appId]);
const {
templateValues,
templateValueEntries,
selectedPreset: templatePreset,
handleTemplateValueChange,
validateTemplateValues,
} = useTemplateValues({
selectedPresetId: appId === "claude" ? selectedPresetId : null,
presetEntries: appId === "claude" ? presetEntries : [],
settingsConfig: form.getValues("settingsConfig"),
onConfigChange: handleSettingsConfigChange,
});
const {
useCommonConfig,
commonConfigSnippet,
commonConfigError,
handleCommonConfigToggle,
handleCommonConfigSnippetChange,
isExtracting: isClaudeExtracting,
handleExtract: handleClaudeExtract,
} = useCommonConfigSnippet({
settingsConfig: form.getValues("settingsConfig"),
onConfigChange: handleSettingsConfigChange,
initialData: appId === "claude" ? initialData : undefined,
initialEnabled:
appId === "claude" ? initialData?.meta?.commonConfigEnabled : undefined,
selectedPresetId: selectedPresetId ?? undefined,
enabled: appId === "claude",
});
const {
useCommonConfig: useCodexCommonConfigFlag,
commonConfigSnippet: codexCommonConfigSnippet,
commonConfigError: codexCommonConfigError,
handleCommonConfigToggle: handleCodexCommonConfigToggle,
handleCommonConfigSnippetChange: handleCodexCommonConfigSnippetChange,
isExtracting: isCodexExtracting,
handleExtract: handleCodexExtract,
clearCommonConfigError: clearCodexCommonConfigError,
} = useCodexCommonConfig({
codexConfig,
onConfigChange: handleCodexConfigChange,
initialData: appId === "codex" ? initialData : undefined,
initialEnabled:
appId === "codex" ? initialData?.meta?.commonConfigEnabled : undefined,
selectedPresetId: selectedPresetId ?? undefined,
});
const {
geminiEnv,
geminiConfig,
geminiApiKey,
geminiBaseUrl,
geminiModel,
envError,
configError: geminiConfigError,
handleGeminiApiKeyChange: originalHandleGeminiApiKeyChange,
handleGeminiBaseUrlChange: originalHandleGeminiBaseUrlChange,
handleGeminiModelChange: originalHandleGeminiModelChange,
handleGeminiEnvChange,
handleGeminiConfigChange,
resetGeminiConfig,
envStringToObj,
envObjToString,
} = useGeminiConfigState({
initialData: appId === "gemini" ? initialData : undefined,
});
const updateGeminiEnvField = useCallback(
(
key: "GEMINI_API_KEY" | "GOOGLE_GEMINI_BASE_URL" | "GEMINI_MODEL",
value: string,
) => {
try {
const config = JSON.parse(form.getValues("settingsConfig") || "{}") as {
env?: Record<string, unknown>;
};
if (!config.env || typeof config.env !== "object") {
config.env = {};
}
config.env[key] = value;
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
} catch {}
},
[form],
);
const handleGeminiApiKeyChange = useCallback(
(key: string) => {
originalHandleGeminiApiKeyChange(key);
updateGeminiEnvField("GEMINI_API_KEY", key.trim());
},
[originalHandleGeminiApiKeyChange, updateGeminiEnvField],
);
const handleGeminiBaseUrlChange = useCallback(
(url: string) => {
originalHandleGeminiBaseUrlChange(url);
updateGeminiEnvField(
"GOOGLE_GEMINI_BASE_URL",
url.trim().replace(/\/+$/, ""),
);
},
[originalHandleGeminiBaseUrlChange, updateGeminiEnvField],
);
const handleGeminiModelChange = useCallback(
(model: string) => {
originalHandleGeminiModelChange(model);
updateGeminiEnvField("GEMINI_MODEL", model.trim());
},
[originalHandleGeminiModelChange, updateGeminiEnvField],
);
const {
useCommonConfig: useGeminiCommonConfigFlag,
commonConfigSnippet: geminiCommonConfigSnippet,
commonConfigError: geminiCommonConfigError,
handleCommonConfigToggle: handleGeminiCommonConfigToggle,
handleCommonConfigSnippetChange: handleGeminiCommonConfigSnippetChange,
isExtracting: isGeminiExtracting,
handleExtract: handleGeminiExtract,
clearCommonConfigError: clearGeminiCommonConfigError,
} = useGeminiCommonConfig({
envValue: geminiEnv,
onEnvChange: handleGeminiEnvChange,
envStringToObj,
envObjToString,
initialData: appId === "gemini" ? initialData : undefined,
initialEnabled:
appId === "gemini" ? initialData?.meta?.commonConfigEnabled : undefined,
selectedPresetId: selectedPresetId ?? undefined,
});
// ── Extracted hooks: OpenCode / OMO / OpenClaw ─────────────────────
const {
omoModelOptions,
omoModelVariantsMap,
omoPresetMetaMap,
existingOpencodeKeys,
} = useOmoModelSource({ isOmoCategory: isAnyOmoCategory, providerId });
const {
data: opencodeLiveProviderIds = [],
isLoading: isOpencodeLiveProviderIdsLoading,
} = useQuery({
queryKey: ["opencodeLiveProviderIds"],
queryFn: () => providersApi.getOpenCodeLiveProviderIds(),
enabled: appId === "opencode" && !isAnyOmoCategory,
});
const opencodeForm = useOpencodeFormState({
initialData,
appId,
providerId,
onSettingsConfigChange: (config) => form.setValue("settingsConfig", config),
getSettingsConfig: () => form.getValues("settingsConfig"),
});
const initialOmoSettings =
appId === "opencode" &&
(initialData?.category === "omo" || initialData?.category === "omo-slim")
? (initialData.settingsConfig as Record<string, unknown> | undefined)
: undefined;
const omoDraft = useOmoDraftState({
initialOmoSettings,
isEditMode,
appId,
category,
});
const openclawForm = useOpenclawFormState({
initialData,
appId,
providerId,
onSettingsConfigChange: (config) => form.setValue("settingsConfig", config),
getSettingsConfig: () => form.getValues("settingsConfig"),
});
const {
data: openclawLiveProviderIds = [],
isLoading: isOpenclawLiveProviderIdsLoading,
} = useOpenClawLiveProviderIds(appId === "openclaw");
const hermesForm = useHermesFormState({
initialData,
appId,
providerId,
onSettingsConfigChange: (config) => form.setValue("settingsConfig", config),
getSettingsConfig: () => form.getValues("settingsConfig"),
});
const {
data: hermesLiveProviderIds = [],
isLoading: isHermesLiveProviderIdsLoading,
} = useHermesLiveProviderIds(appId === "hermes");
const additiveExistingProviderKeys = useMemo(() => {
if (appId === "opencode" && !isAnyOmoCategory) {
return Array.from(
new Set(
[...existingOpencodeKeys, ...opencodeLiveProviderIds].filter(
(key) => key !== providerId,
),
),
);
}
if (appId === "openclaw") {
return Array.from(
new Set(
[
...openclawForm.existingOpenclawKeys,
...openclawLiveProviderIds,
].filter((key) => key !== providerId),
),
);
}
if (appId === "hermes") {
return Array.from(
new Set(
[...hermesForm.existingHermesKeys, ...hermesLiveProviderIds].filter(
(key) => key !== providerId,
),
),
);
}
return [];
}, [
appId,
existingOpencodeKeys,
hermesForm.existingHermesKeys,
hermesLiveProviderIds,
isAnyOmoCategory,
openclawForm.existingOpenclawKeys,
openclawLiveProviderIds,
opencodeLiveProviderIds,
providerId,
]);
const isProviderKeyLockStateLoading = useMemo(() => {
if (!isEditMode) return false;
if (appId === "opencode" && !isAnyOmoCategory) {
return isOpencodeLiveProviderIdsLoading;
}
if (appId === "openclaw") {
return isOpenclawLiveProviderIdsLoading;
}
if (appId === "hermes") {
return isHermesLiveProviderIdsLoading;
}
return false;
}, [
appId,
isAnyOmoCategory,
isEditMode,
isHermesLiveProviderIdsLoading,
isOpenclawLiveProviderIdsLoading,
isOpencodeLiveProviderIdsLoading,
]);
const isProviderKeyLocked = useMemo(() => {
if (!isEditMode || !providerId) return false;
if (appId === "opencode" && !isAnyOmoCategory) {
return opencodeLiveProviderIds.includes(providerId);
}
if (appId === "openclaw") {
return openclawLiveProviderIds.includes(providerId);
}
if (appId === "hermes") {
return hermesLiveProviderIds.includes(providerId);
}
return false;
}, [
appId,
hermesLiveProviderIds,
isAnyOmoCategory,
isEditMode,
openclawLiveProviderIds,
opencodeLiveProviderIds,
providerId,
]);
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[] = [];
// 模板变量未填:A 类(空值)
if (appId === "claude" && templateValueEntries.length > 0) {
const validation = validateTemplateValues();
if (!validation.isValid && validation.missingField) {
issues.push(
t("providerForm.fillParameter", {
label: validation.missingField.label,
defaultValue: `请填写 ${validation.missingField.label}`,
}),
);
}
}
// 供应商名空:A 类
if (!values.name.trim()) {
issues.push(
t("providerForm.fillSupplierName", {
defaultValue: "请填写供应商名称",
}),
);
}
const costMultiplier = pricingConfig.costMultiplier?.trim();
if (
pricingConfig.enabled &&
costMultiplier &&
!isNonNegativeDecimalString(costMultiplier)
) {
toast.error(
t("settings.globalProxy.defaultCostMultiplierInvalid", {
defaultValue: "成本倍率必须为非负数",
}),
);
return;
}
// opencode / openclaw / hermes: providerKey 相关
// A 类(空)归到 issues;B 类(正则不合法 / 重复 / 状态加载中)仍硬拒绝
const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
if (appId === "opencode" && !isAnyOmoCategory) {
// providerKey 是 opencode / openclaw / hermes 的主键 ID,空或格式不合法
// 都属于完整性约束,保留硬拒绝(mutations 层也会 throw,软化只会让错误更晦涩)
if (!opencodeForm.opencodeProviderKey.trim()) {
toast.error(t("opencode.providerKeyRequired"));
return;
}
if (!keyPattern.test(opencodeForm.opencodeProviderKey)) {
toast.error(t("opencode.providerKeyInvalid"));
return;
}
if (isProviderKeyLockStateLoading) {
toast.error(
t("providerForm.providerKeyStatusLoading", {
defaultValue: "正在加载供应商标识状态,请稍后再试",
}),
);
return;
}
if (
!isProviderKeyLocked &&
additiveExistingProviderKeys.includes(opencodeForm.opencodeProviderKey)
) {
toast.error(t("opencode.providerKeyDuplicate"));
return;
}
if (Object.keys(opencodeForm.opencodeModels).length === 0) {
issues.push(t("opencode.modelsRequired"));
}
}
if (appId === "openclaw") {
if (!openclawForm.openclawProviderKey.trim()) {
toast.error(t("openclaw.providerKeyRequired"));
return;
}
if (!keyPattern.test(openclawForm.openclawProviderKey)) {
toast.error(t("openclaw.providerKeyInvalid"));
return;
}
if (isProviderKeyLockStateLoading) {
toast.error(
t("providerForm.providerKeyStatusLoading", {
defaultValue: "正在加载供应商标识状态,请稍后再试",
}),
);
return;
}
if (
!isProviderKeyLocked &&
additiveExistingProviderKeys.includes(openclawForm.openclawProviderKey)
) {
toast.error(t("openclaw.providerKeyDuplicate"));
return;
}
}
if (appId === "hermes") {
if (!hermesForm.hermesProviderKey.trim()) {
toast.error(t("hermes.form.providerKeyRequired"));
return;
}
if (!keyPattern.test(hermesForm.hermesProviderKey)) {
toast.error(t("hermes.form.providerKeyInvalid"));
return;
}
if (isProviderKeyLockStateLoading) {
toast.error(
t("providerForm.providerKeyStatusLoading", {
defaultValue: "正在加载供应商标识状态,请稍后再试",
}),
);
return;
}
if (
!isProviderKeyLocked &&
additiveExistingProviderKeys.includes(hermesForm.hermesProviderKey)
) {
toast.error(t("hermes.form.providerKeyDuplicate"));
return;
}
}
// OAuth 未登录:B 类(token 根本不存在,保存了也没法建立)
const isCopilotProvider =
templatePreset?.providerType === "github_copilot" ||
initialData?.meta?.providerType === "github_copilot" ||
baseUrl.includes("githubcopilot.com");
const isCodexOauthProvider =
templatePreset?.providerType === "codex_oauth" ||
initialData?.meta?.providerType === "codex_oauth";
if (isCopilotProvider && !isCopilotAuthenticated) {
toast.error(
t("copilot.loginRequired", {
defaultValue: "请先登录 GitHub Copilot",
}),
);
return;
}
if (isCodexOauthProvider && !isCodexOauthAuthenticated) {
toast.error(
t("codexOauth.loginRequired", {
defaultValue: "请先登录 ChatGPT 账号",
}),
);
return;
}
// OMO Other Fields JSONB 类(格式错了保存下去数据就坏了)
if (
appId === "opencode" &&
isAnyOmoCategory &&
omoDraft.omoOtherFieldsStr.trim()
) {
try {
const otherFields = parseOmoOtherFieldsObject(
omoDraft.omoOtherFieldsStr,
);
if (!otherFields) {
toast.error(
t("omo.jsonMustBeObject", {
field: t("omo.otherFields", {
defaultValue: "Other Config",
}),
defaultValue: "{{field}} must be a JSON object",
}),
);
return;
}
} catch {
toast.error(
t("omo.invalidJson", {
defaultValue: "Other Fields contains invalid JSON",
}),
);
return;
}
}
// 非官方供应商端点 / API Key 空:A 类
// cloud_provider(如 Bedrock)通过模板变量处理认证,跳过通用校验
if (category !== "official" && category !== "cloud_provider") {
if (appId === "claude") {
if (!isCodexOauthProvider && !baseUrl.trim()) {
issues.push(
t("providerForm.endpointRequired", {
defaultValue: "非官方供应商请填写 API 端点",
}),
);
}
if (!isCopilotProvider && !isCodexOauthProvider && !apiKey.trim()) {
issues.push(
t("providerForm.apiKeyRequired", {
defaultValue: "非官方供应商请填写 API Key",
}),
);
}
} else if (appId === "codex") {
if (!codexBaseUrl.trim()) {
issues.push(
t("providerForm.endpointRequired", {
defaultValue: "非官方供应商请填写 API 端点",
}),
);
}
if (!codexApiKey.trim()) {
issues.push(
t("providerForm.apiKeyRequired", {
defaultValue: "非官方供应商请填写 API Key",
}),
);
}
} else if (appId === "gemini") {
if (!geminiBaseUrl.trim()) {
issues.push(
t("providerForm.endpointRequired", {
defaultValue: "非官方供应商请填写 API 端点",
}),
);
}
if (!geminiApiKey.trim()) {
issues.push(
t("providerForm.apiKeyRequired", {
defaultValue: "非官方供应商请填写 API Key",
}),
);
}
}
}
if (issues.length > 0) {
// 弹确认框让用户决定是否仍要保存
setSoftIssues(issues);
setPendingFormValues(values);
setPendingLocalProxyRequestOverridesResult(overridesResult);
return;
}
await performSubmit(values, overridesResult);
};
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" ||
initialData?.meta?.providerType === "github_copilot" ||
baseUrl.includes("githubcopilot.com");
const isCodexOauthProvider =
templatePreset?.providerType === "codex_oauth" ||
initialData?.meta?.providerType === "codex_oauth";
let settingsConfig: string;
if (appId === "codex") {
try {
const authJson = JSON.parse(codexAuth);
let normalizedCodexConfig =
category !== "official" && (codexConfig ?? "").trim()
? setCodexWireApi(codexConfig ?? "", "responses")
: (codexConfig ?? "");
// Persist the catalog for proxy-takeover (Mode A) OR native-direct
// (Mode B, apiFormat=openai_responses). Native providers run without the
// proxy but still need cc-switch to generate model-catalogs.json, so the
// catalog must survive even when the takeover toggle is off.
const normalizedCatalogModels =
category !== "official" &&
(codexTakeoverEnabled || localCodexApiFormat === "openai_responses")
? normalizeCodexCatalogModelsForSave(codexCatalogModels)
: [];
// Sync first catalog row's model into config.toml so Codex uses it as default
if (normalizedCatalogModels.length > 0) {
normalizedCodexConfig = setCodexModelNameInConfig(
normalizedCodexConfig,
normalizedCatalogModels[0].model,
);
}
const configObj = {
auth: authJson,
config: normalizedCodexConfig,
} as {
auth: unknown;
config: string;
modelCatalog?: { models: CodexCatalogModel[] };
};
if (normalizedCatalogModels.length > 0) {
configObj.modelCatalog = { models: normalizedCatalogModels };
}
settingsConfig = JSON.stringify(configObj);
} catch (err) {
settingsConfig = values.settingsConfig.trim();
}
} else if (appId === "gemini") {
try {
const envObj = envStringToObj(geminiEnv);
const configObj = geminiConfig.trim() ? JSON.parse(geminiConfig) : {};
const combined = {
env: envObj,
config: configObj,
};
settingsConfig = JSON.stringify(combined);
} catch (err) {
settingsConfig = values.settingsConfig.trim();
}
} else if (
appId === "opencode" &&
(category === "omo" || category === "omo-slim")
) {
const omoConfig: Record<string, unknown> = {};
if (Object.keys(omoDraft.omoAgents).length > 0) {
omoConfig.agents = omoDraft.omoAgents;
}
if (
category === "omo" &&
Object.keys(omoDraft.omoCategories).length > 0
) {
omoConfig.categories = omoDraft.omoCategories;
}
if (omoDraft.omoOtherFieldsStr.trim()) {
// 格式已在 handleSubmit 前置校验中验证过,此处可以安全解析
const otherFields = parseOmoOtherFieldsObject(
omoDraft.omoOtherFieldsStr,
);
if (otherFields) {
omoConfig.otherFields = otherFields;
}
}
settingsConfig = JSON.stringify(omoConfig);
} else {
settingsConfig = values.settingsConfig.trim();
}
const payload: ProviderFormValues = {
...values,
name: values.name.trim(),
websiteUrl: values.websiteUrl?.trim() ?? "",
settingsConfig,
};
if (appId === "opencode") {
if (isAnyOmoCategory) {
if (!isEditMode) {
const prefix = category === "omo" ? "omo" : "omo-slim";
payload.providerKey = `${prefix}-${crypto.randomUUID().slice(0, 8)}`;
}
} else {
payload.providerKey = opencodeForm.opencodeProviderKey;
}
} else if (appId === "openclaw") {
payload.providerKey = openclawForm.openclawProviderKey;
} else if (appId === "hermes") {
payload.providerKey = hermesForm.hermesProviderKey;
}
if (isAnyOmoCategory && !payload.presetCategory) {
payload.presetCategory = category;
}
if (activePreset) {
payload.presetId = activePreset.id;
if (activePreset.category) {
payload.presetCategory = activePreset.category;
}
if (activePreset.isPartner) {
payload.isPartner = activePreset.isPartner;
}
// OpenClaw: align preset model refs with the actual submitted provider key.
if (activePreset.suggestedDefaults) {
payload.suggestedDefaults =
appId === "openclaw" && payload.providerKey
? rebaseOpenClawSuggestedDefaults(
activePreset.suggestedDefaults,
payload.providerKey,
)
: activePreset.suggestedDefaults;
}
}
if (!isEditMode && draftCustomEndpoints.length > 0) {
const customEndpointsToSave: Record<
string,
import("@/types").CustomEndpoint
> = draftCustomEndpoints.reduce(
(acc, url) => {
const now = Date.now();
acc[url] = { url, addedAt: now, lastUsed: undefined };
return acc;
},
{} as Record<string, import("@/types").CustomEndpoint>,
);
const hadEndpoints =
initialData?.meta?.custom_endpoints &&
Object.keys(initialData.meta.custom_endpoints).length > 0;
const needsClearEndpoints =
hadEndpoints && draftCustomEndpoints.length === 0;
let mergedMeta = needsClearEndpoints
? mergeProviderMeta(initialData?.meta, {})
: mergeProviderMeta(initialData?.meta, customEndpointsToSave);
if (activePreset?.isPartner) {
mergedMeta = {
...(mergedMeta ?? {}),
isPartner: true,
};
}
if (activePreset?.partnerPromotionKey) {
mergedMeta = {
...(mergedMeta ?? {}),
partnerPromotionKey: activePreset.partnerPromotionKey,
};
}
if (mergedMeta !== undefined) {
payload.meta = mergedMeta;
}
}
const baseMeta: ProviderMeta | undefined =
payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined);
// 确定 providerType(新建时从预设获取,编辑时从现有数据获取)
const providerType =
templatePreset?.providerType || initialData?.meta?.providerType;
const nextMeta: ProviderMeta = {
...(baseMeta ?? {}),
commonConfigEnabled:
appId === "claude"
? useCommonConfig
: appId === "codex"
? useCodexCommonConfigFlag
: appId === "gemini"
? useGeminiCommonConfigFlag
: undefined,
endpointAutoSelect,
claudeDesktopMode: undefined,
// 保存 providerType(用于识别 Copilot / Codex OAuth 等特殊供应商)
providerType,
authBinding: isCopilotProvider
? {
source: "managed_account",
authProvider: "github_copilot",
accountId: selectedGitHubAccountId ?? undefined,
}
: isCodexOauthProvider
? {
source: "managed_account",
authProvider: "codex_oauth",
accountId: selectedCodexAccountId ?? undefined,
}
: undefined,
// GitHub Copilot 多账号:保存关联的账号 ID
githubAccountId:
isCopilotProvider && selectedGitHubAccountId
? selectedGitHubAccountId
: undefined,
codexFastMode: isCodexOauthProvider ? codexFastMode : undefined,
codexChatReasoning:
appId === "codex" &&
category !== "official" &&
codexTakeoverEnabled &&
localCodexApiFormat === "openai_chat"
? normalizeCodexChatReasoningForSave(codexChatReasoning)
: undefined,
customUserAgent:
(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
: undefined,
pricingModelSource:
pricingConfig.enabled && pricingConfig.pricingModelSource !== "inherit"
? pricingConfig.pricingModelSource
: undefined,
apiFormat:
appId === "claude" && category !== "official"
? localApiFormat
: appId === "codex" && category !== "official"
? localCodexApiFormat
: undefined,
apiKeyField:
appId === "claude" &&
category !== "official" &&
localApiKeyField !== "ANTHROPIC_AUTH_TOKEN"
? localApiKeyField
: undefined,
isFullUrl:
supportsFullUrl && category !== "official" && localIsFullUrl
? true
: undefined,
};
if (!isCodexOauthProvider && "codexFastMode" in nextMeta) {
delete nextMeta.codexFastMode;
}
payload.meta = nextMeta;
await onSubmit(payload);
};
const shouldShowSpeedTest =
category !== "official" && category !== "cloud_provider";
const {
shouldShowApiKeyLink: shouldShowClaudeApiKeyLink,
websiteUrl: claudeWebsiteUrl,
isPartner: isClaudePartner,
partnerPromotionKey: claudePartnerPromotionKey,
} = useApiKeyLink({
appId: "claude",
category,
selectedPresetId,
presetEntries,
formWebsiteUrl: form.watch("websiteUrl") || "",
});
const {
shouldShowApiKeyLink: shouldShowCodexApiKeyLink,
websiteUrl: codexWebsiteUrl,
isPartner: isCodexPartner,
partnerPromotionKey: codexPartnerPromotionKey,
} = useApiKeyLink({
appId: "codex",
category,
selectedPresetId,
presetEntries,
formWebsiteUrl: form.watch("websiteUrl") || "",
});
const {
shouldShowApiKeyLink: shouldShowGeminiApiKeyLink,
websiteUrl: geminiWebsiteUrl,
isPartner: isGeminiPartner,
partnerPromotionKey: geminiPartnerPromotionKey,
} = useApiKeyLink({
appId: "gemini",
category,
selectedPresetId,
presetEntries,
formWebsiteUrl: form.watch("websiteUrl") || "",
});
const {
shouldShowApiKeyLink: shouldShowOpencodeApiKeyLink,
websiteUrl: opencodeWebsiteUrl,
isPartner: isOpencodePartner,
partnerPromotionKey: opencodePartnerPromotionKey,
} = useApiKeyLink({
appId: "opencode",
category,
selectedPresetId,
presetEntries,
formWebsiteUrl: form.watch("websiteUrl") || "",
});
// 使用 API Key 链接 hook (OpenClaw)
const {
shouldShowApiKeyLink: shouldShowOpenclawApiKeyLink,
websiteUrl: openclawWebsiteUrl,
isPartner: isOpenclawPartner,
partnerPromotionKey: openclawPartnerPromotionKey,
} = useApiKeyLink({
appId: "openclaw",
category,
selectedPresetId,
presetEntries,
formWebsiteUrl: form.watch("websiteUrl") || "",
});
// 使用 API Key 链接 hook (Hermes)
const {
shouldShowApiKeyLink: shouldShowHermesApiKeyLink,
websiteUrl: hermesWebsiteUrl,
isPartner: isHermesPartner,
partnerPromotionKey: hermesPartnerPromotionKey,
} = useApiKeyLink({
appId: "hermes",
category,
selectedPresetId,
presetEntries,
formWebsiteUrl: form.watch("websiteUrl") || "",
});
// 使用端点测速候选 hook
const speedTestEndpoints = useSpeedTestEndpoints({
appId,
selectedPresetId,
presetEntries,
baseUrl,
codexBaseUrl,
initialData,
});
const handlePresetChange = (value: string) => {
setSelectedPresetId(value);
if (value === "custom") {
setActivePreset(null);
form.reset(defaultValues);
if (appId === "codex") {
const template = getCodexCustomTemplate();
resetCodexConfig(template.auth, template.config);
setCodexChatReasoning({});
setLocalCodexApiFormat(
codexApiFormatFromWireApi(extractCodexWireApi(template.config)) ??
"openai_responses",
);
// 自定义模板无模型映射,路由默认关闭
setCodexTakeoverEnabled(false);
}
if (appId === "gemini") {
resetGeminiConfig({}, {});
}
if (appId === "opencode") {
opencodeForm.resetOpencodeState();
omoDraft.resetOmoDraftState();
}
// OpenClaw 自定义模式:重置为空配置
if (appId === "openclaw") {
openclawForm.resetOpenclawState();
}
if (appId === "hermes") {
hermesForm.resetHermesState();
}
return;
}
const entry = presetEntries.find((item) => item.id === value);
if (!entry) {
return;
}
setActivePreset({
id: value,
category: entry.preset.category,
isPartner: entry.preset.isPartner,
partnerPromotionKey: entry.preset.partnerPromotionKey,
});
if (appId === "codex") {
const preset = entry.preset as CodexProviderPreset;
const auth = preset.auth ?? {};
const config = preset.config ?? "";
resetCodexConfig(auth, config, preset.modelCatalog ?? []);
setCodexChatReasoning(preset.codexChatReasoning ?? {});
setLocalCodexApiFormat(
preset.apiFormat ??
codexApiFormatFromWireApi(extractCodexWireApi(config)) ??
"openai_responses",
);
// 接管开关:原生(openai_responses)预设走直连、不开接管,即便带 catalog
//catalog 用于生成 model-catalogs.json);只有 chat 预设按是否带映射开启。
setCodexTakeoverEnabled(
(preset.apiFormat ?? "openai_responses") !== "openai_responses" &&
(preset.modelCatalog?.length ?? 0) > 0,
);
form.reset({
name: preset.nameKey ? t(preset.nameKey) : preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify({ auth, config }, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
return;
}
if (appId === "gemini") {
const preset = entry.preset as GeminiProviderPreset;
const env = (preset.settingsConfig as any)?.env ?? {};
const config = (preset.settingsConfig as any)?.config ?? {};
resetGeminiConfig(env, config);
form.reset({
name: preset.nameKey ? t(preset.nameKey) : preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify(preset.settingsConfig, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
return;
}
if (appId === "opencode") {
const preset = entry.preset as OpenCodeProviderPreset;
const config = preset.settingsConfig;
if (preset.category === "omo" || preset.category === "omo-slim") {
omoDraft.resetOmoDraftState();
form.reset({
name: preset.category === "omo" ? "OMO" : "OMO Slim",
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify({}, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
return;
}
opencodeForm.resetOpencodeState(config);
form.reset({
name: preset.nameKey ? t(preset.nameKey) : preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify(config, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
return;
}
// OpenClaw preset handling
if (appId === "openclaw") {
const preset = entry.preset as OpenClawProviderPreset;
const config = preset.settingsConfig;
// Update activePreset with suggestedDefaults for OpenClaw
setActivePreset({
id: value,
category: preset.category,
isPartner: preset.isPartner,
partnerPromotionKey: preset.partnerPromotionKey,
suggestedDefaults: preset.suggestedDefaults,
});
openclawForm.resetOpenclawState(config);
// Update form fields
form.reset({
name: preset.nameKey ? t(preset.nameKey) : preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify(config, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
return;
}
// Hermes preset handling
if (appId === "hermes") {
const preset = entry.preset as HermesProviderPreset;
const config = preset.settingsConfig;
hermesForm.resetHermesState(config);
form.reset({
name: preset.nameKey ? t(preset.nameKey) : preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify(config, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
return;
}
const preset = entry.preset as ProviderPreset;
const config = applyTemplateValues(
preset.settingsConfig,
preset.templateValues,
);
if (preset.apiFormat) {
setLocalApiFormat(preset.apiFormat);
} else {
setLocalApiFormat("anthropic");
}
setLocalApiKeyField(preset.apiKeyField ?? "ANTHROPIC_AUTH_TOKEN");
setLocalIsFullUrl(false);
form.reset({
name: preset.nameKey ? t(preset.nameKey) : preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify(config, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
};
const settingsConfigErrorField = (
<FormField
control={form.control}
name="settingsConfig"
render={() => (
<FormItem className="space-y-0">
<FormMessage />
</FormItem>
)}
/>
);
return (
<>
<Form {...form}>
<form
id="provider-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-6 glass rounded-xl p-6 border border-white/10"
>
{!initialData && (
<ProviderPresetSelector
selectedPresetId={selectedPresetId}
presetEntries={presetEntries}
presetCategoryLabels={presetCategoryLabels}
onPresetChange={handlePresetChange}
onUniversalPresetSelect={onUniversalPresetSelect}
onManageUniversalProviders={onManageUniversalProviders}
category={category}
/>
)}
<BasicFormFields
form={form}
beforeNameSlot={
appId === "opencode" && !isAnyOmoCategory ? (
<div className="space-y-2">
<Label htmlFor="opencode-key">
{t("opencode.providerKey")}
<span className="text-destructive ml-1">*</span>
</Label>
<Input
id="opencode-key"
value={opencodeForm.opencodeProviderKey}
onChange={(e) =>
opencodeForm.setOpencodeProviderKey(
e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""),
)
}
placeholder={t("opencode.providerKeyPlaceholder")}
disabled={
isProviderKeyLocked || isProviderKeyLockStateLoading
}
className={
(additiveExistingProviderKeys.includes(
opencodeForm.opencodeProviderKey,
) &&
!isProviderKeyLocked) ||
(opencodeForm.opencodeProviderKey.trim() !== "" &&
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
opencodeForm.opencodeProviderKey,
))
? "border-destructive"
: ""
}
/>
{additiveExistingProviderKeys.includes(
opencodeForm.opencodeProviderKey,
) &&
!isProviderKeyLocked && (
<p className="text-xs text-destructive">
{t("opencode.providerKeyDuplicate")}
</p>
)}
{opencodeForm.opencodeProviderKey.trim() !== "" &&
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
opencodeForm.opencodeProviderKey,
) && (
<p className="text-xs text-destructive">
{t("opencode.providerKeyInvalid")}
</p>
)}
{!(
additiveExistingProviderKeys.includes(
opencodeForm.opencodeProviderKey,
) && !isProviderKeyLocked
) &&
(opencodeForm.opencodeProviderKey.trim() === "" ||
/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
opencodeForm.opencodeProviderKey,
)) && (
<p className="text-xs text-muted-foreground">
{isProviderKeyLocked
? t("opencode.providerKeyLockedHint", {
defaultValue:
"该供应商已添加到应用配置中,供应商标识不可修改",
})
: t("opencode.providerKeyHint")}
</p>
)}
</div>
) : appId === "openclaw" ? (
<div className="space-y-2">
<Label htmlFor="openclaw-key">
{t("openclaw.providerKey")}
<span className="text-destructive ml-1">*</span>
</Label>
<Input
id="openclaw-key"
value={openclawForm.openclawProviderKey}
onChange={(e) =>
openclawForm.setOpenclawProviderKey(
e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""),
)
}
placeholder={t("openclaw.providerKeyPlaceholder")}
disabled={
isProviderKeyLocked || isProviderKeyLockStateLoading
}
className={
(additiveExistingProviderKeys.includes(
openclawForm.openclawProviderKey,
) &&
!isProviderKeyLocked) ||
(openclawForm.openclawProviderKey.trim() !== "" &&
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
openclawForm.openclawProviderKey,
))
? "border-destructive"
: ""
}
/>
{additiveExistingProviderKeys.includes(
openclawForm.openclawProviderKey,
) &&
!isProviderKeyLocked && (
<p className="text-xs text-destructive">
{t("openclaw.providerKeyDuplicate")}
</p>
)}
{openclawForm.openclawProviderKey.trim() !== "" &&
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
openclawForm.openclawProviderKey,
) && (
<p className="text-xs text-destructive">
{t("openclaw.providerKeyInvalid")}
</p>
)}
{!(
additiveExistingProviderKeys.includes(
openclawForm.openclawProviderKey,
) && !isProviderKeyLocked
) &&
(openclawForm.openclawProviderKey.trim() === "" ||
/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
openclawForm.openclawProviderKey,
)) && (
<p className="text-xs text-muted-foreground">
{isProviderKeyLocked
? t("openclaw.providerKeyLockedHint", {
defaultValue:
"该供应商已添加到应用配置中,供应商标识不可修改",
})
: t("openclaw.providerKeyHint")}
</p>
)}
</div>
) : appId === "hermes" ? (
<div className="space-y-2">
<Label htmlFor="hermes-key">
{t("hermes.form.providerKey", {
defaultValue: "Provider Key",
})}
<span className="text-destructive ml-1">*</span>
</Label>
<Input
id="hermes-key"
value={hermesForm.hermesProviderKey}
onChange={(e) =>
hermesForm.setHermesProviderKey(
e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""),
)
}
placeholder={t("hermes.form.providerKeyPlaceholder", {
defaultValue: "my-provider",
})}
disabled={
isProviderKeyLocked || isProviderKeyLockStateLoading
}
className={
(additiveExistingProviderKeys.includes(
hermesForm.hermesProviderKey,
) &&
!isProviderKeyLocked) ||
(hermesForm.hermesProviderKey.trim() !== "" &&
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
hermesForm.hermesProviderKey,
))
? "border-destructive"
: ""
}
/>
{additiveExistingProviderKeys.includes(
hermesForm.hermesProviderKey,
) &&
!isProviderKeyLocked && (
<p className="text-xs text-destructive">
{t("hermes.form.providerKeyDuplicate")}
</p>
)}
{hermesForm.hermesProviderKey.trim() !== "" &&
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
hermesForm.hermesProviderKey,
) && (
<p className="text-xs text-destructive">
{t("hermes.form.providerKeyInvalid")}
</p>
)}
{!(
additiveExistingProviderKeys.includes(
hermesForm.hermesProviderKey,
) && !isProviderKeyLocked
) &&
(hermesForm.hermesProviderKey.trim() === "" ||
/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
hermesForm.hermesProviderKey,
)) && (
<p className="text-xs text-muted-foreground">
{isProviderKeyLocked
? t("hermes.form.providerKeyLockedHint", {
defaultValue:
"This provider is in Hermes config; key is locked.",
})
: t("hermes.form.providerKeyHint", {
defaultValue:
"Lowercase letters, numbers, and hyphens only. Used as the provider name in config.yaml.",
})}
</p>
)}
</div>
) : undefined
}
/>
{appId === "claude" && (
<ClaudeFormFields
providerId={providerId}
shouldShowApiKey={
(category !== "cloud_provider" ||
hasApiKeyField(form.getValues("settingsConfig"), "claude")) &&
shouldShowApiKey(form.getValues("settingsConfig"), isEditMode)
}
apiKey={apiKey}
onApiKeyChange={handleApiKeyChange}
category={category}
shouldShowApiKeyLink={shouldShowClaudeApiKeyLink}
websiteUrl={claudeWebsiteUrl}
isPartner={isClaudePartner}
partnerPromotionKey={claudePartnerPromotionKey}
isCopilotPreset={
templatePreset?.providerType === "github_copilot" ||
initialData?.meta?.providerType === "github_copilot" ||
baseUrl.includes("githubcopilot.com")
}
isCodexOauthPreset={
templatePreset?.providerType === "codex_oauth" ||
initialData?.meta?.providerType === "codex_oauth"
}
usesOAuth={
templatePreset?.requiresOAuth === true ||
templatePreset?.providerType === "github_copilot" ||
initialData?.meta?.providerType === "github_copilot" ||
baseUrl.includes("githubcopilot.com") ||
templatePreset?.providerType === "codex_oauth" ||
initialData?.meta?.providerType === "codex_oauth"
}
isCopilotAuthenticated={isCopilotAuthenticated}
selectedGitHubAccountId={selectedGitHubAccountId}
onGitHubAccountSelect={setSelectedGitHubAccountId}
isCodexOauthAuthenticated={isCodexOauthAuthenticated}
selectedCodexAccountId={selectedCodexAccountId}
onCodexAccountSelect={setSelectedCodexAccountId}
codexFastMode={codexFastMode}
onCodexFastModeChange={setCodexFastMode}
templateValueEntries={templateValueEntries}
templateValues={templateValues}
templatePresetName={templatePreset?.name || ""}
onTemplateValueChange={handleTemplateValueChange}
shouldShowSpeedTest={shouldShowSpeedTest}
baseUrl={baseUrl}
onBaseUrlChange={handleClaudeBaseUrlChange}
isEndpointModalOpen={isEndpointModalOpen}
onEndpointModalToggle={setIsEndpointModalOpen}
onCustomEndpointsChange={
isEditMode ? undefined : setDraftCustomEndpoints
}
autoSelect={endpointAutoSelect}
onAutoSelectChange={setEndpointAutoSelect}
showEndpointTools
shouldShowModelSelector={category !== "official"}
claudeModel={claudeModel}
defaultHaikuModel={defaultHaikuModel}
defaultHaikuModelName={defaultHaikuModelName}
defaultSonnetModel={defaultSonnetModel}
defaultSonnetModelName={defaultSonnetModelName}
defaultOpusModel={defaultOpusModel}
defaultOpusModelName={defaultOpusModelName}
defaultFableModel={defaultFableModel}
defaultFableModelName={defaultFableModelName}
onModelChange={handleModelChange}
speedTestEndpoints={speedTestEndpoints}
apiFormat={localApiFormat}
onApiFormatChange={handleApiFormatChange}
apiKeyField={localApiKeyField}
onApiKeyFieldChange={handleApiKeyFieldChange}
isFullUrl={localIsFullUrl}
onFullUrlChange={setLocalIsFullUrl}
customUserAgent={customUserAgent}
onCustomUserAgentChange={setCustomUserAgent}
localProxyHeadersOverride={localProxyHeadersOverride}
onLocalProxyHeadersOverrideChange={setLocalProxyHeadersOverride}
localProxyBodyOverride={localProxyBodyOverride}
onLocalProxyBodyOverrideChange={setLocalProxyBodyOverride}
/>
)}
{appId === "codex" && (
<CodexFormFields
providerId={providerId}
codexApiKey={codexApiKey}
onApiKeyChange={handleCodexApiKeyChange}
category={category}
shouldShowApiKeyLink={shouldShowCodexApiKeyLink}
websiteUrl={codexWebsiteUrl}
isPartner={isCodexPartner}
partnerPromotionKey={codexPartnerPromotionKey}
shouldShowSpeedTest={shouldShowSpeedTest}
codexBaseUrl={codexBaseUrl}
onBaseUrlChange={handleCodexBaseUrlChange}
isFullUrl={localIsFullUrl}
onFullUrlChange={setLocalIsFullUrl}
isEndpointModalOpen={isCodexEndpointModalOpen}
onEndpointModalToggle={setIsCodexEndpointModalOpen}
onCustomEndpointsChange={
isEditMode ? undefined : setDraftCustomEndpoints
}
autoSelect={endpointAutoSelect}
onAutoSelectChange={setEndpointAutoSelect}
takeoverEnabled={codexTakeoverEnabled}
onTakeoverEnabledChange={setCodexTakeoverEnabled}
apiFormat={localCodexApiFormat}
onApiFormatChange={handleCodexApiFormatChange}
codexChatReasoning={codexChatReasoning}
onCodexChatReasoningChange={setCodexChatReasoning}
catalogModels={codexCatalogModels}
onCatalogModelsChange={setCodexCatalogModels}
speedTestEndpoints={speedTestEndpoints}
customUserAgent={customUserAgent}
onCustomUserAgentChange={setCustomUserAgent}
localProxyHeadersOverride={localProxyHeadersOverride}
onLocalProxyHeadersOverrideChange={setLocalProxyHeadersOverride}
localProxyBodyOverride={localProxyBodyOverride}
onLocalProxyBodyOverrideChange={setLocalProxyBodyOverride}
/>
)}
{appId === "gemini" && (
<GeminiFormFields
providerId={providerId}
shouldShowApiKey={shouldShowApiKey(
form.getValues("settingsConfig"),
isEditMode,
)}
apiKey={geminiApiKey}
onApiKeyChange={handleGeminiApiKeyChange}
category={category}
shouldShowApiKeyLink={shouldShowGeminiApiKeyLink}
websiteUrl={geminiWebsiteUrl}
isPartner={isGeminiPartner}
partnerPromotionKey={geminiPartnerPromotionKey}
shouldShowSpeedTest={shouldShowSpeedTest}
baseUrl={geminiBaseUrl}
onBaseUrlChange={handleGeminiBaseUrlChange}
isEndpointModalOpen={isEndpointModalOpen}
onEndpointModalToggle={setIsEndpointModalOpen}
onCustomEndpointsChange={setDraftCustomEndpoints}
autoSelect={endpointAutoSelect}
onAutoSelectChange={setEndpointAutoSelect}
shouldShowModelField={true}
model={geminiModel}
onModelChange={handleGeminiModelChange}
speedTestEndpoints={speedTestEndpoints}
/>
)}
{appId === "opencode" && !isAnyOmoCategory && (
<OpenCodeFormFields
npm={opencodeForm.opencodeNpm}
onNpmChange={opencodeForm.handleOpencodeNpmChange}
apiKey={opencodeForm.opencodeApiKey}
onApiKeyChange={opencodeForm.handleOpencodeApiKeyChange}
category={category}
shouldShowApiKeyLink={shouldShowOpencodeApiKeyLink}
websiteUrl={opencodeWebsiteUrl}
isPartner={isOpencodePartner}
partnerPromotionKey={opencodePartnerPromotionKey}
baseUrl={opencodeForm.opencodeBaseUrl}
onBaseUrlChange={opencodeForm.handleOpencodeBaseUrlChange}
models={opencodeForm.opencodeModels}
onModelsChange={opencodeForm.handleOpencodeModelsChange}
extraOptions={opencodeForm.opencodeExtraOptions}
onExtraOptionsChange={
opencodeForm.handleOpencodeExtraOptionsChange
}
/>
)}
{appId === "opencode" &&
(category === "omo" || category === "omo-slim") && (
<OmoFormFields
modelOptions={omoModelOptions}
modelVariantsMap={omoModelVariantsMap}
presetMetaMap={omoPresetMetaMap}
agents={omoDraft.omoAgents}
onAgentsChange={omoDraft.setOmoAgents}
categories={
category === "omo" ? omoDraft.omoCategories : undefined
}
onCategoriesChange={
category === "omo" ? omoDraft.setOmoCategories : undefined
}
otherFieldsStr={omoDraft.omoOtherFieldsStr}
onOtherFieldsStrChange={omoDraft.setOmoOtherFieldsStr}
isSlim={category === "omo-slim"}
/>
)}
{/* OpenClaw 专属字段 */}
{appId === "openclaw" && (
<OpenClawFormFields
baseUrl={openclawForm.openclawBaseUrl}
onBaseUrlChange={openclawForm.handleOpenclawBaseUrlChange}
apiKey={openclawForm.openclawApiKey}
onApiKeyChange={openclawForm.handleOpenclawApiKeyChange}
category={category}
shouldShowApiKeyLink={shouldShowOpenclawApiKeyLink}
websiteUrl={openclawWebsiteUrl}
isPartner={isOpenclawPartner}
partnerPromotionKey={openclawPartnerPromotionKey}
api={openclawForm.openclawApi}
onApiChange={openclawForm.handleOpenclawApiChange}
models={openclawForm.openclawModels}
onModelsChange={openclawForm.handleOpenclawModelsChange}
userAgent={openclawForm.openclawUserAgent}
onUserAgentChange={openclawForm.handleOpenclawUserAgentChange}
/>
)}
{/* Hermes 专属字段 */}
{appId === "hermes" && (
<HermesFormFields
baseUrl={hermesForm.hermesBaseUrl}
onBaseUrlChange={hermesForm.handleHermesBaseUrlChange}
apiKey={hermesForm.hermesApiKey}
onApiKeyChange={hermesForm.handleHermesApiKeyChange}
category={category}
shouldShowApiKeyLink={shouldShowHermesApiKeyLink}
websiteUrl={hermesWebsiteUrl}
isPartner={isHermesPartner}
partnerPromotionKey={hermesPartnerPromotionKey}
apiMode={hermesForm.hermesApiMode}
onApiModeChange={hermesForm.handleHermesApiModeChange}
models={hermesForm.hermesModels}
onModelsChange={hermesForm.handleHermesModelsChange}
rateLimitDelay={hermesForm.hermesRateLimitDelay}
onRateLimitDelayChange={
hermesForm.handleHermesRateLimitDelayChange
}
/>
)}
{/* 配置编辑器:Codex、Claude、Gemini 分别使用不同的编辑器 */}
{appId === "codex" ? (
<>
<CodexConfigEditor
authValue={codexAuth}
configValue={codexConfig}
providerName={form.watch("name")}
showRemoteCompaction={category !== "official"}
isProxyTakeover={isProxyTakeover}
onAuthChange={setCodexAuth}
onConfigChange={handleCodexConfigChange}
useCommonConfig={useCodexCommonConfigFlag}
onCommonConfigToggle={handleCodexCommonConfigToggle}
commonConfigSnippet={codexCommonConfigSnippet}
onCommonConfigSnippetChange={
handleCodexCommonConfigSnippetChange
}
onCommonConfigErrorClear={clearCodexCommonConfigError}
commonConfigError={codexCommonConfigError}
authError={codexAuthError}
configError={codexConfigError}
onExtract={handleCodexExtract}
isExtracting={isCodexExtracting}
/>
{settingsConfigErrorField}
</>
) : appId === "gemini" ? (
<>
<GeminiConfigEditor
envValue={geminiEnv}
configValue={geminiConfig}
onEnvChange={handleGeminiEnvChange}
onConfigChange={handleGeminiConfigChange}
useCommonConfig={useGeminiCommonConfigFlag}
onCommonConfigToggle={handleGeminiCommonConfigToggle}
commonConfigSnippet={geminiCommonConfigSnippet}
onCommonConfigSnippetChange={
handleGeminiCommonConfigSnippetChange
}
onCommonConfigErrorClear={clearGeminiCommonConfigError}
commonConfigError={geminiCommonConfigError}
envError={envError}
configError={geminiConfigError}
onExtract={handleGeminiExtract}
isExtracting={isGeminiExtracting}
/>
{settingsConfigErrorField}
</>
) : appId === "opencode" &&
(category === "omo" || category === "omo-slim") ? (
<div className="space-y-2">
<Label>{t("provider.configJson")}</Label>
<JsonEditor
value={omoDraft.mergedOmoJsonPreview}
onChange={() => {}}
rows={14}
showValidation={false}
language="json"
darkMode={isDarkMode}
/>
</div>
) : appId === "opencode" &&
category !== "omo" &&
category !== "omo-slim" ? (
<>
<div className="space-y-2">
<Label htmlFor="settingsConfig">
{t("provider.configJson")}
</Label>
<JsonEditor
value={form.getValues("settingsConfig")}
onChange={(config) => form.setValue("settingsConfig", config)}
placeholder={`{
"npm": "@ai-sdk/openai-compatible",
"options": {
"baseURL": "https://your-api-endpoint.com",
"apiKey": "your-api-key-here"
},
"models": {}
}`}
rows={14}
showValidation={true}
language="json"
darkMode={isDarkMode}
/>
</div>
{settingsConfigErrorField}
</>
) : appId === "openclaw" || appId === "hermes" ? (
<>
<div className="space-y-2">
<Label htmlFor="settingsConfig">
{t("provider.configJson")}
</Label>
<JsonEditor
value={form.getValues("settingsConfig")}
onChange={(config) => form.setValue("settingsConfig", config)}
placeholder={
appId === "hermes"
? `{
"name": "my-provider",
"base_url": "https://api.example.com/v1",
"api_key": ""
}`
: `{
"baseUrl": "https://api.example.com/v1",
"apiKey": "your-api-key-here",
"api": "openai-completions",
"models": []
}`
}
rows={14}
showValidation={true}
language="json"
darkMode={isDarkMode}
/>
</div>
<FormField
control={form.control}
name="settingsConfig"
render={() => (
<FormItem className="space-y-0">
<FormMessage />
</FormItem>
)}
/>
</>
) : (
<>
<CommonConfigEditor
value={form.getValues("settingsConfig")}
onChange={(value) => form.setValue("settingsConfig", value)}
useCommonConfig={useCommonConfig}
onCommonConfigToggle={handleCommonConfigToggle}
commonConfigSnippet={commonConfigSnippet}
onCommonConfigSnippetChange={handleCommonConfigSnippetChange}
commonConfigError={commonConfigError}
onEditClick={() => setIsCommonConfigModalOpen(true)}
isModalOpen={isCommonConfigModalOpen}
onModalClose={() => setIsCommonConfigModalOpen(false)}
onExtract={handleClaudeExtract}
isExtracting={isClaudeExtracting}
/>
{settingsConfigErrorField}
</>
)}
{!isAnyOmoCategory &&
appId !== "opencode" &&
appId !== "openclaw" &&
appId !== "hermes" && (
<ProviderAdvancedConfig
testConfig={testConfig}
pricingConfig={pricingConfig}
onTestConfigChange={setTestConfig}
onPricingConfigChange={setPricingConfig}
/>
)}
{showButtons && (
<div className="flex justify-end gap-2">
<Button variant="outline" type="button" onClick={onCancel}>
{t("common.cancel")}
</Button>
<Button
type="submit"
disabled={isSubmitting || isConfirmSubmitting}
>
{submitLabel}
</Button>
</div>
)}
</form>
</Form>
<ConfirmDialog
isOpen={showCommonConfigNotice}
variant="info"
title={t("confirm.commonConfig.title")}
message={t("confirm.commonConfig.message")}
confirmText={t("confirm.commonConfig.confirm")}
onConfirm={() => void handleCommonConfigConfirm()}
onCancel={() => void handleCommonConfigConfirm()}
/>
<ConfirmDialog
isOpen={softIssues !== null && softIssues.length > 0}
variant="info"
title={t("providerForm.softValidation.title", {
defaultValue: "配置存在以下问题",
})}
message={
(softIssues ?? []).map((issue) => `${issue}`).join("\n") +
"\n\n" +
t("providerForm.softValidation.hint", {
defaultValue:
"仍要保存吗?保存后切换此供应商时可能失败,可以之后再补全。",
})
}
confirmText={t("providerForm.softValidation.saveAnyway", {
defaultValue: "仍要保存",
})}
cancelText={t("common.cancel")}
onConfirm={async () => {
if (isConfirmSubmitting) return;
const values = pendingFormValues;
const overridesResult = pendingLocalProxyRequestOverridesResult;
if (!values || !overridesResult) {
setSoftIssues(null);
setPendingFormValues(null);
setPendingLocalProxyRequestOverridesResult(null);
return;
}
setIsConfirmSubmitting(true);
try {
await performSubmit(values, overridesResult);
setSoftIssues(null);
setPendingFormValues(null);
setPendingLocalProxyRequestOverridesResult(null);
} catch (error) {
console.error("[ProviderForm] soft-confirm submit failed:", error);
// 保留确认框和 pending values,让用户可以重试或取消
} finally {
setIsConfirmSubmitting(false);
}
}}
onCancel={() => {
if (isConfirmSubmitting) return;
setSoftIssues(null);
setPendingFormValues(null);
setPendingLocalProxyRequestOverridesResult(null);
}}
/>
</>
);
}
export type ProviderFormValues = ProviderFormData & {
presetId?: string;
presetCategory?: ProviderCategory;
isPartner?: boolean;
meta?: ProviderMeta;
providerKey?: string; // OpenCode/OpenClaw: user-defined provider key
suggestedDefaults?: OpenClawSuggestedDefaults; // OpenClaw: suggested default model configuration
};