From 781f3f37ea9cb545e18d61b8afd52bf9f6eb91aa Mon Sep 17 00:00:00 2001 From: nosaywanan Date: Sun, 22 Mar 2026 02:23:30 +0800 Subject: [PATCH] fix(provider): keep custom model edits in sync across reopen Replace patched provider configs instead of deep-merging stale model keys, and refresh model/settings dialogs from the latest backend state. Add regression tests for both json and jsonc global config updates to ensure deleted custom models stay deleted. --- .../src/components/dialog-custom-provider.tsx | 29 +++-- .../src/components/dialog-select-model.tsx | 22 +++- .../app/src/components/settings-models.tsx | 13 ++- .../app/src/components/settings-providers.tsx | 19 +++- packages/opencode/src/config/config.ts | 53 ++++++--- packages/opencode/test/config/config.test.ts | 104 ++++++++++++++++++ 6 files changed, 213 insertions(+), 27 deletions(-) diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index 53b66fb451d..6663b4111e7 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -17,6 +17,7 @@ import { DialogSelectProvider } from "./dialog-select-provider" type Props = { back?: "providers" | "close" + providerID?: string } export function DialogCustomProvider(props: Props) { @@ -25,13 +26,25 @@ export function DialogCustomProvider(props: Props) { const globalSDK = useGlobalSDK() const language = useLanguage() + const current = () => (props.providerID ? globalSync.data.config.provider?.[props.providerID] : undefined) + const [form, setForm] = createStore({ - providerID: "", - name: "", - baseURL: "", - apiKey: "", - models: [modelRow()], - headers: [headerRow()], + providerID: props.providerID ?? "", + name: current()?.name ?? "", + baseURL: String(current()?.options?.baseURL ?? ""), + apiKey: current()?.env?.[0] ? `{env:${current()?.env?.[0]}}` : "", + models: (() => { + const models = current()?.models + const items = models ? Object.entries(models) : [] + if (!items.length) return [modelRow()] + return items.map(([id, m]) => ({ ...modelRow(), id, name: String(m?.name ?? id) })) + })(), + headers: (() => { + const headers = current()?.options?.headers + const items = headers && typeof headers === "object" ? Object.entries(headers as Record) : [] + if (!items.length) return [headerRow()] + return items.map(([key, value]) => ({ ...headerRow(), key, value: String(value ?? "") })) + })(), err: {}, }) @@ -102,11 +115,12 @@ export function DialogCustomProvider(props: Props) { } const validate = () => { + const ignore = props.providerID ? new Set([props.providerID]) : new Set() const output = validateCustomProvider({ form, t: language.t, disabledProviders: globalSync.data.config.disabled_providers ?? [], - existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)), + existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id).filter((id) => !ignore.has(id))), }) batch(() => { setForm("err", output.err) @@ -197,6 +211,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/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 3654aab85b9..b976bed7b74 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -1,5 +1,5 @@ import { Popover as Kobalte } from "@kobalte/core/popover" -import { Component, ComponentProps, createMemo, JSX, Show, ValidComponent } from "solid-js" +import { Component, ComponentProps, createMemo, JSX, onMount, Show, ValidComponent } from "solid-js" import { createStore } from "solid-js/store" import { useLocal } from "@/context/local" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -14,6 +14,8 @@ import { DialogSelectProvider } from "./dialog-select-provider" import { DialogManageModels } from "./dialog-manage-models" import { ModelTooltip } from "./model-tooltip" import { useLanguage } from "@/context/language" +import { useGlobalSDK } from "@/context/global-sdk" +import { useGlobalSync } from "@/context/global-sync" const isFree = (provider: string, cost: { input: number } | undefined) => provider === "opencode" && (!cost || cost.input === 0) @@ -104,6 +106,14 @@ export function ModelSelectorPopover(props: { dismiss: null, }) const dialog = useDialog() + const globalSDK = useGlobalSDK() + const globalSync = useGlobalSync() + + const refresh = () => + globalSDK.client.global + .dispose() + .catch(() => undefined) + .then(globalSync.bootstrap) const handleManage = () => { setStore("open", false) @@ -120,6 +130,7 @@ export function ModelSelectorPopover(props: { { + if (next) void refresh() if (next) setStore("dismiss", null) setStore("open", next) }} @@ -192,6 +203,15 @@ export function ModelSelectorPopover(props: { export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => { const dialog = useDialog() const language = useLanguage() + const globalSDK = useGlobalSDK() + const globalSync = useGlobalSync() + + onMount(() => { + void globalSDK.client.global + .dispose() + .catch(() => undefined) + .then(globalSync.bootstrap) + }) return ( = (props) = export const SettingsModels: Component = () => { const language = useLanguage() const models = useModels() + const globalSDK = useGlobalSDK() + const globalSync = useGlobalSync() + + onMount(() => { + void globalSDK.client.global + .dispose() + .catch(() => undefined) + .then(globalSync.bootstrap) + }) const list = useFilteredList({ items: (_filter) => models.list(), diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index cc69327f80d..6661b1cc447 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -162,9 +162,22 @@ export const SettingsProviders: Component = () => { } > - +
+ + + + +
)} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index c464fcb64ab..88ae6bf8f1f 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -75,6 +75,17 @@ export namespace Config { return merged } + function mergePatch(target: Info, source: Info): Info { + const merged = mergeDeep(target, source) + if (source.provider && merged.provider) { + for (const [id, value] of Object.entries(source.provider)) { + if (!value) continue + merged.provider[id] = value + } + } + return merged + } + export const state = Instance.state(async () => { const auth = await Auth.all() @@ -1349,7 +1360,7 @@ export namespace Config { export async function update(config: Info) { const filepath = path.join(Instance.directory, "config.json") const existing = await loadFile(filepath) - await Filesystem.writeJson(filepath, mergeDeep(existing, config)) + await Filesystem.writeJson(filepath, mergePatch(existing, config)) await Instance.dispose() } @@ -1384,6 +1395,20 @@ export namespace Config { }, input) } + function replaceProviderPatchJsonc(input: string, patch: Info): string { + if (!patch.provider) return input + return Object.entries(patch.provider).reduce((result, [id, value]) => { + if (!value) return result + const edits = modify(result, ["provider", id], value, { + formattingOptions: { + insertSpaces: true, + tabSize: 2, + }, + }) + return applyEdits(result, edits) + }, input) + } + function parseConfig(text: string, filepath: string): Info { const errors: JsoncParseError[] = [] const data = parseJsonc(text, errors, { allowTrailingComma: true }) @@ -1428,30 +1453,28 @@ export namespace Config { const next = await (async () => { if (!filepath.endsWith(".jsonc")) { const existing = parseConfig(before, filepath) - const merged = mergeDeep(existing, config) + const merged = mergePatch(existing, config) await Filesystem.writeJson(filepath, merged) return merged } const updated = patchJsonc(before, config) - const merged = parseConfig(updated, filepath) - await Filesystem.write(filepath, updated) + const replaced = replaceProviderPatchJsonc(updated, config) + const merged = parseConfig(replaced, filepath) + await Filesystem.write(filepath, replaced) return merged })() global.reset() - void Instance.disposeAll() - .catch(() => undefined) - .finally(() => { - GlobalBus.emit("event", { - directory: "global", - payload: { - type: Event.Disposed.type, - properties: {}, - }, - }) - }) + await Instance.disposeAll().catch(() => undefined) + GlobalBus.emit("event", { + directory: "global", + payload: { + type: Event.Disposed.type, + properties: {}, + }, + }) return next } diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index eb9c763fa75..7a8e9494731 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -67,6 +67,110 @@ test("loads config with defaults when no files exist", async () => { }) }) +test("updateGlobal replaces models for patched provider", async () => { + await using cfg = await tmpdir() + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = cfg.path + Config.global.reset() + + try { + await writeConfig( + cfg.path, + { + $schema: "https://opencode.ai/config.json", + provider: { + custom: { + name: "Custom", + npm: "@ai-sdk/openai-compatible", + options: { + baseURL: "https://example.com/v1", + }, + models: { + old: { name: "Old" }, + keep: { name: "Keep" }, + }, + }, + }, + }, + "opencode.json", + ) + + await Config.updateGlobal({ + provider: { + custom: { + name: "Custom", + npm: "@ai-sdk/openai-compatible", + options: { + baseURL: "https://example.com/v1", + }, + models: { + keep: { name: "Keep" }, + }, + }, + }, + }) + + const next = JSON.parse((await Filesystem.readText(path.join(cfg.path, "opencode.json"))) ?? "{}") + expect(next.provider.custom.models.old).toBeUndefined() + expect(next.provider.custom.models.keep).toEqual({ name: "Keep" }) + } finally { + await Instance.disposeAll() + ;(Global.Path as { config: string }).config = prev + Config.global.reset() + } +}) + +test("updateGlobal replaces models for patched provider in jsonc", async () => { + await using cfg = await tmpdir() + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = cfg.path + Config.global.reset() + + try { + await Filesystem.write( + path.join(cfg.path, "opencode.jsonc"), + `{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "custom": { + "name": "Custom", + "npm": "@ai-sdk/openai-compatible", + "options": { "baseURL": "https://example.com/v1" }, + "models": { + "old": { "name": "Old" }, + "keep": { "name": "Keep" } + } + } + } +}`, + ) + + await Config.updateGlobal({ + provider: { + custom: { + name: "Custom", + npm: "@ai-sdk/openai-compatible", + options: { + baseURL: "https://example.com/v1", + }, + models: { + keep: { name: "Keep" }, + }, + }, + }, + }) + + const text = (await Filesystem.readText(path.join(cfg.path, "opencode.jsonc"))) ?? "{}" + const next = JSON.parse(text.replace(/^\s*\/\/.*$/gm, "")) + expect(next.provider.custom.models.old).toBeUndefined() + expect(next.provider.custom.models.keep).toEqual({ name: "Keep" }) + } finally { + await Instance.disposeAll() + ;(Global.Path as { config: string }).config = prev + Config.global.reset() + } +}) + test("loads JSON config file", async () => { await using tmp = await tmpdir({ init: async (dir) => {