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
40 changes: 37 additions & 3 deletions packages/app/src/components/dialog-custom-provider-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,18 @@ type ValidateArgs = {
t: Translator
disabledProviders: string[]
existingProviderIDs: Set<string>
editProviderID?: string
}

type BlacklistArgs = {
prevModels: string[]
prevBlacklist: string[]
nextModels: string[]
}

type VisibleArgs = {
models: Record<string, { name?: string } | undefined>
blacklist: string[]
}

export function validateCustomProvider(input: ValidateArgs) {
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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<Pick<ModelRow, "id" | "name">>): ModelRow => ({
row: nextRow(),
id: input?.id ?? "",
name: input?.name ?? "",
err: {},
})
export const headerRow = (input?: Partial<Pick<HeaderRow, "key" | "value">>): HeaderRow => ({
row: nextRow(),
key: input?.key ?? "",
value: input?.value ?? "",
err: {},
})
46 changes: 45 additions & 1 deletion packages/app/src/components/dialog-custom-provider.test.ts
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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" }]])
})
})
60 changes: 52 additions & 8 deletions packages/app/src/components/dialog-custom-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,53 @@ 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) {
const dialog = useDialog()
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<FormState>({
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: {},
})
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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}
/>
Expand Down
19 changes: 19 additions & 0 deletions packages/app/src/components/settings-models.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof useModels>["list"]>[number]

Expand All @@ -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<ModelItem>({
items: (_filter) => models.list(),
Expand Down Expand Up @@ -100,6 +107,18 @@ export const SettingsModels: Component = () => {
<div class="flex items-center gap-2 pb-2">
<ProviderIcon id={group.category} class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong">{group.items[0].provider.name}</span>
<div class="flex-1" />
<Show when={custom(group.category)}>
<Button
size="small"
variant="ghost"
onClick={() => {
dialog.show(() => <DialogCustomProvider back="close" providerID={group.category} />)
}}
>
{language.t("common.edit")}
</Button>
</Show>
</div>
<SettingsList>
<For each={group.items}>
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Loading