From 96d873b10e425c27b338b87355969e5f1e61d847 Mon Sep 17 00:00:00 2001 From: nosaywanan Date: Thu, 19 Mar 2026 17:43:55 +0800 Subject: [PATCH] Add a secondary editing feature for custom suppliers. (setting-models-custom provider) --- .../components/dialog-custom-provider-form.ts | 40 ++++++++++++- .../components/dialog-custom-provider.test.ts | 46 +++++++++++++- .../src/components/dialog-custom-provider.tsx | 60 ++++++++++++++++--- .../app/src/components/settings-models.tsx | 19 ++++++ packages/opencode/package.json | 2 +- 5 files changed, 154 insertions(+), 13 deletions(-) diff --git a/packages/app/src/components/dialog-custom-provider-form.ts b/packages/app/src/components/dialog-custom-provider-form.ts index 92d235c3bcc..378bb44924c 100644 --- a/packages/app/src/components/dialog-custom-provider-form.ts +++ b/packages/app/src/components/dialog-custom-provider-form.ts @@ -47,6 +47,18 @@ type ValidateArgs = { t: Translator disabledProviders: string[] existingProviderIDs: Set + editProviderID?: string +} + +type BlacklistArgs = { + prevModels: string[] + prevBlacklist: string[] + nextModels: string[] +} + +type VisibleArgs = { + models: Record + blacklist: string[] } export function validateCustomProvider(input: ValidateArgs) { @@ -74,7 +86,7 @@ export function validateCustomProvider(input: ValidateArgs) { const disabled = input.disabledProviders.includes(providerID) const existsError = idError ? undefined - : input.existingProviderIDs.has(providerID) && !disabled + : input.existingProviderIDs.has(providerID) && !disabled && input.editProviderID !== providerID ? input.t("provider.custom.error.providerID.exists") : undefined @@ -151,9 +163,31 @@ export function validateCustomProvider(input: ValidateArgs) { } } +export function nextBlacklist(input: BlacklistArgs) { + const next = new Set(input.nextModels) + const removed = input.prevModels.filter((id) => !next.has(id)) + const kept = input.prevBlacklist.filter((id) => !next.has(id)) + return Array.from(new Set([...kept, ...removed])).sort((a, b) => a.localeCompare(b)) +} + +export function visibleModels(input: VisibleArgs) { + const blocked = new Set(input.blacklist) + return Object.entries(input.models).filter(([id]) => !blocked.has(id)) +} + let row = 0 const nextRow = () => `row-${row++}` -export const modelRow = (): ModelRow => ({ row: nextRow(), id: "", name: "", err: {} }) -export const headerRow = (): HeaderRow => ({ row: nextRow(), key: "", value: "", err: {} }) +export const modelRow = (input?: Partial>): ModelRow => ({ + row: nextRow(), + id: input?.id ?? "", + name: input?.name ?? "", + err: {}, +}) +export const headerRow = (input?: Partial>): HeaderRow => ({ + row: nextRow(), + key: input?.key ?? "", + value: input?.value ?? "", + err: {}, +}) diff --git a/packages/app/src/components/dialog-custom-provider.test.ts b/packages/app/src/components/dialog-custom-provider.test.ts index 8cfd78ebeb3..42e2f87325b 100644 --- a/packages/app/src/components/dialog-custom-provider.test.ts +++ b/packages/app/src/components/dialog-custom-provider.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { validateCustomProvider } from "./dialog-custom-provider-form" +import { nextBlacklist, validateCustomProvider, visibleModels } from "./dialog-custom-provider-form" const t = (key: string) => key @@ -79,4 +79,48 @@ describe("validateCustomProvider", () => { value: undefined, }) }) + + test("allows editing existing provider id", () => { + const result = validateCustomProvider({ + form: { + providerID: "custom-provider", + name: "Provider", + baseURL: "https://api.example.com", + apiKey: "", + models: [{ row: "m0", id: "model-a", name: "Model A", err: {} }], + headers: [{ row: "h0", key: "", value: "", err: {} }], + saving: false, + err: {}, + }, + t, + disabledProviders: [], + existingProviderIDs: new Set(["custom-provider"]), + editProviderID: "custom-provider", + }) + + expect(result.result?.providerID).toBe("custom-provider") + expect(result.err.providerID).toBeUndefined() + }) + + test("adds removed models to blacklist in edit mode", () => { + const out = nextBlacklist({ + prevModels: ["a", "b", "c"], + prevBlacklist: ["z", "b"], + nextModels: ["a", "z"], + }) + + expect(out).toEqual(["b", "c"]) + }) + + test("hides blacklisted models from edit form seed", () => { + const out = visibleModels({ + models: { + a: { name: "A" }, + b: { name: "B" }, + }, + blacklist: ["b"], + }) + + expect(out).toEqual([["a", { name: "A" }]]) + }) }) diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index 4d220a0b191..d9892e846b0 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -11,11 +11,19 @@ import { Link } from "@/components/link" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" -import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form" +import { + type FormState, + headerRow, + modelRow, + nextBlacklist, + validateCustomProvider, + visibleModels, +} from "./dialog-custom-provider-form" import { DialogSelectProvider } from "./dialog-select-provider" type Props = { back?: "providers" | "close" + providerID?: string } export function DialogCustomProvider(props: Props) { @@ -23,14 +31,33 @@ export function DialogCustomProvider(props: Props) { const globalSync = useGlobalSync() const globalSDK = useGlobalSDK() const language = useLanguage() + const edit = () => (props.providerID ? globalSync.data.config.provider?.[props.providerID] : undefined) + const seed = () => { + const id = props.providerID ?? "" + const cfg = edit() + const provider = props.providerID ? globalSync.data.provider.all.find((x) => x.id === props.providerID) : undefined + const models = visibleModels({ models: cfg?.models ?? {}, blacklist: cfg?.blacklist ?? [] }).map(([mid, model]) => + modelRow({ id: mid, name: model?.name ?? mid }), + ) + const headers = Object.entries(cfg?.options?.headers ?? {}).map(([key, value]) => headerRow({ key, value })) + return { + providerID: id, + name: cfg?.name ?? provider?.name ?? "", + baseURL: typeof cfg?.options?.baseURL === "string" ? cfg.options.baseURL : "", + apiKey: "", + models: models.length ? models : [modelRow()], + headers: headers.length ? headers : [headerRow()], + } + } + const init = seed() const [form, setForm] = createStore({ - providerID: "", - name: "", - baseURL: "", - apiKey: "", - models: [modelRow()], - headers: [headerRow()], + providerID: init.providerID, + name: init.name, + baseURL: init.baseURL, + apiKey: init.apiKey, + models: init.models, + headers: init.headers, saving: false, err: {}, }) @@ -107,6 +134,7 @@ export function DialogCustomProvider(props: Props) { t: language.t, disabledProviders: globalSync.data.config.disabled_providers ?? [], existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)), + editProviderID: props.providerID, }) batch(() => { setForm("err", output.err) @@ -140,7 +168,22 @@ export function DialogCustomProvider(props: Props) { auth .then(() => - globalSync.updateConfig({ provider: { [result.providerID]: result.config }, disabled_providers: nextDisabled }), + globalSync.updateConfig( + props.providerID + ? { + provider: { + [result.providerID]: { + ...result.config, + blacklist: nextBlacklist({ + prevModels: Object.keys(edit()?.models ?? {}), + prevBlacklist: edit()?.blacklist ?? [], + nextModels: Object.keys(result.config.models ?? {}), + }), + }, + }, + } + : { provider: { [result.providerID]: result.config }, disabled_providers: nextDisabled }, + ), ) .then(() => { dialog.close() @@ -196,6 +239,7 @@ export function DialogCustomProvider(props: Props) { description={language.t("provider.custom.field.providerID.description")} value={form.providerID} onChange={(v) => setField("providerID", v)} + disabled={!!props.providerID} validationState={form.err.providerID ? "invalid" : undefined} error={form.err.providerID} /> diff --git a/packages/app/src/components/settings-models.tsx b/packages/app/src/components/settings-models.tsx index 14667338e95..6d9d65fc731 100644 --- a/packages/app/src/components/settings-models.tsx +++ b/packages/app/src/components/settings-models.tsx @@ -3,12 +3,16 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Switch } from "@opencode-ai/ui/switch" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" +import { Button } from "@opencode-ai/ui/button" import { TextField } from "@opencode-ai/ui/text-field" import { type Component, For, Show } from "solid-js" +import { useDialog } from "@opencode-ai/ui/context/dialog" import { useLanguage } from "@/context/language" +import { useGlobalSync } from "@/context/global-sync" import { useModels } from "@/context/models" import { popularProviders } from "@/hooks/use-providers" import { SettingsList } from "./settings-list" +import { DialogCustomProvider } from "./dialog-custom-provider" type ModelItem = ReturnType["list"]>[number] @@ -33,7 +37,10 @@ const ListEmptyState: Component<{ message: string; filter: string }> = (props) = export const SettingsModels: Component = () => { const language = useLanguage() + const dialog = useDialog() + const globalSync = useGlobalSync() const models = useModels() + const custom = (id: string) => globalSync.data.config.provider?.[id]?.npm === "@ai-sdk/openai-compatible" const list = useFilteredList({ items: (_filter) => models.list(), @@ -100,6 +107,18 @@ export const SettingsModels: Component = () => {
{group.items[0].provider.name} +
+ + +
diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 049573e3e52..c01129d819a 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -82,6 +82,7 @@ "@ai-sdk/xai": "2.0.51", "@aws-sdk/credential-providers": "3.993.0", "@clack/prompts": "1.0.0-alpha.1", + "@effect/platform-node": "catalog:", "@gitlab/gitlab-ai-provider": "3.6.0", "@gitlab/opencode-gitlab-auth": "1.3.3", "@hono/standard-validator": "0.1.5", @@ -97,7 +98,6 @@ "@openrouter/ai-sdk-provider": "1.5.4", "@opentui/core": "0.1.87", "@opentui/solid": "0.1.87", - "@effect/platform-node": "catalog:", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2",