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,
+13
View File
@@ -1359,6 +1359,7 @@
"modelName": "Display Name",
"noModels": "No models configured",
"modelsRequired": "Please add at least one model",
"toggleModelDetails": "Toggle model details",
"providerKey": "Provider Key",
"providerKeyPlaceholder": "my-provider",
"providerKeyHint": "Unique identifier in config file. Use lowercase letters, numbers, and hyphens only.",
@@ -1366,6 +1367,15 @@
"providerKeyRequired": "Provider key is required",
"providerKeyDuplicate": "This key is already in use",
"providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.",
"headers": "Headers",
"headersHint": "Optional HTTP headers sent with provider requests, such as HTTP-Referer or X-Title.",
"addHeader": "Add header",
"headerName": "Header",
"headerValue": "Value",
"headerNamePlaceholder": "X-Title",
"headerValuePlaceholder": "CC Switch",
"removeHeader": "Remove header",
"noHeaders": "No custom headers configured",
"extraOptions": "Extra Options",
"extraOptionsHint": "Configure extra SDK options like timeout, setCacheKey, etc. Values are auto-parsed to appropriate types (number, boolean, etc.).",
"addExtraOption": "Add",
@@ -1375,6 +1385,9 @@
"extraOptionValuePlaceholder": "600000",
"noExtraOptions": "No extra options configured",
"noModelOptions": "Model options, click + to add",
"modelLimits": "Token Limits",
"limitContext": "Context",
"limitOutput": "Output",
"modelExtraFields": "Model Properties",
"noModelExtraFields": "Model properties (variants, cost, etc.), click + to add",
"modelExtraFieldKeyPlaceholder": "variants",
+13
View File
@@ -1359,6 +1359,7 @@
"modelName": "表示名",
"noModels": "モデルが設定されていません",
"modelsRequired": "モデルを少なくとも1つ追加してください",
"toggleModelDetails": "モデル詳細を開閉",
"providerKey": "プロバイダーキー",
"providerKeyPlaceholder": "my-provider",
"providerKeyHint": "設定ファイルの一意の識別子です。小文字、数字、ハイフンのみ使用できます。",
@@ -1366,6 +1367,15 @@
"providerKeyRequired": "プロバイダーキーを入力してください",
"providerKeyDuplicate": "このキーは既に使用されています",
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用できます。",
"headers": "ヘッダー",
"headersHint": "HTTP-Referer や X-Title など、プロバイダーリクエストに付与する任意の HTTP ヘッダーです。",
"addHeader": "ヘッダーを追加",
"headerName": "ヘッダー",
"headerValue": "値",
"headerNamePlaceholder": "X-Title",
"headerValuePlaceholder": "CC Switch",
"removeHeader": "ヘッダーを削除",
"noHeaders": "カスタムヘッダーはありません",
"extraOptions": "追加オプション",
"extraOptionsHint": "timeout、setCacheKey などの SDK オプションを設定。値は自動的に適切な型(数値、真偽値など)に変換されます。",
"addExtraOption": "追加",
@@ -1375,6 +1385,9 @@
"extraOptionValuePlaceholder": "600000",
"noExtraOptions": "追加オプションはありません",
"noModelOptions": "モデルオプション、+ をクリックして追加",
"modelLimits": "トークン制限",
"limitContext": "コンテキスト",
"limitOutput": "出力",
"modelExtraFields": "モデルプロパティ",
"noModelExtraFields": "モデルプロパティ(variants、cost など)、+ をクリックして追加",
"modelExtraFieldKeyPlaceholder": "variants",
+13
View File
@@ -1331,6 +1331,7 @@
"modelName": "顯示名稱",
"noModels": "暫無模型設定",
"modelsRequired": "請至少新增一個模型設定",
"toggleModelDetails": "展開或收合模型詳細資料",
"providerKey": "供應商標識",
"providerKeyPlaceholder": "my-provider",
"providerKeyHint": "設定檔中的唯一識別碼,只能使用小寫字母、數字和連字號",
@@ -1338,6 +1339,15 @@
"providerKeyRequired": "請填寫供應商標識",
"providerKeyDuplicate": "此標識已被使用,請更換",
"providerKeyInvalid": "標識格式無效,只能使用小寫字母、數字和連字號",
"headers": "請求標頭",
"headersHint": "隨供應商請求傳送的選用 HTTP 請求標頭,例如 HTTP-Referer 或 X-Title。",
"addHeader": "新增請求標頭",
"headerName": "請求標頭",
"headerValue": "值",
"headerNamePlaceholder": "X-Title",
"headerValuePlaceholder": "CC Switch",
"removeHeader": "刪除請求標頭",
"noHeaders": "暫無自訂請求標頭",
"extraOptions": "額外選項",
"extraOptionsHint": "設定額外的 SDK 選項,如 timeout、setCacheKey 等。值會自動解析類型(數字、布林值等)。",
"addExtraOption": "新增",
@@ -1347,6 +1357,9 @@
"extraOptionValuePlaceholder": "600000",
"noExtraOptions": "暫無額外選項",
"noModelOptions": "模型選項,點擊 + 新增",
"modelLimits": "Token 限制",
"limitContext": "上下文",
"limitOutput": "輸出",
"modelExtraFields": "模型屬性",
"noModelExtraFields": "模型屬性 (variants, cost 等),點擊 + 新增",
"modelExtraFieldKeyPlaceholder": "variants",
+13
View File
@@ -1359,6 +1359,7 @@
"modelName": "显示名称",
"noModels": "暂无模型配置",
"modelsRequired": "请至少添加一个模型配置",
"toggleModelDetails": "展开或收起模型详情",
"providerKey": "供应商标识",
"providerKeyPlaceholder": "my-provider",
"providerKeyHint": "配置文件中的唯一标识符,只能使用小写字母、数字和连字符",
@@ -1366,6 +1367,15 @@
"providerKeyRequired": "请填写供应商标识",
"providerKeyDuplicate": "此标识已被使用,请更换",
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符",
"headers": "请求头",
"headersHint": "随供应商请求发送的可选 HTTP 请求头,如 HTTP-Referer 或 X-Title。",
"addHeader": "添加请求头",
"headerName": "请求头",
"headerValue": "值",
"headerNamePlaceholder": "X-Title",
"headerValuePlaceholder": "CC Switch",
"removeHeader": "删除请求头",
"noHeaders": "暂无自定义请求头",
"extraOptions": "额外选项",
"extraOptionsHint": "配置额外的 SDK 选项,如 timeout、setCacheKey 等。值会自动解析类型(数字、布尔值等)。",
"addExtraOption": "添加",
@@ -1375,6 +1385,9 @@
"extraOptionValuePlaceholder": "600000",
"noExtraOptions": "暂无额外选项",
"noModelOptions": "模型选项,点击 + 添加",
"modelLimits": "Token 限制",
"limitContext": "上下文",
"limitOutput": "输出",
"modelExtraFields": "模型属性",
"noModelExtraFields": "模型属性 (variants, cost 等),点击 + 添加",
"modelExtraFieldKeyPlaceholder": "variants",
@@ -0,0 +1,203 @@
import { fireEvent, render, screen } from "@testing-library/react";
import type { ComponentProps, PropsWithChildren } from "react";
import { useForm } from "react-hook-form";
import { describe, expect, it, vi } from "vitest";
import { OpenCodeFormFields } from "@/components/providers/forms/OpenCodeFormFields";
import { Form } from "@/components/ui/form";
type OpenCodeFormFieldsProps = ComponentProps<typeof OpenCodeFormFields>;
const FormShell = ({ children }: PropsWithChildren) => {
const form = useForm();
return <Form {...form}>{children}</Form>;
};
const renderOpenCodeForm = (
overrides: Partial<OpenCodeFormFieldsProps> = {},
) => {
const props: OpenCodeFormFieldsProps = {
npm: "@ai-sdk/openai-compatible",
onNpmChange: vi.fn(),
apiKey: "sk-test",
onApiKeyChange: vi.fn(),
category: "custom",
shouldShowApiKeyLink: false,
websiteUrl: "",
baseUrl: "https://api.example.com/v1",
onBaseUrlChange: vi.fn(),
headers: {},
onHeadersChange: vi.fn(),
models: {
"kimi-k2": {
name: "Kimi K2",
limit: { context: 1048576, output: 131072 },
},
},
onModelsChange: vi.fn(),
extraOptions: {},
onExtraOptionsChange: vi.fn(),
...overrides,
};
return {
props,
...render(
<FormShell>
<OpenCodeFormFields {...props} />
</FormShell>,
),
};
};
const expandFirstModel = () => {
fireEvent.click(screen.getByRole("button", { name: "Toggle model details" }));
};
describe("OpenCodeFormFields", () => {
it("surfaces existing provider headers", () => {
renderOpenCodeForm({
headers: {
"HTTP-Referer": "https://cc-switch.app",
"X-Title": "CC Switch",
},
});
expect(screen.getByDisplayValue("HTTP-Referer")).toBeInTheDocument();
expect(
screen.getByDisplayValue("https://cc-switch.app"),
).toBeInTheDocument();
expect(screen.getByDisplayValue("X-Title")).toBeInTheDocument();
expect(screen.getByDisplayValue("CC Switch")).toBeInTheDocument();
});
it("updates provider headers", () => {
const onHeadersChange = vi.fn();
renderOpenCodeForm({
headers: { "X-Title": "CC Switch" },
onHeadersChange,
});
fireEvent.change(screen.getByDisplayValue("CC Switch"), {
target: { value: "OpenCode" },
});
expect(onHeadersChange).toHaveBeenCalledWith({
"X-Title": "OpenCode",
});
});
it("shows a blank header name for newly added headers", () => {
const onHeadersChange = vi.fn();
const { rerender, props } = renderOpenCodeForm({ onHeadersChange });
fireEvent.click(screen.getByRole("button", { name: "Add header" }));
const nextHeaders = onHeadersChange.mock.calls[0][0];
const headerKey = Object.keys(nextHeaders)[0];
expect(headerKey).toMatch(/^draft-header:/);
rerender(
<FormShell>
<OpenCodeFormFields {...props} headers={nextHeaders} />
</FormShell>,
);
expect(screen.getByPlaceholderText("X-Title")).toHaveValue("");
});
it("removes provider headers", () => {
const onHeadersChange = vi.fn();
renderOpenCodeForm({
headers: { "X-Title": "CC Switch" },
onHeadersChange,
});
fireEvent.click(screen.getByRole("button", { name: "Remove header" }));
expect(onHeadersChange).toHaveBeenCalledWith({});
});
it("rejects case-insensitive duplicate header names and restores the input", () => {
const onHeadersChange = vi.fn();
renderOpenCodeForm({
headers: { "X-A": "A", "X-B": "B" },
onHeadersChange,
});
const keyInput = screen.getByDisplayValue("X-B");
fireEvent.change(keyInput, { target: { value: "x-a" } });
fireEvent.blur(keyInput);
expect(onHeadersChange).not.toHaveBeenCalled();
expect(keyInput).toHaveValue("X-B");
});
it("surfaces provider options whose names start with option-", () => {
renderOpenCodeForm({
extraOptions: { "option-mode": "legacy" },
});
expect(screen.getByDisplayValue("option-mode")).toBeInTheDocument();
expect(screen.getByDisplayValue("legacy")).toBeInTheDocument();
});
it("surfaces existing model token limits", () => {
renderOpenCodeForm();
expandFirstModel();
expect(screen.getByLabelText("Context")).toHaveValue(1048576);
expect(screen.getByLabelText("Output")).toHaveValue(131072);
});
it("updates model token limits as structured numbers", () => {
const onModelsChange = vi.fn();
renderOpenCodeForm({ onModelsChange });
expandFirstModel();
fireEvent.change(screen.getByLabelText("Context"), {
target: { value: "2000000" },
});
expect(onModelsChange).toHaveBeenCalledWith({
"kimi-k2": {
name: "Kimi K2",
limit: { context: 2000000, output: 131072 },
},
});
});
it("removes model limit when both fields are cleared", () => {
const onModelsChange = vi.fn();
const { rerender, props } = renderOpenCodeForm({ onModelsChange });
expandFirstModel();
fireEvent.change(screen.getByLabelText("Context"), {
target: { value: "" },
});
const withoutContext = {
"kimi-k2": {
name: "Kimi K2",
limit: { output: 131072 },
},
};
expect(onModelsChange).toHaveBeenLastCalledWith(withoutContext);
rerender(
<FormShell>
<OpenCodeFormFields {...props} models={withoutContext} />
</FormShell>,
);
fireEvent.change(screen.getByLabelText("Output"), {
target: { value: "" },
});
expect(onModelsChange).toHaveBeenLastCalledWith({
"kimi-k2": {
name: "Kimi K2",
},
});
});
});
+132
View File
@@ -0,0 +1,132 @@
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { useOpencodeFormState } from "@/components/providers/forms/hooks/useOpencodeFormState";
const renderOpencodeFormState = (
initialSettingsConfig: Record<string, unknown>,
) => {
let settingsConfig = JSON.stringify(initialSettingsConfig);
const onSettingsConfigChange = vi.fn((nextConfig: string) => {
settingsConfig = nextConfig;
});
const hook = renderHook(() =>
useOpencodeFormState({
appId: "opencode",
initialData: { settingsConfig: initialSettingsConfig },
onSettingsConfigChange,
getSettingsConfig: () => settingsConfig,
}),
);
return {
...hook,
onSettingsConfigChange,
getSettingsConfig: () => settingsConfig,
};
};
describe("useOpencodeFormState", () => {
it("hydrates provider headers from options", () => {
const { result } = renderOpencodeFormState({
npm: "@ai-sdk/openai-compatible",
options: {
headers: {
"HTTP-Referer": "https://cc-switch.app",
"X-Title": "CC Switch",
},
},
models: {},
});
expect(result.current.opencodeHeaders).toEqual({
"HTTP-Referer": "https://cc-switch.app",
"X-Title": "CC Switch",
});
});
it("writes provider headers to options", () => {
const { result, getSettingsConfig } = renderOpencodeFormState({
npm: "@ai-sdk/openai-compatible",
options: {},
models: {},
});
act(() => {
result.current.handleOpencodeHeadersChange({
"X-Title": "CC Switch",
});
});
expect(JSON.parse(getSettingsConfig()).options.headers).toEqual({
"X-Title": "CC Switch",
});
});
it("removes options.headers when all provider headers are removed", () => {
const { result, getSettingsConfig } = renderOpencodeFormState({
npm: "@ai-sdk/openai-compatible",
options: {
headers: {
"X-Title": "CC Switch",
},
},
models: {},
});
act(() => {
result.current.handleOpencodeHeadersChange({});
});
expect(JSON.parse(getSettingsConfig()).options.headers).toBeUndefined();
});
it("preserves legitimate headers whose names start with header-", () => {
const { result, getSettingsConfig } = renderOpencodeFormState({
npm: "@ai-sdk/openai-compatible",
options: {
headers: {
"header-version": "v1",
"X-Title": "Old",
},
},
models: {},
});
act(() => {
result.current.handleOpencodeHeadersChange({
"header-version": "v1",
"X-Title": "New",
});
});
expect(JSON.parse(getSettingsConfig()).options.headers).toEqual({
"header-version": "v1",
"X-Title": "New",
});
});
it("preserves legitimate options whose names start with option-", () => {
const { result, getSettingsConfig } = renderOpencodeFormState({
npm: "@ai-sdk/openai-compatible",
options: {
"option-mode": "legacy",
timeout: 100,
},
models: {},
});
act(() => {
result.current.handleOpencodeExtraOptionsChange({
"option-mode": "legacy",
timeout: "200",
"draft-option:123": "",
});
});
expect(JSON.parse(getSettingsConfig()).options).toEqual({
"option-mode": "legacy",
timeout: 200,
});
});
});