From 00b795946abfd625bcae1d201440fa2107dbb0c9 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 17 Jun 2026 10:16:18 -0400 Subject: [PATCH] fix(jetbrains): preserve provider config scope --- .../KiloBackendProviderSettingsManager.kt | 72 ++++++++++-- .../KiloBackendProviderSettingsManagerTest.kt | 109 ++++++++++++++++++ .../kilocode/backend/testing/MockCliServer.kt | 7 ++ .../kilocode/rpc/dto/ProviderSettingsDto.kt | 3 + 4 files changed, 181 insertions(+), 10 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/provider/KiloBackendProviderSettingsManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/provider/KiloBackendProviderSettingsManager.kt index 074183089aa..cfe2c9b3cde 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/provider/KiloBackendProviderSettingsManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/provider/KiloBackendProviderSettingsManager.kt @@ -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() to (emptyList() to emptyList())) + parsed(get("/global/config")) + } ?: empty val local = load("workspace_config", errors) { - KiloCliDataParser.parseProviderConfig(get("/config?directory=${enc(directory)}")) - } ?: (emptyMap() to (emptyList() 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, local: Map): Map { + 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, local: List): Map> { + 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 = + 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, + val disabled: List, + val enabled: List, + ) } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/provider/KiloBackendProviderSettingsManagerTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/provider/KiloBackendProviderSettingsManagerTest.kt index 4ab4eb59b46..a091bc255ef 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/provider/KiloBackendProviderSettingsManagerTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/provider/KiloBackendProviderSettingsManagerTest.kt @@ -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 = """{ diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index a5fd07bbacb..078cf52c7d0 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -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" -> { diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ProviderSettingsDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ProviderSettingsDto.kt index bfd733b99cb..e45eb8d4f7d 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ProviderSettingsDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ProviderSettingsDto.kt @@ -11,6 +11,8 @@ data class ProviderSettingsDto( val config: Map = emptyMap(), val disabled: List = emptyList(), val enabled: List = emptyList(), + val disabledScopes: Map> = emptyMap(), + val enabledScopes: Map> = emptyMap(), val errors: List = emptyList(), ) @@ -150,4 +152,5 @@ data class CustomProviderConfigDto( val options: Map = emptyMap(), val headers: Map = emptyMap(), val models: Map = emptyMap(), + val scope: String = "global", )