fix: place OpenCode model variants at top level instead of inside options (#1317)

Split the expanded model panel into two editing areas:
- "Model Properties" for top-level fields (variants, cost, etc.)
- "SDK Options" for model.options fields (provider routing, etc.)

Also guard against renaming extra fields to reserved keys (name, limit,
options) or duplicate names, and fix ModelOptionKeyInput blur desync when
a rename is rejected by the parent handler.
This commit is contained in:
Jason
2026-03-15 16:37:37 +08:00
parent f38facd430
commit bb23ab918b
5 changed files with 195 additions and 22 deletions
@@ -14,6 +14,10 @@ import { Plus, Trash2, ChevronRight } from "lucide-react";
import { ApiKeySection } from "./shared";
import { opencodeNpmPackages } from "@/config/opencodeProviderPresets";
import { cn } from "@/lib/utils";
import {
getModelExtraFields,
isKnownModelKey,
} from "./helpers/opencodeFormUtils";
import type { ProviderCategory, OpenCodeModel } from "@/types";
/**
@@ -121,6 +125,10 @@ function ModelOptionKeyInput({
if (trimmed && trimmed !== optionKey) {
onChange(trimmed);
}
// Reset to prop value: if parent accepted the rename, useEffect
// will update localValue when the new optionKey prop arrives;
// if parent rejected, this restores the correct display.
setLocalValue(optionKey.startsWith("option-") ? "" : optionKey);
}}
placeholder={placeholder}
className="flex-1"
@@ -305,6 +313,65 @@ export function OpenCodeFormFields({
});
};
// Model extra field handlers (top-level properties like variants, cost)
const handleAddModelExtraField = (modelKey: string) => {
const model = models[modelKey];
const newFieldKey = `option-${Date.now()}`;
onModelsChange({
...models,
[modelKey]: { ...model, [newFieldKey]: "" },
});
};
const handleRemoveModelExtraField = (modelKey: string, fieldKey: string) => {
const model = models[modelKey];
const newModel = { ...model };
delete newModel[fieldKey];
onModelsChange({
...models,
[modelKey]: newModel,
});
};
const handleModelExtraFieldKeyChange = (
modelKey: string,
oldKey: string,
newKey: string,
) => {
if (!newKey.trim() || oldKey === newKey) return;
const model = models[modelKey];
// Reject reserved keys and duplicate extra field names
if (isKnownModelKey(newKey) || (newKey !== oldKey && newKey in model))
return;
const newModel: Record<string, unknown> = {};
for (const [k, v] of Object.entries(model)) {
if (k === oldKey) newModel[newKey] = v;
else newModel[k] = v;
}
onModelsChange({
...models,
[modelKey]: newModel as OpenCodeModel,
});
};
const handleModelExtraFieldValueChange = (
modelKey: string,
fieldKey: string,
value: string,
) => {
const model = models[modelKey];
let parsedValue: unknown;
try {
parsedValue = JSON.parse(value);
} catch {
parsedValue = value;
}
onModelsChange({
...models,
[modelKey]: { ...model, [fieldKey]: parsedValue },
});
};
// Extra Options handlers
const handleAddExtraOption = () => {
const newKey = `option-${Date.now()}`;
@@ -559,16 +626,96 @@ export function OpenCodeFormFields({
</Button>
</div>
{/* Expanded model options */}
{/* Expanded model details */}
{expandedModels.has(key) && (
<div className="ml-9 pl-4 border-l-2 border-muted space-y-2">
{Object.keys(model.options || {}).length === 0 ? (
<div className="ml-9 pl-4 border-l-2 border-muted space-y-3">
{/* Model Properties (extra fields like variants, cost) */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">
{t("opencode.modelExtraFields", {
defaultValue: "模型属性",
})}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleAddModelExtraField(key)}
className="h-6 px-2 gap-1"
>
<Plus className="h-3 w-3" />
</Button>
</div>
{Object.keys(getModelExtraFields(model)).length === 0 ? (
<p className="text-xs text-muted-foreground py-1">
{t("opencode.noModelOptions", {
defaultValue: "模型选项,点击 + 添加",
{t("opencode.noModelExtraFields", {
defaultValue:
"模型属性 (variants, cost 等),点击 + 添加",
})}
</p>
) : (
Object.entries(getModelExtraFields(model)).map(
([fKey, fValue]) => (
<div key={fKey} className="flex items-center gap-2">
<ModelOptionKeyInput
optionKey={fKey}
onChange={(newKey) =>
handleModelExtraFieldKeyChange(
key,
fKey,
newKey,
)
}
placeholder={t(
"opencode.modelExtraFieldKeyPlaceholder",
{
defaultValue: "variants",
},
)}
/>
<Input
value={fValue}
onChange={(e) =>
handleModelExtraFieldValueChange(
key,
fKey,
e.target.value,
)
}
placeholder={t(
"opencode.modelOptionValuePlaceholder",
{
defaultValue: '{"order": ["baseten"]}',
},
)}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() =>
handleRemoveModelExtraField(key, fKey)
}
className="h-9 w-9 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
),
)
)}
</div>
{/* SDK Options (model.options) */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">
{t("opencode.sdkOptions", {
defaultValue: "SDK 选项",
})}
</span>
<Button
type="button"
variant="ghost"
@@ -579,9 +726,14 @@ export function OpenCodeFormFields({
<Plus className="h-3 w-3" />
</Button>
</div>
) : (
<>
{Object.entries(model.options || {}).map(
{Object.keys(model.options || {}).length === 0 ? (
<p className="text-xs text-muted-foreground py-1">
{t("opencode.noModelOptions", {
defaultValue: "模型选项,点击 + 添加",
})}
</p>
) : (
Object.entries(model.options || {}).map(
([optKey, optValue]) => (
<div
key={optKey}
@@ -637,20 +789,9 @@ export function OpenCodeFormFields({
</Button>
</div>
),
)}
<div className="flex items-center justify-end">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleAddModelOption(key)}
className="h-6 px-2 gap-1"
>
<Plus className="h-3 w-3" />
</Button>
</div>
</>
)}
)
)}
</div>
</div>
)}
</div>
@@ -109,6 +109,26 @@ export function parseOpencodeConfigStrict(
};
}
export const OPENCODE_KNOWN_MODEL_KEYS = ["name", "limit", "options"] as const;
export function isKnownModelKey(key: string): boolean {
return OPENCODE_KNOWN_MODEL_KEYS.includes(
key as (typeof OPENCODE_KNOWN_MODEL_KEYS)[number],
);
}
export function getModelExtraFields(
model: OpenCodeModel,
): Record<string, string> {
const extra: Record<string, string> = {};
for (const [k, v] of Object.entries(model)) {
if (!isKnownModelKey(k)) {
extra[k] = typeof v === "string" ? v : JSON.stringify(v);
}
}
return extra;
}
export function toOpencodeExtraOptions(
options: OpenCodeProviderConfig["options"],
): Record<string, string> {