fix(jetbrains): make custom provider dialog report errors and require a model

Adding a custom OpenAI-compatible provider could silently fail. The dialog
now requires at least one model ID, keeps the Add button disabled until the
model list is non-empty, and surfaces save/fetch errors inline so the user
can correct input and retry without re-entering the form.

Adds a cancellable "Select models" fetch that lists models from the
provider's /models endpoint in a JBList popup styled like the model picker
(check icon, hover selection, standard popup insets, select-all toggle).
This commit is contained in:
kirillk
2026-07-14 15:11:29 -04:00
parent 38d7608965
commit 6077c1c3b3
6 changed files with 617 additions and 13 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Fix adding a Custom OpenAI-Compatible Provider silently failing. The dialog now requires at least one model and reports save errors inline so you can correct your input and retry without re-entering the form.
@@ -282,6 +282,7 @@ internal class KiloBackendProviderSettingsManager(
if (!input.baseUrl.startsWith("http://") && !input.baseUrl.startsWith("https://")) return "Base URL must start with http:// or https://."
if (!env.isNullOrBlank() && !Regex("^[A-Za-z_][A-Za-z0-9_]*$").matches(env)) return "Environment variable name is invalid."
if (input.headers.keys.any { it.isBlank() }) return "Header names cannot be empty."
if (input.models.isEmpty()) return "At least one model ID is required."
if (input.models.any { it.id.isBlank() }) return "Model IDs cannot be empty."
return null
}
@@ -5,6 +5,8 @@ import ai.kilocode.backend.app.KiloBackendAppService
import ai.kilocode.backend.testing.FakeCliServer
import ai.kilocode.backend.testing.MockCliServer
import ai.kilocode.backend.testing.TestLog
import ai.kilocode.rpc.dto.CustomModelDto
import ai.kilocode.rpc.dto.CustomProviderSaveDto
import ai.kilocode.rpc.dto.ProviderConnectDto
import ai.kilocode.rpc.dto.ProviderDisconnectDto
import ai.kilocode.rpc.dto.ProviderEnableDto
@@ -218,6 +220,51 @@ class KiloBackendProviderSettingsManagerTest {
assertEquals(1, mock.requestCount("/global/dispose"))
}
@Test
fun `saving custom provider without models returns error and does not patch config`() = runBlocking {
val manager = manager()
mock.resetCounts()
val result = manager.saveCustom(
CustomProviderSaveDto("/test", "my-openai", "My OpenAI", "https://api.example.com/v1"),
)
assertEquals("At least one model ID is required.", result.error)
assertNull(mock.lastConfigPatchBody)
assertEquals(0, mock.requestCount("/global/dispose"))
}
@Test
fun `saving custom provider with a model patches config and reloads provider`() = runBlocking {
mock.providers = """{
"all":[{"id":"my-openai","name":"My OpenAI","source":"config","models":{"gpt-4o":{"id":"gpt-4o","name":"gpt-4o"}}}],
"default":{},
"connected":[],
"failed":[]
}""".trimIndent()
val manager = manager()
mock.resetCounts()
val result = manager.saveCustom(
CustomProviderSaveDto(
"/test",
"my-openai",
"My OpenAI",
"https://api.example.com/v1",
apiKey = "sk-test",
models = listOf(CustomModelDto("gpt-4o", "gpt-4o")),
),
)
assertNull(result.error)
assertContains(mock.lastConfigPatchBody.orEmpty(), "\"my-openai\"")
assertContains(mock.lastConfigPatchBody.orEmpty(), "\"@ai-sdk/openai-compatible\"")
assertContains(mock.lastConfigPatchBody.orEmpty(), "\"gpt-4o\"")
assertContains(mock.lastAuthPutBody.orEmpty(), "\"key\":\"sk-test\"")
assertTrue(result.state.providers.any { it.id == "my-openai" })
assertEquals(1, mock.requestCount("/global/dispose"))
}
@Test
fun `disconnecting kilo gateway returns error without logout`() = runBlocking {
mock.providers = """{
@@ -10,11 +10,15 @@ import ai.kilocode.client.settings.base.SettingsListView
import ai.kilocode.client.settings.auth.DeviceOAuthInfo
import ai.kilocode.client.settings.auth.DeviceOAuthPanel
import ai.kilocode.client.settings.auth.DeviceOAuthText
import ai.kilocode.client.session.ui.PickerRow
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.dto.CustomModelDto
import ai.kilocode.rpc.dto.CustomModelFetchDto
import ai.kilocode.rpc.dto.CustomModelFetchResultDto
import ai.kilocode.rpc.dto.CustomProviderSaveDto
import ai.kilocode.rpc.dto.ProviderActionResultDto
import ai.kilocode.rpc.dto.ProviderAuthMethodDto
import ai.kilocode.rpc.dto.ProviderAuthOptionDto
import ai.kilocode.rpc.dto.ProviderConnectDto
@@ -42,13 +46,22 @@ import com.intellij.openapi.ui.Messages
import com.intellij.openapi.ui.ValidationInfo
import com.intellij.ui.CollectionListModel
import com.intellij.ui.DocumentAdapter
import com.intellij.ui.GroupHeaderSeparator
import com.intellij.ui.ListUtil
import com.intellij.ui.SearchTextField
import com.intellij.ui.ScrollingUtil
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBList
import com.intellij.ui.components.JBPasswordField
import com.intellij.ui.components.JBScrollPane
import com.intellij.ui.components.JBTextField
import com.intellij.openapi.ui.popup.JBPopup
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.openapi.ui.popup.util.PopupUtil
import com.intellij.ui.NewUI
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.EmptyIcon
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
@@ -58,14 +71,22 @@ import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.awt.BorderLayout
import java.awt.Component
import java.awt.Cursor
import java.awt.Dimension
import java.awt.event.KeyEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.Icon
import javax.swing.JButton
import javax.swing.JComponent
import javax.swing.DefaultListCellRenderer
import javax.swing.JList
import javax.swing.JPanel
import javax.swing.KeyStroke
import javax.swing.ListCellRenderer
import javax.swing.ListSelectionModel
import javax.swing.SwingConstants
import javax.swing.event.DocumentEvent
import javax.swing.Timer
@@ -75,6 +96,83 @@ private val OAUTH_CODE_RE = Regex("""code:\s*(\S+)""", RegexOption.IGNORE_CASE)
private fun oauthCode(text: String?): String? = text?.let { OAUTH_CODE_RE.find(it)?.groupValues?.getOrNull(1) }
private const val CUSTOM_MODEL_POPUP_MIN_WIDTH = 320
private const val CUSTOM_MODEL_POPUP_MAX_ROWS = 10
// Inline error text for a custom-provider save. A blank result with the provider missing from the
// returned list means the CLI dropped it (e.g. no usable models), so surface that instead of closing silently.
internal fun customSaveError(id: String, result: ProviderActionResultDto): String? {
result.error?.let { return it }
if (result.state.providers.none { it.id == id }) return KiloBundle.message("settings.providers.customNotUsable")
return null
}
internal data class CustomModelRow(
val id: String? = null,
val selectAll: Boolean = false,
)
internal fun customModelRows(ids: List<String>): List<CustomModelRow> {
if (ids.isEmpty()) return emptyList()
return listOf(CustomModelRow(selectAll = true)) + ids.map { CustomModelRow(id = it) }
}
private class CustomModelRowRenderer(
private val all: Set<String>,
private val selectedIds: () -> Set<String>,
) : JPanel(BorderLayout()), ListCellRenderer<CustomModelRow> {
private val checked: Icon = AllIcons.Actions.Checked
private val empty: Icon = EmptyIcon.create(checked)
private val check = JBLabel().apply {
horizontalAlignment = SwingConstants.CENTER
verticalAlignment = SwingConstants.CENTER
}
private val title = JBLabel().apply {
border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap())
}
private val sep = GroupHeaderSeparator(JBUI.CurrentTheme.Popup.separatorLabelInsets()).apply {
setHideLine(false)
}
private val row = JPanel(BorderLayout()).apply {
border = JBUI.Borders.empty(
UiStyle.Gap.md(),
UiStyle.Gap.lg(),
UiStyle.Gap.md(),
UiStyle.Gap.pad(),
)
add(check, BorderLayout.WEST)
add(title, BorderLayout.CENTER)
}
private val wrap = PickerRow()
init {
isOpaque = true
UiStyle.Components.transparent(row, check, title)
wrap.setContent(row)
add(wrap, BorderLayout.CENTER)
}
override fun getListCellRendererComponent(
list: JList<out CustomModelRow>,
value: CustomModelRow,
index: Int,
selected: Boolean,
focus: Boolean,
): Component {
val current = selectedIds()
val active = selected || focus || list.hasFocus()
val on = if (value.selectAll) all.isNotEmpty() && all.all { it in current } else value.id in current
check.icon = if (on) checked else empty
title.text = if (value.selectAll) KiloBundle.message("settings.providers.customModelsSelectAll") else value.id.orEmpty()
background = list.background
wrap.update(list, selected, active)
title.foreground = UIUtil.getListForeground(selected, active)
remove(sep)
if (value.selectAll) add(sep, BorderLayout.SOUTH)
return this
}
}
internal class ProvidersSettingsUi(
private val cs: CoroutineScope,
private val directory: String,
@@ -216,14 +314,19 @@ internal class ProvidersSettingsUi(
@RequiresEdt
private fun custom() {
checkEdt()
val dialog = CustomProviderDialog()
// The dialog performs the save itself so failures can be shown inline and the user can
// correct their input without re-typing. It only closes on a verified success.
val dialog = CustomProviderDialog(
cs,
directory,
{ service<KiloProviderService>().fetchCustomModels(it) },
{ service<KiloProviderService>().saveCustom(it) },
)
if (!dialog.showAndGet()) return
val input = dialog.input(directory)
if (!launch("save custom provider") { id ->
val result = service<KiloProviderService>().saveCustom(input)
apply(id, result.state, result.error)
}) return
syncLoading()
val next = dialog.outcome ?: return
state = next
view.update(next)
clearProgress()
}
private fun toolbar(): JComponent {
@@ -539,31 +642,58 @@ private class ApiKeyDialog(title: String, method: ProviderAuthMethodDto?) : Dial
}
}
private class CustomProviderDialog : DialogWrapper(true) {
internal class CustomProviderDialog(
private val cs: CoroutineScope,
private val directory: String,
private val fetch: suspend (CustomModelFetchDto) -> CustomModelFetchResultDto,
private val save: suspend (CustomProviderSaveDto) -> ProviderActionResultDto,
) : DialogWrapper(true) {
private val id = JBTextField()
private val name = JBTextField()
private val url = JBTextField()
private val key = JBPasswordField().apply { columns = 50 }
private val env = JBTextField()
private val models = JBTextField()
private val pick = JButton(KiloBundle.message("settings.providers.customSelectModels"))
private var saving = false
private var fetching = false
private var active = true
private var actionError: String? = null
private var popup: JBPopup? = null
private var job: Job? = null
private var draft: String? = null
private var token = 0
// Set once the save succeeds; the panel reads it after the dialog closes to update the list.
var outcome: ProviderSettingsDto? = null
private set
init {
title = KiloBundle.message("settings.providers.customTitle")
setOKButtonText(KiloBundle.message("settings.providers.customAdd"))
init()
initValidation()
models.document.addDocumentListener(object : DocumentAdapter() {
override fun textChanged(e: DocumentEvent) {
syncActions()
}
})
pick.addActionListener {
if (fetching) cancelFetch()
else selectModels()
}
syncActions()
}
@RequiresEdt
fun input(directory: String) = CustomProviderSaveDto(
private fun input() = CustomProviderSaveDto(
directory = directory,
id = id.text.trim(),
name = name.text.trim(),
baseUrl = url.text.trim(),
apiKey = String(key.password).takeIf { it.isNotBlank() },
envVar = env.text.trim().takeIf { it.isNotBlank() },
models = models.text.split(',').mapNotNull { raw ->
raw.trim().takeIf { it.isNotBlank() }?.let { CustomModelDto(it, it) }
},
models = modelIds().map { CustomModelDto(it, it) },
)
override fun createCenterPanel(): JComponent {
@@ -574,17 +704,276 @@ private class CustomProviderDialog : DialogWrapper(true) {
KiloBundle.message("settings.providers.customUrl") to url,
KiloBundle.message("settings.providers.apiKey") to key,
KiloBundle.message("settings.providers.customEnv") to env,
KiloBundle.message("settings.providers.customModels") to models,
).forEach { (label, field) ->
panel.next(JBLabel(label))
panel.next(field)
}
panel.next(JBLabel(KiloBundle.message("settings.providers.customModels")))
panel.next(JPanel(BorderLayout(UiStyle.Gap.sm(), 0)).apply {
add(models, BorderLayout.CENTER)
add(pick, BorderLayout.EAST)
})
return panel
}
override fun doValidate(): ValidationInfo? {
if (id.text.isBlank()) return ValidationInfo(KiloBundle.message("settings.providers.customIdRequired"), id)
if (url.text.isBlank()) return ValidationInfo(KiloBundle.message("settings.providers.customUrlRequired"), url)
actionError?.let { return ValidationInfo(it) }
if (!fetching && modelIds().isEmpty()) return ValidationInfo(KiloBundle.message("settings.providers.customModelsRequired"), models)
return null
}
override fun doOKAction() {
checkEdt()
ProvidersSettingsUi.LOG.info("custom provider add: clicked saving=$saving fetching=$fetching id='${id.text.trim()}' models=${modelIds().size}")
if (saving) {
ProvidersSettingsUi.LOG.info("custom provider add: ignored, save already in progress")
return
}
actionError = null
setErrorText(null)
val invalid = doValidate()
if (invalid != null) {
ProvidersSettingsUi.LOG.info("custom provider add: blocked by validation: ${invalid.message}")
return
}
val input = input()
ProvidersSettingsUi.LOG.info("custom provider add: saving id='${input.id}' baseUrl='${input.baseUrl}' models=${input.models.size} hasKey=${input.apiKey != null} env='${input.envVar}'")
saving = true
syncActions()
cs.launch {
val result = try {
save(input)
} catch (e: CancellationException) {
ProvidersSettingsUi.LOG.info("custom provider add: save cancelled id='${input.id}'")
throw e
} catch (e: Exception) {
ProvidersSettingsUi.LOG.warn("custom provider save failed id='${input.id}'", e)
withContext(edt) { fail("${e::class.simpleName}: ${e.message}") }
return@launch
}
withContext(edt) {
if (!active) {
ProvidersSettingsUi.LOG.info("custom provider add: dialog no longer active, dropping result id='${input.id}'")
return@withContext
}
val error = customSaveError(input.id, result)
if (error != null) {
ProvidersSettingsUi.LOG.warn("custom provider add: save reported error id='${input.id}': $error")
fail(error)
return@withContext
}
ProvidersSettingsUi.LOG.info("custom provider add: save succeeded id='${input.id}', closing dialog")
outcome = result.state
closeOk()
}
}
}
@RequiresEdt
private fun fail(text: String) {
if (!active) return
saving = false
finishFetch()
actionError = text
setErrorText(text)
syncActions()
}
@RequiresEdt
private fun selectModels() {
checkEdt()
if (saving || fetching) return
actionError = null
setErrorText(null)
val err = fetchValidationError()
if (err != null) {
fail(err)
return
}
startFetch()
val input = CustomModelFetchDto(
baseUrl = url.text.trim(),
apiKey = String(key.password).takeIf { it.isNotBlank() },
)
val current = token
job = cs.launch {
val result = try {
fetch(input)
} catch (e: CancellationException) {
return@launch
} catch (e: Exception) {
ProvidersSettingsUi.LOG.warn("custom provider model fetch failed", e)
withContext(edt) {
if (token == current) fail("${e::class.simpleName}: ${e.message}")
}
return@launch
}
withContext(edt) {
if (!active || token != current) return@withContext
finishFetch()
val error = result.error
if (error != null) {
fail(error)
return@withContext
}
val ids = result.models.mapNotNull { it.trim().takeIf(String::isNotBlank) }.distinct()
if (ids.isEmpty()) {
fail(KiloBundle.message("settings.providers.customModelsEmpty"))
return@withContext
}
showModelPopup(ids)
}
}
}
@RequiresEdt
private fun startFetch() {
draft = models.text
token++
fetching = true
models.isEditable = false
models.text = KiloBundle.message("settings.providers.customFetchingModels")
syncActions()
}
@RequiresEdt
private fun cancelFetch() {
checkEdt()
job?.cancel()
token++
finishFetch()
setErrorText(null)
}
// Restores the field to what it held before the fetch and re-enables editing. The stale-result
// guard uses `token`, so a late response from a cancelled fetch is ignored and never lands here.
@RequiresEdt
private fun finishFetch() {
if (!fetching && draft == null) return
job = null
fetching = false
models.isEditable = true
draft?.let { models.text = it }
draft = null
syncActions()
}
private fun fetchValidationError(): String? {
if (url.text.isBlank()) return KiloBundle.message("settings.providers.customUrlRequired")
if (!url.text.trim().let { it.startsWith("http://") || it.startsWith("https://") }) return KiloBundle.message("settings.providers.customUrlInvalid")
return null
}
@RequiresEdt
private fun showModelPopup(ids: List<String>) {
checkEdt()
popup?.cancel()
val rows = customModelRows(ids)
val data = CollectionListModel(rows)
val listBackground = if (NewUI.isEnabled()) JBUI.CurrentTheme.Popup.BACKGROUND else UIUtil.getListBackground()
val list = JBList(data).apply {
selectionMode = ListSelectionModel.SINGLE_SELECTION
isFocusable = true
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
background = listBackground
border = JBUI.Borders.empty(PopupUtil.getListInsets(false, false))
cellRenderer = CustomModelRowRenderer(ids.toSet()) { modelIds().toSet() }
visibleRowCount = rows.size.coerceAtMost(CUSTOM_MODEL_POPUP_MAX_ROWS)
}
fun sync() {
list.repaint()
syncActions()
}
fun toggle(row: CustomModelRow) {
if (row.selectAll) {
val all = modelIds().toSet().containsAll(ids)
setModelIds(if (all) emptyList() else ids)
sync()
return
}
val id = row.id ?: return
val selected = modelIds().toMutableSet()
if (!selected.add(id)) selected.remove(id)
setModelIds(ids.filter { it in selected })
sync()
}
list.addMouseListener(object : MouseAdapter() {
override fun mouseReleased(e: MouseEvent) {
if (!UIUtil.isActionClick(e, MouseEvent.MOUSE_RELEASED, true)) return
val idx = list.locationToIndex(e.point).takeIf { it >= 0 } ?: return
val bounds = list.getCellBounds(idx, idx) ?: return
if (!bounds.contains(e.point)) return
toggle(data.getElementAt(idx))
e.consume()
}
})
list.registerKeyboardAction(
{ list.selectedValue?.let(::toggle) },
KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, 0),
JComponent.WHEN_FOCUSED,
)
list.registerKeyboardAction(
{ list.selectedValue?.let(::toggle) },
KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0),
JComponent.WHEN_FOCUSED,
)
ListUtil.installAutoSelectOnMouseMove(list)
ScrollingUtil.installActions(list)
val scroll = JBScrollPane(list).apply {
border = JBUI.Borders.empty()
viewportBorder = JBUI.Borders.empty()
background = listBackground
viewport.background = listBackground
viewport.isOpaque = true
preferredSize = Dimension(JBUI.scale(CUSTOM_MODEL_POPUP_MIN_WIDTH), preferredSize.height)
}
popup = JBPopupFactory.getInstance()
.createComponentPopupBuilder(scroll, list)
.setRequestFocus(true)
.setFocusable(true)
.setCancelOnClickOutside(true)
.setCancelKeyEnabled(true)
.setCancelOnWindowDeactivation(true)
.setLocateWithinScreenBounds(true)
.setResizable(false)
.setMovable(false)
.createPopup()
popup?.showUnderneathOf(pick)
}
private fun modelIds(): List<String> {
val text = draft.takeIf { fetching } ?: models.text
return text.split(',').mapNotNull { it.trim().takeIf(String::isNotBlank) }
}
private fun setModelIds(ids: Collection<String>) {
draft = null
models.text = ids.distinct().joinToString(", ")
}
private fun syncActions() {
isOKActionEnabled = !saving && !fetching && modelIds().isNotEmpty()
pick.isEnabled = !saving
pick.text = if (fetching) {
KiloBundle.message("settings.providers.customCancelModels")
} else {
KiloBundle.message("settings.providers.customSelectModels")
}
}
private fun closeOk() = super.doOKAction()
override fun dispose() {
active = false
token++
job?.cancel()
popup?.cancel()
super.dispose()
}
private fun checkEdt() {
check(ApplicationManager.getApplication().isDispatchThread) { "Custom provider dialog updates must run on EDT" }
}
}
@@ -468,13 +468,22 @@ settings.providers.note.vercel=Unified access to AI models with smart routing
settings.providers.apiKey=API key
settings.providers.apiKeyRequired=API key is required.
settings.providers.customTitle=Custom OpenAI-Compatible Provider
settings.providers.customAdd=Add
settings.providers.customSelectModels=Select models
settings.providers.customCancelModels=Cancel
settings.providers.customFetchingModels=Fetching models...
settings.providers.customId=Provider ID
settings.providers.customName=Display name
settings.providers.customUrl=Base URL
settings.providers.customEnv=API key environment variable
settings.providers.customModels=Model IDs (comma-separated)
settings.providers.customModelsSelectAll=Select All
settings.providers.customModelsEmpty=No models found at this Base URL.
settings.providers.customIdRequired=Provider ID is required.
settings.providers.customUrlRequired=Base URL is required.
settings.providers.customUrlInvalid=Base URL must start with http:// or https://.
settings.providers.customModelsRequired=Add at least one model ID.
settings.providers.customNotUsable=Provider saved but has no usable models. Check the Base URL, API key, and model IDs, then try again.
settings.login.message=Sign in to Kilo Code to access account-backed features and manage billing.
settings.login.action=Open User Profile
settings.models.defaultModel.title=Default Model
@@ -1,6 +1,7 @@
package ai.kilocode.client.settings.providers
import ai.kilocode.client.app.KiloProviderService
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.settings.base.SettingsListConfig
import ai.kilocode.client.settings.base.SettingsListItem
import ai.kilocode.client.settings.base.SettingsListRenderer
@@ -12,7 +13,9 @@ import ai.kilocode.client.settings.base.settingsListVisibleCells
import ai.kilocode.client.testing.FakeProviderRpcApi
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.rpc.dto.CustomProviderConfigDto
import ai.kilocode.rpc.dto.CustomModelFetchResultDto
import ai.kilocode.rpc.dto.ModelDto
import ai.kilocode.rpc.dto.ProviderActionResultDto
import ai.kilocode.rpc.dto.ProviderAuthMethodDto
import ai.kilocode.rpc.dto.ProviderDisconnectDto
import ai.kilocode.rpc.dto.ProviderMetadataDto
@@ -20,6 +23,7 @@ import ai.kilocode.rpc.dto.ProviderOAuthReadyDto
import ai.kilocode.rpc.dto.ProviderSettingsDto
import ai.kilocode.rpc.dto.ProviderSettingsProviderDto
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.ui.ValidationInfo
import com.intellij.testFramework.replaceService
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.CollectionListModel
@@ -71,6 +75,137 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() {
}
}
fun `test custom save error surfaces backend error`() {
val result = ProviderActionResultDto(ProviderSettingsDto(), error = "boom")
assertEquals("boom", customSaveError("my-openai", result))
}
fun `test custom save error reports dropped provider`() {
val result = ProviderActionResultDto(ProviderSettingsDto())
assertEquals(KiloBundle.message("settings.providers.customNotUsable"), customSaveError("my-openai", result))
}
fun `test custom save error passes when provider present`() {
val result = ProviderActionResultDto(providerState(provider("my-openai", "My OpenAI")))
assertNull(customSaveError("my-openai", result))
}
fun `test custom model rows start with select all`() {
val rows = customModelRows(listOf("gpt-4o", "gpt-4o-mini"))
assertTrue(rows[0].selectAll)
assertEquals(listOf("gpt-4o", "gpt-4o-mini"), rows.drop(1).map { it.id })
}
fun `test custom dialog add is disabled until model list exists`() {
val cs = CoroutineScope(SupervisorJob())
scope = cs
val dialog = edt {
val dialog = CustomProviderDialog(
cs,
"/tmp",
{ CustomModelFetchResultDto(listOf("gpt-4o")) },
{ ProviderActionResultDto(providerState(provider("my-openai", "My OpenAI"))) },
)
val fields = components(center(dialog)).filterIsInstance<JTextField>()
fields[0].text = "my-openai"
fields[2].text = "https://example.com/v1"
dialog
}
edt {
assertFalse(dialog.isOKActionEnabled)
components(center(dialog)).filterIsInstance<JTextField>()[5].text = "gpt-4o"
assertTrue(dialog.isOKActionEnabled)
components(center(dialog)).filterIsInstance<JTextField>()[5].text = ""
assertFalse(dialog.isOKActionEnabled)
dispose(dialog)
}
}
fun `test custom dialog cancels model fetch and ignores late result`() {
val cs = CoroutineScope(SupervisorJob())
scope = cs
val gate = CompletableDeferred<CustomModelFetchResultDto>()
lateinit var pick: JButton
lateinit var field: JTextField
val dialog = edt {
val dialog = CustomProviderDialog(
cs,
"/tmp",
{ gate.await() },
{ ProviderActionResultDto(providerState(provider("my-openai", "My OpenAI"))) },
)
val panel = center(dialog)
val fields = components(panel).filterIsInstance<JTextField>()
fields[0].text = "my-openai"
fields[2].text = "http://127.0.0.1:8080"
pick = components(panel).filterIsInstance<JButton>().first()
field = fields[5]
pick.doClick()
dialog
}
edt {
assertEquals(KiloBundle.message("settings.providers.customFetchingModels"), field.text)
assertEquals(KiloBundle.message("settings.providers.customCancelModels"), pick.text)
assertFalse(field.isEditable)
assertFalse(dialog.isOKActionEnabled)
assertNull(validation(dialog))
pick.doClick()
assertEquals("", field.text)
assertEquals(KiloBundle.message("settings.providers.customSelectModels"), pick.text)
assertTrue(field.isEditable)
}
gate.complete(CustomModelFetchResultDto(listOf("gpt-4o")))
flushUntil { edt { field.isEditable } }
edt {
assertEquals("", field.text)
assertEquals(KiloBundle.message("settings.providers.customSelectModels"), pick.text)
assertFalse(dialog.isOKActionEnabled)
dispose(dialog)
}
}
fun `test custom dialog save error stays until next add`() {
val cs = CoroutineScope(SupervisorJob())
scope = cs
val next = CompletableDeferred<ProviderActionResultDto>()
var calls = 0
val dialog = edt {
val dialog = CustomProviderDialog(
cs,
"/tmp",
{ CustomModelFetchResultDto(listOf("gpt-4o")) },
{
calls++
if (calls == 1) ProviderActionResultDto(ProviderSettingsDto(), error = "boom") else next.await()
},
)
val fields = components(center(dialog)).filterIsInstance<JTextField>()
fields[0].text = "my-openai"
fields[2].text = "https://example.com/v1"
fields[5].text = "gpt-4o"
submit(dialog)
dialog
}
flushUntil { edt { validation(dialog) == "boom" } }
edt {
assertEquals("boom", validation(dialog))
submit(dialog)
assertNull(validation(dialog))
}
next.complete(ProviderActionResultDto(providerState(provider("my-openai", "My OpenAI"))))
flushUntil { edt { dialog.outcome != null } }
edt { dispose(dialog) }
}
fun `test catalog provider without auth methods is connectable`() {
val content = content()
@@ -879,6 +1014,24 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() {
private fun fieldsByName(root: Container, name: String): List<JTextField> = components(root).filterIsInstance<JTextField>().filter { it.name == name }
private fun center(dialog: CustomProviderDialog) = call(dialog, "createCenterPanel") as JComponent
private fun submit(dialog: CustomProviderDialog) {
call(dialog, "doOKAction")
}
private fun validation(dialog: CustomProviderDialog) = (call(dialog, "doValidate") as ValidationInfo?)?.message
private fun dispose(dialog: CustomProviderDialog) {
call(dialog, "dispose")
}
private fun call(dialog: CustomProviderDialog, name: String): Any? {
val method = dialog.javaClass.getDeclaredMethod(name)
method.isAccessible = true
return method.invoke(dialog)
}
private fun center(rect: Rectangle) = Point(rect.x + rect.width / 2, rect.y + rect.height / 2)
private fun renderer(row: ProviderListRow) = SettingsListRenderer(CollectionListModel<SettingsListItem>(listOf(row)))