#18550 · @neversaywanan · opened Mar 21, 2026 at 6:31 PM UTC · last updated Mar 21, 2026 at 6:40 PM UTC

feat: manage custom providers within model settings

appfeat
57
+213276 files

Score breakdown

Impact

8.0

Clarity

9.0

Urgency

4.0

Ease Of Review

8.0

Guidelines

7.0

Readiness

7.0

Size

3.0

Trust

5.0

Traction

2.0

Summary

This PR introduces an edit flow for custom providers within model settings, addressing a requested feature to improve usability by allowing modifications without recreating providers. Verification includes unit tests and manual steps to cover specific configuration updates and model blacklisting. A video asset is provided, but the screenshot section still contains a 'TODO' marker.

Open in GitHub

Description

Issue for this PR

Closes #18224

Type of change

  • [ ] Bug fix
  • [x] New feature
  • [ ] Refactor / code improvement
  • [ ] Documentation

What does this PR do?

Adds an edit flow for custom providers from the model settings page.

Custom providers now show an Edit action in Settings > Models, which opens the existing custom provider dialog in edit mode. The form is prefilled from the current provider config, keeps the provider ID locked while editing, and updates the saved config without failing duplicate ID validation for the provider being edited.

When models are removed during editing, they are moved into the provider blacklist so hidden models stay hidden instead of reappearing when the provider is saved again.

How did you verify your code works?

  • Ran bun test --preload ./happydom.ts ./src/components/dialog-custom-provider.test.ts from packages/app
  • Verified editing an existing provider does not fail duplicate ID validation
  • Verified removed models are added to the blacklist during edit saves
  • Verified blacklisted models stay hidden when the edit form is seeded
  • Manually tested the custom provider flow locally from the model settings page

Screenshots / recordings

TODO: add before/after screenshots or a short recording of editing a custom provider from Settings > Models

https://github.com/user-attachments/assets/bdc16ea3-0068-4690-af26-0e3f7a43b416

Checklist

  • [x] I have tested my changes locally
  • [x] I have not included unrelated changes in this PR

Linked Issues

#18224 [FEATURE]:allow editing custom providers from model settings

View issue

Comments

PR comments

neversaywanan

@adamdotdevin Hi! The CI shows “This workflow requires approval from a maintainer” (3 workflows awaiting approval). Could you please click Approve and run for this PR? I don’t have the permissions.

Changed Files

packages/app/src/components/dialog-custom-provider.tsx

+227
@@ -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<FormState>({
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

packages/app/src/components/dialog-select-model.tsx

+211
@@ -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(() => unde

packages/app/src/components/settings-models.tsx

+121
@@ -4,7 +4,9 @@ import { Switch } from "@opencode-ai/ui/switch"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextField } from "@opencode-ai/ui/text-field"
import { type Component, For, Show } from "solid-js"
import { type Component, For, onMount, Show } from "solid-js"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useModels } from "@/context/models"
import { popularProviders } from "@/hooks/use-providers"
@@ -34,6 +36,15 @@ const ListEmptyState: Component<{ message: string; filter: string }> = (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<ModelItem>({
items: (_filter) => models.list(),

packages/app/src/components/settings-providers.tsx

+163
@@ -162,9 +162,22 @@ export const SettingsProviders: Component = () => {
</span>
}
>
<Button size="large" variant="ghost" onClick={() => void disconnect(item.id, item.name)}>
{language.t("common.disconnect")}
</Button>
<div class="flex items-center gap-1">
<Show when={isConfigCustom(item.id)}>
<Button
size="large"
variant="ghost"
onClick={() => {
dialog.show(() => <DialogCustomProvider back="close" providerID={item.id} />)
}}
>
{language.t("common.edit")}
</Button>
</Show>
<Button size="large" variant="ghost" onClick={() => void disconnect(item.id, item.name)}>
{language.t("common.disconnect")}
</Button>
</div>

packages/opencode/src/config/config.ts

+3815
@@ -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 = mod

packages/opencode/test/config/config.test.ts

+1040
@@ -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