Skip to content
Closed
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
49 changes: 45 additions & 4 deletions packages/app/src/components/dialog-custom-provider-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ type Translator = (key: string, vars?: Record<string, string | number | boolean>
export type ModelErr = {
id?: string
name?: string
context?: string
output?: string
}

export type HeaderErr = {
Expand All @@ -17,6 +19,9 @@ export type ModelRow = {
row: string
id: string
name: string
reasoning: boolean
context: string
output: string
err: ModelErr
}

Expand Down Expand Up @@ -80,6 +85,9 @@ export function validateCustomProvider(input: ValidateArgs) {
const seenModels = new Set<string>()
const models = input.form.models.map((m) => {
const id = m.id.trim()
const context = m.context.trim()
const output = m.output.trim()
const hasLimit = !!context || !!output
const idError = !id
? input.t("provider.custom.error.required")
: seenModels.has(id)
Expand All @@ -89,10 +97,35 @@ export function validateCustomProvider(input: ValidateArgs) {
return undefined
})()
const nameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
return { id: idError, name: nameError }
const contextError = !hasLimit
? undefined
: !context
? input.t("provider.custom.error.required")
: !Number.isSafeInteger(Number(context)) || Number(context) <= 0
? input.t("provider.custom.error.positiveInteger")
: undefined
const outputError = !hasLimit
? undefined
: !output
? input.t("provider.custom.error.required")
: !Number.isSafeInteger(Number(output)) || Number(output) <= 0
? input.t("provider.custom.error.positiveInteger")
: undefined
return { id: idError, name: nameError, context: contextError, output: outputError }
})
const modelsValid = models.every((m) => !m.id && !m.name)
const modelConfig = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
const modelsValid = models.every((m) => !m.id && !m.name && !m.context && !m.output)
const modelConfig = Object.fromEntries(
input.form.models.map((m) => [
m.id.trim(),
{
name: m.name.trim(),
reasoning: m.reasoning,
...(m.context.trim() && m.output.trim()
? { limit: { context: Number(m.context), output: Number(m.output) } }
: {}),
},
]),
)

const seenHeaders = new Set<string>()
const headers = input.form.headers.map((h) => {
Expand Down Expand Up @@ -154,5 +187,13 @@ let row = 0

const nextRow = () => `row-${row++}`

export const modelRow = (): ModelRow => ({ row: nextRow(), id: "", name: "", err: {} })
export const modelRow = (): ModelRow => ({
row: nextRow(),
id: "",
name: "",
reasoning: false,
context: "",
output: "",
err: {},
})
export const headerRow = (): HeaderRow => ({ row: nextRow(), key: "", value: "", err: {} })
78 changes: 74 additions & 4 deletions packages/app/src/components/dialog-custom-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,17 @@ describe("validateCustomProvider", () => {
name: " Custom Provider ",
baseURL: "https://api.example.com ",
apiKey: " {env: CUSTOM_PROVIDER_KEY} ",
models: [{ row: "m0", id: " model-a ", name: " Model A ", err: {} }],
models: [
{
row: "m0",
id: " model-a ",
name: " Model A ",
reasoning: true,
context: " 128000 ",
output: " 8192 ",
err: {},
},
],
headers: [
{ row: "h0", key: " X-Test ", value: " enabled ", err: {} },
{ row: "h1", key: "", value: "", err: {} },
Expand All @@ -38,7 +48,14 @@ describe("validateCustomProvider", () => {
},
},
models: {
"model-a": { name: "Model A" },
"model-a": {
name: "Model A",
reasoning: true,
limit: {
context: 128000,
output: 8192,
},
},
},
},
})
Expand All @@ -52,8 +69,24 @@ describe("validateCustomProvider", () => {
baseURL: "https://api.example.com",
apiKey: "secret",
models: [
{ row: "m0", id: "model-a", name: "Model A", err: {} },
{ row: "m1", id: "model-a", name: "Model A 2", err: {} },
{
row: "m0",
id: "model-a",
name: "Model A",
reasoning: false,
context: "",
output: "",
err: {},
},
{
row: "m1",
id: "model-a",
name: "Model A 2",
reasoning: false,
context: "",
output: "",
err: {},
},
],
headers: [
{ row: "h0", key: "Authorization", value: "one", err: {} },
Expand All @@ -71,10 +104,47 @@ describe("validateCustomProvider", () => {
expect(result.models[1]).toEqual({
id: "provider.custom.error.duplicate",
name: undefined,
context: undefined,
output: undefined,
})
expect(result.headers[1]).toEqual({
key: "provider.custom.error.duplicate",
value: undefined,
})
})

test("requires a complete positive token limit", () => {
const result = validateCustomProvider({
form: {
providerID: "custom-provider",
name: "Provider",
baseURL: "https://api.example.com",
apiKey: "",
models: [
{
row: "m0",
id: "model-a",
name: "Model A",
reasoning: false,
context: "128000",
output: "",
err: {},
},
],
headers: [{ row: "h0", key: "", value: "", err: {} }],
err: {},
},
t,
disabledProviders: [],
existingProviderIDs: new Set(),
})

expect(result.result).toBeUndefined()
expect(result.models[0]).toEqual({
id: undefined,
name: undefined,
context: undefined,
output: "provider.custom.error.required",
})
})
})
102 changes: 72 additions & 30 deletions packages/app/src/components/dialog-custom-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Dialog } from "@opencode-ai/ui/dialog"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { useMutation } from "@tanstack/solid-query"
import { Switch } from "@opencode-ai/ui/switch"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@/utils/toast"
import { batch, For } from "solid-js"
Expand Down Expand Up @@ -100,10 +101,12 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
setForm("err", key, undefined)
}

const setModel = (index: number, key: "id" | "name", value: string) => {
const setModel = (index: number, key: "id" | "name" | "context" | "output", value: string) => {
batch(() => {
setForm("models", index, key, value)
setForm("models", index, "err", key, undefined)
if (key === "context") setForm("models", index, "err", "output", undefined)
if (key === "output") setForm("models", index, "err", "context", undefined)
})
}

Expand Down Expand Up @@ -230,38 +233,77 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
<label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
<For each={form.models}>
{(m, i) => (
<div class="flex gap-2 items-start" data-row={m.row}>
<div class="flex-1">
<TextField
label={language.t("provider.custom.models.id.label")}
hideLabel
placeholder={language.t("provider.custom.models.id.placeholder")}
value={m.id}
onChange={(v) => setModel(i(), "id", v)}
validationState={m.err.id ? "invalid" : undefined}
error={m.err.id}
<div class="flex flex-col gap-2" data-row={m.row}>
<div class="flex gap-2 items-start">
<div class="flex-1">
<TextField
label={language.t("provider.custom.models.id.label")}
hideLabel
placeholder={language.t("provider.custom.models.id.placeholder")}
value={m.id}
onChange={(v) => setModel(i(), "id", v)}
validationState={m.err.id ? "invalid" : undefined}
error={m.err.id}
/>
</div>
<div class="flex-1">
<TextField
label={language.t("provider.custom.models.name.label")}
hideLabel
placeholder={language.t("provider.custom.models.name.placeholder")}
value={m.name}
onChange={(v) => setModel(i(), "name", v)}
validationState={m.err.name ? "invalid" : undefined}
error={m.err.name}
/>
</div>
<IconButton
type="button"
icon="trash"
variant="ghost"
class="mt-1.5"
onClick={() => removeModel(i())}
disabled={form.models.length <= 1}
aria-label={language.t("provider.custom.models.remove")}
/>
</div>
<div class="flex-1">
<TextField
label={language.t("provider.custom.models.name.label")}
hideLabel
placeholder={language.t("provider.custom.models.name.placeholder")}
value={m.name}
onChange={(v) => setModel(i(), "name", v)}
validationState={m.err.name ? "invalid" : undefined}
error={m.err.name}
/>
<div class="flex gap-2 items-start pr-10">
<div class="flex-1">
<TextField
type="number"
min="1"
step="1"
label={language.t("provider.custom.models.context.label")}
hideLabel
placeholder={language.t("provider.custom.models.context.placeholder")}
value={m.context}
onChange={(v) => setModel(i(), "context", v)}
validationState={m.err.context ? "invalid" : undefined}
error={m.err.context}
/>
</div>
<div class="flex-1">
<TextField
type="number"
min="1"
step="1"
label={language.t("provider.custom.models.output.label")}
hideLabel
placeholder={language.t("provider.custom.models.output.placeholder")}
value={m.output}
onChange={(v) => setModel(i(), "output", v)}
validationState={m.err.output ? "invalid" : undefined}
error={m.err.output}
/>
</div>
<Switch
class="mt-1.5 shrink-0"
checked={m.reasoning}
onChange={(checked) => setForm("models", i(), "reasoning", checked)}
>
{language.t("provider.custom.models.reasoning.label")}
</Switch>
</div>
<IconButton
type="button"
icon="trash"
variant="ghost"
class="mt-1.5"
onClick={() => removeModel(i())}
disabled={form.models.length <= 1}
aria-label={language.t("provider.custom.models.remove")}
/>
</div>
)}
</For>
Expand Down
6 changes: 6 additions & 0 deletions packages/app/src/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,11 @@ export const dict = {
"provider.custom.models.id.placeholder": "model-id",
"provider.custom.models.name.label": "الاسم",
"provider.custom.models.name.placeholder": "اسم العرض",
"provider.custom.models.context.label": "حجم السياق",
"provider.custom.models.context.placeholder": "حجم السياق",
"provider.custom.models.output.label": "الحد الأقصى للإخراج",
"provider.custom.models.output.placeholder": "الحد الأقصى للإخراج",
"provider.custom.models.reasoning.label": "يدعم الاستدلال",
"provider.custom.models.remove": "إزالة النموذج",
"provider.custom.models.add": "إضافة نموذج",
"provider.custom.headers.label": "الترويسات (اختياري)",
Expand All @@ -182,6 +187,7 @@ export const dict = {
"provider.custom.error.baseURL.required": "عنوان URL الأساسي مطلوب",
"provider.custom.error.baseURL.format": "يجب أن يبدأ بـ http:// أو https://",
"provider.custom.error.required": "مطلوب",
"provider.custom.error.positiveInteger": "أدخل عددًا صحيحًا موجبًا",
"provider.custom.error.duplicate": "مكرر",
"provider.disconnect.toast.disconnected.title": "تم فصل {{provider}}",
"provider.disconnect.toast.disconnected.description": "لم تعد نماذج {{provider}} متاحة.",
Expand Down
6 changes: 6 additions & 0 deletions packages/app/src/i18n/br.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,11 @@ export const dict = {
"provider.custom.models.id.placeholder": "id-do-modelo",
"provider.custom.models.name.label": "Nome",
"provider.custom.models.name.placeholder": "Nome de Exibição",
"provider.custom.models.context.label": "Tamanho do contexto",
"provider.custom.models.context.placeholder": "Tamanho do contexto",
"provider.custom.models.output.label": "Saída máxima",
"provider.custom.models.output.placeholder": "Saída máxima",
"provider.custom.models.reasoning.label": "Suporta raciocínio",
"provider.custom.models.remove": "Remover modelo",
"provider.custom.models.add": "Adicionar modelo",
"provider.custom.headers.label": "Cabeçalhos (opcional)",
Expand All @@ -182,6 +187,7 @@ export const dict = {
"provider.custom.error.baseURL.required": "URL Base é obrigatória",
"provider.custom.error.baseURL.format": "Deve começar com http:// ou https://",
"provider.custom.error.required": "Obrigatório",
"provider.custom.error.positiveInteger": "Insira um número inteiro positivo",
"provider.custom.error.duplicate": "Duplicado",
"provider.disconnect.toast.disconnected.title": "{{provider}} desconectado",
"provider.disconnect.toast.disconnected.description": "Os modelos de {{provider}} não estão mais disponíveis.",
Expand Down
6 changes: 6 additions & 0 deletions packages/app/src/i18n/bs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,11 @@ export const dict = {
"provider.custom.models.id.placeholder": "model-id",
"provider.custom.models.name.label": "Ime",
"provider.custom.models.name.placeholder": "Prikazano ime",
"provider.custom.models.context.label": "Veličina konteksta",
"provider.custom.models.context.placeholder": "Veličina konteksta",
"provider.custom.models.output.label": "Maksimalni izlaz",
"provider.custom.models.output.placeholder": "Maksimalni izlaz",
"provider.custom.models.reasoning.label": "Podržava rasuđivanje",
"provider.custom.models.remove": "Ukloni model",
"provider.custom.models.add": "Dodaj model",
"provider.custom.headers.label": "Zaglavlja (opcionalno)",
Expand All @@ -196,6 +201,7 @@ export const dict = {
"provider.custom.error.baseURL.required": "Bazni URL je obavezan",
"provider.custom.error.baseURL.format": "Mora početi sa http:// ili https://",
"provider.custom.error.required": "Obavezno",
"provider.custom.error.positiveInteger": "Unesite pozitivan cijeli broj",
"provider.custom.error.duplicate": "Duplikat",

"provider.disconnect.toast.disconnected.title": "{{provider}} odspojen",
Expand Down
Loading
Loading