Fix/opencode known field editors (#2907)

* fix: add opencode model limit editor

* fix: add opencode headers editor

* style: refine opencode advanced field layout

* fix: preserve valid opencode headers

* fix: preserve opencode extra options and sync translations

---------

Co-authored-by: wzk <wx13571681304@outlook.com>
Co-authored-by: Jason Young <44939412+farion1231@users.noreply.github.com>
Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
v2v
2026-07-11 16:38:00 +08:00
committed by GitHub
parent 50270d5e42
commit 6245caa6c5
10 changed files with 781 additions and 71 deletions
@@ -3,6 +3,11 @@ import { useTranslation } from "react-i18next";
import { FormLabel } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Select,
SelectContent,
@@ -23,6 +28,8 @@ import { cn } from "@/lib/utils";
import {
getModelExtraFields,
isKnownModelKey,
OPENCODE_EXTRA_OPTION_DRAFT_PREFIX,
OPENCODE_HEADER_DRAFT_PREFIX,
} from "./helpers/opencodeFormUtils";
import type { ProviderCategory, OpenCodeModel } from "@/types";
@@ -72,19 +79,23 @@ function ExtraOptionKeyInput({
optionKey,
onChange,
placeholder,
placeholderPrefixes = [OPENCODE_EXTRA_OPTION_DRAFT_PREFIX],
}: {
optionKey: string;
onChange: (newKey: string) => void;
onChange: (newKey: string) => boolean | void;
placeholder?: string;
placeholderPrefixes?: string[];
}) {
// For new options with placeholder keys like "option-123", show empty string
const displayValue = optionKey.startsWith("option-") ? "" : optionKey;
const isPlaceholderKey = placeholderPrefixes.some((prefix) =>
optionKey.startsWith(prefix),
);
const displayValue = isPlaceholderKey ? "" : optionKey;
const [localValue, setLocalValue] = useState(displayValue);
// Sync when external key changes
useEffect(() => {
setLocalValue(optionKey.startsWith("option-") ? "" : optionKey);
}, [optionKey]);
setLocalValue(isPlaceholderKey ? "" : optionKey);
}, [isPlaceholderKey, optionKey]);
return (
<Input
@@ -93,7 +104,10 @@ function ExtraOptionKeyInput({
onBlur={() => {
const trimmed = localValue.trim();
if (trimmed && trimmed !== optionKey) {
onChange(trimmed);
const accepted = onChange(trimmed);
if (accepted === false) {
setLocalValue(displayValue);
}
}
}}
placeholder={placeholder}
@@ -160,6 +174,10 @@ interface OpenCodeFormFieldsProps {
baseUrl: string;
onBaseUrlChange: (value: string) => void;
// Headers
headers: Record<string, string>;
onHeadersChange: (headers: Record<string, string>) => void;
// Models
models: Record<string, OpenCodeModel>;
onModelsChange: (models: Record<string, OpenCodeModel>) => void;
@@ -181,6 +199,8 @@ export function OpenCodeFormFields({
partnerPromotionKey,
baseUrl,
onBaseUrlChange,
headers,
onHeadersChange,
models,
onModelsChange,
extraOptions,
@@ -190,6 +210,15 @@ export function OpenCodeFormFields({
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
const [isFetchingModels, setIsFetchingModels] = useState(false);
const [extraOptionsOpen, setExtraOptionsOpen] = useState(
() => Object.keys(extraOptions).length > 0,
);
useEffect(() => {
if (Object.keys(extraOptions).length > 0) {
setExtraOptionsOpen(true);
}
}, [extraOptions]);
const handleFetchModels = useCallback(() => {
if (!baseUrl || !apiKey) {
@@ -284,6 +313,77 @@ export function OpenCodeFormFields({
});
};
const handleModelLimitChange = (
modelKey: string,
limitKey: "context" | "output",
value: string,
) => {
const model = models[modelKey];
const nextLimit = { ...(model.limit || {}) };
const trimmedValue = value.trim();
if (trimmedValue === "") {
delete nextLimit[limitKey];
} else {
const parsed = Number(trimmedValue);
if (!Number.isFinite(parsed) || parsed < 0) return;
nextLimit[limitKey] = Math.trunc(parsed);
}
const nextModel = { ...model };
if (Object.keys(nextLimit).length > 0) {
nextModel.limit = nextLimit;
} else {
delete nextModel.limit;
}
onModelsChange({
...models,
[modelKey]: nextModel,
});
};
// Header handlers
const handleAddHeader = () => {
const newKey = `${OPENCODE_HEADER_DRAFT_PREFIX}${Date.now()}`;
onHeadersChange({
...headers,
[newKey]: "",
});
};
const handleRemoveHeader = (key: string) => {
const newHeaders = { ...headers };
delete newHeaders[key];
onHeadersChange(newHeaders);
};
const handleHeaderKeyChange = (oldKey: string, newKey: string): boolean => {
const trimmedKey = newKey.trim();
if (!trimmedKey || oldKey === trimmedKey) return false;
const normalizedKey = trimmedKey.toLowerCase();
const hasDuplicate = Object.keys(headers).some(
(key) => key !== oldKey && key.toLowerCase() === normalizedKey,
);
if (hasDuplicate) return false;
const newHeaders: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
if (key === oldKey) newHeaders[trimmedKey] = value;
else newHeaders[key] = value;
}
onHeadersChange(newHeaders);
return true;
};
const handleHeaderValueChange = (key: string, value: string) => {
onHeadersChange({
...headers,
[key]: value,
});
};
// Model options handlers
const handleAddModelOption = (modelKey: string) => {
const model = models[modelKey];
@@ -410,7 +510,7 @@ export function OpenCodeFormFields({
// Extra Options handlers
const handleAddExtraOption = () => {
const newKey = `option-${Date.now()}`;
const newKey = `${OPENCODE_EXTRA_OPTION_DRAFT_PREFIX}${Date.now()}`;
onExtraOptionsChange({
...extraOptions,
[newKey]: "",
@@ -506,82 +606,193 @@ export function OpenCodeFormFields({
</p>
</div>
{/* Extra Options Editor */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<FormLabel>
{t("opencode.extraOptions", { defaultValue: "额外选项" })}
</FormLabel>
{/* Headers Editor */}
<div className="space-y-2 border-l border-border-default pl-3">
<div className="flex items-start justify-between gap-3">
<div className="max-w-3xl space-y-1">
<FormLabel>
{t("opencode.headers", { defaultValue: "Headers" })}
</FormLabel>
<p className="text-xs text-muted-foreground">
{t("opencode.headersHint", {
defaultValue:
"Optional HTTP headers sent with provider requests, such as HTTP-Referer or X-Title.",
})}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddExtraOption}
onClick={handleAddHeader}
aria-label={t("opencode.addHeader", { defaultValue: "Add header" })}
className="h-7 gap-1"
>
<Plus className="h-3.5 w-3.5" />
{t("opencode.addExtraOption", { defaultValue: "添加" })}
{t("opencode.addHeader", { defaultValue: "Add" })}
</Button>
</div>
{Object.keys(extraOptions).length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
{t("opencode.noExtraOptions", {
defaultValue: "暂无额外选项",
})}
</p>
) : (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground px-1 mb-1">
<span className="flex-1">
{t("opencode.extraOptionKey", { defaultValue: "键名" })}
</span>
<span className="flex-1">
{t("opencode.extraOptionValue", { defaultValue: "值" })}
</span>
<span className="w-9" />
</div>
{Object.entries(extraOptions).map(([key, value]) => (
<div key={key} className="flex items-center gap-2">
<ExtraOptionKeyInput
optionKey={key}
onChange={(newKey) => handleExtraOptionKeyChange(key, newKey)}
placeholder={t("opencode.extraOptionKeyPlaceholder", {
defaultValue: "timeout",
})}
/>
<Input
value={value}
onChange={(e) =>
handleExtraOptionValueChange(key, e.target.value)
}
placeholder={t("opencode.extraOptionValuePlaceholder", {
defaultValue: "600000",
})}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveExtraOption(key)}
className="h-9 w-9 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
<div className="max-w-3xl">
{Object.keys(headers).length === 0 ? (
<p className="text-sm text-muted-foreground py-1">
{t("opencode.noHeaders", {
defaultValue: "No custom headers configured",
})}
</p>
) : (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground px-1 mb-1">
<span className="flex-1">
{t("opencode.headerName", { defaultValue: "Header" })}
</span>
<span className="flex-1">
{t("opencode.headerValue", { defaultValue: "Value" })}
</span>
<span className="w-9" />
</div>
))}
</div>
)}
<p className="text-xs text-muted-foreground">
{t("opencode.extraOptionsHint", {
defaultValue:
"配置额外的 SDK 选项,如 timeout、setCacheKey 等。值会自动解析类型(数字、布尔值等)。",
})}
</p>
{Object.entries(headers).map(([key, value]) => (
<div key={key} className="flex items-center gap-2">
<ExtraOptionKeyInput
optionKey={key}
onChange={(newKey) => handleHeaderKeyChange(key, newKey)}
placeholder={t("opencode.headerNamePlaceholder", {
defaultValue: "X-Title",
})}
placeholderPrefixes={[OPENCODE_HEADER_DRAFT_PREFIX]}
/>
<Input
value={value}
onChange={(e) =>
handleHeaderValueChange(key, e.target.value)
}
placeholder={t("opencode.headerValuePlaceholder", {
defaultValue: "CC Switch",
})}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveHeader(key)}
aria-label={t("opencode.removeHeader", {
defaultValue: "Remove header",
})}
className="h-9 w-9 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
{/* Extra Options Editor */}
<Collapsible
open={extraOptionsOpen}
onOpenChange={setExtraOptionsOpen}
className="space-y-2 border-l border-border-default pl-3"
>
<div className="flex items-start justify-between gap-3">
<CollapsibleTrigger asChild>
<button
type="button"
className="flex min-w-0 max-w-3xl flex-1 items-start gap-2 text-left"
>
<ChevronRight
className={cn(
"mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform",
extraOptionsOpen && "rotate-90",
)}
/>
<span className="space-y-1">
<span className="block text-sm font-medium text-foreground">
{t("opencode.extraOptions", {
defaultValue: "Extra SDK Options",
})}
</span>
<span className="block text-xs text-muted-foreground">
{t("opencode.extraOptionsHint", {
defaultValue:
"Advanced SDK options not exposed by the structured fields.",
})}
</span>
</span>
</button>
</CollapsibleTrigger>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setExtraOptionsOpen(true);
handleAddExtraOption();
}}
className="h-7 gap-1"
>
<Plus className="h-3.5 w-3.5" />
{t("opencode.addExtraOption", { defaultValue: "Add" })}
</Button>
</div>
<CollapsibleContent className="max-w-3xl space-y-2">
{Object.keys(extraOptions).length === 0 ? (
<p className="text-sm text-muted-foreground py-1">
{t("opencode.noExtraOptions", {
defaultValue: "No extra SDK options configured",
})}
</p>
) : (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground px-1 mb-1">
<span className="flex-1">
{t("opencode.extraOptionKey", { defaultValue: "Key" })}
</span>
<span className="flex-1">
{t("opencode.extraOptionValue", { defaultValue: "Value" })}
</span>
<span className="w-9" />
</div>
{Object.entries(extraOptions).map(([key, value]) => (
<div key={key} className="flex items-center gap-2">
<ExtraOptionKeyInput
optionKey={key}
onChange={(newKey) =>
handleExtraOptionKeyChange(key, newKey)
}
placeholder={t("opencode.extraOptionKeyPlaceholder", {
defaultValue: "timeout",
})}
/>
<Input
value={value}
onChange={(e) =>
handleExtraOptionValueChange(key, e.target.value)
}
placeholder={t("opencode.extraOptionValuePlaceholder", {
defaultValue: "600000",
})}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveExtraOption(key)}
className="h-9 w-9 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</CollapsibleContent>
</Collapsible>
{/* Models Editor */}
<div className="space-y-3">
<div className="flex items-center justify-between">
@@ -644,6 +855,9 @@ export function OpenCodeFormFields({
variant="ghost"
size="icon"
onClick={() => toggleModelExpand(key)}
aria-label={t("opencode.toggleModelDetails", {
defaultValue: "Toggle model details",
})}
className="h-9 w-9 shrink-0"
>
<ChevronRight
@@ -690,6 +904,67 @@ export function OpenCodeFormFields({
{/* Expanded model details */}
{expandedModels.has(key) && (
<div className="ml-9 pl-4 border-l-2 border-muted space-y-3">
{/* Token limits (model.limit) */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground">
{t("opencode.modelLimits", {
defaultValue: "Token Limits",
})}
</span>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div className="space-y-1">
<FormLabel
htmlFor={`opencode-${key}-limit-context`}
className="text-xs text-muted-foreground"
>
{t("opencode.limitContext", {
defaultValue: "Context",
})}
</FormLabel>
<Input
id={`opencode-${key}-limit-context`}
type="number"
min={0}
step={1}
value={model.limit?.context ?? ""}
onChange={(e) =>
handleModelLimitChange(
key,
"context",
e.target.value,
)
}
placeholder="1048576"
/>
</div>
<div className="space-y-1">
<FormLabel
htmlFor={`opencode-${key}-limit-output`}
className="text-xs text-muted-foreground"
>
{t("opencode.limitOutput", {
defaultValue: "Output",
})}
</FormLabel>
<Input
id={`opencode-${key}-limit-output`}
type="number"
min={0}
step={1}
value={model.limit?.output ?? ""}
onChange={(e) =>
handleModelLimitChange(
key,
"output",
e.target.value,
)
}
placeholder="131072"
/>
</div>
</div>
</div>
{/* Model Properties (extra fields like variants, cost) */}
<div className="space-y-2">
<div className="flex items-center justify-between">
@@ -2232,6 +2232,8 @@ function ProviderFormFull({
partnerPromotionKey={opencodePartnerPromotionKey}
baseUrl={opencodeForm.opencodeBaseUrl}
onBaseUrlChange={opencodeForm.handleOpencodeBaseUrlChange}
headers={opencodeForm.opencodeHeaders}
onHeadersChange={opencodeForm.handleOpencodeHeadersChange}
models={opencodeForm.opencodeModels}
onModelsChange={opencodeForm.handleOpencodeModelsChange}
extraOptions={opencodeForm.opencodeExtraOptions}
@@ -51,6 +51,11 @@ export const OPENCODE_KNOWN_OPTION_KEYS = [
"headers",
] as const;
// Contains ":", which is not valid in an HTTP field name, so it cannot
// collide with a legitimate custom header from an existing configuration.
export const OPENCODE_HEADER_DRAFT_PREFIX = "draft-header:";
export const OPENCODE_EXTRA_OPTION_DRAFT_PREFIX = "draft-option:";
export const OPENCLAW_DEFAULT_CONFIG = JSON.stringify(
{
baseUrl: "",
@@ -3,6 +3,8 @@ import type { OpenCodeModel, OpenCodeProviderConfig } from "@/types";
import {
OPENCODE_DEFAULT_NPM,
OPENCODE_DEFAULT_CONFIG,
OPENCODE_EXTRA_OPTION_DRAFT_PREFIX,
OPENCODE_HEADER_DRAFT_PREFIX,
isKnownOpencodeOptionKey,
parseOpencodeConfig,
toOpencodeExtraOptions,
@@ -24,11 +26,13 @@ export interface OpencodeFormState {
opencodeNpm: string;
opencodeApiKey: string;
opencodeBaseUrl: string;
opencodeHeaders: Record<string, string>;
opencodeModels: Record<string, OpenCodeModel>;
opencodeExtraOptions: Record<string, string>;
handleOpencodeNpmChange: (npm: string) => void;
handleOpencodeApiKeyChange: (apiKey: string) => void;
handleOpencodeBaseUrlChange: (baseUrl: string) => void;
handleOpencodeHeadersChange: (headers: Record<string, string>) => void;
handleOpencodeModelsChange: (models: Record<string, OpenCodeModel>) => void;
handleOpencodeExtraOptionsChange: (options: Record<string, string>) => void;
resetOpencodeState: (config?: OpenCodeProviderConfig) => void;
@@ -69,6 +73,16 @@ export function useOpencodeFormState({
return typeof value === "string" ? value : "";
});
const [opencodeHeaders, setOpencodeHeaders] = useState<
Record<string, string>
>(() => {
if (appId !== "opencode") return {};
const headers = initialOpencodeOptions.headers;
return headers && typeof headers === "object"
? (headers as Record<string, string>)
: {};
});
const [opencodeModels, setOpencodeModels] = useState<
Record<string, OpenCodeModel>
>(() => {
@@ -128,6 +142,30 @@ export function useOpencodeFormState({
[updateOpencodeSettings],
);
const handleOpencodeHeadersChange = useCallback(
(headers: Record<string, string>) => {
setOpencodeHeaders(headers);
updateOpencodeSettings((config) => {
if (!config.options) config.options = {};
const nextHeaders: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
const trimmedKey = key.trim();
if (trimmedKey && !key.startsWith(OPENCODE_HEADER_DRAFT_PREFIX)) {
nextHeaders[trimmedKey] = value;
}
}
if (Object.keys(nextHeaders).length > 0) {
config.options.headers = nextHeaders;
} else {
delete config.options.headers;
}
});
},
[updateOpencodeSettings],
);
const handleOpencodeModelsChange = useCallback(
(models: Record<string, OpenCodeModel>) => {
setOpencodeModels(models);
@@ -152,7 +190,7 @@ export function useOpencodeFormState({
for (const [k, v] of Object.entries(options)) {
const trimmedKey = k.trim();
if (trimmedKey && !trimmedKey.startsWith("option-")) {
if (trimmedKey && !k.startsWith(OPENCODE_EXTRA_OPTION_DRAFT_PREFIX)) {
try {
config.options[trimmedKey] = JSON.parse(v);
} catch {
@@ -170,6 +208,7 @@ export function useOpencodeFormState({
setOpencodeNpm(config?.npm || OPENCODE_DEFAULT_NPM);
setOpencodeBaseUrl(config?.options?.baseURL || "");
setOpencodeApiKey(config?.options?.apiKey || "");
setOpencodeHeaders(config?.options?.headers || {});
setOpencodeModels(config?.models || {});
setOpencodeExtraOptions(toOpencodeExtraOptions(config?.options || {}));
}, []);
@@ -180,11 +219,13 @@ export function useOpencodeFormState({
opencodeNpm,
opencodeApiKey,
opencodeBaseUrl,
opencodeHeaders,
opencodeModels,
opencodeExtraOptions,
handleOpencodeNpmChange,
handleOpencodeApiKeyChange,
handleOpencodeBaseUrlChange,
handleOpencodeHeadersChange,
handleOpencodeModelsChange,
handleOpencodeExtraOptionsChange,
resetOpencodeState,