-
{def.display}
+
+ {def.display}
+
+
+
+ {t(def.tooltipKey)}
+
+
+
- {isZh ? def.descZh : def.descEn}
+ {t(def.descKey)}
{renderModelSelect(
@@ -727,16 +901,14 @@ export function OmoFormFields({
className="border-b border-border/30 last:border-b-0"
>
-
updateCustom({ key: e.target.value })}
+ onCommit={(value) => updateCustom({ key: value })}
placeholder={keyPlaceholder}
className="w-32 shrink-0 h-8 text-sm text-primary"
/>
- {renderModelSelect(
- item.model,
- (value) => updateCustom({ model: value }),
- t("omo.modelNamePlaceholder", { defaultValue: "model-name" }),
+ {renderModelSelect(item.model, (value) =>
+ updateCustom({ model: value }),
)}
{renderVariantSelect(item.model, currentVariant, (value) => {
if (!item.key) return;
@@ -877,11 +1049,17 @@ export function OmoFormFields({
const addCustomModel = (scope: AdvancedScope) => {
if (scope === "agent") {
- setCustomAgents((prev) => [...prev, { key: "", model: "" }]);
+ setCustomAgents((prev) => [
+ ...prev,
+ { key: "", model: "", sourceKey: "" },
+ ]);
setSubAgentsOpen(true);
return;
}
- setCustomCategories((prev) => [...prev, { key: "", model: "" }]);
+ setCustomCategories((prev) => [
+ ...prev,
+ { key: "", model: "", sourceKey: "" },
+ ]);
setCategoriesOpen(true);
};
@@ -931,7 +1109,7 @@ export function OmoFormFields({
·{" "}
{t("omo.enabledModelsCount", {
count: modelOptions.length,
- defaultValue: "{{count}} enabled models available",
+ defaultValue: "{{count}} configured models available",
})}
{localFilePath && (
diff --git a/src/components/providers/forms/OpenClawFormFields.tsx b/src/components/providers/forms/OpenClawFormFields.tsx
new file mode 100644
index 000000000..bb4a26770
--- /dev/null
+++ b/src/components/providers/forms/OpenClawFormFields.tsx
@@ -0,0 +1,472 @@
+import { useTranslation } from "react-i18next";
+import { useState, useRef, useCallback } from "react";
+import { FormLabel } from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { Switch } from "@/components/ui/switch";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { Plus, Trash2, ChevronDown, ChevronRight } from "lucide-react";
+import { ApiKeySection } from "./shared";
+import { openclawApiProtocols } from "@/config/openclawProviderPresets";
+import type { ProviderCategory, OpenClawModel } from "@/types";
+
+interface OpenClawFormFieldsProps {
+ // Base URL
+ baseUrl: string;
+ onBaseUrlChange: (value: string) => void;
+
+ // API Key
+ apiKey: string;
+ onApiKeyChange: (value: string) => void;
+ category?: ProviderCategory;
+ shouldShowApiKeyLink: boolean;
+ websiteUrl: string;
+ isPartner?: boolean;
+ partnerPromotionKey?: string;
+
+ // API Protocol
+ api: string;
+ onApiChange: (value: string) => void;
+
+ // Models
+ models: OpenClawModel[];
+ onModelsChange: (models: OpenClawModel[]) => void;
+}
+
+export function OpenClawFormFields({
+ baseUrl,
+ onBaseUrlChange,
+ apiKey,
+ onApiKeyChange,
+ category,
+ shouldShowApiKeyLink,
+ websiteUrl,
+ isPartner,
+ partnerPromotionKey,
+ api,
+ onApiChange,
+ models,
+ onModelsChange,
+}: OpenClawFormFieldsProps) {
+ const { t } = useTranslation();
+ const [expandedModels, setExpandedModels] = useState
>(
+ {},
+ );
+
+ // Stable key tracking for models list
+ const modelKeysRef = useRef([]);
+ const getModelKeys = useCallback(() => {
+ // Grow keys array if models were added externally
+ while (modelKeysRef.current.length < models.length) {
+ modelKeysRef.current.push(crypto.randomUUID());
+ }
+ // Shrink if models were removed externally
+ if (modelKeysRef.current.length > models.length) {
+ modelKeysRef.current.length = models.length;
+ }
+ return modelKeysRef.current;
+ }, [models.length]);
+ const modelKeys = getModelKeys();
+
+ // Toggle advanced section for a model
+ const toggleModelAdvanced = (index: number) => {
+ setExpandedModels((prev) => ({ ...prev, [index]: !prev[index] }));
+ };
+
+ // Add a new model entry
+ const handleAddModel = () => {
+ modelKeysRef.current.push(crypto.randomUUID());
+ onModelsChange([
+ ...models,
+ {
+ id: "",
+ name: "",
+ contextWindow: undefined,
+ maxTokens: undefined,
+ cost: undefined,
+ },
+ ]);
+ };
+
+ // Remove a model entry
+ const handleRemoveModel = (index: number) => {
+ modelKeysRef.current.splice(index, 1);
+ const newModels = [...models];
+ newModels.splice(index, 1);
+ onModelsChange(newModels);
+ // Clean up expanded state
+ setExpandedModels((prev) => {
+ const updated = { ...prev };
+ delete updated[index];
+ return updated;
+ });
+ };
+
+ // Update model field
+ const handleModelChange = (
+ index: number,
+ field: keyof OpenClawModel,
+ value: unknown,
+ ) => {
+ const newModels = [...models];
+ newModels[index] = { ...newModels[index], [field]: value };
+ onModelsChange(newModels);
+ };
+
+ // Update model cost
+ const handleCostChange = (
+ index: number,
+ costField: "input" | "output" | "cacheRead" | "cacheWrite",
+ value: string,
+ ) => {
+ const newModels = [...models];
+ const numValue = parseFloat(value);
+ const currentCost = newModels[index].cost || { input: 0, output: 0 };
+ newModels[index] = {
+ ...newModels[index],
+ cost: {
+ ...currentCost,
+ [costField]: isNaN(numValue) ? undefined : numValue,
+ },
+ };
+ onModelsChange(newModels);
+ };
+
+ return (
+ <>
+ {/* API Protocol Selector */}
+
+
+ {t("openclaw.apiProtocol", {
+ defaultValue: "API 协议",
+ })}
+
+
+
+
+
+
+ {openclawApiProtocols.map((protocol) => (
+
+ {protocol.label}
+
+ ))}
+
+
+
+ {t("openclaw.apiProtocolHint", {
+ defaultValue:
+ "选择与供应商 API 兼容的协议类型。大多数供应商使用 OpenAI Completions 格式。",
+ })}
+
+
+
+ {/* Base URL */}
+
+
+ {t("openclaw.baseUrl", { defaultValue: "API 端点" })}
+
+
onBaseUrlChange(e.target.value)}
+ placeholder="https://api.example.com/v1"
+ />
+
+ {t("openclaw.baseUrlHint", {
+ defaultValue: "供应商的 API 端点地址。",
+ })}
+
+
+
+ {/* API Key */}
+
+
+ {/* Models Editor */}
+
+
+
+ {t("openclaw.models", { defaultValue: "模型列表" })}
+
+
+
+ {t("openclaw.addModel", { defaultValue: "添加模型" })}
+
+
+
+ {models.length === 0 ? (
+
+ {t("openclaw.noModels", {
+ defaultValue: "暂无模型配置。点击添加模型来配置可用模型。",
+ })}
+
+ ) : (
+
+ {models.map((model, index) => (
+
+ {/* Model ID and Name row */}
+
+
+
+ {t("openclaw.modelId", { defaultValue: "模型 ID" })}
+
+
+ handleModelChange(index, "id", e.target.value)
+ }
+ placeholder={t("openclaw.modelIdPlaceholder", {
+ defaultValue: "claude-3-sonnet",
+ })}
+ />
+
+
+
+ {t("openclaw.modelName", { defaultValue: "显示名称" })}
+
+
+ handleModelChange(index, "name", e.target.value)
+ }
+ placeholder={t("openclaw.modelNamePlaceholder", {
+ defaultValue: "Claude 3 Sonnet",
+ })}
+ />
+
+
handleRemoveModel(index)}
+ className="h-9 w-9 mt-5 text-muted-foreground hover:text-destructive"
+ >
+
+
+
+
+ {/* Advanced Options (Collapsible) */}
+
toggleModelAdvanced(index)}
+ >
+
+
+ {expandedModels[index] ? (
+
+ ) : (
+
+ )}
+ {t("openclaw.advancedOptions", {
+ defaultValue: "高级选项",
+ })}
+
+
+
+ {/* Context Window, Max Tokens and Reasoning row */}
+
+
+
+ {t("openclaw.contextWindow", {
+ defaultValue: "上下文窗口",
+ })}
+
+
+ handleModelChange(
+ index,
+ "contextWindow",
+ e.target.value
+ ? parseInt(e.target.value)
+ : undefined,
+ )
+ }
+ placeholder="200000"
+ />
+
+
+
+ {t("openclaw.maxTokens", {
+ defaultValue: "最大输出 Tokens",
+ })}
+
+
+ handleModelChange(
+ index,
+ "maxTokens",
+ e.target.value
+ ? parseInt(e.target.value)
+ : undefined,
+ )
+ }
+ placeholder="32000"
+ />
+
+
+
+ {t("openclaw.reasoning", {
+ defaultValue: "推理模式",
+ })}
+
+
+
+ handleModelChange(index, "reasoning", checked)
+ }
+ />
+
+ {model.reasoning
+ ? t("openclaw.reasoningOn", {
+ defaultValue: "启用",
+ })
+ : t("openclaw.reasoningOff", {
+ defaultValue: "关闭",
+ })}
+
+
+
+
+
+ {/* Cost row */}
+
+
+
+ {t("openclaw.inputCost", {
+ defaultValue: "输入价格 ($/M tokens)",
+ })}
+
+
+ handleCostChange(index, "input", e.target.value)
+ }
+ placeholder="3"
+ />
+
+
+
+ {t("openclaw.outputCost", {
+ defaultValue: "输出价格 ($/M tokens)",
+ })}
+
+
+ handleCostChange(index, "output", e.target.value)
+ }
+ placeholder="15"
+ />
+
+
+
+
+ {/* Cache Cost row */}
+
+
+
+ {t("openclaw.cacheReadCost", {
+ defaultValue: "缓存读取价格 ($/M tokens)",
+ })}
+
+
+ handleCostChange(index, "cacheRead", e.target.value)
+ }
+ placeholder="0.3"
+ />
+
+
+
+ {t("openclaw.cacheWriteCost", {
+ defaultValue: "缓存写入价格 ($/M tokens)",
+ })}
+
+
+ handleCostChange(
+ index,
+ "cacheWrite",
+ e.target.value,
+ )
+ }
+ placeholder="3.75"
+ />
+
+
+
+
+ {t("openclaw.cacheCostHint", {
+ defaultValue:
+ "缓存价格用于计算 Prompt Caching 的成本。如不使用缓存可留空。",
+ })}
+
+
+
+
+ ))}
+
+ )}
+
+
+ {t("openclaw.modelsHint", {
+ defaultValue:
+ "配置该供应商支持的模型。模型 ID 用于 API 调用,显示名称用于界面展示。",
+ })}
+
+
+ >
+ );
+}
diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx
index 8a3db5749..fba1fd9ab 100644
--- a/src/components/providers/forms/ProviderForm.tsx
+++ b/src/components/providers/forms/ProviderForm.tsx
@@ -16,6 +16,7 @@ import type {
ClaudeApiFormat,
OpenCodeModel,
OpenCodeProviderConfig,
+ OpenClawModel,
} from "@/types";
import {
providerPresets,
@@ -31,9 +32,16 @@ import {
} from "@/config/geminiProviderPresets";
import {
opencodeProviderPresets,
+ OPENCODE_PRESET_MODEL_VARIANTS,
type OpenCodeProviderPreset,
} from "@/config/opencodeProviderPresets";
+import {
+ openclawProviderPresets,
+ type OpenClawProviderPreset,
+ type OpenClawSuggestedDefaults,
+} from "@/config/openclawProviderPresets";
import { OpenCodeFormFields } from "./OpenCodeFormFields";
+import { OpenClawFormFields } from "./OpenClawFormFields";
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
import {
applyTemplateValues,
@@ -56,7 +64,7 @@ import { type OmoGlobalConfigFieldsRef } from "./OmoGlobalConfigFields";
import { OmoCommonConfigEditor } from "./OmoCommonConfigEditor";
import * as configApi from "@/lib/api/config";
import type { OmoGlobalConfig } from "@/types/omo";
-import { mergeOmoConfigPreview } from "@/types/omo";
+import { mergeOmoConfigPreview, parseOmoOtherFieldsObject } from "@/types/omo";
import {
ProviderAdvancedConfig,
type PricingModelSourceOption,
@@ -115,21 +123,25 @@ const isKnownOpencodeOptionKey = (key: string) =>
function parseOpencodeConfig(
settingsConfig?: Record,
): OpenCodeProviderConfig {
+ const normalize = (
+ parsed: Partial,
+ ): OpenCodeProviderConfig => ({
+ npm: parsed.npm || OPENCODE_DEFAULT_NPM,
+ options:
+ parsed.options && typeof parsed.options === "object"
+ ? (parsed.options as OpenCodeProviderConfig["options"])
+ : {},
+ models:
+ parsed.models && typeof parsed.models === "object"
+ ? (parsed.models as Record)
+ : {},
+ });
+
try {
const parsed = JSON.parse(
settingsConfig ? JSON.stringify(settingsConfig) : OPENCODE_DEFAULT_CONFIG,
) as Partial;
- return {
- npm: parsed.npm || OPENCODE_DEFAULT_NPM,
- options:
- parsed.options && typeof parsed.options === "object"
- ? (parsed.options as OpenCodeProviderConfig["options"])
- : {},
- models:
- parsed.models && typeof parsed.models === "object"
- ? (parsed.models as Record)
- : {},
- };
+ return normalize(parsed);
} catch {
return {
npm: OPENCODE_DEFAULT_NPM,
@@ -139,6 +151,25 @@ function parseOpencodeConfig(
}
}
+function parseOpencodeConfigStrict(
+ settingsConfig?: Record,
+): OpenCodeProviderConfig {
+ const parsed = JSON.parse(
+ settingsConfig ? JSON.stringify(settingsConfig) : OPENCODE_DEFAULT_CONFIG,
+ ) as Partial;
+ return {
+ npm: parsed.npm || OPENCODE_DEFAULT_NPM,
+ options:
+ parsed.options && typeof parsed.options === "object"
+ ? (parsed.options as OpenCodeProviderConfig["options"])
+ : {},
+ models:
+ parsed.models && typeof parsed.models === "object"
+ ? (parsed.models as Record)
+ : {},
+ };
+}
+
function toOpencodeExtraOptions(
options: OpenCodeProviderConfig["options"],
): Record {
@@ -151,13 +182,25 @@ function toOpencodeExtraOptions(
return extra;
}
+const OPENCLAW_DEFAULT_CONFIG = JSON.stringify(
+ {
+ baseUrl: "",
+ apiKey: "",
+ api: "openai-completions",
+ models: [],
+ },
+ null,
+ 2,
+);
+
type PresetEntry = {
id: string;
preset:
| ProviderPreset
| CodexProviderPreset
| GeminiProviderPreset
- | OpenCodeProviderPreset;
+ | OpenCodeProviderPreset
+ | OpenClawProviderPreset;
};
interface ProviderFormProps {
@@ -198,8 +241,10 @@ function buildOmoProfilePreview(
}
if (otherFieldsStr.trim()) {
try {
- const other = JSON.parse(otherFieldsStr);
- Object.assign(profileOnly, other);
+ const other = parseOmoOtherFieldsObject(otherFieldsStr);
+ if (other) {
+ Object.assign(profileOnly, other);
+ }
} catch {}
}
return profileOnly;
@@ -236,6 +281,7 @@ export function ProviderForm({
category?: ProviderCategory;
isPartner?: boolean;
partnerPromotionKey?: string;
+ suggestedDefaults?: OpenClawSuggestedDefaults;
} | null>(null);
const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false);
const [isCodexEndpointModalOpen, setIsCodexEndpointModalOpen] =
@@ -314,7 +360,9 @@ export function ProviderForm({
? GEMINI_DEFAULT_CONFIG
: appId === "opencode"
? OPENCODE_DEFAULT_CONFIG
- : CLAUDE_DEFAULT_CONFIG,
+ : appId === "openclaw"
+ ? OPENCLAW_DEFAULT_CONFIG
+ : CLAUDE_DEFAULT_CONFIG,
icon: initialData?.icon ?? "",
iconColor: initialData?.iconColor ?? "",
}),
@@ -444,6 +492,11 @@ export function ProviderForm({
id: `opencode-${index}`,
preset,
}));
+ } else if (appId === "openclaw") {
+ return openclawProviderPresets.map((preset, index) => ({
+ id: `openclaw-${index}`,
+ preset,
+ }));
}
return providerPresets.map((preset, index) => ({
id: `claude-${index}`,
@@ -586,19 +639,24 @@ export function ProviderForm({
);
}, [opencodeProvidersData?.providers, providerId]);
const [enabledOpencodeProviderIds, setEnabledOpencodeProviderIds] = useState<
- string[]
- >([]);
+ string[] | null
+ >(null);
+ const [omoLiveIdsLoadFailed, setOmoLiveIdsLoadFailed] = useState(false);
+ const lastOmoModelSourceWarningRef = useRef("");
useEffect(() => {
let active = true;
-
if (!isOmoCategory) {
- setEnabledOpencodeProviderIds([]);
+ setEnabledOpencodeProviderIds(null);
+ setOmoLiveIdsLoadFailed(false);
return () => {
active = false;
};
}
+ setEnabledOpencodeProviderIds(null);
+ setOmoLiveIdsLoadFailed(false);
+
(async () => {
try {
const ids = await providersApi.getOpenCodeLiveProviderIds();
@@ -606,9 +664,13 @@ export function ProviderForm({
setEnabledOpencodeProviderIds(ids);
}
} catch (error) {
- console.error("Failed to load OpenCode live provider ids:", error);
+ console.warn(
+ "[OMO_MODEL_SOURCE_LIVE_IDS_FAILED] failed to load live provider ids",
+ error,
+ );
if (active) {
- setEnabledOpencodeProviderIds([]);
+ setOmoLiveIdsLoadFailed(true);
+ setEnabledOpencodeProviderIds(null);
}
}
})();
@@ -618,23 +680,71 @@ export function ProviderForm({
};
}, [isOmoCategory]);
- const omoModelOptions = useMemo(() => {
- if (!isOmoCategory) return [];
+ const omoModelBuild = useMemo(() => {
+ const empty = {
+ options: [] as Array<{ value: string; label: string }>,
+ variantsMap: {} as Record,
+ presetMetaMap: {} as Record<
+ string,
+ {
+ options?: Record;
+ limit?: { context?: number; output?: number };
+ }
+ >,
+ parseFailedProviders: [] as string[],
+ usedFallbackSource: false,
+ };
+ if (!isOmoCategory) {
+ return empty;
+ }
const allProviders = opencodeProvidersData?.providers;
- if (!allProviders) return [];
+ if (!allProviders) {
+ return empty;
+ }
- const enabledSet = new Set(enabledOpencodeProviderIds);
- if (enabledSet.size === 0) return [];
+ const shouldFilterByLive = !omoLiveIdsLoadFailed;
+ if (shouldFilterByLive && enabledOpencodeProviderIds === null) {
+ return empty;
+ }
+ const liveSet =
+ shouldFilterByLive && enabledOpencodeProviderIds
+ ? new Set(enabledOpencodeProviderIds)
+ : null;
const dedupedOptions = new Map();
+ const variantsMap: Record = {};
+ const presetMetaMap: Record<
+ string,
+ {
+ options?: Record;
+ limit?: { context?: number; output?: number };
+ }
+ > = {};
+ const parseFailedProviders: string[] = [];
for (const [providerKey, provider] of Object.entries(allProviders)) {
- if (provider.category === "omo" || !enabledSet.has(providerKey)) {
+ if (provider.category === "omo") {
+ continue;
+ }
+ if (liveSet && !liveSet.has(providerKey)) {
continue;
}
- const parsedConfig = parseOpencodeConfig(provider.settingsConfig);
+ let parsedConfig: OpenCodeProviderConfig;
+ try {
+ parsedConfig = parseOpencodeConfigStrict(provider.settingsConfig);
+ } catch (error) {
+ parseFailedProviders.push(providerKey);
+ console.warn(
+ "[OMO_MODEL_SOURCE_PARSE_FAILED] failed to parse provider settings",
+ {
+ providerKey,
+ error,
+ },
+ );
+ continue;
+ }
for (const [modelId, model] of Object.entries(
parsedConfig.models || {},
)) {
@@ -651,63 +761,107 @@ export function ProviderForm({
if (!dedupedOptions.has(value)) {
dedupedOptions.set(value, label);
}
- }
- }
- return Array.from(dedupedOptions.entries())
- .map(([value, label]) => ({ value, label }))
- .sort((a, b) => a.label.localeCompare(b.label, "zh-CN"));
- }, [
- isOmoCategory,
- opencodeProvidersData?.providers,
- enabledOpencodeProviderIds,
- ]);
- const omoModelVariantsMap = useMemo(() => {
- const variantsMap: Record = {};
- if (!isOmoCategory) {
- return variantsMap;
- }
-
- const allProviders = opencodeProvidersData?.providers;
- if (!allProviders) {
- return variantsMap;
- }
-
- const enabledSet = new Set(enabledOpencodeProviderIds);
- if (enabledSet.size === 0) {
- return variantsMap;
- }
-
- for (const [providerKey, provider] of Object.entries(allProviders)) {
- if (provider.category === "omo" || !enabledSet.has(providerKey)) {
- continue;
- }
-
- const parsedConfig = parseOpencodeConfig(provider.settingsConfig);
- for (const [modelId, model] of Object.entries(
- parsedConfig.models || {},
- )) {
const rawVariants = model.variants;
if (
- !rawVariants ||
- typeof rawVariants !== "object" ||
- Array.isArray(rawVariants)
+ rawVariants &&
+ typeof rawVariants === "object" &&
+ !Array.isArray(rawVariants)
) {
- continue;
+ const variantKeys = Object.keys(rawVariants).filter(Boolean);
+ if (variantKeys.length > 0) {
+ variantsMap[value] = variantKeys;
+ }
}
- const variantKeys = Object.keys(rawVariants).filter(Boolean);
- if (variantKeys.length === 0) {
- continue;
+ }
+
+ // Preset fallback: for models without config-defined variants,
+ // check if the npm package has preset variant definitions.
+ // Also collect preset metadata (options, limit) for enrichment.
+ const presetModels = OPENCODE_PRESET_MODEL_VARIANTS[parsedConfig.npm];
+ if (presetModels) {
+ for (const modelId of Object.keys(parsedConfig.models || {})) {
+ const fullKey = `${providerKey}/${modelId}`;
+ const preset = presetModels.find((p) => p.id === modelId);
+ if (!preset) continue;
+
+ // Variant fallback
+ if (!variantsMap[fullKey] && preset.variants) {
+ const presetKeys = Object.keys(preset.variants).filter(Boolean);
+ if (presetKeys.length > 0) {
+ variantsMap[fullKey] = presetKeys;
+ }
+ }
+
+ // Collect preset metadata for model enrichment
+ const meta: (typeof presetMetaMap)[string] = {};
+ if (preset.options) meta.options = preset.options;
+ if (preset.contextLimit || preset.outputLimit) {
+ meta.limit = {};
+ if (preset.contextLimit) meta.limit.context = preset.contextLimit;
+ if (preset.outputLimit) meta.limit.output = preset.outputLimit;
+ }
+ if (Object.keys(meta).length > 0) {
+ presetMetaMap[fullKey] = meta;
+ }
}
- variantsMap[`${providerKey}/${modelId}`] = variantKeys;
}
}
- return variantsMap;
+ return {
+ options: Array.from(dedupedOptions.entries())
+ .map(([value, label]) => ({ value, label }))
+ .sort((a, b) => a.label.localeCompare(b.label, "zh-CN")),
+ variantsMap,
+ presetMetaMap,
+ parseFailedProviders,
+ usedFallbackSource: omoLiveIdsLoadFailed,
+ };
}, [
isOmoCategory,
opencodeProvidersData?.providers,
enabledOpencodeProviderIds,
+ omoLiveIdsLoadFailed,
+ ]);
+ const omoModelOptions = omoModelBuild.options;
+ const omoModelVariantsMap = omoModelBuild.variantsMap;
+ const omoPresetMetaMap = omoModelBuild.presetMetaMap;
+
+ useEffect(() => {
+ if (!isOmoCategory) return;
+ const failed = omoModelBuild.parseFailedProviders;
+ const fallback = omoModelBuild.usedFallbackSource;
+ if (failed.length === 0 && !fallback) return;
+
+ const signature = `${fallback ? "fallback:" : ""}${failed
+ .slice()
+ .sort()
+ .join(",")}`;
+ if (lastOmoModelSourceWarningRef.current === signature) return;
+ lastOmoModelSourceWarningRef.current = signature;
+
+ if (failed.length > 0) {
+ toast.warning(
+ t("omo.modelSourcePartialWarning", {
+ count: failed.length,
+ defaultValue:
+ "Some provider model configs are invalid and were skipped.",
+ }),
+ );
+ }
+ if (fallback) {
+ toast.warning(
+ t("omo.modelSourceFallbackWarning", {
+ defaultValue:
+ "Failed to load live provider state. Falling back to configured providers.",
+ }),
+ );
+ }
+ }, [
+ isOmoCategory,
+ omoModelBuild.parseFailedProviders,
+ omoModelBuild.usedFallbackSource,
+ t,
]);
const initialOmoSettings =
@@ -725,6 +879,24 @@ export function ProviderForm({
return providerId || "";
});
+ // OpenClaw: query existing providers for duplicate key checking
+ const { data: openclawProvidersData } = useProvidersQuery("openclaw");
+ const existingOpenclawKeys = useMemo(() => {
+ if (!openclawProvidersData?.providers) return [];
+ // Exclude current provider ID when in edit mode
+ return Object.keys(openclawProvidersData.providers).filter(
+ (k) => k !== providerId,
+ );
+ }, [openclawProvidersData?.providers, providerId]);
+
+ // OpenClaw Provider Key state
+ const [openclawProviderKey, setOpenclawProviderKey] = useState(() => {
+ if (appId !== "openclaw") return "";
+ // In edit mode, use the existing provider ID as the key
+ return providerId || "";
+ });
+
+ // OpenCode 配置状态
const [opencodeNpm, setOpencodeNpm] = useState(() => {
if (appId !== "opencode") return OPENCODE_DEFAULT_NPM;
return initialOpencodeConfig?.npm || OPENCODE_DEFAULT_NPM;
@@ -866,6 +1038,128 @@ export function ProviderForm({
setUseOmoCommonConfig(useCommonConfig);
}, []);
+ // OpenClaw 配置状态
+ const [openclawBaseUrl, setOpenclawBaseUrl] = useState(() => {
+ if (appId !== "openclaw") return "";
+ try {
+ const config = JSON.parse(
+ initialData?.settingsConfig
+ ? JSON.stringify(initialData.settingsConfig)
+ : OPENCLAW_DEFAULT_CONFIG,
+ );
+ return config.baseUrl || "";
+ } catch {
+ return "";
+ }
+ });
+
+ const [openclawApiKey, setOpenclawApiKey] = useState(() => {
+ if (appId !== "openclaw") return "";
+ try {
+ const config = JSON.parse(
+ initialData?.settingsConfig
+ ? JSON.stringify(initialData.settingsConfig)
+ : OPENCLAW_DEFAULT_CONFIG,
+ );
+ return config.apiKey || "";
+ } catch {
+ return "";
+ }
+ });
+
+ const [openclawApi, setOpenclawApi] = useState(() => {
+ if (appId !== "openclaw") return "openai-completions";
+ try {
+ const config = JSON.parse(
+ initialData?.settingsConfig
+ ? JSON.stringify(initialData.settingsConfig)
+ : OPENCLAW_DEFAULT_CONFIG,
+ );
+ return config.api || "openai-completions";
+ } catch {
+ return "openai-completions";
+ }
+ });
+
+ const [openclawModels, setOpenclawModels] = useState(() => {
+ if (appId !== "openclaw") return [];
+ try {
+ const config = JSON.parse(
+ initialData?.settingsConfig
+ ? JSON.stringify(initialData.settingsConfig)
+ : OPENCLAW_DEFAULT_CONFIG,
+ );
+ return config.models || [];
+ } catch {
+ return [];
+ }
+ });
+
+ // OpenClaw handlers - sync state to form
+ const handleOpenclawBaseUrlChange = useCallback(
+ (baseUrl: string) => {
+ setOpenclawBaseUrl(baseUrl);
+ try {
+ const config = JSON.parse(
+ form.getValues("settingsConfig") || OPENCLAW_DEFAULT_CONFIG,
+ );
+ config.baseUrl = baseUrl.trim().replace(/\/+$/, "");
+ form.setValue("settingsConfig", JSON.stringify(config, null, 2));
+ } catch {
+ // ignore
+ }
+ },
+ [form],
+ );
+
+ const handleOpenclawApiKeyChange = useCallback(
+ (apiKey: string) => {
+ setOpenclawApiKey(apiKey);
+ try {
+ const config = JSON.parse(
+ form.getValues("settingsConfig") || OPENCLAW_DEFAULT_CONFIG,
+ );
+ config.apiKey = apiKey;
+ form.setValue("settingsConfig", JSON.stringify(config, null, 2));
+ } catch {
+ // ignore
+ }
+ },
+ [form],
+ );
+
+ const handleOpenclawApiChange = useCallback(
+ (api: string) => {
+ setOpenclawApi(api);
+ try {
+ const config = JSON.parse(
+ form.getValues("settingsConfig") || OPENCLAW_DEFAULT_CONFIG,
+ );
+ config.api = api;
+ form.setValue("settingsConfig", JSON.stringify(config, null, 2));
+ } catch {
+ // ignore
+ }
+ },
+ [form],
+ );
+
+ const handleOpenclawModelsChange = useCallback(
+ (models: OpenClawModel[]) => {
+ setOpenclawModels(models);
+ try {
+ const config = JSON.parse(
+ form.getValues("settingsConfig") || OPENCLAW_DEFAULT_CONFIG,
+ );
+ config.models = models;
+ form.setValue("settingsConfig", JSON.stringify(config, null, 2));
+ } catch {
+ // ignore
+ }
+ },
+ [form],
+ );
+
const updateOpencodeSettings = useCallback(
(updater: (config: Record) => void) => {
try {
@@ -993,6 +1287,24 @@ export function ProviderForm({
}
}
+ // OpenClaw: validate provider key
+ if (appId === "openclaw") {
+ const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
+ if (!openclawProviderKey.trim()) {
+ toast.error(t("openclaw.providerKeyRequired"));
+ return;
+ }
+ if (!keyPattern.test(openclawProviderKey)) {
+ toast.error(t("openclaw.providerKeyInvalid"));
+ return;
+ }
+ if (!isEditMode && existingOpenclawKeys.includes(openclawProviderKey)) {
+ toast.error(t("openclaw.providerKeyDuplicate"));
+ return;
+ }
+ }
+
+ // 非官方供应商必填校验:端点和 API Key
if (category !== "official") {
if (appId === "claude") {
if (!baseUrl.trim()) {
@@ -1084,7 +1396,19 @@ export function ProviderForm({
}
if (omoOtherFieldsStr.trim()) {
try {
- omoConfig.otherFields = JSON.parse(omoOtherFieldsStr);
+ const otherFields = parseOmoOtherFieldsObject(omoOtherFieldsStr);
+ if (!otherFields) {
+ toast.error(
+ t("omo.jsonMustBeObject", {
+ field: t("omo.otherFields", {
+ defaultValue: "Other Config",
+ }),
+ defaultValue: "{{field}} must be a JSON object",
+ }),
+ );
+ return;
+ }
+ omoConfig.otherFields = otherFields;
} catch {
toast.error(
t("omo.invalidJson", {
@@ -1114,6 +1438,8 @@ export function ProviderForm({
} else {
payload.providerKey = opencodeProviderKey;
}
+ } else if (appId === "openclaw") {
+ payload.providerKey = openclawProviderKey;
}
if (category === "omo" && !payload.presetCategory) {
@@ -1128,6 +1454,10 @@ export function ProviderForm({
if (activePreset.isPartner) {
payload.isPartner = activePreset.isPartner;
}
+ // OpenClaw: 传递预设的 suggestedDefaults 到提交数据
+ if (activePreset.suggestedDefaults) {
+ payload.suggestedDefaults = activePreset.suggestedDefaults;
+ }
}
if (!isEditMode && draftCustomEndpoints.length > 0) {
@@ -1268,6 +1598,21 @@ export function ProviderForm({
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") || "",
+ });
+
+ // 使用端点测速候选 hook
const speedTestEndpoints = useSpeedTestEndpoints({
appId,
selectedPresetId,
@@ -1299,6 +1644,14 @@ export function ProviderForm({
setOpencodeExtraOptions({});
resetOmoDraftState();
}
+ // OpenClaw 自定义模式:重置为空配置
+ if (appId === "openclaw") {
+ setOpenclawProviderKey("");
+ setOpenclawBaseUrl("");
+ setOpenclawApiKey("");
+ setOpenclawApi("openai-completions");
+ setOpenclawModels([]);
+ }
return;
}
@@ -1382,6 +1735,40 @@ export function ProviderForm({
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,
+ });
+
+ // Clear provider key (user must enter their own unique key)
+ setOpenclawProviderKey("");
+
+ // Update OpenClaw-specific states
+ setOpenclawBaseUrl(config.baseUrl || "");
+ setOpenclawApiKey(config.apiKey || "");
+ setOpenclawApi(config.api || "openai-completions");
+ setOpenclawModels(config.models || []);
+
+ // Update form fields
+ form.reset({
+ name: 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,
@@ -1486,6 +1873,54 @@ export function ProviderForm({
)}
+ ) : appId === "openclaw" ? (
+
+
+ {t("openclaw.providerKey")}
+ *
+
+
+ setOpenclawProviderKey(
+ e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""),
+ )
+ }
+ placeholder={t("openclaw.providerKeyPlaceholder")}
+ disabled={isEditMode}
+ className={
+ (existingOpenclawKeys.includes(openclawProviderKey) &&
+ !isEditMode) ||
+ (openclawProviderKey.trim() !== "" &&
+ !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(openclawProviderKey))
+ ? "border-destructive"
+ : ""
+ }
+ />
+ {existingOpenclawKeys.includes(openclawProviderKey) &&
+ !isEditMode && (
+
+ {t("openclaw.providerKeyDuplicate")}
+
+ )}
+ {openclawProviderKey.trim() !== "" &&
+ !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(openclawProviderKey) && (
+
+ {t("openclaw.providerKeyInvalid")}
+
+ )}
+ {!(
+ existingOpenclawKeys.includes(openclawProviderKey) &&
+ !isEditMode
+ ) &&
+ (openclawProviderKey.trim() === "" ||
+ /^[a-z0-9]+(-[a-z0-9]+)*$/.test(openclawProviderKey)) && (
+
+ {t("openclaw.providerKeyHint")}
+
+ )}
+
) : undefined
}
/>
@@ -1611,6 +2046,7 @@ export function ProviderForm({
)}
+ {/* OpenClaw 专属字段 */}
+ {appId === "openclaw" && (
+
+ )}
+
+ {/* 配置编辑器:Codex、Claude、Gemini 分别使用不同的编辑器 */}
{appId === "codex" ? (
<>
{settingsConfigErrorField}
>
+ ) : appId === "openclaw" ? (
+ <>
+
+ {t("provider.configJson")}
+ form.setValue("settingsConfig", config)}
+ placeholder={`{
+ "baseUrl": "https://api.example.com/v1",
+ "apiKey": "your-api-key-here",
+ "api": "openai-completions",
+ "models": []
+}`}
+ rows={14}
+ showValidation={true}
+ language="json"
+ />
+
+ (
+
+
+
+ )}
+ />
+ >
) : (
<>
({
enabled: true,
requestThinkingSignature: true,
+ requestThinkingBudget: true,
});
const [isLoading, setIsLoading] = useState(true);
@@ -69,6 +70,21 @@ export function RectifierConfigPanel() {
}
/>