fix(jetbrains): preserve provider config scope

This commit is contained in:
kirillk
2026-06-17 10:16:18 -04:00
parent f6083176b1
commit 00b795946a
4 changed files with 181 additions and 10 deletions
@@ -7,6 +7,7 @@ import ai.kilocode.backend.rpc.KiloWorkspaceDtoMapper
import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.dto.CustomModelFetchDto
import ai.kilocode.rpc.dto.CustomModelFetchResultDto
import ai.kilocode.rpc.dto.CustomProviderConfigDto
import ai.kilocode.rpc.dto.CustomProviderSaveDto
import ai.kilocode.rpc.dto.LoadErrorDto
import ai.kilocode.rpc.dto.ProviderActionResultDto
@@ -53,15 +54,18 @@ internal class KiloBackendProviderSettingsManager(
val auth = load("provider_auth", errors) {
KiloCliDataParser.parseProviderAuth(get("/provider/auth?directory=${enc(directory)}"))
} ?: emptyMap()
val empty = ParsedConfig(emptyMap(), emptyList(), emptyList())
val global = load("global_config", errors) {
KiloCliDataParser.parseProviderConfig(get("/global/config"))
} ?: (emptyMap<String, ai.kilocode.rpc.dto.CustomProviderConfigDto>() to (emptyList<String>() to emptyList()))
parsed(get("/global/config"))
} ?: empty
val local = load("workspace_config", errors) {
KiloCliDataParser.parseProviderConfig(get("/config?directory=${enc(directory)}"))
} ?: (emptyMap<String, ai.kilocode.rpc.dto.CustomProviderConfigDto>() to (emptyList<String>() to emptyList()))
val cfg = global.first + local.first
val disabled = (global.second.first + local.second.first).distinct().sorted()
val enabled = (global.second.second + local.second.second).distinct().sorted()
parsed(get("/config?directory=${enc(directory)}"))
} ?: empty
val cfg = scopedConfig(global.config, local.config)
val disabled = (global.disabled + local.disabled).distinct().sorted()
val enabled = (global.enabled + local.enabled).distinct().sorted()
val disabledScopes = scopedIds(global.disabled, local.disabled)
val enabledScopes = scopedIds(global.enabled, local.enabled)
val result = ProviderSettingsDto(
providers = providers?.first ?: emptyList(),
connected = providers?.second ?: emptyList(),
@@ -70,6 +74,8 @@ internal class KiloBackendProviderSettingsManager(
config = cfg,
disabled = disabled,
enabled = enabled,
disabledScopes = disabledScopes,
enabledScopes = enabledScopes,
errors = errors,
)
result.providers.forEach { provider ->
@@ -118,13 +124,15 @@ internal class KiloBackendProviderSettingsManager(
return ProviderActionResultDto(current, error = "Provider is not connected.")
}
if (cfg?.npm == "@ai-sdk/openai-compatible") {
patch(KiloCliDataParser.buildCustomProviderDeletePatch(input.providerId))
patch(input.directory, cfg.scope, KiloCliDataParser.buildCustomProviderDeletePatch(input.providerId))
deleteAuth(input.providerId)
dispose()
return ProviderActionResultDto(state(input.directory))
}
if (provider?.source == "config") {
patch(KiloCliDataParser.buildDisabledProviderPatch(current.disabled + input.providerId))
val scope = cfg?.scope ?: "global"
val ids = disabledFor(current, scope) + input.providerId
patch(input.directory, scope, KiloCliDataParser.buildDisabledProviderPatch(ids))
dispose()
return ProviderActionResultDto(state(input.directory))
}
@@ -135,7 +143,10 @@ internal class KiloBackendProviderSettingsManager(
suspend fun enable(input: ProviderEnableDto): ProviderActionResultDto {
val current = state(input.directory)
patch(KiloCliDataParser.buildDisabledProviderPatch(current.disabled.filter { it != input.providerId }))
val scopes = current.disabledScopes[input.providerId]?.takeIf { it.isNotEmpty() } ?: listOf("global")
scopes.distinct().forEach { scope ->
patch(input.directory, scope, KiloCliDataParser.buildDisabledProviderPatch(disabledFor(current, scope).filter { it != input.providerId }))
}
dispose()
return ProviderActionResultDto(state(input.directory))
}
@@ -193,6 +204,41 @@ internal class KiloBackendProviderSettingsManager(
private suspend fun post(path: String, body: String, timeoutSeconds: Long = CALL_TIMEOUT_SECONDS) = request(Request.Builder().url(url(path)).post(body.toRequestBody(JSON)).build(), timeoutSeconds)
private suspend fun put(path: String, body: String) = request(Request.Builder().url(url(path)).put(body.toRequestBody(JSON)).build())
private suspend fun patch(body: String) = request(Request.Builder().url(url("/global/config")).patch(body.toRequestBody(JSON)).build())
private suspend fun patch(directory: String, scope: String, body: String) {
val path = if (scope == "workspace") "/config?directory=${enc(directory)}" else "/global/config"
request(Request.Builder().url(url(path)).patch(body.toRequestBody(JSON)).build())
}
private fun parsed(raw: String): ParsedConfig {
val result = KiloCliDataParser.parseProviderConfig(raw)
return ParsedConfig(result.first, result.second.first, result.second.second)
}
private fun scopedConfig(global: Map<String, CustomProviderConfigDto>, local: Map<String, CustomProviderConfigDto>): Map<String, CustomProviderConfigDto> {
val ids = (global.keys + local.keys).distinct()
return ids.mapNotNull { id ->
val localCfg = local[id]
val globalCfg = global[id]
val cfg = localCfg ?: globalCfg ?: return@mapNotNull null
val scope = if (localCfg != null && (globalCfg == null || localCfg.withoutScope() != globalCfg.withoutScope())) "workspace" else "global"
id to cfg.copy(scope = scope)
}.toMap()
}
private fun scopedIds(global: List<String>, local: List<String>): Map<String, List<String>> {
val globals = global.toSet()
return (global + local).distinct().associateWith { id ->
buildList {
if (id in globals) add("global")
if (id in local && id !in globals) add("workspace")
}
}
}
private fun disabledFor(state: ProviderSettingsDto, scope: String): List<String> =
state.disabledScopes.entries.filter { scope in it.value }.map { it.key }
private fun CustomProviderConfigDto.withoutScope() = copy(scope = "global")
private suspend fun deleteAuth(id: String) {
runCatching { request(Request.Builder().url(url("/auth/${enc(id)}")).delete().build()) }
@@ -239,4 +285,10 @@ internal class KiloBackendProviderSettingsManager(
if (input.models.any { it.id.isBlank() }) return "Model IDs cannot be empty."
return null
}
private data class ParsedConfig(
val config: Map<String, CustomProviderConfigDto>,
val disabled: List<String>,
val enabled: List<String>,
)
}
@@ -6,6 +6,7 @@ import ai.kilocode.backend.testing.FakeCliServer
import ai.kilocode.backend.testing.MockCliServer
import ai.kilocode.backend.testing.TestLog
import ai.kilocode.rpc.dto.ProviderDisconnectDto
import ai.kilocode.rpc.dto.ProviderEnableDto
import kotlinx.coroutines.async
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -78,10 +79,118 @@ class KiloBackendProviderSettingsManagerTest {
assertNull(result.error)
assertContains(mock.lastConfigPatchBody.orEmpty(), "\"local-openai\":null")
assertNull(mock.lastWorkspaceConfigPatchBody)
assertEquals("/auth/local-openai", mock.lastAuthDeletePath)
assertEquals(1, mock.requestCount("/global/dispose"))
}
@Test
fun `state marks provider config scopes`() = runBlocking {
mock.config = """{
"provider":{
"global-openai":{"name":"Global OpenAI","npm":"@ai-sdk/openai-compatible","options":{"baseURL":"https://global.test"}},
"overridden-openai":{"name":"Global Override","npm":"@ai-sdk/openai-compatible","options":{"baseURL":"https://global.test"}}
},
"disabled_providers":["global-disabled"],
"enabled_providers":["global-enabled"]
}""".trimIndent()
mock.workspaceConfig = """{
"provider":{
"global-openai":{"name":"Global OpenAI","npm":"@ai-sdk/openai-compatible","options":{"baseURL":"https://global.test"}},
"overridden-openai":{"name":"Workspace Override","npm":"@ai-sdk/openai-compatible","options":{"baseURL":"https://workspace.test"}},
"workspace-openai":{"name":"Workspace OpenAI","npm":"@ai-sdk/openai-compatible","options":{"baseURL":"https://workspace.test"}}
},
"disabled_providers":["global-disabled","workspace-disabled"],
"enabled_providers":["global-enabled","workspace-enabled"]
}""".trimIndent()
val manager = manager()
val state = manager.state("/test")
assertEquals("global", state.config["global-openai"]?.scope)
assertEquals("workspace", state.config["overridden-openai"]?.scope)
assertEquals("workspace", state.config["workspace-openai"]?.scope)
assertEquals(listOf("global"), state.disabledScopes["global-disabled"])
assertEquals(listOf("workspace"), state.disabledScopes["workspace-disabled"])
assertEquals(listOf("global"), state.enabledScopes["global-enabled"])
assertEquals(listOf("workspace"), state.enabledScopes["workspace-enabled"])
}
@Test
fun `disconnecting workspace openai compatible custom provider patches workspace config`() = runBlocking {
mock.workspaceConfig = """{
"provider":{
"local-openai":{"name":"Local OpenAI","npm":"@ai-sdk/openai-compatible","options":{"baseURL":"http://localhost:11434"}}
}
}""".trimIndent()
mock.providers = """{
"all":[{"id":"local-openai","name":"Local OpenAI","source":"config","models":{}}],
"default":{},
"connected":["local-openai"],
"failed":[]
}""".trimIndent()
val manager = manager()
mock.resetCounts()
val result = manager.disconnect(ProviderDisconnectDto("/test project", "local-openai"))
assertNull(result.error)
assertNull(mock.lastConfigPatchBody)
assertEquals("/config?directory=%2Ftest+project", mock.lastWorkspaceConfigPatchPath)
assertContains(mock.lastWorkspaceConfigPatchBody.orEmpty(), "\"local-openai\":null")
assertEquals("/auth/local-openai", mock.lastAuthDeletePath)
assertEquals(1, mock.requestCount("/global/dispose"))
}
@Test
fun `disconnecting workspace configured provider patches workspace disabled providers`() = runBlocking {
mock.config = """{"disabled_providers":["global-disabled"]}"""
mock.workspaceConfig = """{
"provider":{"anthropic":{"name":"Anthropic","npm":"@ai-sdk/anthropic"}},
"disabled_providers":["global-disabled","workspace-disabled"]
}""".trimIndent()
mock.providers = """{
"all":[{"id":"anthropic","name":"Anthropic","source":"config","models":{}}],
"default":{},
"connected":[],
"failed":[]
}""".trimIndent()
val manager = manager()
mock.resetCounts()
val result = manager.disconnect(ProviderDisconnectDto("/test", "anthropic"))
assertNull(result.error)
assertNull(mock.lastConfigPatchBody)
assertContains(mock.lastWorkspaceConfigPatchBody.orEmpty(), "\"anthropic\"")
assertContains(mock.lastWorkspaceConfigPatchBody.orEmpty(), "\"workspace-disabled\"")
assertFalse(mock.lastWorkspaceConfigPatchBody.orEmpty().contains("global-disabled"))
assertEquals(1, mock.requestCount("/global/dispose"))
}
@Test
fun `enabling workspace disabled provider patches workspace disabled providers`() = runBlocking {
mock.config = """{"disabled_providers":["global-disabled"]}"""
mock.workspaceConfig = """{"disabled_providers":["global-disabled","workspace-disabled","anthropic"]}"""
mock.providers = """{
"all":[{"id":"anthropic","name":"Anthropic","source":"config","models":{}}],
"default":{},
"connected":[],
"failed":[]
}""".trimIndent()
val manager = manager()
mock.resetCounts()
val result = manager.enable(ProviderEnableDto("/test", "anthropic"))
assertNull(result.error)
assertNull(mock.lastConfigPatchBody)
assertContains(mock.lastWorkspaceConfigPatchBody.orEmpty(), "\"workspace-disabled\"")
assertFalse(mock.lastWorkspaceConfigPatchBody.orEmpty().contains("anthropic"))
assertFalse(mock.lastWorkspaceConfigPatchBody.orEmpty().contains("global-disabled"))
assertEquals(1, mock.requestCount("/global/dispose"))
}
@Test
fun `disconnecting kilo gateway returns error without logout`() = runBlocking {
mock.providers = """{
@@ -55,6 +55,8 @@ class MockCliServer : AutoCloseable {
@Volatile var lastAuthPutBody: String? = null
@Volatile var lastAuthDeletePath: String? = null
@Volatile var lastConfigPatchBody: String? = null
@Volatile var lastWorkspaceConfigPatchPath: String? = null
@Volatile var lastWorkspaceConfigPatchBody: String? = null
@Volatile var lastOrganizationSetBody: String? = null
// Project-scoped REST responses
@@ -283,6 +285,11 @@ class MockCliServer : AutoCloseable {
}
bare == "/global/dispose" && method == "POST" -> respond(output, disposeStatus, "true")
path.startsWith("/config/warnings") -> respond(output, warningsStatus, warnings)
bare == "/config" && method == "PATCH" -> {
lastWorkspaceConfigPatchPath = path
lastWorkspaceConfigPatchBody = body
respond(output, workspaceConfigStatus, workspaceConfig)
}
bare == "/config" -> respond(output, workspaceConfigStatus, workspaceConfig)
path.startsWith("/kilo/notifications") -> respond(output, notificationsStatus, notifications)
path.startsWith("/kilo/profile") && method == "GET" -> {
@@ -11,6 +11,8 @@ data class ProviderSettingsDto(
val config: Map<String, CustomProviderConfigDto> = emptyMap(),
val disabled: List<String> = emptyList(),
val enabled: List<String> = emptyList(),
val disabledScopes: Map<String, List<String>> = emptyMap(),
val enabledScopes: Map<String, List<String>> = emptyMap(),
val errors: List<LoadErrorDto> = emptyList(),
)
@@ -150,4 +152,5 @@ data class CustomProviderConfigDto(
val options: Map<String, String> = emptyMap(),
val headers: Map<String, String> = emptyMap(),
val models: Map<String, CustomModelDto> = emptyMap(),
val scope: String = "global",
)