feat(kimi): lay out the provider editor like Pi and OpenCode

Models are now one row each (expand arrow, model ID, display name,
delete) with capabilities and the context window in the expanded
details, instead of one large card per model. Fields follow the same
order as Pi and OpenCode (API format, API key, base URL), the API
format menu shows readable protocol names, request headers are shown
inline, and required fields use the shared asterisk style.

The new display name column writes the model's display_name, which
Kimi Code shows in /model; clearing it removes the field. The model
selection hint moves into a "?" beside the models heading, and the
Kimi-only labels that duplicated OpenCode's are removed from all
locales.
This commit is contained in:
Jason committed 2026-10-23 15:48:09 +08:00
1 parent d6db0c7570
commit 0f76829001
7 files changed
+291 -215

No files matched your search

@@ -1,18 +1,14 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, Plus, Trash2 } from "lucide-react";
import { ChevronRight, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Form } from "@/components/ui/form";
import { Form, FormLabel } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Switch } from "@/components/ui/switch";
import { HelpTip } from "@/components/ui/help-tip";
import { HoverTip } from "@/components/ui/hover-tip";
import {
Select,
SelectContent,
@@ -30,6 +26,7 @@ import {
type FetchedModel,
} from "@/lib/api/model-fetch";
import { toast } from "@/lib/toast";
import { cn } from "@/lib/utils";
import { useProvidersQuery } from "@/lib/query/queries";
import type { ProviderFormData } from "@/lib/schemas/provider";
import type { ProviderCategory } from "@/types";
@@ -48,8 +45,13 @@ import {
findRequestHeaderValue,
normalizeRequestHeaders,
} from "./helpers/requestHeaders";
import { BasicFormFields } from "./BasicFormFields";
import { ApiKeySection, ModelDropdown } from "./shared";
import { BasicFormFields, REQUIRED_LABEL } from "./BasicFormFields";
import {
ApiKeySection,
EndpointField,
GptContextWindowHint,
ModelDropdown,
} from "./shared";
import {
FetchedModelPicker,
type FetchedModelSelection,
@@ -76,6 +78,19 @@ const MODEL_FETCH_API_FORMATS: Partial<
"google-genai": "google-generative-ai",
};
// 和 Pi、OpenCode 的「接口格式」下拉用同一套叫法。
const PROVIDER_TYPE_LABELS: Record<
KimiProviderConfig["provider"]["type"],
string
> = {
kimi: "Kimi",
openai: "OpenAI Chat Completions",
openai_responses: "OpenAI Responses",
anthropic: "Anthropic Messages",
"google-genai": "Google Generative AI",
vertexai: "Google Vertex AI",
};
const PRESETS = [
{ id: KIMI_ACCOUNT_PRESET_ID, preset: kimiAccountPreset },
...kimiProviderPresets.map((preset, index) => ({
@@ -183,6 +198,15 @@ export function KimiProviderForm({
};
// 窗口还是占位值的行(这次新加、用户没改过窗口),选中模型时可以换成查到的值。
const placeholderContextKeys = useRef(new Set<string>());
const [expandedModels, setExpandedModels] = useState<Set<string>>(
() => new Set(),
);
const toggleModelExpand = (key: string) =>
setExpandedModels((current) => {
const next = new Set(current);
if (!next.delete(key)) next.add(key);
return next;
});
const modelKeyPrefix = providerKey || "custom";
const uniqueModelKey = (models: KimiProviderConfig["models"], id: string) => {
let key = `${modelKeyPrefix}/${id}`;
@@ -197,13 +221,6 @@ export function KimiProviderForm({
const { type: providerType, base_url: baseUrl = "" } = config.provider;
const apiKey = config.provider.api_key ?? "";
const headersKey = JSON.stringify(config.provider.custom_headers ?? {});
// 请求头很少用到,收在高级选项里;已经有值(编辑已有卡、选了带请求头的预设)时展开。
const hasCustomHeaders =
Object.keys(config.provider.custom_headers ?? {}).length > 0;
const [advancedExpanded, setAdvancedExpanded] = useState(hasCustomHeaders);
useEffect(() => {
if (hasCustomHeaders) setAdvancedExpanded(true);
}, [hasCustomHeaders]);
useEffect(() => {
setFetchedModels((prev) => (prev.length === 0 ? prev : []));
setIsFetchingModels(false);
@@ -438,7 +455,6 @@ export function KimiProviderForm({
{error}
</p>
)}
<p className="text-sm text-fg-2">{t("kimi.modelSelectionHint")}</p>
<fieldset
disabled={busy || !jsonValid}
className="min-w-0 space-y-6 border-0 p-0 disabled:opacity-50"
@@ -447,8 +463,8 @@ export function KimiProviderForm({
form={form}
beforeNameSlot={
<div className="space-y-2">
<Label htmlFor="kimi-provider-key">
{t("opencode.providerKey")} *
<Label htmlFor="kimi-provider-key" className={REQUIRED_LABEL}>
{t("opencode.providerKey")}
</Label>
<Input
id="kimi-provider-key"
@@ -471,6 +487,9 @@ export function KimiProviderForm({
placeholderContextKeys.current = new Set(
[...placeholderContextKeys.current].map(rename),
);
setExpandedModels(
(current) => new Set([...current].map(rename)),
);
setProviderKey(next);
update({ ...config, models });
}}
@@ -491,7 +510,9 @@ export function KimiProviderForm({
}
/>
<div className="space-y-2">
<Label htmlFor="kimi-provider-type">{t("kimi.providerType")}</Label>
<FormLabel htmlFor="kimi-provider-type">
{t("opencode.npmPackage")}
</FormLabel>
<Select
value={config.provider.type}
onValueChange={(type) =>
@@ -506,21 +527,12 @@ export function KimiProviderForm({
<SelectContent>
{KIMI_PROVIDER_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{type}
{PROVIDER_TYPE_LABELS[type]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="kimi-base-url">{t("kimi.baseUrl")} *</Label>
<Input
id="kimi-base-url"
value={config.provider.base_url ?? ""}
onChange={(e) => updateProvider({ base_url: e.target.value })}
placeholder="https://api.example.com/v1"
/>
</div>
<ApiKeySection
id="kimi-api-key"
value={config.provider.api_key ?? ""}
@@ -532,38 +544,27 @@ export function KimiProviderForm({
partnerPromotionKey={preset?.partnerPromotionKey}
required={!config.provider.oauth}
/>
<Collapsible
open={advancedExpanded}
onOpenChange={setAdvancedExpanded}
className="rounded-lg border border-border p-4"
>
<CollapsibleTrigger asChild>
<Button
type="button"
variant={null}
size="sm"
className="h-8 w-full justify-start gap-1.5 px-0 text-sm font-medium text-fg-1 hover:opacity-70 active:scale-100"
>
{advancedExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
{t("providerForm.advancedOptionsToggle")}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-2">
<RequestHeadersEditor
headers={config.provider.custom_headers ?? {}}
onHeadersChange={(custom_headers) =>
updateProvider({ custom_headers })
}
/>
</CollapsibleContent>
</Collapsible>
<div className="space-y-3">
<EndpointField
id="kimi-base-url"
label={t("opencode.baseUrl")}
value={config.provider.base_url ?? ""}
onChange={(base_url) => updateProvider({ base_url })}
placeholder="https://api.example.com/v1"
/>
<RequestHeadersEditor
headers={config.provider.custom_headers ?? {}}
onHeadersChange={(custom_headers) =>
updateProvider({ custom_headers })
}
/>
<div className="space-y-3 border-l border-border pl-3">
<div className="flex items-center justify-between">
<Label>{t("kimi.models")}</Label>
<div className="flex items-center gap-1">
<FormLabel>{t("opencode.models")}</FormLabel>
<HelpTip title={t("opencode.models")}>
{t("kimi.modelSelectionHint")}
</HelpTip>
</div>
<div className="flex gap-1">
<FetchedModelPicker
models={fetchedModels}
@@ -579,6 +580,7 @@ export function KimiProviderForm({
type="button"
variant="outline"
size="sm"
className="h-7 gap-1"
onClick={() => {
let index = Object.keys(config.models).length + 1;
let key = `${modelKeyPrefix}/model-${index}`;
@@ -599,111 +601,177 @@ export function KimiProviderForm({
});
}}
>
<Plus className="mr-1 h-4 w-4" />
{t("kimi.addModel")}
<Plus className="h-3.5 w-3.5" />
{t("opencode.addModel")}
</Button>
</div>
</div>
{Object.entries(config.models).map(([key, model]) => (
<div
key={key}
className="space-y-3 rounded-lg border border-border p-4"
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm text-fg-2">{key}</span>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("kimi.removeModel")}
onClick={() => {
const models = { ...config.models };
delete models[key];
placeholderContextKeys.current.delete(key);
update({ ...config, models });
}}
>
<Trash2 className="h-4 w-4" />
</Button>
{Object.keys(config.models).length === 0 ? (
<p className="py-2 text-sm text-fg-2">{t("opencode.noModels")}</p>
) : (
<div className="space-y-2">
<div className="mb-1 flex items-center gap-2 px-1 text-xs text-fg-2">
<span className="w-9" />
<span className={cn("flex-1", REQUIRED_LABEL)}>
{t("opencode.modelId")}
</span>
<span className="flex-1">{t("opencode.modelName")}</span>
<span className="w-9" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor={`kimi-model-${key}`}>
{t("kimi.modelId")} *
</Label>
<div className="flex gap-1">
{Object.entries(config.models).map(([key, model]) => (
<div key={key} className="space-y-2">
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => toggleModelExpand(key)}
aria-label={t("opencode.toggleModelDetails")}
aria-expanded={expandedModels.has(key)}
className="h-9 w-9 shrink-0"
>
<ChevronRight
className={cn(
"h-4 w-4 transition-transform motion-reduce:transition-none",
expandedModels.has(key) && "rotate-90",
)}
/>
</Button>
<div className="flex min-w-0 flex-1 gap-1">
<Input
id={`kimi-model-${key}`}
value={model.model}
onChange={(e) =>
updateModel(key, { model: e.target.value })
}
placeholder="model-id"
aria-label={t("opencode.modelId")}
className="min-w-0 flex-1"
/>
{fetchedModels.length > 0 && (
<ModelDropdown
models={fetchedModels}
onSelect={(id) => {
updateModel(key, { model: id });
fillModelMetadataFor(key, id);
}}
/>
)}
</div>
<Input
id={`kimi-model-${key}`}
value={model.model}
id={`kimi-model-name-${key}`}
value={model.display_name ?? ""}
onChange={(e) =>
updateModel(key, { model: e.target.value })
updateModel(key, {
display_name: e.target.value || undefined,
})
}
placeholder={t("opencode.modelName")}
aria-label={t("opencode.modelName")}
className="min-w-0 flex-1"
/>
{fetchedModels.length > 0 && (
<ModelDropdown
models={fetchedModels}
onSelect={(id) => {
updateModel(key, { model: id });
fillModelMetadataFor(key, id);
<HoverTip content={t("common.delete")}>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("common.delete")}
className="h-9 w-9 shrink-0 text-fg-2 hover:text-destructive"
onClick={() => {
const models = { ...config.models };
delete models[key];
placeholderContextKeys.current.delete(key);
setExpandedModels((current) => {
const next = new Set(current);
next.delete(key);
return next;
});
update({ ...config, models });
}}
/>
)}
>
<Trash2 className="h-4 w-4" />
</Button>
</HoverTip>
</div>
{expandedModels.has(key) && (
<div className="ml-9 grid gap-3 border-l-2 border-muted pl-4 sm:grid-cols-2">
<div className="flex min-h-9 flex-wrap items-center gap-x-8 gap-y-2 sm:col-span-2">
{KIMI_CAPABILITIES.map((capability) => (
<div
key={capability}
className="flex items-center gap-2.5"
>
<div className="flex items-center gap-0.5">
<Label
htmlFor={`kimi-model-${capability}-${key}`}
className="cursor-pointer"
>
{t(`kimi.capabilities.${capability}`)}
</Label>
{CAPABILITIES_WITH_HELP.has(capability) && (
<HelpTip
title={t(`kimi.capabilities.${capability}`)}
>
{t(`kimi.capabilityHints.${capability}`)}
</HelpTip>
)}
</div>
<Switch
id={`kimi-model-${capability}-${key}`}
checked={
model.capabilities?.includes(capability) ??
false
}
onCheckedChange={(checked) =>
updateModel(key, {
capabilities: checked
? [
...new Set([
...(model.capabilities ?? []),
capability,
]),
]
: (model.capabilities ?? []).filter(
(item) => item !== capability,
),
})
}
/>
</div>
))}
</div>
<div className="space-y-2">
<Label
htmlFor={`kimi-context-${key}`}
className={REQUIRED_LABEL}
>
{t("kimi.contextSize")}
</Label>
<Input
id={`kimi-context-${key}`}
type="number"
min={1}
step={1}
value={model.max_context_size}
onChange={(e) => {
placeholderContextKeys.current.delete(key);
updateModel(key, {
max_context_size: Number(e.target.value),
});
}}
placeholder="262144"
/>
<GptContextWindowHint
modelId={model.model}
contextWindow={model.max_context_size}
/>
</div>
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor={`kimi-context-${key}`}>
{t("kimi.contextSize")} *
</Label>
<Input
id={`kimi-context-${key}`}
type="number"
min={1}
step={1}
value={model.max_context_size}
onChange={(e) => {
placeholderContextKeys.current.delete(key);
updateModel(key, {
max_context_size: Number(e.target.value),
});
}}
/>
</div>
</div>
<div className="flex flex-wrap gap-4">
{KIMI_CAPABILITIES.map((capability) => (
<div key={capability} className="flex items-center gap-1">
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={
model.capabilities?.includes(capability) ?? false
}
onCheckedChange={(checked) =>
updateModel(key, {
capabilities: checked
? [
...new Set([
...(model.capabilities ?? []),
capability,
]),
]
: (model.capabilities ?? []).filter(
(item) => item !== capability,
),
})
}
/>
{t(`kimi.capabilities.${capability}`)}
</label>
{CAPABILITIES_WITH_HELP.has(capability) && (
<HelpTip title={t(`kimi.capabilities.${capability}`)}>
{t(`kimi.capabilityHints.${capability}`)}
</HelpTip>
)}
</div>
))}
</div>
))}
</div>
))}
)}
</div>
</fieldset>
<div className="space-y-2">
-6
View File
@@ -4944,12 +4944,6 @@
"help": "Add several Kimi coding plan accounts; each gets its own provider and models in Kimi Code. \"Use this account\" switches Kimi Code's default model to that account, and new sessions use it from then on. The account Kimi Code itself is signed in with shows up here automatically and is always used for web search and fetch; removing it here only hides it from the list and never signs Kimi Code out."
},
"modelSelectionHint": "Use the switch button on a provider card to select it. To choose another model, use /model in Kimi Code.",
"providerType": "Provider type",
"baseUrl": "Base URL",
"models": "Models",
"addModel": "Add model",
"removeModel": "Remove model",
"modelId": "Model ID",
"contextSize": "Context size",
"capabilities": {
"image_in": "Image input",
-6
View File
@@ -4944,12 +4944,6 @@
"help": "Kimi コーディングプランのアカウントを複数追加でき、それぞれ Kimi Code 内に専用のプロバイダーとモデルを持ちます。「このアカウントを使用」で Kimi Code の既定モデルをそのアカウントに切り替え、以降の新しいセッションで使われます。Kimi Code 自身がログインしているアカウントは自動的にここに表示され、Web 検索と取得には常にこれが使われます。ここで削除してもリストから外すだけで、Kimi Code はログアウトしません。"
},
"modelSelectionHint": "プロバイダーカードの切り替えボタンで既定のプロバイダーを選択します。別のモデルを選ぶには、Kimi Code で /model を使用してください。",
"providerType": "プロバイダーの種類",
"baseUrl": "リクエスト URL",
"models": "モデル",
"addModel": "モデルを追加",
"removeModel": "モデルを削除",
"modelId": "モデル ID",
"contextSize": "コンテキスト長",
"deleteSessionHint": "インデックスと履歴を同期するため、Kimi Code 内でセッションを削除してください。",
"empty": {
-6
View File
@@ -4805,12 +4805,6 @@
"managedHint": "Resmî Kimi hesaplarını Yetkilendirme Merkezi'nde yönetin."
},
"modelSelectionHint": "Sağlayıcıyı seçmek için karttaki geçiş düğmesini kullanın. Başka bir model seçmek için Kimi Code içinde /model komutunu kullanın.",
"providerType": "Sağlayıcı türü",
"baseUrl": "İstek adresi",
"models": "Modeller",
"addModel": "Model ekle",
"removeModel": "Modeli kaldır",
"modelId": "Model kimliği",
"contextSize": "Bağlam uzunluğu",
"deleteSessionHint": "Dizin ve geçmişin birlikte güncellenmesi için oturumu Kimi Code içinden silin.",
"empty": {
-6
View File
@@ -4945,12 +4945,6 @@
"help": "可新增多個 Kimi 程式設計方案帳號,每個帳號在 Kimi Code 中有自己的供應商和模型。「使用此帳號」會把 Kimi Code 的預設模型換成這個帳號的,之後新開的對話都用它。Kimi Code 自己登入的帳號會自動出現在這裡,聯網搜尋和網頁擷取始終用它;在這裡刪除它只會移出列表,不會讓 Kimi Code 登出。"
},
"modelSelectionHint": "點擊供應商卡片的切換按鈕選擇預設供應商;若要指定其他模型,請在 Kimi Code 中使用 /model。",
"providerType": "供應商類型",
"baseUrl": "請求位址",
"models": "模型",
"addModel": "新增模型",
"removeModel": "移除模型",
"modelId": "模型 ID",
"contextSize": "上下文長度",
"deleteSessionHint": "請在 Kimi Code 中刪除,以同時更新工作階段索引和歷史紀錄。",
"empty": {
-6
View File
@@ -4944,12 +4944,6 @@
"help": "可添加多个 Kimi 编程套餐账号,每个号在 Kimi Code 里有自己的供应商和模型。「使用此账号」会把 Kimi Code 的默认模型换成这个号的,之后新开的会话都用它。Kimi Code 自己登着的号会自动出现在这里,联网搜索和网页抓取始终用它;在这里删掉它只会移出列表,不会让 Kimi Code 退出登录。"
},
"modelSelectionHint": "点击供应商卡片的切换按钮选择默认供应商;需要指定其他模型时,在 Kimi Code 中使用 /model。",
"providerType": "供应商类型",
"baseUrl": "请求地址",
"models": "模型",
"addModel": "添加模型",
"removeModel": "移除模型",
"modelId": "模型 ID",
"contextSize": "上下文长度",
"capabilities": {
"image_in": "图片输入",
+68 -30
View File
@@ -1,11 +1,5 @@
import { QueryClientProvider } from "@tanstack/react-query";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
@@ -57,6 +51,14 @@ const config = {
},
};
// 模型参数收在每行的展开区里,和 Pi、OpenCode 一样。
function expandModels() {
for (const toggle of screen.getAllByRole("button", {
name: "opencode.toggleModelDetails",
}))
fireEvent.click(toggle);
}
function setup(
onSubmit = vi.fn(),
category?: ProviderCategory,
@@ -121,16 +123,17 @@ describe("KimiProviderForm", () => {
target: { value: JSON.stringify(native) },
});
}
expandModels();
expect(
screen.getByRole("checkbox", { name: "kimi.capabilities.image_in" }),
screen.getByRole("switch", { name: "kimi.capabilities.image_in" }),
).toBeChecked();
expect(
screen.queryByRole("checkbox", { name: "kimi.capabilities.audio_in" }),
screen.queryByRole("switch", { name: "kimi.capabilities.audio_in" }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("checkbox", { name: "kimi.capabilities.video_in" }),
screen.queryByRole("switch", { name: "kimi.capabilities.video_in" }),
).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/kimi\.baseUrl/), {
fireEvent.change(screen.getByLabelText("opencode.baseUrl"), {
target: { value: "https://updated.example.com/v1" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
@@ -154,8 +157,9 @@ describe("KimiProviderForm", () => {
"future_capability",
);
const submit = setup(vi.fn(), undefined, native);
expandModels();
fireEvent.click(
screen.getByRole("checkbox", { name: "kimi.capabilities.image_in" }),
screen.getByRole("switch", { name: "kimi.capabilities.image_in" }),
);
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(submit).toHaveBeenCalledOnce());
@@ -202,10 +206,10 @@ describe("KimiProviderForm", () => {
}),
);
await user.click(screen.getByRole("button", { name }));
expect(screen.getByLabelText("opencode.providerKey *")).toHaveValue(
expect(screen.getByLabelText("opencode.providerKey")).toHaveValue(
selected.providerKey,
);
expect(screen.getByLabelText("kimi.baseUrl *")).toHaveValue(
expect(screen.getByLabelText("opencode.baseUrl")).toHaveValue(
selected.settingsConfig.provider.base_url,
);
expect(
@@ -262,7 +266,7 @@ describe("KimiProviderForm", () => {
it("edits native endpoints while preserving OAuth and model overrides", async () => {
const submit = setup();
fireEvent.change(screen.getByLabelText("kimi.baseUrl *"), {
fireEvent.change(screen.getByLabelText("opencode.baseUrl"), {
target: { value: "https://example.com/v1" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
@@ -276,19 +280,52 @@ describe("KimiProviderForm", () => {
expect(submit.mock.calls[0][0].providerKey).toBe("managed:kimi-code");
});
it("keeps request headers in advanced options, open only when some are set", () => {
it("shows request headers inline like Pi", () => {
setup();
expect(screen.getByDisplayValue("X-Custom")).toBeInTheDocument();
cleanup();
expect(
screen.queryByRole("button", {
name: "providerForm.advancedOptionsToggle",
}),
).not.toBeInTheDocument();
});
const { custom_headers: _, ...provider } = config.provider;
setup(vi.fn(), undefined, { ...config, provider });
const toggle = screen.getByRole("button", {
name: "providerForm.advancedOptionsToggle",
});
expect(toggle).toHaveAttribute("aria-expanded", "false");
fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "true");
it("flags a preset whose provider key collides with an existing card", async () => {
const user = userEvent.setup();
const selected = kimiProviderPresets.find(
(preset) => preset.name === "Kimi",
)!;
server.use(
http.post("http://tauri.local/get_providers", () =>
HttpResponse.json({
[selected.providerKey]: {
id: selected.providerKey,
name: "Kimi",
settingsConfig: {},
},
}),
),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<KimiProviderForm
appId="kimi"
submitLabel="Save"
onSubmit={vi.fn()}
onCancel={() => {}}
/>
</QueryClientProvider>,
);
await user.click(
screen.getByRole("button", { name: /^providerPreset\.group\.vendor / }),
);
await user.click(screen.getByRole("button", { name: "Kimi" }));
expect(
await screen.findByText("opencode.providerKeyDuplicate"),
).toBeInTheDocument();
expect(screen.getByLabelText("opencode.providerKey")).toHaveValue(
selected.providerKey,
);
});
it("blocks invalid JSON until the editor is corrected", () => {
@@ -303,15 +340,16 @@ describe("KimiProviderForm", () => {
it("requires at least one model with a positive context length", () => {
setup();
fireEvent.change(screen.getByLabelText("kimi.contextSize *"), {
expandModels();
fireEvent.change(screen.getByLabelText("kimi.contextSize"), {
target: { value: "0" },
});
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
fireEvent.change(screen.getByLabelText("kimi.contextSize *"), {
fireEvent.change(screen.getByLabelText("kimi.contextSize"), {
target: { value: "262144" },
});
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
fireEvent.click(screen.getByRole("button", { name: "kimi.removeModel" }));
fireEvent.click(screen.getByRole("button", { name: "common.delete" }));
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
});
@@ -355,10 +393,10 @@ describe("KimiProviderForm", () => {
/>
</QueryClientProvider>,
);
fireEvent.change(screen.getByLabelText("opencode.providerKey *"), {
fireEvent.change(screen.getByLabelText("opencode.providerKey"), {
target: { value: "acme" },
});
fireEvent.change(screen.getByLabelText("kimi.baseUrl *"), {
fireEvent.change(screen.getByLabelText("opencode.baseUrl"), {
target: { value: "https://api.example.com/v1" },
});
fireEvent.change(screen.getByLabelText("API Key"), {