refactor(openclaw): align provider model editor (#6201)

Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
SaladDayandJason authored and GitHub committed 2026-08-11 23:20:06 +08:00
1 parent 076c2744ce
commit 95b95da6ab
12 files changed
+590 -328

No files matched your search

+77 -8
View File
@@ -2,6 +2,7 @@ import {
Activity,
BarChart3,
Check,
ChevronDown,
Copy,
Edit,
Loader2,
@@ -14,9 +15,21 @@ import {
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import type { AppId } from "@/lib/api";
interface OpenClawDefaultModelOption {
id: string;
name?: string;
}
interface ProviderActionsProps {
appId?: AppId;
isCurrent: boolean;
@@ -41,7 +54,8 @@ interface ProviderActionsProps {
isReadOnly?: boolean;
// OpenClaw: default model
isDefaultModel?: boolean;
onSetAsDefault?: () => void;
defaultModelOptions?: OpenClawDefaultModelOption[];
onSetAsDefault?: (modelId?: string) => void;
}
// 主按钮的呈现状态。title 用于 disabled 态向用户解释为何不可点击;
@@ -79,6 +93,7 @@ export function ProviderActions({
isReadOnly = false,
// OpenClaw: default model
isDefaultModel = false,
defaultModelOptions = [],
onSetAsDefault,
}: ProviderActionsProps) {
const { t } = useTranslation();
@@ -240,18 +255,72 @@ export function ProviderActions({
appId === "hermes"
? t("provider.enable", { defaultValue: "启用" })
: t("provider.setAsDefault", { defaultValue: "设为默认" });
const defaultButtonClassName = cn(
"w-fit px-2.5",
isDefaultModel
? "bg-gray-200 text-muted-foreground dark:bg-gray-700 opacity-60 cursor-not-allowed"
: "bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
);
if (
appId === "openclaw" &&
!isDefaultModel &&
defaultModelOptions.length > 1
) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="default"
className={defaultButtonClassName}
>
<Zap className="h-4 w-4" />
{inactiveLabel}
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="max-h-72 min-w-64 overflow-y-auto"
>
<DropdownMenuLabel>
{t("openclaw.selectDefaultModel", {
defaultValue: "选择默认模型",
})}
</DropdownMenuLabel>
{defaultModelOptions.map((model) => (
<DropdownMenuItem
key={model.id}
onSelect={() => onSetAsDefault(model.id)}
className="flex min-w-0 flex-col items-start gap-0.5"
>
<span className="max-w-72 truncate">
{model.name?.trim() || model.id}
</span>
{model.name?.trim() && model.name.trim() !== model.id && (
<span className="max-w-72 truncate font-mono text-xs text-muted-foreground">
{model.id}
</span>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
return (
<Button
size="sm"
variant={isDefaultModel ? "secondary" : "default"}
onClick={isDefaultModel ? undefined : onSetAsDefault}
disabled={isDefaultModel}
className={cn(
"w-fit px-2.5",
onClick={
isDefaultModel
? "bg-gray-200 text-muted-foreground dark:bg-gray-700 opacity-60 cursor-not-allowed"
: "bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
)}
? undefined
: () => onSetAsDefault(defaultModelOptions[0]?.id)
}
disabled={isDefaultModel}
className={defaultButtonClassName}
>
<Zap className="h-4 w-4" />
{isDefaultModel ? activeLabel : inactiveLabel}
+12 -2
View File
@@ -5,7 +5,7 @@ import type {
DraggableAttributes,
DraggableSyntheticListeners,
} from "@dnd-kit/core";
import type { Provider } from "@/types";
import type { OpenClawProviderConfig, Provider } from "@/types";
import type { AppId } from "@/lib/api";
import { cn } from "@/lib/utils";
import { ProviderActions } from "@/components/providers/ProviderActions";
@@ -66,7 +66,7 @@ interface ProviderCardProps {
activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框)
// OpenClaw: default model
isDefaultModel?: boolean;
onSetAsDefault?: () => void;
onSetAsDefault?: (modelId?: string) => void;
}
/** 判断是否为官方供应商(无自定义 base URL / API key,直连官方 API) */
@@ -184,6 +184,15 @@ export function ProviderCard({
return extractApiUrl(provider, fallbackUrlText);
}, [provider, fallbackUrlText]);
const openclawDefaultModelOptions = useMemo(() => {
if (appId !== "openclaw") return [];
const config = provider.settingsConfig as OpenClawProviderConfig;
if (!Array.isArray(config?.models)) return [];
return config.models
.filter((model) => typeof model.id === "string" && model.id.trim())
.map((model) => ({ id: model.id, name: model.name }));
}, [appId, provider.settingsConfig]);
const isClickableUrl = useMemo(() => {
if (provider.notes?.trim()) {
return false;
@@ -602,6 +611,7 @@ export function ProviderCard({
onToggleFailover={onToggleFailover}
// OpenClaw: default model
isDefaultModel={isDefaultModel}
defaultModelOptions={openclawDefaultModelOptions}
onSetAsDefault={onSetAsDefault}
/>
</div>
+5 -3
View File
@@ -67,7 +67,7 @@ interface ProviderListProps {
isProxyRunning?: boolean; // 代理服务运行状态
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管)
activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框)
onSetAsDefault?: (provider: Provider) => void; // OpenClaw: set as default model
onSetAsDefault?: (provider: Provider, modelId?: string) => void; // OpenClaw: set as default model
}
export function ProviderList({
@@ -438,7 +438,9 @@ export function ProviderList({
: isProviderDefaultModel(provider.id)
}
onSetAsDefault={
onSetAsDefault ? () => onSetAsDefault(provider) : undefined
onSetAsDefault
? (modelId) => onSetAsDefault(provider, modelId)
: undefined
}
/>
);
@@ -570,7 +572,7 @@ interface SortableProviderCardProps {
activeProviderId?: string;
// OpenClaw: default model
isDefaultModel?: boolean;
onSetAsDefault?: () => void;
onSetAsDefault?: (modelId?: string) => void;
}
function SortableProviderCard({
@@ -11,20 +11,8 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { toast } from "sonner";
import {
Download,
Plus,
Trash2,
ChevronDown,
ChevronRight,
Loader2,
} from "lucide-react";
import { Download, Plus, Trash2, ChevronRight, Loader2 } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { ApiKeySection, ModelDropdown } from "./shared";
import {
@@ -80,9 +68,7 @@ export function OpenClawFormFields({
onUserAgentChange,
}: OpenClawFormFieldsProps) {
const { t } = useTranslation();
const [expandedModels, setExpandedModels] = useState<Record<number, boolean>>(
{},
);
const [expandedModels, setExpandedModels] = useState<Set<string>>(new Set());
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
const [isFetchingModels, setIsFetchingModels] = useState(false);
@@ -102,8 +88,16 @@ export function OpenClawFormFields({
const modelKeys = getModelKeys();
// Toggle advanced section for a model
const toggleModelAdvanced = (index: number) => {
setExpandedModels((prev) => ({ ...prev, [index]: !prev[index] }));
const toggleModelAdvanced = (modelKey: string) => {
setExpandedModels((prev) => {
const next = new Set(prev);
if (next.has(modelKey)) {
next.delete(modelKey);
} else {
next.add(modelKey);
}
return next;
});
};
// Add a new model entry
@@ -152,16 +146,18 @@ export function OpenClawFormFields({
// Remove a model entry
const handleRemoveModel = (index: number) => {
const removedKey = modelKeysRef.current[index];
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;
});
if (removedKey) {
setExpandedModels((prev) => {
const next = new Set(prev);
next.delete(removedKey);
return next;
});
}
};
// Update model field
@@ -274,10 +270,10 @@ export function OpenClawFormFields({
</div>
{/* Models Editor */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="space-y-3 border-l border-border-default pl-3">
<div className="flex items-center justify-between gap-3">
<FormLabel>
{t("openclaw.models", { defaultValue: "模型列表" })}
{t("openclaw.models", { defaultValue: "模型配置" })}
</FormLabel>
<div className="flex gap-1">
<Button
@@ -309,52 +305,59 @@ export function OpenClawFormFields({
</div>
{models.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
<p role="status" className="py-2 text-sm text-muted-foreground">
{t("openclaw.noModels", {
defaultValue: "暂无模型配置。点击添加模型来配置可用模型。",
defaultValue: "暂无模型配置",
})}
</p>
) : (
<div className="space-y-4">
{models.map((model, index) => (
<div
key={modelKeys[index]}
className="p-3 border border-border/50 rounded-lg space-y-3"
>
{/* Role badge */}
<div className="flex items-center">
<span
className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${
index === 0
? "bg-blue-500/15 text-blue-600 dark:text-blue-400"
: "bg-muted text-muted-foreground"
}`}
>
{index === 0
? t("openclaw.primaryModel", {
defaultValue: "默认模型",
})
: t("openclaw.fallbackModel", {
defaultValue: "回退模型",
})}
</span>
</div>
{/* Model ID and Name row */}
<div className="flex items-center gap-2">
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("openclaw.modelId", { defaultValue: "模型 ID" })}
</label>
<div className="flex gap-1">
<div className="space-y-2">
<div className="flex items-center gap-2 px-1 text-xs text-muted-foreground">
<span className="w-9" />
<span className="flex-1">
{t("openclaw.modelId", { defaultValue: "模型 ID" })}
</span>
<span className="flex-1">
{t("openclaw.modelName", { defaultValue: "显示名称" })}
</span>
<span className="w-9" />
</div>
{models.map((model, index) => {
const modelKey = modelKeys[index];
const isExpanded = expandedModels.has(modelKey);
return (
<div key={modelKey} className="space-y-2">
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => toggleModelAdvanced(modelKey)}
aria-expanded={isExpanded}
aria-label={t("openclaw.toggleModelDetails", {
defaultValue: "展开或收起模型详情",
})}
className="h-9 w-9 shrink-0"
>
<ChevronRight
className={`h-4 w-4 transition-transform motion-reduce:transition-none ${
isExpanded ? "rotate-90" : ""
}`}
/>
</Button>
<div className="flex min-w-0 flex-1 gap-1">
<Input
value={model.id}
onChange={(e) =>
handleModelChange(index, "id", e.target.value)
onChange={(event) =>
handleModelChange(index, "id", event.target.value)
}
placeholder={t("openclaw.modelIdPlaceholder", {
defaultValue: "claude-3-sonnet",
})}
className="flex-1"
aria-label={t("openclaw.modelId", {
defaultValue: "模型 ID",
})}
className="min-w-0 flex-1"
/>
{fetchedModels.length > 0 && (
<ModelDropdown
@@ -363,95 +366,63 @@ export function OpenClawFormFields({
/>
)}
</div>
</div>
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("openclaw.modelName", { defaultValue: "显示名称" })}
</label>
<Input
value={model.name}
onChange={(e) =>
handleModelChange(index, "name", e.target.value)
onChange={(event) =>
handleModelChange(index, "name", event.target.value)
}
placeholder={t("openclaw.modelNamePlaceholder", {
defaultValue: "Claude 3 Sonnet",
})}
aria-label={t("openclaw.modelName", {
defaultValue: "显示名称",
})}
className="min-w-0 flex-1"
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveModel(index)}
className="h-9 w-9 mt-5 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Advanced Options (Collapsible) */}
<Collapsible
open={expandedModels[index] ?? false}
onOpenChange={() => toggleModelAdvanced(index)}
>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1 text-xs text-muted-foreground hover:text-foreground"
>
{expandedModels[index] ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
{t("openclaw.advancedOptions", {
defaultValue: "高级选项",
size="icon"
onClick={() => handleRemoveModel(index)}
aria-label={t("openclaw.removeModel", {
defaultValue: "移除模型",
})}
className="h-9 w-9 shrink-0 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-2">
{/* Reasoning, Input Types row */}
<div className="flex items-center gap-2">
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("openclaw.reasoning", {
defaultValue: "推理模式",
})}
</label>
<div className="flex items-center h-9 gap-2">
</div>
{isExpanded && (
<div className="ml-9 grid gap-3 border-l-2 border-muted pl-4 sm:grid-cols-2 animate-in fade-in-0 slide-in-from-top-1 duration-200 motion-reduce:animate-none">
<div className="flex min-h-9 flex-wrap items-center gap-x-8 gap-y-2 sm:col-span-2">
<div className="flex items-center gap-2.5">
<FormLabel
htmlFor={`openclaw-model-reasoning-${modelKey}`}
className="cursor-pointer"
>
{t("openclaw.reasoning", {
defaultValue: "支持扩展思考",
})}
</FormLabel>
<Switch
id={`openclaw-model-reasoning-${modelKey}`}
checked={model.reasoning ?? false}
onCheckedChange={(checked) =>
handleModelChange(index, "reasoning", checked)
}
/>
<span className="text-xs text-muted-foreground">
{model.reasoning
? t("openclaw.reasoningOn", {
defaultValue: "启用",
})
: t("openclaw.reasoningOff", {
defaultValue: "关闭",
})}
</span>
</div>
</div>
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("openclaw.inputTypes", {
defaultValue: "输入类型",
})}
</label>
{/* "text" is checked by default but can be unchecked —
some models genuinely don't support text input, and
OpenClaw works fine with an empty or image-only array. */}
<div className="flex items-center gap-4 h-9">
<div className="flex items-center gap-3">
<FormLabel>
{t("openclaw.inputTypes", {
defaultValue: "输入类型",
})}
</FormLabel>
{(["text", "image"] as const).map((type) => (
<label
key={type}
className="flex items-center gap-1.5 cursor-pointer select-none"
className="flex cursor-pointer select-none items-center gap-1.5"
>
<Checkbox
checked={(model.input ?? ["text"]).includes(
@@ -461,7 +432,7 @@ export function OpenClawFormFields({
const current = model.input ?? ["text"];
const next = checked
? [...new Set([...current, type])]
: current.filter((v) => v !== type);
: current.filter((value) => value !== type);
handleModelChange(index, "input", next);
}}
/>
@@ -470,152 +441,178 @@ export function OpenClawFormFields({
))}
</div>
</div>
<div className="flex-1" />
</div>
{/* Context Window and Max Tokens row */}
<div className="flex items-center gap-2">
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
<div className="space-y-1">
<FormLabel
htmlFor={`openclaw-model-context-${modelKey}`}
className="text-xs text-muted-foreground"
>
{t("openclaw.contextWindow", {
defaultValue: "上下文窗口",
defaultValue: "上下文长度",
})}
</label>
</FormLabel>
<Input
id={`openclaw-model-context-${modelKey}`}
type="number"
min={1}
step={1}
value={model.contextWindow ?? ""}
onChange={(e) =>
onChange={(event) =>
handleModelChange(
index,
"contextWindow",
e.target.value
? parseInt(e.target.value)
event.target.value
? parseInt(event.target.value)
: undefined,
)
}
placeholder="200000"
/>
</div>
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
<div className="space-y-1">
<FormLabel
htmlFor={`openclaw-model-max-tokens-${modelKey}`}
className="text-xs text-muted-foreground"
>
{t("openclaw.maxTokens", {
defaultValue: "最大输出 Tokens",
defaultValue: "最大输出 Token 数",
})}
</label>
</FormLabel>
<Input
id={`openclaw-model-max-tokens-${modelKey}`}
type="number"
min={1}
step={1}
value={model.maxTokens ?? ""}
onChange={(e) =>
onChange={(event) =>
handleModelChange(
index,
"maxTokens",
e.target.value
? parseInt(e.target.value)
event.target.value
? parseInt(event.target.value)
: undefined,
)
}
placeholder="32000"
/>
</div>
<div className="flex-1" />
</div>
{/* Cost row */}
<div className="flex items-center gap-2">
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("openclaw.inputCost", {
defaultValue: "输入价格 ($/M tokens)",
<div className="space-y-2 sm:col-span-2">
<span className="text-xs font-medium text-muted-foreground">
{t("openclaw.modelCost", {
defaultValue: "成本($/百万 Token)",
})}
</label>
<Input
type="number"
step="0.001"
value={model.cost?.input ?? ""}
onChange={(e) =>
handleCostChange(index, "input", e.target.value)
}
placeholder="3"
/>
</span>
<div className="grid gap-2 sm:grid-cols-2">
<div className="space-y-1">
<FormLabel
htmlFor={`openclaw-model-input-cost-${modelKey}`}
className="text-xs text-muted-foreground"
>
{t("openclaw.inputCost", {
defaultValue: "输入",
})}
</FormLabel>
<Input
id={`openclaw-model-input-cost-${modelKey}`}
type="number"
min={0}
step="0.001"
value={model.cost?.input ?? ""}
onChange={(event) =>
handleCostChange(
index,
"input",
event.target.value,
)
}
placeholder="3"
/>
</div>
<div className="space-y-1">
<FormLabel
htmlFor={`openclaw-model-output-cost-${modelKey}`}
className="text-xs text-muted-foreground"
>
{t("openclaw.outputCost", {
defaultValue: "输出",
})}
</FormLabel>
<Input
id={`openclaw-model-output-cost-${modelKey}`}
type="number"
min={0}
step="0.001"
value={model.cost?.output ?? ""}
onChange={(event) =>
handleCostChange(
index,
"output",
event.target.value,
)
}
placeholder="15"
/>
</div>
<div className="space-y-1">
<FormLabel
htmlFor={`openclaw-model-cache-read-${modelKey}`}
className="text-xs text-muted-foreground"
>
{t("openclaw.cacheReadCost", {
defaultValue: "缓存读取",
})}
</FormLabel>
<Input
id={`openclaw-model-cache-read-${modelKey}`}
type="number"
min={0}
step="0.001"
value={model.cost?.cacheRead ?? ""}
onChange={(event) =>
handleCostChange(
index,
"cacheRead",
event.target.value,
)
}
placeholder="0.3"
/>
</div>
<div className="space-y-1">
<FormLabel
htmlFor={`openclaw-model-cache-write-${modelKey}`}
className="text-xs text-muted-foreground"
>
{t("openclaw.cacheWriteCost", {
defaultValue: "缓存写入",
})}
</FormLabel>
<Input
id={`openclaw-model-cache-write-${modelKey}`}
type="number"
min={0}
step="0.001"
value={model.cost?.cacheWrite ?? ""}
onChange={(event) =>
handleCostChange(
index,
"cacheWrite",
event.target.value,
)
}
placeholder="3.75"
/>
</div>
</div>
</div>
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("openclaw.outputCost", {
defaultValue: "输出价格 ($/M tokens)",
})}
</label>
<Input
type="number"
step="0.001"
value={model.cost?.output ?? ""}
onChange={(e) =>
handleCostChange(index, "output", e.target.value)
}
placeholder="15"
/>
</div>
<div className="flex-1" />
</div>
{/* Cache Cost row */}
<div className="flex items-center gap-2">
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("openclaw.cacheReadCost", {
defaultValue: "缓存读取价格 ($/M tokens)",
})}
</label>
<Input
type="number"
step="0.001"
value={model.cost?.cacheRead ?? ""}
onChange={(e) =>
handleCostChange(index, "cacheRead", e.target.value)
}
placeholder="0.3"
/>
</div>
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("openclaw.cacheWriteCost", {
defaultValue: "缓存写入价格 ($/M tokens)",
})}
</label>
<Input
type="number"
step="0.001"
value={model.cost?.cacheWrite ?? ""}
onChange={(e) =>
handleCostChange(
index,
"cacheWrite",
e.target.value,
)
}
placeholder="3.75"
/>
</div>
<div className="flex-1" />
</div>
<p className="text-xs text-muted-foreground">
{t("openclaw.cacheCostHint", {
defaultValue:
"缓存价格用于计算 Prompt Caching 的成本。如不使用缓存可留空。",
})}
</p>
</CollapsibleContent>
</Collapsible>
</div>
))}
)}
</div>
);
})}
</div>
)}
<p className="text-xs text-muted-foreground">
{t("openclaw.modelsHint", {
defaultValue:
"配置该供应商支持的模型。第一个模型为默认模型(Primary),其余为回退模型(Fallback)。拖拽或调整顺序可更改默认模型。",
})}
</p>
</div>
</>
);
+24 -5
View File
@@ -394,7 +394,7 @@ export function useProviderActions(
// Set provider as default model (OpenClaw only)
const setAsDefaultModel = useCallback(
async (provider: Provider) => {
async (provider: Provider, modelId?: string) => {
const config = provider.settingsConfig as OpenClawProviderConfig;
if (!config.models || config.models.length === 0) {
toast.error(
@@ -405,12 +405,31 @@ export function useProviderActions(
return;
}
const model: OpenClawDefaultModel = {
primary: `${provider.id}/${config.models[0].id}`,
fallbacks: config.models.slice(1).map((m) => `${provider.id}/${m.id}`),
};
const selectedModel = modelId
? config.models.find((model) => model.id === modelId)
: config.models[0];
if (!selectedModel) {
toast.error(
t("notifications.openclawModelNotFound", {
defaultValue: "所选模型已不存在,请刷新后重试",
}),
);
return;
}
try {
const primary = `${provider.id}/${selectedModel.id}`;
const existingDefault = await openclawApi.getDefaultModel();
const model: OpenClawDefaultModel = {
...(existingDefault ?? {}),
primary,
};
if (existingDefault?.fallbacks) {
model.fallbacks = existingDefault.fallbacks.filter(
(fallback) => fallback !== primary,
);
}
await openclawApi.setDefaultModel(model);
await queryClient.invalidateQueries({
queryKey: openclawKeys.defaultModel,
+14 -16
View File
@@ -272,6 +272,7 @@
"openclawDefaultModelSet": "Set as default model",
"openclawDefaultModelSetFailed": "Failed to set default model",
"openclawNoModels": "No models configured",
"openclawModelNotFound": "The selected model no longer exists. Refresh and try again.",
"backfillWarning": "Switched successfully, but failed to save changes back to the previous provider",
"windowControlFailed": "Window control failed: {{error}}",
"officialBlockedByProxy": "Cannot switch to official provider while local routing is active. Using routing with official APIs may cause account bans.",
@@ -2080,26 +2081,25 @@
"apiProtocolHint": "Select the protocol type compatible with the provider's API. Most providers use OpenAI Completions format.",
"baseUrl": "API Endpoint",
"baseUrlHint": "The provider's API endpoint address.",
"models": "Models",
"models": "Model Configuration",
"addModel": "Add Model",
"noModels": "No models configured. Click Add Model to configure available models.",
"selectDefaultModel": "Select Default Model",
"noModels": "No models configured",
"modelId": "Model ID",
"modelIdPlaceholder": "claude-3-sonnet",
"modelName": "Display Name",
"modelNamePlaceholder": "Claude 3 Sonnet",
"contextWindow": "Context Window",
"toggleModelDetails": "Expand or collapse model details",
"removeModel": "Remove model",
"contextWindow": "Context Length",
"maxTokens": "Max Output Tokens",
"reasoning": "Reasoning Mode",
"reasoningOn": "Enabled",
"reasoningOff": "Disabled",
"reasoning": "Supports Extended Thinking",
"inputTypes": "Input Types",
"inputCost": "Input Cost ($/M tokens)",
"outputCost": "Output Cost ($/M tokens)",
"advancedOptions": "Advanced Options",
"cacheReadCost": "Cache Read Cost ($/M tokens)",
"cacheWriteCost": "Cache Write Cost ($/M tokens)",
"cacheCostHint": "Cache costs are used to calculate Prompt Caching costs. Leave empty if not using caching.",
"modelsHint": "Configure the models supported by this provider. Model ID is used for API calls, Display Name for the interface.",
"modelCost": "Cost ($/million tokens)",
"inputCost": "Input",
"outputCost": "Output",
"cacheReadCost": "Cache Read",
"cacheWriteCost": "Cache Write",
"userAgent": "Send User-Agent",
"userAgentHint": "Some providers require a browser User-Agent header to work properly.",
"env": {
@@ -2168,9 +2168,7 @@
"stringifiedEnvVars": "env.vars should be an object, but the current value looks stringified or malformed.",
"stringifiedShellEnv": "env.shellEnv should be an object, but the current value looks stringified or malformed.",
"parseFailed": "openclaw.json could not be parsed as valid JSON5. Fix the file before editing it here."
},
"primaryModel": "Primary Model",
"fallbackModel": "Fallback Model"
}
},
"hermes": {
"form": {
+14 -16
View File
@@ -272,6 +272,7 @@
"openclawDefaultModelSet": "デフォルトモデルに設定しました",
"openclawDefaultModelSetFailed": "デフォルトモデルの設定に失敗しました",
"openclawNoModels": "モデルが設定されていません",
"openclawModelNotFound": "選択したモデルは存在しません。更新してからもう一度お試しください。",
"backfillWarning": "切り替え成功しましたが、前のプロバイダーへの設定保存に失敗しました",
"windowControlFailed": "ウィンドウ操作に失敗しました: {{error}}",
"officialBlockedByProxy": "ローカルルーティングモード中は公式プロバイダーに切り替えできません。ルーティング経由で公式 API にアクセスするとアカウントが停止される可能性があります。",
@@ -2080,26 +2081,25 @@
"apiProtocolHint": "プロバイダーの API と互換性のあるプロトコルタイプを選択してください。ほとんどのプロバイダーは OpenAI Completions 形式を使用します。",
"baseUrl": "API エンドポイント",
"baseUrlHint": "プロバイダーの API エンドポイントアドレス。",
"models": "モデル一覧",
"models": "モデル設定",
"addModel": "モデルを追加",
"noModels": "モデルが設定されていません。「モデルを追加」をクリックして利用可能なモデルを設定してください。",
"selectDefaultModel": "デフォルトモデルを選択",
"noModels": "モデルが設定されていません",
"modelId": "モデル ID",
"modelIdPlaceholder": "claude-3-sonnet",
"modelName": "表示名",
"modelNamePlaceholder": "Claude 3 Sonnet",
"contextWindow": "コンテキストウィンドウ",
"toggleModelDetails": "モデル詳細を展開または折りたたむ",
"removeModel": "モデルを削除",
"contextWindow": "コンテキスト長",
"maxTokens": "最大出力トークン数",
"reasoning": "推論モード",
"reasoningOn": "有効",
"reasoningOff": "無効",
"reasoning": "拡張思考に対応",
"inputTypes": "入力タイプ",
"inputCost": "入力コスト ($/M トークン)",
"outputCost": "出力コスト ($/M トークン)",
"advancedOptions": "詳細オプション",
"cacheReadCost": "キャッシュ読取コスト ($/M トークン)",
"cacheWriteCost": "キャッシュ書込コスト ($/M トークン)",
"cacheCostHint": "キャッシュコストは Prompt Caching のコスト計算に使用されます。キャッシュを使用しない場合は空欄のままにしてください。",
"modelsHint": "このプロバイダーがサポートするモデルを設定します。モデル ID は API 呼び出しに、表示名はインターフェースに使用されます。",
"modelCost": "コスト($/100万トークン)",
"inputCost": "入力",
"outputCost": "出力",
"cacheReadCost": "キャッシュ読み取り",
"cacheWriteCost": "キャッシュ書き込み",
"userAgent": "User-Agent を送信",
"userAgentHint": "一部のプロバイダーはブラウザの User-Agent ヘッダーが必要です。",
"env": {
@@ -2168,9 +2168,7 @@
"stringifiedEnvVars": "env.vars はオブジェクトである必要がありますが、現在の値は文字列化または破損しているようです。",
"stringifiedShellEnv": "env.shellEnv はオブジェクトである必要がありますが、現在の値は文字列化または破損しているようです。",
"parseFailed": "openclaw.json を有効な JSON5 として解析できませんでした。ここで編集する前にファイルを修正してください。"
},
"primaryModel": "プライマリモデル",
"fallbackModel": "フォールバックモデル"
}
},
"hermes": {
"form": {
+15 -17
View File
@@ -272,6 +272,7 @@
"openclawDefaultModelSet": "已設為預設模型",
"openclawDefaultModelSetFailed": "設定預設模型失敗",
"openclawNoModels": "該供應商沒有設定模型",
"openclawModelNotFound": "所選模型已不存在,請重新整理後再試",
"backfillWarning": "切換成功,但舊供應商設定回填失敗,您手動修改的設定可能未儲存",
"windowControlFailed": "視窗控制失敗:{{error}}",
"officialBlockedByProxy": "本地路由模式下不能切換至官方供應商,使用路由存取官方 API 可能導致帳號被封鎖",
@@ -2081,26 +2082,25 @@
"apiProtocolHint": "選擇與供應商 API 相容的協定類型。大多數供應商使用 OpenAI Completions 格式。",
"baseUrl": "API 端點",
"baseUrlHint": "供應商的 API 端點位址。",
"models": "模型清單",
"models": "模型設定",
"addModel": "新增模型",
"noModels": "暫無模型設定。點擊新增模型來設定可用模型。",
"selectDefaultModel": "選擇預設模型",
"noModels": "暫無模型設定",
"modelId": "模型 ID",
"modelIdPlaceholder": "claude-3-sonnet",
"modelName": "顯示名稱",
"modelNamePlaceholder": "Claude 3 Sonnet",
"contextWindow": "上下文視窗",
"maxTokens": "最大輸出 Tokens",
"reasoning": "推理模式",
"reasoningOn": "啟用",
"reasoningOff": "關閉",
"toggleModelDetails": "展開或收合模型詳情",
"removeModel": "移除模型",
"contextWindow": "上下文長度",
"maxTokens": "最大輸出 Token 數",
"reasoning": "支援擴展思考",
"inputTypes": "輸入類型",
"inputCost": "輸入價格 ($/M tokens)",
"outputCost": "輸出價格 ($/M tokens)",
"advancedOptions": "進階選項",
"cacheReadCost": "快取讀取價格 ($/M tokens)",
"cacheWriteCost": "快取寫入價格 ($/M tokens)",
"cacheCostHint": "快取價格用於計算 Prompt Caching 的成本。如不使用快取可留空。",
"modelsHint": "設定該供應商支援的模型。模型 ID 用於 API 呼叫,顯示名稱用於介面展示。",
"modelCost": "成本($/百萬 Token)",
"inputCost": "輸入",
"outputCost": "輸出",
"cacheReadCost": "快取讀取",
"cacheWriteCost": "快取寫入",
"userAgent": "發送 User-Agent",
"userAgentHint": "部分供應商需要瀏覽器 User-Agent 才能正常存取。",
"env": {
@@ -2169,9 +2169,7 @@
"stringifiedEnvVars": "env.vars 應為物件,但目前值看起來像被字串化或已損壞。",
"stringifiedShellEnv": "env.shellEnv 應為物件,但目前值看起來像被字串化或已損壞。",
"parseFailed": "openclaw.json 不是合法 JSON5。請先修復檔案,再透過這裡編輯。"
},
"primaryModel": "預設模型",
"fallbackModel": "備用模型"
}
},
"hermes": {
"form": {
+15 -17
View File
@@ -272,6 +272,7 @@
"openclawDefaultModelSet": "已设为默认模型",
"openclawDefaultModelSetFailed": "设置默认模型失败",
"openclawNoModels": "该供应商没有配置模型",
"openclawModelNotFound": "所选模型已不存在,请刷新后重试",
"backfillWarning": "切换成功,但旧供应商配置回填失败,您手动修改的配置可能未保存",
"windowControlFailed": "窗口控制失败:{{error}}",
"officialBlockedByProxy": "本地路由模式下不能切换到官方供应商,使用路由访问官方 API 可能导致账号被封禁",
@@ -2080,26 +2081,25 @@
"apiProtocolHint": "选择与供应商 API 兼容的协议类型。大多数供应商使用 OpenAI Completions 格式。",
"baseUrl": "API 端点",
"baseUrlHint": "供应商的 API 端点地址。",
"models": "模型列表",
"models": "模型配置",
"addModel": "添加模型",
"noModels": "暂无模型配置。点击添加模型来配置可用模型。",
"selectDefaultModel": "选择默认模型",
"noModels": "暂无模型配置",
"modelId": "模型 ID",
"modelIdPlaceholder": "claude-3-sonnet",
"modelName": "显示名称",
"modelNamePlaceholder": "Claude 3 Sonnet",
"contextWindow": "上下文窗口",
"maxTokens": "最大输出 Tokens",
"reasoning": "推理模式",
"reasoningOn": "启用",
"reasoningOff": "关闭",
"toggleModelDetails": "展开或收起模型详情",
"removeModel": "移除模型",
"contextWindow": "上下文长度",
"maxTokens": "最大输出 Token 数",
"reasoning": "支持扩展思考",
"inputTypes": "输入类型",
"inputCost": "输入价格 ($/M tokens)",
"outputCost": "输出价格 ($/M tokens)",
"advancedOptions": "高级选项",
"cacheReadCost": "缓存读取价格 ($/M tokens)",
"cacheWriteCost": "缓存写入价格 ($/M tokens)",
"cacheCostHint": "缓存价格用于计算 Prompt Caching 的成本。如不使用缓存可留空。",
"modelsHint": "配置该供应商支持的模型。模型 ID 用于 API 调用,显示名称用于界面展示。",
"modelCost": "成本($/百万 Token)",
"inputCost": "输入",
"outputCost": "输出",
"cacheReadCost": "缓存读取",
"cacheWriteCost": "缓存写入",
"userAgent": "发送 User-Agent",
"userAgentHint": "部分供应商需要浏览器 User-Agent 才能正常访问。",
"env": {
@@ -2168,9 +2168,7 @@
"stringifiedEnvVars": "env.vars 应为对象,但当前值看起来像被字符串化或已损坏。",
"stringifiedShellEnv": "env.shellEnv 应为对象,但当前值看起来像被字符串化或已损坏。",
"parseFailed": "openclaw.json 不是合法 JSON5。请先修复文件,再通过这里编辑。"
},
"primaryModel": "默认模型",
"fallbackModel": "回退模型"
}
},
"hermes": {
"form": {
@@ -0,0 +1,84 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { FormProvider, useForm } from "react-hook-form";
import { OpenClawFormFields } from "@/components/providers/forms/OpenClawFormFields";
const models = [
{
id: "gpt-5.6-sol",
name: "GPT-5.6 Sol",
reasoning: true,
input: ["text", "image"],
contextWindow: 272000,
maxTokens: 128000,
cost: {
input: 2.5,
output: 15,
cacheRead: 0.25,
cacheWrite: 3.125,
},
},
{
id: "gpt-5.6-luna",
name: "GPT-5.6 Luna",
input: ["text"],
},
];
function renderForm() {
const onModelsChange = vi.fn();
function TestForm() {
const form = useForm();
return (
<FormProvider {...form}>
<OpenClawFormFields
baseUrl="https://api.example.com/v1"
onBaseUrlChange={vi.fn()}
apiKey="test-key"
onApiKeyChange={vi.fn()}
shouldShowApiKeyLink={false}
websiteUrl=""
api="openai-responses"
onApiChange={vi.fn()}
models={models}
onModelsChange={onModelsChange}
userAgent={false}
onUserAgentChange={vi.fn()}
/>
</FormProvider>
);
}
render(<TestForm />);
return { onModelsChange };
}
describe("OpenClawFormFields model editor", () => {
it("uses the family model-row layout without inferred model roles", () => {
renderForm();
expect(screen.getByText("模型配置")).toBeInTheDocument();
expect(screen.getAllByText("模型 ID")).toHaveLength(1);
expect(screen.getAllByText("显示名称")).toHaveLength(1);
expect(screen.queryByText("默认模型")).not.toBeInTheDocument();
expect(screen.queryByText("回退模型")).not.toBeInTheDocument();
expect(screen.getByDisplayValue("gpt-5.6-sol")).toBeInTheDocument();
expect(screen.getByDisplayValue("GPT-5.6 Luna")).toBeInTheDocument();
});
it("reveals native OpenClaw model details from the row chevron", async () => {
const user = userEvent.setup();
renderForm();
const toggles = screen.getAllByRole("button", {
name: "展开或收起模型详情",
});
await user.click(toggles[0]);
expect(screen.getByText("支持扩展思考")).toBeInTheDocument();
expect(screen.getByText("输入类型")).toBeInTheDocument();
expect(screen.getByText("上下文长度")).toBeInTheDocument();
expect(screen.getByText("最大输出 Token 数")).toBeInTheDocument();
expect(screen.getByText("成本($/百万 Token)")).toBeInTheDocument();
});
});
@@ -0,0 +1,58 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ProviderActions } from "@/components/providers/ProviderActions";
describe("ProviderActions OpenClaw default model selection", () => {
it("asks which model to use when a provider has multiple models", async () => {
const user = userEvent.setup();
const onSetAsDefault = vi.fn();
render(
<ProviderActions
appId="openclaw"
isCurrent={false}
isInConfig
isDefaultModel={false}
defaultModelOptions={[
{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol" },
{ id: "gpt-5.6-luna", name: "GPT-5.6 Luna" },
]}
onSwitch={vi.fn()}
onEdit={vi.fn()}
onDuplicate={vi.fn()}
onDelete={vi.fn()}
onSetAsDefault={onSetAsDefault}
/>,
);
await user.click(screen.getByRole("button", { name: /设为默认/ }));
await user.click(screen.getByText("GPT-5.6 Luna"));
expect(onSetAsDefault).toHaveBeenCalledWith("gpt-5.6-luna");
});
it("uses the only configured model without opening a picker", async () => {
const user = userEvent.setup();
const onSetAsDefault = vi.fn();
render(
<ProviderActions
appId="openclaw"
isCurrent={false}
isInConfig
isDefaultModel={false}
defaultModelOptions={[{ id: "claude-opus-5", name: "Claude Opus 5" }]}
onSwitch={vi.fn()}
onEdit={vi.fn()}
onDuplicate={vi.fn()}
onDelete={vi.fn()}
onSetAsDefault={onSetAsDefault}
/>,
);
await user.click(screen.getByRole("button", { name: "设为默认" }));
expect(onSetAsDefault).toHaveBeenCalledWith("claude-opus-5");
});
});
+33 -2
View File
@@ -720,7 +720,7 @@ describe("useProviderActions", () => {
expect(result.current.isLoading).toBe(true);
});
it("does not show backup details when setting OpenClaw default model", async () => {
it("sets the first OpenClaw model without inventing a fallback chain", async () => {
openclawApiSetDefaultModelMock.mockResolvedValueOnce({
backupPath: "/tmp/openclaw-backup.json5",
warnings: [],
@@ -743,11 +743,42 @@ describe("useProviderActions", () => {
expect(openclawApiSetDefaultModelMock).toHaveBeenCalledWith({
primary: "provider-1/gpt-4.1",
fallbacks: ["provider-1/gpt-4.1-mini"],
});
expect(toastSuccessMock).toHaveBeenCalledTimes(1);
expect(toastSuccessMock.mock.calls[0]?.[1]).toEqual({ closeButton: true });
});
it("sets the explicitly selected OpenClaw model and preserves existing fallbacks", async () => {
openclawApiGetDefaultModelMock.mockResolvedValueOnce({
primary: "other/old-primary",
fallbacks: ["provider-1/gpt-4.1-mini", "other/fallback"],
customPolicy: "preserve-me",
});
openclawApiSetDefaultModelMock.mockResolvedValueOnce({
warnings: [],
});
const { wrapper } = createWrapper();
const provider = createProvider({
settingsConfig: {
models: [{ id: "gpt-4.1" }, { id: "gpt-4.1-mini" }],
},
});
const { result } = renderHook(() => useProviderActions("openclaw"), {
wrapper,
});
await act(async () => {
await result.current.setAsDefaultModel(provider, "gpt-4.1-mini");
});
expect(openclawApiSetDefaultModelMock).toHaveBeenCalledWith({
primary: "provider-1/gpt-4.1-mini",
fallbacks: ["other/fallback"],
customPolicy: "preserve-me",
});
});
});
it("clears loading flag when all mutations idle", () => {
addProviderMutation.isPending = false;