mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
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:
@@ -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.
|
||||
+1
@@ -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
|
||||
}
|
||||
|
||||
+47
@@ -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 = """{
|
||||
|
||||
+402
-13
@@ -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
|
||||
|
||||
+153
@@ -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)))
|
||||
|
||||
Reference in New Issue
Block a user