From 6245caa6c5edd88f867f2b465cd4974b9db21b63 Mon Sep 17 00:00:00 2001 From: v2v <127324695+git1677967754@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:38:00 +0800 Subject: [PATCH] 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 Co-authored-by: Jason Young <44939412+farion1231@users.noreply.github.com> Co-authored-by: Jason --- .../providers/forms/OpenCodeFormFields.tsx | 415 +++++++++++++++--- .../providers/forms/ProviderForm.tsx | 2 + .../forms/helpers/opencodeFormUtils.ts | 5 + .../forms/hooks/useOpencodeFormState.ts | 43 +- src/i18n/locales/en.json | 13 + src/i18n/locales/ja.json | 13 + src/i18n/locales/zh-TW.json | 13 + src/i18n/locales/zh.json | 13 + tests/components/OpenCodeFormFields.test.tsx | 203 +++++++++ tests/hooks/useOpencodeFormState.test.tsx | 132 ++++++ 10 files changed, 781 insertions(+), 71 deletions(-) create mode 100644 tests/components/OpenCodeFormFields.test.tsx create mode 100644 tests/hooks/useOpencodeFormState.test.tsx diff --git a/src/components/providers/forms/OpenCodeFormFields.tsx b/src/components/providers/forms/OpenCodeFormFields.tsx index 70ba3307a..774a1e31a 100644 --- a/src/components/providers/forms/OpenCodeFormFields.tsx +++ b/src/components/providers/forms/OpenCodeFormFields.tsx @@ -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 ( { 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; + onHeadersChange: (headers: Record) => void; + // Models models: Record; onModelsChange: (models: Record) => 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([]); 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 = {}; + 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({

- {/* Extra Options Editor */} -
-
- - {t("opencode.extraOptions", { defaultValue: "额外选项" })} - + {/* Headers Editor */} +
+
+
+ + {t("opencode.headers", { defaultValue: "Headers" })} + +

+ {t("opencode.headersHint", { + defaultValue: + "Optional HTTP headers sent with provider requests, such as HTTP-Referer or X-Title.", + })} +

+
- {Object.keys(extraOptions).length === 0 ? ( -

- {t("opencode.noExtraOptions", { - defaultValue: "暂无额外选项", - })} -

- ) : ( -
-
- - {t("opencode.extraOptionKey", { defaultValue: "键名" })} - - - {t("opencode.extraOptionValue", { defaultValue: "值" })} - - -
- {Object.entries(extraOptions).map(([key, value]) => ( -
- handleExtraOptionKeyChange(key, newKey)} - placeholder={t("opencode.extraOptionKeyPlaceholder", { - defaultValue: "timeout", - })} - /> - - handleExtraOptionValueChange(key, e.target.value) - } - placeholder={t("opencode.extraOptionValuePlaceholder", { - defaultValue: "600000", - })} - className="flex-1" - /> - +
+ {Object.keys(headers).length === 0 ? ( +

+ {t("opencode.noHeaders", { + defaultValue: "No custom headers configured", + })} +

+ ) : ( +
+
+ + {t("opencode.headerName", { defaultValue: "Header" })} + + + {t("opencode.headerValue", { defaultValue: "Value" })} + +
- ))} -
- )} - -

- {t("opencode.extraOptionsHint", { - defaultValue: - "配置额外的 SDK 选项,如 timeout、setCacheKey 等。值会自动解析类型(数字、布尔值等)。", - })} -

+ {Object.entries(headers).map(([key, value]) => ( +
+ handleHeaderKeyChange(key, newKey)} + placeholder={t("opencode.headerNamePlaceholder", { + defaultValue: "X-Title", + })} + placeholderPrefixes={[OPENCODE_HEADER_DRAFT_PREFIX]} + /> + + handleHeaderValueChange(key, e.target.value) + } + placeholder={t("opencode.headerValuePlaceholder", { + defaultValue: "CC Switch", + })} + className="flex-1" + /> + +
+ ))} +
+ )} +
+ {/* Extra Options Editor */} + +
+ + + + +
+ + + {Object.keys(extraOptions).length === 0 ? ( +

+ {t("opencode.noExtraOptions", { + defaultValue: "No extra SDK options configured", + })} +

+ ) : ( +
+
+ + {t("opencode.extraOptionKey", { defaultValue: "Key" })} + + + {t("opencode.extraOptionValue", { defaultValue: "Value" })} + + +
+ {Object.entries(extraOptions).map(([key, value]) => ( +
+ + handleExtraOptionKeyChange(key, newKey) + } + placeholder={t("opencode.extraOptionKeyPlaceholder", { + defaultValue: "timeout", + })} + /> + + handleExtraOptionValueChange(key, e.target.value) + } + placeholder={t("opencode.extraOptionValuePlaceholder", { + defaultValue: "600000", + })} + className="flex-1" + /> + +
+ ))} +
+ )} +
+
+ {/* Models Editor */}
@@ -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" > + {/* Token limits (model.limit) */} +
+ + {t("opencode.modelLimits", { + defaultValue: "Token Limits", + })} + +
+
+ + {t("opencode.limitContext", { + defaultValue: "Context", + })} + + + handleModelLimitChange( + key, + "context", + e.target.value, + ) + } + placeholder="1048576" + /> +
+
+ + {t("opencode.limitOutput", { + defaultValue: "Output", + })} + + + handleModelLimitChange( + key, + "output", + e.target.value, + ) + } + placeholder="131072" + /> +
+
+
+ {/* Model Properties (extra fields like variants, cost) */}
diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index d5192dde5..f359982fc 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -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} diff --git a/src/components/providers/forms/helpers/opencodeFormUtils.ts b/src/components/providers/forms/helpers/opencodeFormUtils.ts index dfb9a6e2b..a080bfd46 100644 --- a/src/components/providers/forms/helpers/opencodeFormUtils.ts +++ b/src/components/providers/forms/helpers/opencodeFormUtils.ts @@ -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: "", diff --git a/src/components/providers/forms/hooks/useOpencodeFormState.ts b/src/components/providers/forms/hooks/useOpencodeFormState.ts index 9bfba4a3c..c94e11df5 100644 --- a/src/components/providers/forms/hooks/useOpencodeFormState.ts +++ b/src/components/providers/forms/hooks/useOpencodeFormState.ts @@ -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; opencodeModels: Record; opencodeExtraOptions: Record; handleOpencodeNpmChange: (npm: string) => void; handleOpencodeApiKeyChange: (apiKey: string) => void; handleOpencodeBaseUrlChange: (baseUrl: string) => void; + handleOpencodeHeadersChange: (headers: Record) => void; handleOpencodeModelsChange: (models: Record) => void; handleOpencodeExtraOptionsChange: (options: Record) => void; resetOpencodeState: (config?: OpenCodeProviderConfig) => void; @@ -69,6 +73,16 @@ export function useOpencodeFormState({ return typeof value === "string" ? value : ""; }); + const [opencodeHeaders, setOpencodeHeaders] = useState< + Record + >(() => { + if (appId !== "opencode") return {}; + const headers = initialOpencodeOptions.headers; + return headers && typeof headers === "object" + ? (headers as Record) + : {}; + }); + const [opencodeModels, setOpencodeModels] = useState< Record >(() => { @@ -128,6 +142,30 @@ export function useOpencodeFormState({ [updateOpencodeSettings], ); + const handleOpencodeHeadersChange = useCallback( + (headers: Record) => { + setOpencodeHeaders(headers); + updateOpencodeSettings((config) => { + if (!config.options) config.options = {}; + + const nextHeaders: Record = {}; + 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) => { 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, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b05f8514b..313f5a93e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 79a338457..4353b5bf1 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -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", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 9f09c1175..fb3821a4e 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -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", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 6437a7f39..c6cc6dc7f 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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", diff --git a/tests/components/OpenCodeFormFields.test.tsx b/tests/components/OpenCodeFormFields.test.tsx new file mode 100644 index 000000000..bbfa95582 --- /dev/null +++ b/tests/components/OpenCodeFormFields.test.tsx @@ -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; + +const FormShell = ({ children }: PropsWithChildren) => { + const form = useForm(); + + return
{children}
; +}; + +const renderOpenCodeForm = ( + overrides: Partial = {}, +) => { + 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( + + + , + ), + }; +}; + +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( + + + , + ); + + 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( + + + , + ); + fireEvent.change(screen.getByLabelText("Output"), { + target: { value: "" }, + }); + + expect(onModelsChange).toHaveBeenLastCalledWith({ + "kimi-k2": { + name: "Kimi K2", + }, + }); + }); +}); diff --git a/tests/hooks/useOpencodeFormState.test.tsx b/tests/hooks/useOpencodeFormState.test.tsx new file mode 100644 index 000000000..536beb294 --- /dev/null +++ b/tests/hooks/useOpencodeFormState.test.tsx @@ -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, +) => { + 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, + }); + }); +});