Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions packages/app/src/components/dialog-custom-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { DialogSelectProvider } from "./dialog-select-provider"

type Props = {
back?: "providers" | "close"
providerID?: string
}

export function DialogCustomProvider(props: Props) {
Expand All @@ -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
const items = headers && typeof headers === "object" ? Object.entries(headers as Record<string, unknown>) : []
if (!items.length) return [headerRow()]
return items.map(([key, value]) => ({ ...headerRow(), key, value: String(value ?? "") }))
})(),
err: {},
})

Expand Down Expand Up @@ -102,11 +115,12 @@ export function DialogCustomProvider(props: Props) {
}

const validate = () => {
const ignore = props.providerID ? new Set([props.providerID]) : new Set<string>()
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)
Expand Down Expand Up @@ -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}
/>
Expand Down
22 changes: 21 additions & 1 deletion packages/app/src/components/dialog-select-model.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -120,6 +130,7 @@ export function ModelSelectorPopover(props: {
<Kobalte
open={store.open}
onOpenChange={(next) => {
if (next) void refresh()
if (next) setStore("dismiss", null)
setStore("open", next)
}}
Expand Down Expand Up @@ -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 (
<Dialog
Expand Down
13 changes: 12 additions & 1 deletion packages/app/src/components/settings-models.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(),
Expand Down
19 changes: 16 additions & 3 deletions packages/app/src/components/settings-providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
</Show>
</div>
)}
Expand Down
53 changes: 38 additions & 15 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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()
}

Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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
}
Expand Down
104 changes: 104 additions & 0 deletions packages/opencode/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading